The toolbox that all katoptra mirrors include from a URL.
This repository has one rule: the code that all mirrors use is here, one time. This code does these tasks:
- It starts each run in a container.
- It gets the secrets of the run.
- It does a check of the pipeline, and it gives a report of each run.
- It moves the bytes. Each transport has one engine here:
engines/rsync.ymlcopies an rsync upstream into a bucket.engines/proton.ymlcopies a staging tree into Proton Drive.
A mirror contains only its identity, the hooks that it fills, and the verbs that no other mirror uses.
This file is the manual. The README of a mirror gives the upstream that the mirror copies, and the steps to fork it. For each part that all mirrors use, it has a link to this file. Read this file one time, from the start to the end. This gives you the full system. After that, you can read each section without the other sections.
flowchart LR
subgraph up["Upstreams"]
u1["Trees over rsync, for example CTAN and GNU"]
u2["GitHub, over the API and git"]
u3["Dropbox, over the API"]
end
sched["katoptra/dispatch<br/>workflow_dispatch, on a schedule"] --> host
subgraph job["One GitHub Actions job per run"]
host["The runner: task sync"] --> box["The toolbox image: task pipeline"]
end
vault["A 1Password vault<br/>one item per mirror"] -. "op run: values, by name" .-> host
u1 --> box
u2 --> box
u3 --> box
box --> r2["An S3-compatible bucket, R2<br/>a public mirror: the mirror itself<br/>a Proton mirror: the session, and state"]
box --> pd["Proton Drive<br/>the private mirrors' sink"]
r2 --> cf["A custom domain on the bucket"] --> cl["tlmgr, browsers, anyone"]
box --> hc["healthchecks.io, one ping per run"] --> site["katoptra.org, live status"]
Each mirror has the structure of this diagram, with one upstream and one sink. There are two types of mirror:
- A public mirror copies an rsync tree into a bucket, and a domain serves the bucket. The bucket is the mirror.
- A private mirror copies an account into Proton Drive. Its bucket contains only the data that the next run uses.
The two types of mirror do a run with the same steps:
- The scheduler starts a job on GitHub Actions.
- The job downloads one image.
- The job gets the secrets of the mirror from a vault, with the name of each secret.
- The job runs
task pipelinein the image.
The same command runs on a laptop.
flowchart TB
subgraph mirror["A mirror repository"]
tf["Taskfile.yml<br/>root vars: the identity<br/>the hooks it fills, verbs of its own"]
opv["op.env<br/>op:// references, the vault by UUID"]
rt["render.txt<br/>the committed dry run"]
cw["sync.yml, check.yml<br/>ten-line callers"]
end
subgraph lib["katoptra/lib, at v2"]
tb["toolbox.yml<br/>menu, image, run, op, sync, plan, check<br/>clock, due, reconciled, report, ping, failed"]
en["engines/rsync.yml, engines/proton.yml<br/>the pipeline vocabulary, one file per transport"]
im["ghcr.io/katoptra/toolbox:rsync-v2, :proton-v2<br/>docker/, toolchain.lock.toml"]
wf[".github/workflows/sync.yml, check.yml<br/>.github/actions/toolbox"]
end
tf -- "includes, flattened" --> tb
tf -- "includes, flattened" --> en
tb -- "runs pipeline inside" --> im
cw -- "uses, at a release SHA" --> wf
wf -- "task sync, task check" --> tb
| Layer | Location | Contents | Does a mirror change it? |
|---|---|---|---|
| Toolbox | toolbox.yml |
How a run starts, gets its secrets and stays in its container. The render, the check and the report of a run | No. A mirror includes it with no change |
| Engine | engines/<transport>.yml |
How bytes move for one transport, and the sequence of the pipeline | Yes, to fill a hook: it puts the hook in excludes:, and it writes the hook |
| Image | docker/, toolchain.lock.toml |
All the tools that a run uses. Each tool has a pin: its checksum | No. The engine gives the image, or the mirror gives it in IMAGE |
| Workflows | .github/workflows, .github/actions/toolbox |
How Actions installs the tools and runs task sync and task check |
No. Each caller in a mirror has only a small number of lines |
| Mirror | The repository of the mirror | The identity, the hooks, and the verbs that no other mirror uses | Yes, always. These items are all that a mirror contains |
A run has the same steps on a laptop and in Actions. On a laptop, the run starts at
task sync.
sequenceDiagram
participant D as katoptra/dispatch
participant W as sync.yml (reusable)
participant H as Host: task
participant O as op run
participant C as Container: task
D->>W: workflow_dispatch of the mirror's caller, no inputs
W->>W: toolbox action installs task and op from the lock
W->>H: task sync -- RECONCILE=... MAX_BATCHES=...
H->>H: image: pull IMAGE if missing
H->>O: op: op run --env-file=op.env
O->>C: run: engine run --rm -v repo:/work -e NAME ... IMAGE task pipeline
C->>C: clock
C->>C: the engine's verbs, and the mirror's hooks
C->>C: report, then ping
alt the pipeline failed
C->>C: failed: report STATUS=failed, then ping-fail
else the runner cut the run off
W->>C: task op -- task failed
else .run/chain exists
W->>W: gh workflow run, the workflow file of the caller: the next run
end
The diagram does not show these four items:
- Secrets go into the container with their names, not with their values. On the host,
op rungets the value of each reference inop.env, and it exports the values. For each name inop.envand inPASS,rungives-e NAMEto the container. It also always givesHEALTHCHECK_URL,GITHUB_STEP_SUMMARYandGITHUB_RUN_ID. Thus, no value shows on a command line or in a log. Secrets gives the full path. - A run reads the vault one time. One
op runcommand runs all of the pipeline, and the failure path,failed, runs in the same container. Thus, a run with a failure does not read the vault a second time. If the runner stops a run, at the timeout or because a person cancels it, the run does not start its failure path. Then the workflow runsfailedagain from the host, and this reads the vault one more time. Such a run does not occur frequently. - In the container, task does not use the network for its Taskfiles. The image sets
TASK_REMOTE_OFFLINE=1. The.task/remotecache of the mirror goes into the container with the repository. planissyncwith only the read-only half. It runsplan-pipeline, notpipeline, in the same image and with the same secrets.
toolbox.yml contains the verbs that all mirrors have, with all engines. Half of these
verbs run on the host and start the container. The other half run in the container: they
are the first steps and the last steps of each pipeline.
flowchart TB
sync["task sync -- K=v"] --> op
plan["task plan -- K=v"] --> op
op["op: op run --env-file=op.env, when the file exists"] --> run
run["run: engine run --rm -v repo:/work -e NAME ... IMAGE"] --> image["image: pull IMAGE unless it is present"]
run --> pipeline["task pipeline, inside the image"]
run --> pp["task plan-pipeline, inside the image"]
pipeline -- "on failure" --> failed["failed: report STATUS=failed, then ping-fail"]
check["task check"] --> render["render: task --dry --force pipeline<br/>inside the image, over an empty .run"]
render --> image
render --> d["diff against render.txt"]
ru["task render-update"] --> render
ru --> cp["accept it as render.txt"]
These verbs are the contract that the workflows use and that the menu shows. A mirror does not override them.
| Verb | Function |
|---|---|
default |
Prints the menu in groups, from NAME, DESC and MENU |
sync -- [K=v ...] |
op -- task pipeline: one run. The words after -- are task vars for the pipeline in the image |
plan -- [K=v ...] |
op -- task plan-pipeline: the read-only half |
check |
render, then a diff with render.txt |
render-update |
render, then it copies the render to render.txt |
render |
task --dry --force pipeline in the image, with an empty .run. It writes the result to .run/render.txt. No secret goes into the container |
run -- <cmd> |
Runs a command in the image, with the repository at /work |
op -- <cmd> |
run, in op run --env-file=op.env if the repository has op.env |
image |
Downloads IMAGE. While the host has the image, it does not download it again |
image-build |
Builds IMAGE from LIB_DIR/docker/<variant>.Dockerfile. The default LIB_DIR is ../lib |
image-clean |
Removes IMAGE |
clean |
Deletes .run, staging and the Taskfile cache, and no other file |
The container runtime (ENGINE) is Apple container when its daemon operates. If not, it
is Docker. ENGINE=docker selects Docker. A host has only these tools:
- go-task
- The container runtime
- The 1Password CLI, for a task that reads the vault.
These verbs are the first verbs and the last verbs of each pipeline. A mirror or an engine can override one of them with a verb of the same name.
| Verb | Function |
|---|---|
clock |
Writes the start epoch of the run to .run/start.txt. It deletes the chain file and the .run/reconcile of the last run |
due |
Finds if this run does a reconcile, from the time of the last reconcile in .state/reconciled. If it does, due writes .run/reconcile. due-rule is the rule, and it does not download the file |
reconciled |
Records the start of this run in .state/reconciled. An engine runs it when it completes a reconcile |
report |
Adds the run summary to the job page in Actions. If it does not run in Actions, it writes the summary to stdout. The report has the rows of the toolbox first, then the rows of report-engine, then the rows of report-mirror |
report-mirror |
A hook, empty here, for the rows of the mirror |
ping |
Sends a GET to HEALTHCHECK_URL. If HEALTHCHECK_URL is empty, it sends no request |
ping-fail |
Sends a GET to HEALTHCHECK_URL/fail. If HEALTHCHECK_URL is empty, it sends no request |
failed |
The failure path: report STATUS=failed, then ping-fail |
sync runs pipeline in one container. If the pipeline has a failure, sync runs
failed in the same container. The report is one table with three layers. Each row is a
| Label | value | line that report adds to the same file. The rows are in this
sequence:
- The rows of the toolbox. They give the start time and the length of the run, and the image. They also give the time of the next run: immediately with the chain, or at its next slot.
- The rows of the engine
- The rows of the mirror.
Each row operates if a file is missing, because the report also runs after a pipeline with a failure.
A reconcile compares the mirror with its destination, not with its state. The time of the
last reconcile gives the time of the next one. The start time of a run does not give it,
because the scheduler is not in the mirror, and its times can change. A pipeline that does a
reconcile runs due near its start. Its reconcile step runs only if .run/reconcile is
there.
| Var | Default | Function |
|---|---|---|
RECONCILE |
auto |
Each run can set it: task sync -- RECONCILE=true. With true, the run does a reconcile. With false, it does not. With auto, it does a reconcile if no run recorded a reconcile. It also does one if the last reconcile occurred RECONCILE_HOURS or more before the run |
RECONCILE_HOURS |
24 | The number of full hours between two reconciles. For a different interval, a mirror sets it in its root vars |
.state/reconciled contains the start epoch of the run that completed the last reconcile.
The pull and push of the engine read and write this file. Thus, with the rsync
engine, the file has xz compression, the same as the state.
The limit is half an hour less than RECONCILE_HOURS. A daily run starts some seconds
before or after the time that is 24 h after the last run. With a limit of 24 h and no
less, a reconcile occurs only on each second day.
In these conditions, a run records no reconcile, and the reconcile is also necessary at the next run:
- The run stops with a failure before it completes its reconcile.
- An rsync run has batches that wait for a subsequent run. Its reconcile waits for the run of the chain that uploads the last batch.
| Name | Type | Function |
|---|---|---|
NAME, DESC |
Vars in includes:, necessary |
The title and the line of the menu |
IMAGE |
Var in includes:, necessary if the engine does not give it |
The image in which the run operates. If there is a value in includes: and also in the engine, the run uses the value in includes: |
pipeline, plan-pipeline |
Tasks, necessary | The full run and its read-only half, in the image. An engine supplies the two tasks. A mirror with no engine, or that puts them in excludes:, writes them |
report-engine |
Task, necessary | The rows of the engine in the run summary. report runs it. An engine supplies it. A mirror with no engine, or that puts it in excludes:, writes it, with no commands if it has no rows |
op.env |
File | op:// references, one for each secret. With no op.env, the environment has the values before the run starts. On a laptop, these are the exported values. In Actions, the secrets of the caller go into the container with the names in PASS |
PASS |
Var in includes: |
The names of host environment variables that go into the container. The names in op.env also go into the container. Task vars are not environment variables: they go after -- |
MENU |
Var in includes: |
More lines for the menu, one for each verb that only this mirror has |
report-mirror |
Task | The rows of the mirror in the run summary. The mirror puts it in excludes: in the toolbox entry of includes: |
LIB_DIR |
Var in includes: |
The location where image-build finds docker/. The default is ../lib |
RECONCILE_HOURS |
Root var | The hours between two reconciles, if not 24. Refer to When a reconcile runs |
pull, push |
Tasks | The tasks that due and reconciled use to read and write .state/reconciled. An engine supplies the two tasks |
excludes: |
Key in includes: |
The verbs of lib that the mirror overrides. Refer to Changing it |
An engine is a second entry in includes:, with the verbs for one transport. The names of
these verbs are the same in all engines, and a mirror cannot use these names for a
different verb. Thus, the pipeline of a mirror has the same verbs with each engine.
An engine reads the root vars of the mirror and the environment. It has no vars: default
for a var that a mirror sets. With such a default, the run uses the default, not the value
of the mirror. Each var that a mirror can set has its default inline:
{{.X | default N}}.
IMAGE is different. The engine sets it in vars: as {{.IMAGE | default "..."}}. Thus,
the run uses an IMAGE in the toolbox entry of includes:, not the default of the engine.
If a mirror sets IMAGE in its root vars, the run does not always use that value.
The hooks are the empty verbs of an engine, for a mirror to fill. For each hook that a
mirror writes, the mirror puts the hook in excludes: in the engine entry of includes:.
To add to a verb, use a hook, not a copy. For example, a mirror that wants the smoke of
the engine and more checks writes smoke-mirror. smoke runs smoke-mirror last.
engines/rsync.yml copies an rsync upstream into an S3 bucket. It compares two listings,
and it does not keep a local copy of the tree. The runner has 14 GB of disk, and CTAN has
140 GB. A run does these steps:
- It makes a listing of upstream.
- It compares the listing with the state file that the last run put in the bucket.
- It copies only the delta, in batches.
checkpointrecords each batch in the state before the next batch starts.
flowchart LR
clock --> due --> list --> state --> rebuild["rebuild<br/>only if the state was missing"] --> diff --> split --> prepare --> batches
subgraph b["batch, for each of the first MAX_BATCHES"]
direction LR
fetch --> verify --> publish --> checkpoint
end
batches --> b --> delete --> reconcile["reconcile<br/>when due"] --> index --> smoke --> report --> ping
smoke --> fr["fresh"]
smoke --> sm["smoke-mirror"]
report --> re["report-engine"] --> rm["report-mirror"]
classDef hook stroke-dasharray: 5 5
class prepare,verify,index,sm,re,rm hook
The verbs with a broken line are hooks. These hooks do no work until a mirror sets a value:
prepareandverifydo no work untilTL_KEYhas a value.indexdoes no work untilINDEXhas a value.smoke-mirrordoes no work until a mirror fills it.
| Verb | Function |
|---|---|
pipeline, plan-pipeline |
The verbs in the diagram, in sequence, and the read-only half: clock, list, state, diff, split |
list |
rsync -rL --list-only of SOURCE, with FILTER. normalise writes it to .run/upstream.txt as path TAB size TAB mtime, in the sequence of byte values. A listing that does not have more lines than LIST_FLOOR stops the run. If rsync cannot read a directory, it exits 23, and retry accepts exit 23: the run continues. The listing then does not have the files of that directory, and delete removes them from the bucket, the same as files that upstream deleted. When rsync can read the directory again, a run fetches its files again |
state |
Downloads .state/applied.txt.xz from the bucket. If the file is missing, state tells rebuild to make it |
rebuild |
Makes a listing of the bucket. The new state has each key that the bucket contains with the number of bytes that upstream gives. It uploads the new state as one PutObject |
diff |
Writes changed.txt, deleted.txt and paths.txt. changed.txt has the lines that upstream has and the state does not have. It also has the file of each changed signed sha512. deleted.txt has the paths that the state has and upstream does not have |
split |
Rejects a tree that is larger than CEILING_GB, or a file that the disk cannot contain. It divides the delta into batch-NNNN.txt files of BATCH_GB each, with the decision batch last |
prepare |
A hook. If TL_KEY has a value and the delta has a TL path: it fetches the tlpdb and compares its signature with the pinned key |
batches |
Runs fetch, verify, publish and checkpoint for each of the first MAX_BATCHES batches. If there are remaining batches, it makes the file .run/chain |
fetch |
Copies the files of the batch into staging/ with rsync, and copies the target of each symlink. If upstream deletes a path after the listing, fetch does not copy it |
verify |
A hook. If TL_KEY has a value: it compares each signed file and each container in the batch with the tlpdb |
publish |
Runs label on the batch. Then it runs aws s3 cp --recursive one time for each type, with the files of each type in a different tree. It sends each file as one PutObject, and it makes no listing of the bucket. The engine does not use aws s3 sync. The FRESH_KEY file goes last |
label |
Writes .run/labels.txt: the Content-Type of each file in staging, from its name and its first KiB. Refer to Content types |
checkpoint |
Runs merge to add the uploaded files to the state. Then it uploads the state as one PutObject, and it deletes the files in staging |
delete |
Removes the keys in deleted.txt from the bucket, 1,000 in each call, and removes them from the state. It waits for the run that uploads the last batch |
reconcile |
If due wrote .run/reconcile, it runs rebuild. Then it deletes each key that is not a path of upstream, a key in OWN, or a directory of the state. Then it runs reconciled |
index |
A hook. If INDEX has a value: it runs pages, uploads the two sets of keys, and removes the keys of the directories that became empty. Then it moves .state/indexed.txt.xz to the new state. A mirror with a different landing page overrides index |
pages |
Writes a page for each directory that the run changed, from the state, into staging. The keys with no slash at the end go in one tree for each depth |
smoke |
Reads a sample back from HOST. The list below the table gives each item of the sample. Then it runs fresh and smoke-mirror |
fresh |
If FRESH_KEY has a value: it reads the clock of upstream in that file, from HOST. If the clock is more than FRESH_HOURS before the time of the run, it stops the run |
smoke-mirror |
A hook, empty here. A mirror with more items to read back writes it |
report-engine |
A hook: the rows of the engine in the run summary |
retry |
Runs a command a maximum of five times. After a transport error of rsync (exit 5, 10, 12, 30 or 35), it runs the command again, with backoff. It accepts exit 23 and exit 24, and the run continues. rsync exits 23 if it cannot read a path, and 24 if upstream deletes a file while rsync sends it. Thus, a directory that rsync cannot read does not stop the run, and delete removes its files, the same as files that upstream deleted. Only LIST_FLOOR stops a large loss |
The verbs in the table also run these verbs: normalise, pull, push, batch,
label-trees, merge and remove. pull and push move a key with xz compression
between the bucket and .run. A verb of a mirror can also run them.
smoke reads these items back from HOST, and it stops the run if one of them is not
correct:
- One key of each type that the run published. Its length in bytes must agree with the listing, and its Content-Type must agree with the label.
- The
FRESH_KEYfile, for its length.smokereads it only if the state has it. While the engine fills an empty bucket, the runs that stop atMAX_BATCHESdo not upload the decision batch. The bucket gives 404 for a key that it did not contain before. - If
TLhas a value:texlive.tlpdb.sha512. It must be the same as the verified copy. - If
INDEXhas a value: one page thatindexwrote, at its two keys. - If
CANARYhas a value: that file, read aslibwww-perl. It must be the same as the copy in the bucket, byte for byte. - The first
.tar.gzthat is not empty. It must have noContent-Encoding.
smoke reads one byte of an object, and it gets the length of the object from the
headers. It does not use a HEAD: Cloudflare sends no content-length for text/html.
smoke also finds each path in the listing of upstream that rsync did not send. Each run
fetches these paths again, the report counts them, and the first 20 are warnings on the
run.
flowchart LR
up["upstream, rsync --list-only<br/>path TAB size TAB mtime"] --> diff
st[".state/applied.txt.xz<br/>what the bucket holds, at upstream's size and mtime"] --> diff
diff --> ch["changed.txt<br/>upstream has, the state lacks"] --> split --> bt["batch-NNNN.txt"] --> batches
diff --> de["deleted.txt<br/>the state has, upstream lacks"] --> delete
batches -- "checkpoint, once per batch:<br/>merge what landed, one PutObject" --> st
delete -- "checkpoint" --> st
bucket["the bucket, listed"] -- "rebuild: join to upstream on size" --> st
reconcile -- "rebuild, then delete what neither<br/>upstream, OWN nor the state's directories own" --> bucket
An hourly run makes one listing of upstream, and no listing of the bucket. The state line
is path TAB size TAB mtime, with the path first, in the sequence of byte values. Each
sort, comm and join of a listing runs with LC_ALL=C, because with a different
sequence comm compares the incorrect lines. This has three results:
- The engine fills an empty bucket with no other step. If the state file is missing,
rebuildmakes it from a listing of the bucket. An empty bucket gives an empty state, and the delta of the first run is the full tree. Each run doesMAX_BATCHESbatches, and it starts the next run with the chain until the bucket is full. No flag is necessary. The cost of a missing state file is one listing. - The state records only the uploaded files.
mergecompares the batch with the files in staging. If upstream deletes a path afterlistruns and beforefetchruns, the path does not go into the state. Thus, the state has no key that the bucket does not have. RECONCILEis the check on a mirror that serves clients. A reconcile always makes the state from the bucket again, one time in eachRECONCILE_HOURS(the default is 24). Then it deletes the keys that upstream and the state do not have.
The engine writes the state one time for each batch, after it uploads the batch, as one
PutObject. If
a run stops before its end, the next run does one batch again, at most. A run that stops
at MAX_BATCHES with remaining batches is not a failure.
The bucket is the one item that a mirror cannot make again. The engine makes all other data again from the bucket and from upstream. This includes the state file and the staging tree.
split puts these files in the last batch: the files that a client reads to find the files
to fetch. These files are the tlpkg/ of the signed subtree and each file at the root of
the bucket, for example timestamp. Thus, each container is in the bucket before the tlpdb
that gives its name. delete waits for the run that uploads the last batch. These are the
results:
- The tlpdb that clients read does not give the name of a container that
deleteremoved. - If
tlmgrruns while the engine publishes a batch, it gets the last tlpdb. It does not get a tlpdb that gives a file that is not in the bucket.
If TL and TL_KEY have values, the engine verifies the signatures of TeX Live before it
publishes a file. prepare runs after split, before the first batch. verify runs on
each batch, before the engine uploads the batch.
The engine fetches the keyring from the same mirror as the signature, and it reads the
keyring as a plain file. Thus, gpgv imports no key before the check. Only the pinned
fingerprint is a check that the mirror cannot change: VALIDSIG must have TL_KEY at its
end. The engine also must find GOODSIG, because gpgv shows an expired or revoked key as
VALIDSIG with exit 0. tlmgr accepts such a key, but the engine does not.
flowchart TB
subgraph p["prepare: once a run, when the delta touches TL"]
f1["fetch tlpkg/texlive.tlpdb.xz, .sha512, .sha512.asc, gpg/pubring.gpg"] --> f2["shasum -a 512 -c"] --> f3["gpgv: GOODSIG, and VALIDSIG ending in TL_KEY"]
end
subgraph v["verify: every batch, before publish"]
v1["every *.sha512 in the batch: shasum, then gpgv against the same keyring"]
v2["every archive/ container: its checksum in the verified tlpdb, under both of its names"]
v3["the decision batch: the tlpdb equals prepare's, the .xz decompresses to it,<br/>every container it names is in the bucket after this run"]
end
p --> v
verify does these checks:
- It verifies each signed sha512 in the batch:
texlive.tlpdb.sha512, and each sha512 at the root of the tree, for example theinstall-tl*.sha512andupdate-tlmgr-latest.*.sha512files. - It compares each container in the batch with the
containerchecksum,doccontainerchecksumorsrccontainerchecksumin the verified tlpdb. A source container is<name>.source.tar.xz. If the tlpdb does not give a container,verifystops the run. - In the decision batch,
texlive.tlpdb.xzmust decompress to the verifiedtexlive.tlpdb, byte for byte. Each container that the tlpdb gives must be in the bucket after this run.
Upstream keeps each container with a name that has its revision, for example
foo.r123.tar.xz, and a symlink foo.tar.xz points to it. fetch uses -L. Thus, a
mirror serves the two names, unless its FILTER removes the names with a revision. The
FILTER of tlnet removes them. verify finds the checksum for the two names. It gets the
revision from the revision line of the stanza in the tlpdb.
If a check finds a problem in a batch, the engine does not upload that batch, and clients
continue to get the last good copy. After the engine uploads the batch, smoke reads
texlive.tlpdb.sha512 back from the domain, and compares it with the verified copy.
tlmgr does the signature check again on the client.
R2 serves no directory listings. If INDEX has a value, index writes a page for each
directory that a run changed. It writes the pages from the state, not from upstream, and
each page has two keys:
<dir>/<INDEX>: a Transform Rule of the zone serves this key for/dir/.<dir>, with no slash at the end: this key serves/dir. A mirror on a file system sends 301 for that URL. The URL does not show if a name is a file or a directory. Thus, no Cloudflare rule can send 301.
A <base href> lets one document serve the two URLs. A file system cannot contain a/b and
a/b/ at the same time. Thus, pages puts the keys with no slash in one tree for each
depth, and index uploads one tree at a time. index gives these keys
--content-type text/html. Without it, a key with no suffix gets binary/octet-stream,
and a browser downloads the page and does not show it.
No page goes into the state. merge adds to the state only the files that are in staging
and also in the batch, and a page is in no batch.
.state/indexed.txt.xz records the state that the pages showed last. If a run stops before
it moves that file, the next run writes the same pages again. If you delete the file, the
next run writes all the pages again, one time. Thus, the pages of the directories that did
not change also get a change to the HTML of the page or to PAGE_FOOT. reconcile does
not delete the two keys.
The zone has one rule for each host:
when: ends_with(http.request.uri.path, "/")
rewrite path: concat(http.request.uri.path, http.host, ".directory.index.html")
This rule also serves the page of the root for /. In ctan, the rule does not include /,
because / serves the index.html of CTAN.
R2 serves the Content-Type that the object has in the bucket. Without a type in the
command, the AWS CLI uses the first half of the type that Python gets from the name. For
foo.diff.gz, Python gives a diff with gzip compression, and the CLI then stores
text/x-diff. A browser then shows the file as text.
The result of Python also changes with the image. Thus, the same type of file can have two
different Content-Types in one bucket. For these causes, label sets each type, and the
CLI sets no type:
- A name with a
COMPRESSEDextension (for example.gz,.tgz,.bz2,.xz,.Z,.zstand.lz) gets that compressed type. The other parts of the name have no effect. The types are the types of ftp.gnu.org. - An extension that Python reads as compressed, and that is not in
COMPRESSED, gets the type of its signature, orapplication/octet-stream. It does not get a text type. - If not, the
mime.typesof the image gives the type. But the name can give a type that a browser shows (text, XML, JSON, JavaScript, PDF), and the first KiB can show a different type. Then the bytes set the type. This occurs if the first KiB has a compression signature or a NUL, or if a PDF has no%PDF-. - A name with no type gets its type from the bytes. A known signature gives its type. If
the bytes have no NUL, the type is
text/plain. If they have a NUL, the type isapplication/octet-stream.
A control byte is not a NUL. Thus, a .sty file of CTAN with a ^Z at its end stays
text/x-tex. Three checks make sure that label obeys these rules:
offlinegives a set of names and bytes tolabel, and stops on each change. If an update of the image changesmime.types, this check shows it.smokereads one key of each type back from the domain, and compares the served type with the label.- The
.tar.gzcheck makes sure that tarballs have noContent-Encoding.
A mirror always sets SOURCE, BUCKET and HOST in its root vars. All other vars have an
inline default, and a mirror sets only the vars that are different:
| Var | Default | Function |
|---|---|---|
CEILING_GB |
0, no ceiling | split rejects a tree that is larger than this number of decimal GB |
BATCH_GB |
4 | Decimal GB in each batch. A larger file is a batch with no other file |
MAX_BATCHES |
4 | The batches in each run. The next run, which the chain starts, does the other batches |
LIST_FLOOR |
0, no guard | A listing that does not have more lines than this number is truncated, and the engine does not use it to delete keys. Set it to approximately 90% of the usual number of lines |
RECONCILE, RECONCILE_HOURS |
auto, 24 |
The vars of the toolbox: refer to When a reconcile runs |
RETRY_BASE |
15 | Seconds. Before retry i, retry waits RETRY_BASE * 2^i seconds, plus jitter |
TL, TL_KEY |
Empty | A signed TeX Live subtree, and the fingerprint of the key that signs it. If they are empty, the engine does no signature checks |
FILTER |
Empty | rsync filter arguments that make the listing smaller, for a mirror of a subtree |
OWN |
Empty | Keys at the root of the bucket that the mirror writes, not upstream. A space is between two keys. reconcile does not delete them |
INDEX |
Empty | The key suffix of the directory pages. If it has a value, index writes the pages. reconcile does not delete the pages, or a key with the name of a directory of the state |
PAGE_FOOT |
Empty | The HTML at the end of each directory page. %s is the encoded path of the directory, and %% gives one % |
CANARY |
Empty | A file that smoke reads from the domain as a Perl client, and compares with the bucket, byte for byte. All files can be the canary. A file with http:// links (not https://) or mailto: addresses also finds the HTML rewriters of the zone. The path must have no character that a URL encodes |
FRESH_KEY |
Empty | A file in which upstream records its clock, as Unix time or as YYYY-MM-DD-HH-MM (the format of CTAN). If it has a value, fresh stops the run when the clock is more than FRESH_HOURS before the time of the run |
FRESH_HOURS |
24 | Hours. This value is less than 28 hours: at 28 hours, the mirror checks of GNU and CTAN show a problem |
A var in the root vars: of a mirror has the same value for all runs. In an included verb,
the run uses a root value, not a KEY=value on the command line. A var that the mirror
does not set keeps its default, and each run can set it:
task sync -- MAX_BATCHES=8 RECONCILE=true.
Do not set a root var to the default of the engine. If you do, the command line cannot change that var.
The job log shows the step that stopped the run. The name of that step is the verb. The table in this section gives each verb of the engine that can stop a run, with the cause and the repair. The README of a mirror gives only the values of that mirror: its ceiling, its upstream, and the steps that only that mirror has.
To find the step, and the number of files that the mirror does not have, use these commands:
gh run list --workflow sync.yml --limit 5
gh run view <id> --log-failed | tail -50
curl -sI https://<HOST>/<FRESH_KEY> | grep -i -E 'last-modified|cf-cache-status'
aws s3 cp s3://<BUCKET>/.state/applied.txt.xz - | xz -d | wc -lCompare the number of lines in the state with the number of lines in the listing. The difference shows how many files the mirror does not have.
You can always start the run again, unless a row of the table tells you differently. A
second run writes the same bytes again, or it writes no bytes. To start it, use
gh workflow run sync.yml or task sync.
The state has the batches until the last checkpoint, and the second run calculates the
remaining delta again. While the engine fills an empty bucket, this second run continues
the work. On a laptop,
task sync uses op run. Thus, sign in to the 1Password CLI first.
| Verb | Cause | Repair |
|---|---|---|
image |
The toolbox could not download the image from GHCR. The mirror has no problem: the run uploaded no file, and the state did not change | Start the run again |
list |
The listing does not have more lines than LIST_FLOOR. The engine does not use a truncated listing to delete keys |
Start the run again. If the problem continues, examine upstream. If upstream became more than 10% smaller, decrease LIST_FLOOR |
retry, in list, prepare or fetch |
rsync gave a transport exit five times. If upstream moved, each run uses approximately ten minutes for the retries, and then it stops | Examine SOURCE. If upstream moved, change SOURCE in the Taskfile |
split: the ceiling |
The tree is larger than CEILING_GB. The mirror gets no new files until you increase CEILING_GB. A larger ceiling also increases the cost |
Increase CEILING_GB |
split: the disk |
One file of the delta is too large for the disk of the runner, with 1 GiB free for the other data. The log shows no room for <path>. CEILING_GB has no effect on this check |
Remove the file from the listing with FILTER, or give the run a larger disk |
prepare, verify |
A signature or a checksum did not agree. The engine uploaded no file from that batch, and clients continue to get the last good copy | If TeX Live changed its key, compare TL_KEY with https://www.tug.org/texlive/verify.html. Do not use a different source for the key |
smoke: the canary |
The domain served bytes that are not the bytes in the bucket, or it rejected a Perl client. A zone rule is missing, or the scope of a zone rule became wider. The bucket is correct | Examine the Configuration Rule of the zone. Refer to The parts of a bucket |
smoke: the Content-Encoding |
A .tar.gz had a Content-Encoding when smoke read it. Clients then decompress it while they download it |
Examine label and the AWS CLI version in toolchain.lock.toml. Refer to Content types |
smoke: the type or the length |
A key of the sample had a Content-Type that is not its label, or a length that is not its length in the listing. The CLI, R2 or the zone changed the object | Examine label and the AWS CLI version in toolchain.lock.toml. Then examine the rules of the zone |
fresh: the clock stopped |
The clock in the FRESH_KEY file did not change for more than FRESH_HOURS. Upstream stopped, or the mirror that SOURCE gives stopped. A secondary mirror can stop its sync, and rsync can continue to connect to it. The run copied all the bytes that it got |
Examine upstream. If SOURCE is a secondary mirror, set SOURCE to a different secondary mirror. If upstream stopped, no change in the mirror is necessary |
These procedures repair other problems:
-
The state is not correct. Use this procedure if the state file is damaged, or if you do not know if it is correct. It makes the state again from the bucket:
gh workflow run sync.yml -f vars='RECONCILE=true' # or: task sync -- RECONCILE=true
The run makes a listing of the bucket, and compares it with the listing of upstream. It accepts each object with the length that upstream gives as correct. Thus, if a file changed but kept its length while the state was not correct, the run does not find the change. It finds the change when upstream changes the file again.
rebuilduploads only the new state, and no file of upstream. This procedure is not necessary for a missing state file: the next run makes it. -
Fill an empty bucket. No flag is necessary. An empty bucket gives an empty state, and the delta is the full tree. Each run does
MAX_BATCHESbatches (4), and then it starts the next run with the chain. For more batches in each run, usegh workflow run sync.yml -f vars='MAX_BATCHES=8'. Before the engine fills an empty bucket, pause the healthcheck (refer to Monitoring). -
Delete one key. Use
aws s3 rm s3://<BUCKET>/<key>. While the Cache Rule of the zone bypasses the cache, the edge contains no copy to remove. If upstream has the key, the next reconcile finds that the key is missing, and the subsequent run fetches it again. Do not edit the state manually to get the key back before the next reconcile. An error in the state can make the engine delete or fetch the incorrect files. -
Write all directory pages again. Use
aws s3 rm s3://<BUCKET>/.state/indexed.txt.xz. The next run writes the page of each directory at its two keys. This procedure is safe: it does not change the files in the bucket. -
A directory URL gives 404. The page is at its key, with or without the zone rule. Use
curl -sI https://<HOST>/<dir>/<INDEX>. If this command gives 200 and the directory URL does not, the Transform Rule is missing, or its scope is not correct. -
Change a secret. Edit the
r2section of the item of the mirror in the 1Password app. Then usegh workflow run sync.yml && gh run watch. After a run with no failure, delete the previous R2 token in the Cloudflare dashboard. This procedure is safe: the first call with the new credentials only reads. -
Abort a multipart upload that did not complete. Find the upload. Then abort it:
aws s3api list-multipart-uploads --bucket <BUCKET> --query 'Uploads[].[Key,UploadId,Initiated]' --output text aws s3api abort-multipart-upload --bucket <BUCKET> --key <key> --upload-id <id>
This procedure is safe, and it has no cost. If you do not do it, R2 aborts the upload after 7 days.
engines/proton.yml copies a staging tree into one Proton Drive folder, with the
proton-drive CLI of Proton. It is for a mirror that can copy its full upstream in one run.
The mirror fills staging/, and the engine does all the other steps.
The bucket contains only the CLI session of the mirror. The engine keeps no other data there, because of these two facts:
- The CLI does not upload a file if Proton has its content.
-f create-new-revisionmakes a new revision of a file that changed.
Thus, the version history of Proton is the history of the mirror.
flowchart LR
clock --> session --> destination --> stage --> upload --> confirm --> prune --> report --> ping
report --> re["report-engine"] --> rm["report-mirror"]
classDef hook stroke-dasharray: 5 5
class stage,prune,re,rm hook
| Verb | Function |
|---|---|
pipeline, plan-pipeline |
The verbs in the diagram, in sequence. The read-only half runs to stage, and then prints the number of files and MB that the engine can upload |
session |
Downloads .state/session.tar.age from the bucket, decrypts it with MIRROR_AGE_IDENTITY, and puts the two session files from the tar into .run/session |
destination |
Makes a listing of the parent of MIRROR_PROTON_DESTINATION. It stops the run unless the parent has one folder of that name, and only one, with the UID MIRROR_PROTON_DESTINATION_UID |
stage |
A hook that each mirror writes. It fills staging/ with the files that Proton must contain |
upload |
Runs one filesystem upload -f create-new-revision -d merge -t --json of staging/* into the destination. It writes the summary to .run/upload.json |
confirm |
In the summary, transferredItems and skippedItems together must be equal to the files and folders in staging, and failedItems must be 0. It writes the result to .run/confirm.txt |
prune |
A hook, empty here. A mirror that moves to the trash the items that its upstream deleted writes it, with list-folder and trash |
pd |
Runs each CLI call, and writes the stderr of the call to .run/pd.err. After each call, with all exit codes, it examines the session. If the token changed, it seals the session back into the bucket |
list-folder, trash |
list-folder writes the JSON listing of a folder to OUT. trash moves the nodes at PATHS to the trash of Proton |
session-seal -- <dir> |
On the host: it encrypts the two files of a laptop login into the bucket |
empty-trash |
On the host: it deletes all the items in the trash of Proton, permanently, but only after you tell it to continue |
report-engine |
A hook: the rows of the engine in the run summary |
The verbs in the table also run these verbs: session-push, seal, age, pull, push
and empty-trash-pipeline. pull and push move a key between the bucket and .run,
with no compression. A verb of a mirror can also run them.
The stderr of list-folder goes to the log of the run, not to .run/pd.err. Its command
has || echo "[]" > OUT at its end. Thus, the stderr redirect of pd goes to the echo,
not to the CLI.
The engine reads no root var. It reads only the environment, with the names that the
op.env of each Proton mirror has:
| Name | Value |
|---|---|
MIRROR_PROTON_DESTINATION |
The CLI path of the destination folder, for example /my-files/GitHub |
MIRROR_PROTON_DESTINATION_UID |
The UID of that folder. Each run compares it before it writes the first file |
MIRROR_R2_BUCKET |
The bucket that contains the session |
MIRROR_AGE_IDENTITY |
The AGE-SECRET-KEY-... line that encrypts and decrypts the session |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_ENDPOINT_URL_S3 |
The credentials and the endpoint for s3. The proton mirrors use the _S3 name for the endpoint. The AWS CLI and boto3 read the two names, AWS_ENDPOINT_URL and AWS_ENDPOINT_URL_S3 |
Only a sign-in in a browser can start a session of the CLI. Proton does not accept a new
sign-in from a datacenter address. Thus, you make the session one time on a laptop, and
each run gets it from the bucket, encrypted. The CLI changes the refresh token when it uses
the session. Thus, after each CLI call, pd examines the session. If the token changed,
pd seals the session back into the bucket.
flowchart LR
subgraph laptop["Once, on a laptop"]
l1["proton-drive auth login<br/>with the session as plain files under .run/pd"] --> l2["task session-seal -- .run/pd<br/>tar, age-encrypt, push"]
end
l2 --> key[".state/session.tar.age<br/>in the bucket"]
subgraph run["Every run"]
r1["session: pull, decrypt, extract to .run/session"] --> r2["pd: one CLI call"] --> r3{"token rotated?"}
r3 -- yes --> r4["session-push: seal it back"]
r3 -- no --> r2
end
key --> r1
r4 --> key
To make a session, do these steps on a laptop:
-
On the Settings page of your Proton account, set telemetry to OFF. Do this for each Proton account.
-
Run these commands:
export PROTON_DRIVE_CACHE_DIR=.run/pd PROTON_DRIVE_CREDENTIALS_STORE=unsafe_file proton-drive auth login # a browser opens; .run/pd holds two JSON files after proton-drive filesystem create-folder /my-files <Name> # /my-files is the drive's own root, not a folder you make proton-drive filesystem list -j /my-files # the entry named <Name>: its uid is the destination UID task session-seal -- .run/pd # once the bucket and the vault exist
Install the CLI at the version that toolchain.lock.toml pins. The format of the session
file changes with the version, and the Linux binary in the image must read the files that
the laptop wrote. Put .run/pd in the repository, because the container gets only the
repository. git ignores .run/, and task clean deletes it.
Obey these three rules:
- Do not let two mirrors use one session. The CLI changes the refresh token at each call. Then Proton rejects the token that the other mirror has, and a new login is necessary.
- Do not run
task sync,task planortask empty-trashfrom a laptop while an Actions run can be in progress. The three commands use the one session. If the laptop and the run use it at the same time, a new login can be necessary. - Do not keep a history of the session in the bucket. A previous copy contains a token that the CLI changed, and the CLI cannot use that copy.
A mirror that uses a program for its work writes pipeline and plan-pipeline in its
Taskfile. Their steps are the clock, report and ping of the toolbox, and the steps of
the program. The mirror puts these two verbs in excludes: in the engine entry of
includes:. dropbox has this structure:
- It includes the proton engine, which supplies
session,session-seal,empty-trash,pullandpush. - Each step is one
python -m migrator <command>. - The Taskfile sets the sequence of the steps.
- The Python makes each decision, but the
dueof the toolbox finds if a run does a reconcile. - The
protonimage supplies the interpreter, boto3 and the CLI.
A mirror of this structure fills report-mirror with its report. It changes no other part
of the toolbox.
All credentials and all account identifiers are in one vault, and a run gets each of them
with its name. A repository contains only op:// references.
flowchart LR
item["A vault item, one per mirror<br/>sections per service: r2, healthcheck, proton, age, ..."] --> ref["op.env<br/>NAME=op://vault-uuid/item/section/field"]
ref --> oprun["op run --env-file=op.env, on the host<br/>values exported into its child's environment only"]
sa["A service account that reads the vault<br/>its token: the secret OP_SERVICE_ACCOUNT_TOKEN"] --> oprun
oprun --> run["run: -e NAME for every name in op.env and PASS<br/>values never on a command line, never in a log"]
run --> tool["the tool inside the image reads its environment<br/>aws, boto3, curl, git"]
There is one vault, with one item for each mirror. Each item has the name of its
repository. An item has one section for each system that the mirror uses (r2,
healthcheck, proton, age, github, dropbox). The README of the mirror gives the
names of the fields. The name and the UUID of the vault are not secrets: without the token
of the service account, they give access to no item.
The references give the UUID of the vault: op://<uuid>/<item>/<section>/<field>. Thus, if
the vault gets a new name, the runs continue to operate. Also, a reference cannot contain a
vault name that has a slash. To find the UUID, use this command:
op vault get <name> --format json | jq -r .idOne service account can read that vault, and no other vault. Its token is the secret
OP_SERVICE_ACCOUNT_TOKEN:
- On an organization, the token is one organization secret that all repositories inherit.
- On a personal account, the token is one repository secret for each mirror.
Only a dispatched sync run gets the token. A check of a pull request gets no secret.
op run --env-file=op.env gets the value of each reference. It exports the values into the
environment of one command, task run -- .... The output of that command does not show the
values, because op run removes them. On a laptop, op uses the desktop app, and you must
sign in to the app. In Actions, op uses the token of the service account, which the
reusable workflow puts in its environment. A run reads the vault one time.
For each name in op.env and in the PASS of the mirror, run gives -e NAME to the
container runtime. It also always gives HEALTHCHECK_URL, GITHUB_STEP_SUMMARY and
GITHUB_RUN_ID. The container runtime copies the value of each variable that run gives
from the environment of the host. Thus, no value is on the command line.
If the pipeline reads a host variable that op.env and PASS do not give, the variable is
empty in the container. GITHUB_STEP_SUMMARY is a path on the runner. Thus, run gives
the variable, and it also puts the file into the container at that same path.
| Name | The tool that reads it | Its source |
|---|---|---|
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
The AWS CLI and boto3 | The r2 section |
AWS_ENDPOINT_URL |
The AWS CLI, in the rsync image |
The endpoint field of the r2 section |
AWS_ENDPOINT_URL_S3 |
boto3, in the proton image. The AWS CLI also reads it |
The same field. The proton mirrors use this name |
AWS_REGION |
The two tools | The value auto, in the image. R2 has one region. If op run removes the value auto from the output, the output is incorrect |
MIRROR_R2_BUCKET |
s3, in the proton engine |
The bucket field of the r2 section |
MIRROR_AGE_IDENTITY |
age, in the proton engine |
The age section |
MIRROR_PROTON_DESTINATION, _UID |
destination, in the proton engine |
The proton section |
HEALTHCHECK_URL |
ping, ping-fail |
The healthcheck section. It is optional |
MIRROR_* names that one mirror uses |
That mirror | A section for that mirror: github, dropbox |
A mirror with no op.env runs on the secrets of its caller:
- The reusable workflow exports each inherited secret into the environment of the sync step, with its name.
- The secrets that the mirror gives in
PASSgo into the container. - The workflow does not install the 1Password CLI.
On a laptop, the run uses the exported variables. This method is not the usual method. It operates, but it puts the values in a second location.
Each mirror has one S3-compatible bucket. The contents of the bucket are different for each engine.
| Engine | Key | Contents |
|---|---|---|
| rsync | Each path of upstream, at the root | The mirror. A public domain serves the bucket |
| rsync | .state/applied.txt.xz |
The state: the keys that the bucket contains, with the length and the mtime of upstream |
rsync, with INDEX |
.state/indexed.txt.xz, <dir>/<INDEX>, <dir> |
The state that the directory pages showed last, and the pages with their two keys |
| rsync, a key of the mirror | index.html |
The landing page of tlnet. reconcile does not delete it, because it is in OWN |
| proton | .state/session.tar.age |
The CLI session, encrypted. This is the only key |
| A mirror with its pipeline | .state/state.sqlite.xz.age, .state/history/<epoch>-<label>... |
The state of dropbox and its copies with a date. A lifecycle rule of the bucket deletes the history after a time |
| Each mirror that does a reconcile | .state/reconciled |
The start epoch of the run that completed the last reconcile. The push of the engine writes it. With rsync, it has xz compression but no .xz suffix. With dropbox, it is plain text |
The engines keep only one prefix for their data: .state/. No upstream of the organization
has a .state entry at its root. The root of gnu has other dot-files, .header.shtml and
.message. The engine copies them the same as all other files.
- A bucket. Its name is in the
BUCKETroot var of the mirror (rsync), or in ther2/bucketfield of the vault (proton). - An API token with Object Read & Write, with the scope of that bucket only. Thus, if an
unwanted person gets the token, the token gives access to only one bucket. Its ID and its
secret are in the
r2section. - The endpoint. On R2, it is
https://<account-id>.r2.cloudflarestorage.com, in theendpointfield of ther2section. All S3 endpoints operate: the engines use no function that only R2 has. - A custom domain on the bucket, for a public mirror. This domain is
HOST. Its zone has Cloudflare rules, each with the scope of the hostname of the mirror. You set these rules one time, not in the pipeline, and the pipeline does not change them. The pipeline makes no call to the Cloudflare API. The README of each public mirror gives its rules.
Each public mirror has a Configuration Rule. It sets these four items of the zone to Off:
- Email Obfuscation
- Rocket Loader
- Automatic HTTPS Rewrites
- Browser Integrity Check.
The first three change HTML while the zone serves it. The fourth sends 403 to Perl and Python clients. The canary stops the run if one of them is on again.
On R2, the cost is storage and Class A writes. Egress has no cost. Thus, R2 is the default. A full CTAN mirror is approximately 140 GB: approximately $2.10 a month, at $0.015 for each GB-month.
R2 has a free tier for each account, not for each bucket. The free tier is 10 GB-month of storage, 1 million Class A operations and 10 million Class B operations each month. All the buckets of katoptra are in one account, and the largest mirrors use all of the free tier. Thus, each README gives gross costs: storage only, at $0.015 for each GB-month, with no free tier subtracted.
The engines use these facts about R2:
AWS_REGION=autois anENVline in the two images.- R2 rejects the default checksum headers of the SDK. Thus, the
protonimage setsAWS_REQUEST_CHECKSUM_CALCULATIONandAWS_RESPONSE_CHECKSUM_VALIDATIONtowhen_required. - R2 does not give keys in the sequence of byte values. Thus, the engine sorts each
listing of the bucket again before
joinorcomm. - The limit for an upload in one part is 4.995 GiB. The
rsyncimage setsAWS_CONFIG_FILEto its/etc/aws.config. In that file,multipart_thresholdandmultipart_chunksizemake the CLI send a file larger than 4 GiB in parts of 512 MiB. The cost of a multipart upload is one Class A operation for each part. DeleteObjectsaccepts 1,000 keys in each call, and it has no cost. The CLI exits 0 whenDeleteObjectsgives errors. Thus,removeexamines the response: if it has anErrorsarray,removestops the run.
Three systems monitor a mirror. The toolbox supplies data to each of them.
healthchecks.io. ping is the last verb of each pipeline. ping-fail runs from the
failure path. Each mirror has a check:
- The check has a cron expression, with the same times as the slots of the mirror. A check with a period starts its period again at each ping. Thus, if the runs start after their slots for a long time, the times of the check move, and the check does not show it. A cron expression keeps the time of each ping on its slot.
- The grace is sufficient for a run in the queue and one full run. A run that the
scheduler starts during a run waits for it (
concurrency: one pending run for each group). Thus, a ping can occur a long time after its slot. - If a slot gets no ping in the time of the grace, healthchecks.io sends an email.
- No step sends
/start. Thus, the grace is not a limit on the length of a run. Before the engine fills an empty bucket, or before a large delta, pause the check in the healthchecks.io UI. A run of many hours can be longer than the grace. A paused check starts again at its next ping. - No part of a mirror starts a run. Thus, the check also monitors the scheduler, katoptra/dispatch. A tick that does not start a run and a pipeline that stops before its end give the same result: no ping.
- Without
HEALTHCHECK_URL,pingsends no request, and healthchecks.io sends no email for the mirror.
The run summary. report adds one table to the job page in Actions. The table has three
layers: the rows of the toolbox, the rows of the engine and the rows of the mirror. report
counts all values from .run/, not from the log, because Actions removes log lines after a
limit and shows no warning. Read this table first if a run had no failure but its result is
not correct.
report finds the job page with GITHUB_STEP_SUMMARY. If the summary of a run shows in
the step log, that variable did not go into the container.
katoptra.org. The status.js of the site reads the healthchecks.io badge and the
Actions run list of each mirror when a person opens the page. Thus, each tile shows the
status and the last sync time at the time that a person opens the page.
On a public repository, the logs of a run are public. A pipeline writes counts and phase
lines in the log. It does not write a credential or an account identifier there. The output
of op run does not show the values that it got.
The data of a proton mirror is private. Thus, the proton engine also does not write the path of an item in the log:
- The stderr of git and of the CLI goes to files in
.run/. But the stderr oflist-foldergoes to the log. - A failure gives the position of an item in the listing, not its name.
The list-folder and trash verbs of the proton engine are different. The log shows the
command of each verb, with the folder that list-folder reads and each path that trash
moves to the trash.
The data of an rsync mirror is public. The rsync engine writes the paths of public files in
the log, for example in smoke and fresh.
There is one image for each engine. lib builds the images, and each run downloads its image from GHCR. A run does not build an image, and it does not use Docker Hub.
| Variant | Base | Tools | For |
|---|---|---|---|
rsync |
ubuntu 24.04 | rsync, gpgv, xz, curl, perl (shasum), go-task, AWS CLI v2 with only s3 and sts | rsync upstreams into a bucket |
proton |
python 3.13 slim | proton-drive, age, git, go-task, boto3, requests, pytest, ruff, and s3, a boto3 script that gets and puts objects |
Proton Drive sinks, with the proton engine or with the Python of the mirror |
The two images have these properties:
- Each image uses the repository at
/work, as a bind mount. - Each image sets
TASK_REMOTE_OFFLINE=1andAWS_REGION=auto. - Each image pins its base with a digest.
- The last stage of each image makes sure that each tool shows the version in
toolchain.lock.toml.
toolchain.lock.toml is one file for all the images. For each tool, it gives the version
and, for each architecture, the archive and its checksum. docker/lock.py reads this file
for the Dockerfiles and for the toolbox action. The build examines each tool before it uses
it.
The AWS CLI has a different check. Upstream publishes no checksum for it, only a detached
PGP signature. Thus, the fetch stage verifies the signature with docker/aws-cli.pub, and
it must find the fingerprint that toolchain.lock.toml pins. It uses the same GOODSIG and
VALIDSIG check that the engine uses for TeX Live. On each CI run,
docker/gpg-gate-check.sh makes sure that this check rejects each signature that it must
reject.
flowchart LR
lock["toolchain.lock.toml<br/>versions, checksums, one key fingerprint"] --> df["docker/rsync.Dockerfile<br/>docker/proton.Dockerfile"]
lock --> act[".github/actions/toolbox<br/>task and op on the runner"]
df --> ci["ci.yml, every pull request and push to main<br/>build, tools, check, offline, per variant"]
df --> rel["release.yml, on a tag vX.Y.Z"]
rel --> ghcr["ghcr.io/katoptra/toolbox:variant-vX.Y.Z<br/>and :variant-vX, amd64 and arm64"]
rel --> tag["the git tag vX, moved"]
tag --> gr["the GitHub release vX.Y.Z"]
tag --> inc["mirrors include toolbox.yml and an engine at v2"]
ghcr --> img["engines name IMAGE at -v2"]
There are two types of pin, with one policy:
- The URLs in
includes:use the git tagv2, and the image uses the tag<variant>-v2. The two tags move. When they move, a change to a verb or a tool goes into each mirror at its next run. - Each mirror pins each
uses:of a lib workflow to the commit of a release, and Dependabot updates it. The repositories of the public mirrors have an Actions policy: eachuses:must have a full SHA. The other repositories pin eachuses:with the same method.
A change that breaks the name or the contract of a verb is a new major version.
lib has two reusable workflows and one composite action. Each caller in a mirror has only a small number of lines.
This workflow does one run of one mirror. The caller is a workflow_dispatch that
katoptra/dispatch starts. The caller gives its
vars to this workflow, and this workflow inherits the secrets of the caller.
flowchart TB
a["checkout the mirror"] --> b["checkout katoptra/lib at the commit of this workflow, into .lib<br/>so the action and the lock are the release the workflow is"]
b --> c["toolbox action: install task, and op if op.env exists, from the lock"]
c --> d["task image"] --> e["validate-vars.sh: vars is KEY=value pairs and nothing else"]
e --> f["task sync -- VARS<br/>every inherited secret exported by name, in memory"]
f -- "cut off: the timeout, a cancellation" --> g["task op -- task failed"]
f -- "success, and .run/chain exists" --> h["gh workflow run, the workflow file of the caller"]
-
Inputs.
varsis a string ofKEY=valuepairs for the pipeline in the image.timeout-minuteshas the default 355, less than the job limit of six hours. The caller setstimeout-minutesinwith:only for a different value. -
The vars guard. The shell divides
varsinto words, and each word becomes a task variable. An engine can put a task variable into a command. Thus, a step that has no secrets examines each word. It rejects a word that is not a key in uppercase letters with a value that has no special characters. In CI,validate-vars.sh --checkruns the test cases of the guard. -
Concurrency. The caller sets
concurrency: {group: sync, cancel-in-progress: false}. Thus, a run that the scheduler starts during a run waits for that run. A group has only one pending run, and GitHub cancels the other pending runs. The delta of the next run includes the work of each canceled run. -
The chain. If the pipeline wrote
.run/chain, the workflow dispatches the file of the caller again. With the chain, an empty bucket fills, and a run with a time limit continues. The chain is the only cause foractions: writein the caller. A called workflow cannot increase its permissions. -
The image. No cache keeps the image between jobs. Each job is a new VM, and it downloads the image from GHCR one time, before the pipeline. If the host has the image, the
statusguard ofimagedoes not download it again. Thus, the job downloads the image one time for all thetask runcommands. A cache of the image in Actions costs more work than the download that it prevents.If the run stops at
task image, the toolbox could not download the image, and the mirror has no problem.
On each pull request, this workflow does a checkout of the mirror, and of lib at the same
commit. Then it runs the toolbox action without op, and task check. No secret goes into
this job. Thus, a pull request from a fork can run it safely. A mirror with an offline
verb sets offline: true. The job then also runs task run -- task offline in the image.
.github/actions/toolbox installs go-task on the runner. Unless op: 'false', it also
installs the 1Password CLI. It uses the versions and the checksums in
toolchain.lock.toml. With this action, a runner can run task sync. All other tools run
in the image.
A mirror repository contains eight files:
Taskfile.yml root vars, the two includes, the hooks it fills
.taskrc.yml trusts raw.githubusercontent.com, refetches at most hourly
op.env op:// references, one per secret
render.txt the committed dry run
.gitignore .run/, .task/ and /staging/
.github/workflows/sync.yml calls katoptra/lib/.github/workflows/sync.yml at a release commit
.github/workflows/check.yml calls katoptra/lib/.github/workflows/check.yml at a release commit
.github/dependabot.yml bumps the two calls on a lib release
# Taskfile.yml
version: '3'
vars:
SOURCE: rsync://rsync.dante.ctan.org/CTAN/
BUCKET: ctan
HOST: ctan.katoptra.org
includes:
toolbox:
taskfile: https://raw.githubusercontent.com/katoptra/lib/v2/toolbox.yml
flatten: true
vars:
NAME: ctan
DESC: an hourly mirror of CTAN at https://ctan.katoptra.org/
rsync:
taskfile: https://raw.githubusercontent.com/katoptra/lib/v2/engines/rsync.yml
flatten: true# op.env
AWS_ACCESS_KEY_ID=op://<vault-uuid>/ctan/r2/access_key_id
AWS_SECRET_ACCESS_KEY=op://<vault-uuid>/ctan/r2/secret_access_key
AWS_ENDPOINT_URL=op://<vault-uuid>/ctan/r2/endpoint
HEALTHCHECK_URL=op://<vault-uuid>/ctan/healthcheck/urlThat is a full mirror. The engine supplies pipeline, plan-pipeline, report-engine
and the IMAGE, ghcr.io/katoptra/toolbox:rsync-v2.
Each public mirror also sets these root vars:
LIST_FLOOR:listuses it to find a truncated listing.CANARY:smokeuses it to find a zone that changes bytes or rejects a Perl client.FRESH_KEY:freshuses it to find an upstream that stopped.
The other public mirrors have these structures:
- A mirror of one signed subtree, for example tlnet, adds
TL,TL_KEY,CEILING_GB,OWNand aFILTERto its root vars. It putsindexinexcludes:in the engine entry ofincludes:. It writes anindexthat uploads its landing page, and areport-mirrorwith its row. - A mirror with directories that a browser can show, for example ctan, gnu and nongnu, adds
INDEXandPAGE_FOOTto its root vars. It adds the Transform Rule of Directory pages to its zone. The engine does all the other work.
version: '3'
vars:
OWNERS: jshvn katoptra
includes:
toolbox:
taskfile: https://raw.githubusercontent.com/katoptra/lib/v2/toolbox.yml
flatten: true
excludes: [report-mirror]
vars:
NAME: github
DESC: a daily mirror of every repository under {{.OWNERS}} into Proton Drive
proton:
taskfile: https://raw.githubusercontent.com/katoptra/lib/v2/engines/proton.yml
flatten: true
excludes: [stage, prune]
tasks:
stage: {cmds: ['# list the repositories, clone each as a mirror, bundle it under {{.STAGING}}/<owner>/']}
prune: {cmds: ['# list-folder each owner in Proton; trash the bundles no repository has']}
report-mirror: {cmds: ['# the mirror rows']}Its op.env has the seven names in The proton engine,
HEALTHCHECK_URL, and the names that only this mirror uses. The engine supplies the
IMAGE, ghcr.io/katoptra/toolbox:proton-v2.
In the two workflow files, change <release-sha> to the commit SHA of a lib release.
Change vX.Y.Z to the version of that release.
# .taskrc.yml
remote:
trusted-hosts: [raw.githubusercontent.com]
cache-expiry: 1h# .github/workflows/sync.yml
name: sync
on:
workflow_dispatch:
inputs: {vars: {type: string, default: ''}}
permissions: {contents: read, actions: write} # the chain step; a called workflow cannot raise this
concurrency: {group: sync, cancel-in-progress: false}
jobs:
sync:
uses: katoptra/lib/.github/workflows/sync.yml@<release-sha> # vX.Y.Z
with: {vars: '${{ inputs.vars }}'}
secrets: inherit# .github/workflows/check.yml
name: check
on: {pull_request: {}}
permissions: {contents: read}
jobs:
check:
uses: katoptra/lib/.github/workflows/check.yml@<release-sha> # vX.Y.Z# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule: {interval: weekly}
groups: {actions: {patterns: ['*']}}# .gitignore
.run/
.task/
/staging/
Then run task render-update one time. Commit render.txt. After that, use
task check. If a pull request changes the commands of the mirror, render.txt shows a
diff, and that diff is the review.
- Root vars contain only the values of the mirror. Do not set a root var to an engine default. If you do, the command line cannot change that var.
- A task that a verb runs does not get the call vars of that verb. Also, a root var
cannot read a var of an included Taskfile. A verb can run a hook with a
RUNor aSTAGINGthat a caller can change. In that condition, the verb must give these vars to the hook.smokedoes this. - Do not put
dir:on a verb of an included Taskfile. If task reads a flattened Taskfile ofincludes:from a path, it adds eachdir:to the directory of that Taskfile, also an absolutedir:. Thus,verifystarts each command withcd. renderputs an empty directory on.run. Thus, ash:var that reads.runwhen task reads the Taskfile gives the same render in each checkout. A verb that reads.runat that time must accept an empty.run.task run -- task <x>exits 201 for each failure in the container. A test can only examine that the exit is not 0.
Put the verb in excludes: in the entry of includes: that has it. Then write the verb in
the mirror. A second verb with the same name and no excludes: is a parse error. Thus, a
mirror cannot override a verb without excludes:.
includes:
rsync:
taskfile: https://raw.githubusercontent.com/katoptra/lib/v2/engines/rsync.yml
flatten: true
excludes: [index] # the mirror draws its own landing page
tasks:
index:
cmds: [...]Obey these rules when you override a verb:
- Keep the name and the function of the verb. A
verifythat does a different task is a new verb. Give it a new name. Add it toMENU. - Add to a verb with a hook, not with a copy. A mirror that wants the
smokeof the engine and more checks writessmoke-mirror, whichsmokeruns last. Do not putsmokeinexcludes:and copy it into the mirror. A copy does not get the changes of subsequent releases. - The new verb reads the root vars of the mirror, the same as the verb of lib.
- Override engine verbs and the toolbox verbs that run in the container. Do not override
the host verbs.
sync,plan,checkandrunare the contract of the workflows and of the menu.
Make each change to the transport of bytes, or to the checks on a tree, in the engine. Then all mirrors get it. If two mirrors can copy a verb, put the verb in the engine. Obey these rules:
- A new hook is an empty task, with a
descthat gives the mirror that fills it. The verb that runs the hook gives it the vars that it uses. - A new var that a mirror can set is an inline
{{.X | default N}}in the command that reads it. Write the default one time. Add a row to the table in The vars. - A change to a verb is a change to
examples/<variant>/render.txt. The diff of that file in the pull request is the review.
There is one engine for each transport, and one image variant for each engine. A new engine is five files and two matrix entries:
- Tools. Put the version of each tool, and its checksum for each architecture, in
toolchain.lock.toml. - Image. Write
docker/<variant>.Dockerfile. Usedocker/rsync.Dockerfileas the example. The Dockerfile must have these properties:- A base with a digest pin
- Each tool from
toolchain.lock.toml TASK_REMOTE_OFFLINE=1- A last stage that makes sure that each tool shows the version in
toolchain.lock.toml.
- Verbs. Write
engines/<variant>.ymlwith the verbs of the pipeline:clockfirst, andreportandpinglast. Do not give avars:default for a var that a mirror sets. - Example. Make
examples/<variant>/. This example is the check of the engine. It must have these files:- A Taskfile with a
pipelinethat runs all the verbs - A committed
render.txt - An
offlineverb that runs the verbs that do not use a bucket, onfixtures/.
- A Taskfile with a
- CI. Add the variant to the
matrixofci.ymland ofrelease.yml.
An HTTPS engine can use the same base as rsync, with curl and the AWS CLI. Its list
reads an index, not rsync --list-only.
Change its version and each checksum below it in toolchain.lock.toml. The last stage of
the Dockerfile stops the build if a tool does not show the new version.
The key that signs the AWS CLI has an end date: 2027-07-01. Before that date, get
docker/aws-cli.pub and the fingerprint again from the AWS CLI User Guide.
The session format of the Proton CLI changes with its version. After you update the Proton
CLI, do a new login on a laptop. Then run session-seal for each Proton mirror.
Tag a commit vX.Y.Z. Push the tag. The release workflow then does these steps:
- It builds the two images for amd64 and arm64.
- It uploads
<variant>-vX.Y.Zand<variant>-vXto GHCR. - It moves the
vXgit tag. - It publishes the GitHub release
vX.Y.Z. Its notes give the pull requests that the repository merged after the last release.
Each mirror that uses vX gets the change at its next run. Dependabot updates the
workflow callers. A change that breaks the name or the contract of a verb is a new major
version. Then each mirror changes its two v2 strings manually.
git clone https://github.com/katoptra/lib
cd lib/examples/rsync && task image-build && task run -- task tools && task check && task run -- task offline
cd ../proton && task image-build && task run -- task tools && task check && task run -- task offlineThese commands do these steps:
task image-buildbuilds the image of the example fromdocker/.task run -- task toolsgets the version of each tool.task checkmakes a render of the pipeline of the example in the image, and compares it withrender.txt.task run -- task offlineruns the verbs of the engine that do not use a bucket, onfixtures/.
For rsync, offline does these checks:
- The list diff,
retryanddue prepareandverifyon a signed subtree, with a test key that the example pinspages, compared with the page set of ctan, byte for byte, and the page read-back ofsmokelabelon a set of names and bytes, andlabel-treesin the two directionsfreshwith three clocks: two that it accepts, and one from 48 hours before the check.
For proton, offline runs confirm on two upload summaries: one that it accepts and one
that it rejects. Then it does a dry run of empty-trash-pipeline from the command line,
because empty-trash starts that task from the command line in the image. Last, it
encrypts and decrypts one file with age.
CI runs the same four steps for each variant on each pull request and on each push to
main. It also runs the test
cases of the three guards: .github/validate-vars.sh, .github/chain-file.sh and
docker/gpg-gate-check.sh.
When you change a verb, run task render-update in each example. The diff in the pull
request is the review. CONTRIBUTING.md gives the three rules that this repository adds to
the rules of the organization.
These steps make the full system again, from zero. Do them in this sequence. Each step is work that you do one time, manually. No repository does these steps.
- Accounts. Make these accounts:
- A GitHub organization, or a personal account. The difference is the location of the one secret.
- A Cloudflare account with R2, and a zone for the domain of each public mirror.
- A 1Password account with a service account.
- A healthchecks.io account.
- The images. Use
ghcr.io/katoptra/toolboxwith no change: the packages are public. Or, make images in your fork:- Fork this repository.
- In the two engines, change the
IMAGEdefault to the GHCR of your fork. - In
release.yml, changeghcr.io/katoptra/toolboxto the GHCR of your fork. - Push a tag
v2.0.0to your fork.release.ymlbuilds the images and uploads them to your GHCR, with the token of the repository. - Make the packages public, one time.
- In each mirror, change the URLs in
includes:to your fork.
- The vault. Make one vault. Record its UUID. Make one service account that can read
the vault. Put its token in the organization secret
OP_SERVICE_ACCOUNT_TOKEN. - Actions policy. Let the repositories use only GitHub-owned actions,
go-task/*, and the actions of the organization. Set the policy that eachuses:must have a full SHA. Dependabot updates the pins. - A mirror. Do these steps for each mirror:
- Fork one mirror.
- Edit its identity vars.
- Make its bucket, and a token with the scope of that bucket.
- For a public mirror, make its custom domain and its zone rules.
- Make its vault item, with the sections that its README gives.
- Put the UUID of the vault in
op.env. - Run
task check. - In Actions, select the sync workflow. Then select Run workflow.
- Make its healthcheck. Put the URL of the healthcheck in the vault item.
- A trigger for the runs. Add a
schedule:trigger to thesync.ymlof the mirror, or use an external scheduler. GitHub disables a scheduled workflow in a public repository that has no activity for 60 days. Thus, an external scheduler dispatches the katoptra mirrors. - The site. This step is optional. Fork katoptra/site. Add one entry for each mirror
in
data/mirrors.toml. Deploy the site on a static host.
Pull requests are welcome.
MIT licensed. Built by Josh Vaughen.