htrflow-campaigns CLI
htrflow-campaigns is the converter's command line. It reads a campaigns
repo, checks it, renders it to Kubernetes objects and applies them. The
file formats it reads are in Campaign & Pipeline YAML.
What a render produces, field by field, is in
Rendered objects.
Source: packages/converter/src/htrflow_converter/cli.py.
Commands
| Command | What it does |
|---|---|
init <dir> [--force] [--ci github\|azure] |
Writes a new campaigns repo from the template, with GitHub Actions (default) or Azure Pipelines CI. |
validate <repo> [--rendered] |
Checks converter.yaml, campaigns/ and pipelines/, and prints one line per problem. --rendered also refuses unless the committed rendered/ is exactly what this checkout renders. |
render <repo> --out <dir> |
Writes the rendered objects to <dir>. Renders nothing at all if there is one problem. |
apply <repo> [flags] |
Renders into a temporary directory (or --out), then applies to the cluster. See below. |
retry <campaign> --namespace <ns> [flags] |
Starts a campaign's unfinished volumes again as a new campaign in the cluster. See retry. |
pipeline add <file> --namespace <ns> --like <id> [flags] |
Puts a pipeline file in the cluster with no campaigns repo, for retry --pipeline. See pipeline add. |
export --pipeline <id> --namespace <ns> --volumes <ids> --to <dir> [flags] |
Writes finished volumes' ALTO into a publishing folder, one folder per volume. See export. |
make campaigns-apply DIR=<repo> runs
uv run htrflow-campaigns apply <repo> --out <repo>/rendered --namespace
<namespace>, the namespace being HTR_NAMESPACE. PRUNE=1 adds
--prune, and ALLOW_EMPTY=1 adds --allow-empty.
apply flag |
Meaning |
|---|---|
--prune |
Delete the converter-labelled objects this render did not produce. Opt-in. See Prune. |
--allow-empty |
Let --prune run on a render with no campaigns, which cancels every campaign in the namespace. |
--namespace <ns> |
The namespace this apply is meant for. Refused, with nothing applied, unless converter.yaml names the same one. |
--pause-wait <s> |
How long to wait for a new paused campaign's Kueue Workload (default 10). |
--dry-run |
Render and print would apply: <Kind>/<name> lines. Opens no connection. |
--out <dir> |
Render here instead of a temporary directory. |
apply talks to the API server through the official Kubernetes client,
with server-side apply and no kubectl. It authenticates from
$KUBECONFIG or, in a pod, from the mounted ServiceAccount token. The chart
renders a suitable ServiceAccount behind apply.rbac.enabled
(Chart Values).
export: publishing finished volumes
htrflow-campaigns export --pipeline <id> --namespace <ns>
(--volumes ID,ID | --volumes-file FILE) --to <dir>
[--results-url URL] [--min-quality 0.75 | --no-quality-filter]
[--empty-pages 1] [--manifests DIR] [--fix-positions] [--user NAME]
[--report FILE] [--dry-run]
What it reads. Each volume's results at
<results-url>/<namespace>/<pipeline>/<volume id>/, the same place the
viewer reads them. No campaigns repo is needed: the pipeline is the one the
campaign file names, and the volumes are those it lists. --volumes takes
ids separated by commas and --volumes-file one id per line, with blank
lines and # comments skipped (a UTF-8 byte-order mark too); both may be
given, and the flag's ids come first. An id listed twice (letter case
ignored), or one that is not a volume id, refuses the run.
What it writes. For each volume, a folder <dir>/<volume id>/ holding
one ALTO file per image, named <volume id>_<nnnnn>.xml after the page's
own source image id, never after its place in the canvas order. Inside the
file that id is the fileName, and the Page's ID is its five-digit image
number behind an underscore (_00004), whatever name the run gave the page. A volume is
published whole or not at all: it is built beside the target and renamed
into place only when every page is there, so the real folder name never
holds a partial volume, and an earlier export of the same volume is replaced
as a whole. Only a whole image series is published: a volume made from an
images: list, or whose image numbers are not exactly 1 to its page count,
fails. So does a volume whose results change during the export: its
manifest.json differs at the end from what was read first, or a page
recorded as transcribed has no ALTO. Point --to at a folder inside the
share, not at the bare mount point: an unmounted share leaves the mount
point as an empty local directory, while a folder inside it is then missing
and the run is refused. The files and folders are written with the person's
own umask and ownership; the publishing side must be able to read and remove
them, so check the share's setup (group, ACLs, squash) before the first
export.
The quality rule. A volume is exported only if the mean predicted quality
of its pages reaches --min-quality (default 0.75). The mean counts pages
that have a score other than exactly 0 and are not among the empty pages.
A volume with no such page is skipped as no scores; --no-quality-filter
exports volumes whatever they score.
The empty-page rule. --empty-pages (default 1, the first page)
names pages, counted from 1 and written as 1,3-4, that are published as an
empty ALTO whatever was read; none empties nothing, and positions past a
volume's end are ignored. A page with no ALTO (a page that failed) is
published empty too, so every volume holds exactly its manifest's pages. An
empty ALTO states only the page's size and its source file name.
Scaling. Results are read at the size the pipeline worked at, and the export rewrites every coordinate to the scan's full resolution, read from the image server. A volume whose page proportions differ from its scan's by more than one per cent fails instead of being published stretched.
Positions on the page. htrflow writes two positions from the wrong
corner: a word's box is counted from the corner of its text block instead of
the page's, and a block's outline is moved by that corner once more. Word
boxes come only from a pipeline that recognises word by word; blocks' and
lines' own boxes are right. Without --fix-positions both are published as
htrflow wrote them, so a search hit is drawn up and to the left of its word.
With it, the export puts both where they are on the page before it scales.
It tells the two forms apart by the file's own geometry: an outline whose
corner is exactly twice its block's is moved back, and a page's words are
moved by their blocks' corners only when that puts more of them inside their
lines. A file that is already right is published as it is, so the option is
safe to pass for every volume. The results themselves are not changed.
Without the image server. The full resolution is not in the results, so
by default each page's size is read from the image server, one request per
page. Where the export runs without access to the image server, download
each volume's IIIF manifest elsewhere, save it as <volume id>.json in one
folder, and pass that folder as --manifests. Each page's size is then its
canvas's in that file, and the image server is never asked. A volume fails,
with nothing written, when its file is missing or unreadable, when the
file's own id is not the manifest URL the volume's results recorded, when
a page's image is not on exactly one canvas, when a canvas has no size, or
when an image states a size other than its canvas's.
The login. The export reads only through the results proxy at
--results-url (else HTRFLOW_RESULTS_URL), the site's address ending in
/results, for example https://<web front host>/results. It logs in once with
--user or HTRFLOW_USER, else a prompt, and the password from
HTRFLOW_PASSWORD or a prompt without echo, never an argument. The results
address must be https, except plain http to the workstation's own
loopback address, so the password never crosses the network in clear. A
volume the account may not read is skipped as forbidden; an expired
session is renewed once, and then the run stops.
Progress. While it runs, the export writes one line per volume to stderr
as each one is done, with its outcome, page count, time taken and, for a
failed volume, the reason, then a last line with the total time. In a
terminal, a counter for the slow steps (reading page sizes, building the
ALTO) is redrawn in place; written to a file or a pipe, only the finished
lines appear. The report below goes to stdout, so 2>/dev/null hides the
progress and leaves the report.
The report. One row per volume listed is printed as a table and
written as CSV to --report (default ./export-<pipeline>.csv in the
current directory, never inside the target) with the columns volume,
outcome, pages, pages_with_text, pages_empty_filled,
pages_empty_chosen, quality_mean, quality_pages and reason. The
outcomes are exported, below threshold, not finished, no scores,
forbidden and failed. When every volume reads as not finished, a note
says to check that --namespace, --pipeline and --results-url point
at the deployment that ran those volumes. --dry-run reads and checks
everything, writes nothing to the target and still writes the report; a
volume that would be exported is reported as would export.
Exit status. 2 when the run is refused before any request: the target
is not an existing directory (it is never created, so an unmounted share
cannot become a local folder), the report's folder or the --manifests
folder does not exist, no volume is listed or the list cannot be read, an id
is listed twice or is not a volume id, the namespace or pipeline is not a
valid name, no results address is given or it is not https or does not end
in /results, no user name or password could be read, or an option is out
of range. 1 when any volume failed or was forbidden, or the run
stopped (the session was lost or the target became unusable); the report
then still lists every volume, the unfinished ones as failed. Otherwise
0: every volume was exported (or would export), below threshold,
not finished or had no scores.
The design is in the campaign export spec among the design specs.
retry: starting failed volumes again
htrflow-campaigns retry <campaign> --namespace <ns>
[--pipeline ID] [--include-permanent] [--no-old-apply]
[--results-url URL] [--user NAME] [--dry-run]
What it reads. The campaign's record in the cluster (its volume list and
its Job's settings) and, from the results proxy, whether each volume has a
manifest.json. A volume without one is unfinished; its run log says
whether it failed for good (permanent), will be tried again (retry), or
never started. Never-started volumes are retried. Volumes that failed for
good are skipped, and named in the retry's record, unless
--include-permanent takes them.
Credentials are the ones export takes: --user, else HTRFLOW_USER,
else a prompt; the password from HTRFLOW_PASSWORD or a prompt, never an
argument. The results address is --results-url, else the RESULTS_URL on
the original Job.
What it starts. A new campaign, <campaign>-retry-<n>, with one more
n than the highest retry of that campaign. It is the original's Job with
only its name, labels, volumes and counts changed (and suspend dropped, so
a paused original does not leave its retry paused), so its settings are
copied: a cause that lives in the settings, such as a too-small workdir, is not
fixed by a retry. A retry of a retry is named after the original, and one of
a split campaign after its first part (<name>-part1-retry-<n>). Its
ConfigMap records the volumes (volumes.txt), the ones left out (the
annotations <label-domain>/retry-skipped, capped at 64 KiB, and
retry-skipped-count, on the first part's ConfigMap only), who ran it and
when. A large retry is split into parts the way render splits a campaign.
If the cluster refuses a write part-way, the ConfigMap just written for that
part is deleted, and the error names every object already created and still
live.
campaign kyrk: 4 volumes, 1 done, 3 not done
2 retry no run log: the volume never started
1 permanent manifest: canvas 4 has no image (skipped; --include-permanent takes it)
would start: Job/kyrk-retry-1 with 2 volumes
The summary is the tally, then one line per kind of failure, largest first.
--dry-run prints the would start: line and creates nothing; a real run
prints started: Job/kyrk-retry-1 with 2 volumes instead. With nothing to
start, the summary is followed by nothing to retry: all N volumes of
<campaign> are done, or nothing to retry: every volume not done failed for
good (--include-permanent takes them), and the exit code is 0.
It refuses, with one sentence on standard error and nothing created, when:
- the campaign, or an earlier retry of it, is still running;
- the pipeline's warm-up Job is missing or not complete, or no longer runs
the image the campaign ran (a retry runs only what the repo approved: run
the volumes with
--pipelineon the pipeline that replaced it, or as a new campaign file); --pipelinenames a pipeline whose ConfigMap or warm-up Job is not in the namespace, or, with no campaign on it to copy, one that runs at another size than the campaign's pipeline (or the campaign's pipeline is gone, so its size is not known);- another running campaign on the pipeline holds one of the volumes, or has no volume list to check;
- there is no campaign of that name in the namespace;
- the campaign has no stored Job to copy, its own Job is gone and no other campaign on the same pipeline and image has one either, or the stored Job cannot be read;
- the cluster's last apply ran a converter whose prune does not know
retries, or, on a pipeline
pipeline addadded, does not keep added pipelines, unless--no-old-applyis given.
A campaign applied by a converter older than this feature has no stored
Job. While its own Job is there, that is copied. Once the TTL has reaped it,
the settings are copied from another campaign's Job, live or stored, on the
same pipeline and image, the newest first: everything in a campaign Job but
its name, counts, volume list, window and priority comes from the pipeline
and converter.yaml, and the first three are rewritten anyway. The window is
that campaign's; the priority is dropped, so the retry runs at the default
class. The summary says so: settings: copied from campaign <other>'s Job,
on the same pipeline and image (...); default priority.
Upgrade the Argo CD hook's converter first and let it apply once: until
then retry refuses, because that apply would prune the retry. Where no
hook runs and every apply is made by hand, --no-old-apply starts the retry
anyway, on the operator's word that no apply by an older converter runs
until it is done; it says that on standard error and claims no mark on the
Lease. An apply by this version stores the Job on every finished campaign that lacks one and whose
campaign file is still in the repo: it renders that Job anyway, and adds it
to the campaign's ConfigMap without running anything again. The stored Job is
what the repo renders at that apply, so a setting changed since the campaign
ran, such as a raised workdir, is the one a retry gets. A stored Job that
is already there is never replaced.
On another pipeline. --pipeline <id> runs the volumes on a pipeline the
repo has applied in the namespace, or pipeline add
has added there, instead of the one the campaign ran on.
Done means done there: a volume counts as done when that pipeline's results
hold its manifest.json, so the first such retry takes every volume and a
later one picks up where it stopped. Results are kept per pipeline, so the
old pipeline's stay where they are. The settings come from a campaign that
has run on that pipeline and its image, live or stored, the newest first,
with the campaign's own priority; with none, the campaign's own Job is moved
onto the pipeline from its warm-up Job: the image, the pipeline ConfigMap,
the model-cache directory and the warm-up marker. A pipeline file's own
max_seconds and ttl_seconds_after_finished are not on the cluster, so a
moved Job keeps the deadline and TTL it had. The summary says which:
pipeline: demo-v1 -> demo-v2, image <image@digest>
settings: kyrk's Job moved onto demo-v2; its per-volume deadline and TTL stay as they were, ...
The retry is recorded on the new pipeline, so retrying it again stays there. A pipeline the repo applied has to keep its file in the repo while the retry runs, as for any campaign.
Permissions. The operator running retry needs: list and get on Jobs and
ConfigMaps; get, update and create on the Lease htrflow-campaigns-apply;
create on Jobs and ConfigMaps (dry runs included); delete on ConfigMaps, for
taking back the one written for a part whose Job was refused; and read on the
results prefix.
Prune keeps a retry for as long as the original campaign's file is in git, and removes both when that file leaves git.
| Exit | Meaning |
|---|---|
0 |
Started, a dry run, or nothing to retry. |
1 |
Refused, or the cluster could not be read, or the results could not be read (cannot read the results: ...). |
2 |
no results URL: ... (pass --results-url), or no credentials. |
pipeline add: a pipeline with no repo
htrflow-campaigns pipeline add <file> --namespace <ns> --like <id>
[--no-old-apply] [--dry-run]
Puts one pipeline file in the namespace, so that
retry --pipeline can run a
campaign's volumes on it, with no campaigns repo and no commit. The file is
the shape of pipelines/<id>.yaml, and its id is the file name without
.yaml.
What it makes. The two objects apply makes for a pipeline: its
ConfigMap and its warm-up Job, which downloads the models into the model
cache. The file is validated as validate validates one in a repo. What the
two objects take from converter.yaml (the data volume, the GPU
RuntimeClass, the node placement, the Hub token's Secret) is the
namespace's, and on every warm-up Job apply made, so the warm-up of the
pipeline --like names is copied, with the new pipeline's id, image, recipe
and model-cache directory set. The size is --like's too: what a size gives
a pod is in converter.yaml, which is not read here. Both objects carry the
<label-domain>/added label.
pipeline demo-v2: image <image@digest>
settings: copied from pipeline demo-v1's warm-up Job (data volume, GPU RuntimeClass, node placement, Hub token Secret), and its size
added: ConfigMap/htr-pipeline-demo-v2
added: Job/htr-warmup-demo-v2
next: once Job/htr-warmup-demo-v2 has completed, htrflow-campaigns retry CAMPAIGN --namespace <ns> --pipeline demo-v2
--dry-run prints would add: lines and creates nothing. Adding a file
whose pipeline is there already with the same recipe changes nothing.
It refuses, with nothing created, when:
- the file does not validate (one line per problem, as
validateprints); --likenames a pipeline whose ConfigMap or warm-up Job is not in the namespace, or was not made byhtrflow-campaigns;- the file names a
size:other than--like's; - a pipeline of that id is in the namespace with another recipe, or only one of its two objects is;
- the cluster's last apply ran a converter whose prune does not keep added
pipelines, unless
--no-old-applyis given.
Prune keeps an added pipeline. No file renders it, so an apply's prune
would delete it, and with its ConfigMap the retries on it could not start
their pods. A prune by a converter that has pipeline add keeps every object
with the added label, and every apply by one marks the Lease
htrflow-campaigns-apply to say so. pipeline add, and retry on an added
pipeline, refuse until that mark is there: upgrade the Argo CD hook's
converter and let it apply once. --no-old-apply adds the pipeline anyway,
on the operator's word that no apply by an older converter runs until the
retries on it are done.
Into the repo later. An apply of a repo that holds the same file takes
the two objects over, and its render has no added label, so from then on
the pipeline is the repo's like any other. A pipeline id names one recipe
for good, so the file must be the same; an apply refuses another recipe
under that id while a Job runs on it.
Permissions. As for retry: get on ConfigMaps and Jobs; get, update and
create on the Lease; create on ConfigMaps and Jobs (dry runs included); and
delete on ConfigMaps, for taking back the ConfigMap when the API server
refuses the warm-up Job.
What apply does, in order
- Render the repo. A render that does not pass stops here.
- Check the namespace against
--namespace, and refuse an empty render under--prunewithout--allow-empty. - Take the Lease
htrflow-campaigns-applyfor the whole run (One apply at a time). - Check against the live cluster, before anything is sent. Any of
these stops the whole apply with exit
1:- a campaign whose live ConfigMap has a different volume list, pipeline or image
- a pipeline whose live steps differ while a campaign Job that has not ended still mounts it
- a live, unpaused campaign Job whose pod count the render would change (Changing a window)
- Record check. For each campaign, read the live Job and write the ending it shows into the campaign's status record. A campaign that is finished and whose volume list has not moved is left alone (Finished campaigns).
- Volume check. Hold back a campaign that shares a volume with another campaign on the same pipeline that is still running, or starting in this apply.
- Dry-run each campaign Job (
dryRun=All, admission webhooks included). A campaign whose Job would be refused keeps its ConfigMap unchanged too. - Apply pipelines: each pipeline's ConfigMap and warm-up Job. They go first because a campaign Job mounts its pipeline's ConfigMap.
- Apply campaigns: each campaign's ConfigMap, then its Job.
- Pause sync: set each campaign's Kueue Workload
spec.activefrom git (Pausing). This reaches campaign Jobs the API server refused too. - Prune, with
--pruneonly.
The pause sync runs before the prune on purpose. A missing pause burns GPU time now, so a prune problem must never stop it.
Every action prints one line: applied: <Kind>/<name>, replaced: …,
pruned: …, or a sentence on stderr for a refusal.
rendered/
rendered/
pipelines/<id>.yaml # ConfigMap htr-pipeline-<id> + Job htr-warmup-<id>
campaigns/<name>.yaml # ConfigMap campaign-<name> + the campaign's Indexed Job
sync.yaml # ConfigMap htrflow-campaigns-render: the render's digest
The campaigns repo's CI renders this on main and commits it. Never edit
it by hand.
- Only
htrflow-campaigns applyapplies it. Neverkubectl applyit, and never let Argo CD apply it. A finished campaign's Job is deleted at its TTL while its file stays inrendered/, and a tool that makes the cluster match the directory creates that Job again and runs every volume again. It would also skip every check above. - Every object is an Argo CD Skip hook. Each object under
pipelines/andcampaigns/carriesargocd.argoproj.io/hook: Skip. An Application that syncsrendered/applies, heals and prunes none of them. - The committed
rendered/is the record.--outsays only where a render is written.validateandrenderalways hold the repo against its own committedrendered/, so rendering into a fresh directory does not make a running campaign new. renderremoves files it did not produce. Deletingcampaigns/<name>.yamldeletesrendered/campaigns/<name>.yamltoo.renderwrites the whole render beside--outand moves it in only when it is complete. Anything in--outother thanpipelines/,campaigns/andsync.yamlis left alone.- The static shape is in the skeletons. The four objects are real YAML
under
manifests/, with placeholders thatrender.pyfills in.
With Argo CD
Argo CD syncs one object and runs the command as a hook:
- The Application's source is the campaigns repo, with
directory.recurse: true,directory.includeset to{rendered/sync.yaml,argocd/*.yaml}, and automated sync on. rendered/sync.yamlis a ConfigMap holding a digest of the render. A render that changes anything changes it, so the Application goes OutOfSync and syncs. Without it, an Application whose other objects are all Skip hooks is never OutOfSync. It carries no converter label, so a prune leaves it alone.- The hook is a
PostSyncJob that runshtrflow-campaigns apply --pruneon a checkout of the campaigns repo (The hook manifest).
A refresh, a self-heal or a re-sync with no new render changes nothing.
Cancelling is the command's --prune: Argo CD never applied a campaign
object, so it has none to prune.
An Application that already applied rendered/ itself
Such an Application tracks every campaign object it applied. Once a
render makes them Skip hooks, its next sync with pruning on may delete
them. Turn its pruning off, or delete it with
argocd app delete --cascade=false, before the first such render
reaches it.
The hook manifest
htrflow-campaigns init writes it as argocd/apply.yaml
(in the template).
It is a PostSync Job on the htrflow-campaigns image with three steps:
- An init container clones the campaigns repo over HTTPS with dulwich (pure-Python git; the image has no git binary and no shell).
- A second init container runs
htrflow-campaigns validate --rendered /repo. - The main container runs
htrflow-campaigns apply --prune --namespace <the hook's namespace> /repo.
What to set:
| Setting | Value |
|---|---|
REPO_URL, REPO_BRANCH |
The clone step's env: the campaigns repo's HTTPS URL and the branch CI renders on. |
HTRFLOW_APPLIED_BY |
The apply container's env: argocd-hook/<application>. It becomes the record's applied-by; without it the record says unknown. |
Secret htrflow-campaigns-git, key token |
A read-only token for the repo, in the release namespace: kubectl -n <namespace> create secret generic htrflow-campaigns-git --from-literal=token=<token>. |
Chart apply.rbac.enabled=true |
The ServiceAccount the Job runs as. |
Chart apply.gitCidrs (and apply.gitPorts if not 443) |
The egress rule to the git host, by address. The hook's default-deny NetworkPolicy opens only DNS and the API server. |
Why validate --rendered. The hook clones the tracked branch, not the
revision Argo CD synced, because a hook Job cannot reliably learn that
revision. So the check renders the checkout and refuses it unless the
result matches the committed rendered/, compared as parsed objects. A
commit merged after CI's last render fails the hook and applies nothing.
CI's render commit for it then changes sync.yaml and starts the next
sync. The check also fails when CI's CONVERTER_REF and the hook's image
render different objects, so keep them on the same converter release.
Exit codes in a hook. The hook fails the sync on any non-zero exit.
Exit 3 still changed the cluster, and its summary line names what is left
to fix. Read the summary line rather than the code
(Exit codes).
Pausing
suspend: true in a campaign file renders spec.suspend: true on its Job.
But Kueue owns spec.suspend on a Job it has admitted and undoes any edit
within seconds. So the apply enforces the pause on the Workload instead
(Queueing → Pause):
- Every campaign's Workload gets
spec.active:falsefor a suspended campaign,trueotherwise. The patch is idempotent. - Deactivating evicts the campaign's pods and keeps every finished
index.
kubectl get jobthen showssuspend: true. - Reactivating continues at the next unfinished index.
active: trueis written for every unpaused campaign. A Workload Kueue deactivated on its own (a requeue limit,maximumExecutionTimeSeconds) is re-admitted at the next apply. Nothing stays paused unless git says so.- A new paused campaign has no Workload yet when its Job is created.
The apply waits up to
--pause-waitseconds for it, and exits1if it never appears: re-run the apply. An unpaused campaign whose Workload is missing is skipped, since a Workload that does not exist is not admitted. - Resuming a campaign Kueue never admitted keeps its Job suspended
through the apply, under a second field manager
(
htrflow-campaigns-suspend), until Kueue admits it. - A campaign Job the API server refused still has its pause synced. A converter upgrade can make every live campaign Job refused, and a paused campaign must still stop.
With Argo CD, a merged suspend: true takes effect on the sync its render
triggers.
A campaign committed as suspend: true still runs one pod for a few seconds
Kueue creates, admits and unsuspends the Job in the same second it is created, before the apply can reach it. One pod starts and is deleted a few seconds later. Nothing is written for that volume, and the index runs again when the campaign resumes.
Rendering a paused Job without the Kueue queue label, so Kueue never sees it, does not work. It strands an admitted Workload that keeps the campaign's quota, and Kueue refuses to put the label back on resume.
Prune: cancelling a campaign
Deleting a campaign file removes its rendered file, but the objects stay in the cluster until an apply prunes them:
apply --prunelists every Job and ConfigMap in the namespace that carries the converter'smanaged-by=converterlabel and deletes the ones this render did not produce. Every object the converter renders carries that label. So does the status record, which a prune removes together with its campaign.- It is opt-in. Without
--prune, a deleted campaign's Job simply stays. The Argo CD hook always passes it. - Never prune from a partial checkout. Anything missing from the render is deleted.
- An empty render is refused. A render with no campaigns at all under
--prunelooks like an emptycampaigns/, a mistyped directory or a checkout that never happened. Pass--allow-emptywhen retiring the last campaign is what you mean.--dry-runstill shows what such a prune would delete. - A deleted pipeline file takes its ConfigMap and warm-up Job with it.
- A pipeline
pipeline addmade is kept: no file renders it. Seepipeline add.
Results in S3 are never touched by a prune, or by anything else here.
One apply at a time
Two applies at once, such as the Argo CD hook and a hand-run apply, would interleave. A prune from the older checkout would delete what the newer one just created. So:
applyholds thecoordination.k8s.ioLeasehtrflow-campaigns-applyin the namespace for its whole run, and releases it however it ends, SIGTERM included.- A second apply while the Lease is held sends nothing, names the holder
and exits
1. Re-run it once the first is done. - The holder renews the Lease as it goes. A Lease not renewed for ten minutes belongs to an apply that died, and the next apply takes it over.
- An apply that cannot renew, or finds the Lease taken over, stops and
exits
1, since another apply may be running. - Both the renewal times and the takeover judgement use the API server's clock, never the local machine's.
Every request has a 5 s connect and a 60 s read timeout, so a half-closed connection cannot hang an apply for ever. A 429 or a 5xx is retried three times, after 1, 2 and 4 s, since a control plane being upgraded answers with those; any other refusal is an answer about the request and is not retried.
--namespace guards the other side: the chart's policies match the release
namespace only, so an apply from a kubeconfig with wider rights must not
put Jobs anywhere else. The Argo CD hook passes its own namespace.
Checks apply makes against the cluster
validate and render hold a repo against its committed rendered/. That
can be missing or behind the cluster, so apply checks the live objects as
well.
Append-only and immutable recipes
What apply finds |
What happens |
|---|---|
| A campaign's live ConfigMap has a different volume list, pipeline or image | campaign <name> is in the cluster with different …, nothing is applied, exit 1. |
| A pipeline's live steps or size differ, and a campaign Job that has not ended mounts it | pipeline <id> is in the cluster with different steps and campaigns … still run it (or with a different size), nothing is applied, exit 1. Steps are compared parsed. |
| A ConfigMap it may not read | Stops the same way: a check that cannot be made has not passed. |
The rules themselves are in Campaign & Pipeline YAML → Immutability.
Changing a running campaign's window
Kueue compares an admitted Job's pod count (the smaller of parallelism
and the volume count) with its Workload. When they differ, it stops every
running pod and queues the campaign again. So:
validateandrenderprint awarning:when a window change would change a rendered, unpaused campaign's pod count, and go on.applyrefuses the change for a live, unpaused campaign Job:campaign <name> runs <n> pods at a time and would now run <m> … nothing was applied, exit1.
The safe way: pause the campaign, change the window once the pause is applied, then resume it. A paused campaign's Workload is updated in place.
A size's flavor against the cluster's
A campaign whose size names a flavor is sent only while converter.yaml's
flavors are the ClusterQueue's, names and node labels alike. apply reads
the LocalQueue converter.yaml names, its ClusterQueue and each of that
queue's ResourceFlavors, by name. When they differ, the campaign is left as
it was with a sentence naming each difference (a flavor one side has and the
other does not, a label value spelt otherwise), and the apply exits 3.
Campaigns with no flavor, and the pipelines, go out as usual. An identity
that may not make those reads (a kubeconfig without the rights the chart's
apply identity has) gets a warning, and the campaign goes out unchecked.
Several sorts of GPU
says why a difference matters.
Two campaigns on one volume
Results are keyed by pipeline and volume, so two campaigns on one pipeline
must not run the same volume at once. apply compares each campaign's
volume ids with those of every other campaign on the same pipeline whose
Job has not ended, and with campaigns earlier in the same apply. A campaign
that shares one is left as it was, named with the other campaign and the
volumes, and the apply exits 3. Once the other campaign has ended, the
next apply sends it. If the running campaigns cannot be read, each new
campaign is held back the same way.
Listing a failed volume again in a new campaign is how it runs again, and that is fine once the old campaign is over.
Finished campaigns are not run again
apply reads each campaign's live Job and its status record
(campaign-<name>-status) before it sends anything:
- A live Job decides. While the Job exists, it alone says whether the campaign is over, and its ending is written into the record.
- With no Job, the record decides. A campaign the record says is
Succeeded,FailedorPartiallyFailed, whosevolumes.txthas not moved, is left alone:campaign kyrk finished <date>, unchanged, left alone (120/120 volumes). - A record counts only if it names this campaign's Job.
applystamps the uid of the Job it created on the campaign ConfigMap (job-uid). A record about another Job, or about a Job never created, is not believed:applysays so and applies the campaign. A ConfigMap from before the stamp existed carries no uid, and its record is believed. - Left alone is not left unrecorded. A finished campaign's ConfigMap
that lacks the stored Job (
job-spec) a retry copies, or ajob-uidwhile its Job is still there, gets the missing one and nothing else: its volumes, its apply time and its Job stay as they were. - A Job or record that cannot be read leaves that campaign exactly as
it is, named, with exit
3. - There is no
--force. A campaign that should run again is a new campaign file.
What the record holds is in Campaigns → The record.
When the API server refuses an object
apply sends each object on its own. One refusal is one object's
problem: it is named on stderr, everything else is still applied, and a
summary line lists what was left unchanged. A campaign's ConfigMap and Job
are one change: if the Job's dry run is refused, the ConfigMap is kept as
it was too.
An object that already exists without the converter's managed-by: converter
label (made by hand, or by something else) is never overwritten, whoever runs
the apply: it is refused as … exists … and was not made by htrflow-campaigns,
and so are the campaign pair and the Jobs that would mount it. Rename the
pipeline or campaign, or remove that object.
Job htr-warmup-demo: the pod template changed and a Job's pod template is immutable once the Job exists — a pipeline id is a permanent name for a recipe, so a changed recipe is a new pipeline file, and a Job that has to change is deleted and created again
2 of 6 objects were refused by the API server and are unchanged: ConfigMap/campaign-kyrk, Job/kyrk — the other 4 were applied (exit 3)
A pause sync that did not reach a Workload (the patch was refused, or a
refused Job's live Job could not be read) has its own closing line:
the pause sync did not reach the Kueue Workload of Job/kyrk; see above
(exit 1). The code is 1 when git says that campaign is paused, since the
pause is not enforced, and 3 when it only had to stay running (unless
something else in the apply already makes it 1).
A Job's pod template is fixed once the Job exists. Two different changes
move it: a recipe edit, and a converter upgrade or converter.yaml setting
that reaches every Job (the RuntimeClass, the Hub token's env). They get
different answers:
- A warm-up Job is replaced. It holds no state beyond its marker on the
cache PVC, so
applydeletes it, creates it again and printsreplaced: Job/htr-warmup-<id> — its pod template changed ….- A warm-up that is running is left alone and reported, since
deleting it would kill the download. If the change was to the recipe,
re-run the apply once the old warm-up finishes, before the new
recipe's campaigns run out of
warmup_wait_seconds. - A warm-up that has failed is replaced even with an unchanged
template, since a failed Job never runs again:
replaced: Job/htr-warmup-<id> — it had failed ….
- A warm-up that is running is left alone and reported, since
deleting it would kill the download. If the change was to the recipe,
re-run the apply once the old warm-up finishes, before the new
recipe's campaigns run out of
- A campaign Job is never replaced. Its completed indexes are the
campaign, and deleting it would start every volume over. It is reported,
left as it is, and the apply exits
3. Its pause still follows git.
Exit codes
The codes are a precedence, highest first: 1 beats 3 beats 0.
| Exit | Meaning |
|---|---|
1 |
A pause is not enforced, whatever else was applied: a paused campaign's Workload never appeared, the Workload patch failed, or its Job was refused and could not be read. Or nothing reached the cluster: no credentials, an unreachable API server, a render that did not pass, every object refused, the Lease held by another apply, a --namespace mismatch, or a live-cluster check that failed. Or the API server stopped answering part-way, or the Lease was lost: the line names the object the apply stopped at, and a re-run finishes the job. |
3 |
Some objects were refused and are unchanged, a prune could not delete some, a campaign's Job or record could not be read, or a campaign was held back for sharing a volume. Everything else was applied, and every pause holds. The summary line names each one. |
0 |
Everything was applied. |
export has its own codes, set out above:
0 when no volume failed or was forbidden, 1 when one was or the run
stopped, 2 when it was refused before any request.
retry has its own codes, set out above.
validate and render exit non-zero on any problem, with one line per
problem (When something is wrong).