Facts#
A fact is something a step found out during a run: the kernel a target booted, the names it gave its devices, the version a build produced. The configuration says what a task wants, and a run never changes it. Facts say what the run observed. A script writes them, and the steps after it read them.
Motivation#
Before facts, a step that found something out had nowhere to put it. A later
step could not read it, so each producer wrote a file of its own, and a later
run loaded that file as one more --config. That has three problems.
The observation reaches only the next run, never the next step of the same run.
Once loaded, the observation is configuration. It can override what a person configured, and nothing says that it did.
Nothing records which step found what, or when.
Facts keep observations apart from configuration. Steps in the same run read them, and so can a later run. Every write is saved, the report shows the facts, and each step’s state lists the keys it wrote.
Example#
The core.facts example gathers facts about the target, and the steps
after it use what was found. Emit it and run it with:
cijoe --example core.facts
cd cijoe-example-core.facts
cijoe --monitor
The configuration says what the task wants, at least one CPU:
# What the 'core.facts' example task wants of its target. The configuration
# says what is wanted; what the task finds out is kept as facts, which a
# configuration file cannot set.
[example]
min_cpus = 1
The task’s first step runs core.facts_gather, which records the target’s
OS, kernel, CPUs, memory, PCI functions and NVMe controllers. The second shows
some of them, and the third checks the observation against what the
configuration wants:
---
doc: |
An example of facts: core.facts_gather observes the target, and the steps
after it use what it found.
The configuration says what is wanted, here at least 'example.min_cpus' CPUs.
The first step records the target's OS, kernel, CPUs, memory, PCI functions
and NVMe controllers as facts under 'targets.<target>'. The second shows some
of them, read as the facts of the default target, substituted when the step
starts, and the third checks the observation against the wish.
steps:
- name: gather
uses: core.facts_gather
- name: show
run: |
echo "{{ target.os.pretty_name }}, kernel {{ target.kernel.release }}"
echo "{{ target.system.cpus }} CPUs, {{ target.system.memory_kib }} KiB"
- name: check
run: |
test {{ target.system.cpus }} -ge {{ config.example.min_cpus }}
After the run, cijoe-output/facts.toml holds what was observed. Here it was
run on a WSL2 workstation, whose only target is the initiator; its pci list
is cut from this excerpt, and its nvme table is empty, as it has no NVMe
controllers:
[targets.initiator.os]
family = "linux"
id = "ubuntu"
version_id = "26.04"
name = "Ubuntu"
pretty_name = "Ubuntu 26.04 LTS"
[targets.initiator.kernel]
name = "Linux"
release = "6.6.87.2-microsoft-standard-WSL2"
machine = "x86_64"
[targets.initiator.system]
cpus = 16
memory_kib = 31733944
The report’s Facts tab lists the same facts, each with the step that last
wrote it, here gather for all. A step that wrote facts has a facts
button, which shows them with their values; task.state lists, for each
step, the keys it wrote.
Facts about targets#
Facts about a machine are kept under targets.<name>, where the name is the
one the configuration gives it as [cijoe.targets.<name>], or
initiator. A QEMU guest and a machine on the bench are alike: what is known
about either is under its name, whoever wrote it. The default target is the
one cijoe.run() reaches when given no target, see
Target Configuration. While a step runs, it is the step’s
target:, and cijoe.default_target names it.
A task reads the default target’s facts as {{ target.* }}, short for
{{ facts.targets.<default target>.* }}, and another target’s in full:
- name: show
run: |
echo "{{ target.os.pretty_name }} on {{ target.kernel.machine }}"
echo "{{ facts.targets.machineB.os.id }}"
A script reads and writes them with cijoe.facts.target(), which takes a
target’s name and defaults to the default target, and has the calls of
cijoe.facts on keys within the target:
cijoe.facts.target().set("gpu", {"vendor": "0x1002"}) # targets.<default>.gpu
release = cijoe.facts.target("machineB").get("kernel.release")
About the step’s target, core.facts_gather writes these sections, or those
named with sections, less those named with skip. It finds the target’s
OS family by asking it, uname -s, or PowerShell where there is no uname.
Before each section, it checks the tools that section runs on that family, such
as pciconf on FreeBSD, and records them as facts; it leaves out a section
whose tools the target lacks, or that it has no way of gathering on that family:
Section |
Keys |
Gathered on |
|---|---|---|
|
|
Linux, FreeBSD, macOS, Windows |
|
|
Linux, FreeBSD, macOS, Windows |
|
|
Linux, FreeBSD, macOS, Windows |
|
a list, by BDF, of |
Linux, FreeBSD |
|
|
Linux, FreeBSD, Windows, by |
|
a list of the PCI functions of class display or accelerator, as in
|
Linux, FreeBSD |
|
a list of the PCI functions of class network, as in |
Linux, FreeBSD |
A consumer finds the device it wants by what identifies it: an NVMe controller
by its serial, as a QEMU device’s is set by its configuration, and any PCI
function by its bdf, which a guest’s topology fixes. The names a target
gives its devices, nvme9 or eth1, are its own and may change from boot
to boot.
Every run gathers the initiator’s os, kernel and system before its
first step, so a report always says which machine it ran from, in its Facts tab
under targets.initiator, written by (start of run), and a tool check on
the initiator knows its OS family. The configuration names other sections,
or none:
[cijoe.facts]
initiator = ["os", "kernel", "system"] # the default; [] gathers nothing
Other targets are gathered only by a step that runs core.facts_gather for
them.
A package gathers sections of its own the same way, with
cijoe.facts.target().set(), under the same target.
Tools#
Which tools a target has, and at which versions, is a fact too, kept as
targets.<name>.tools.<tool>: name, found, and when found its
path and version. A tool’s name with a dot is kept with an underscore,
as a key cannot hold a dot: python3.12 as python3_12. What a run needs
of them is configuration. A configuration describes a tool once, with the
command that prints its version and the least version the run needs:
[tools.fio]
version = "fio --version" # how to ask; default: '<tool> --version'
min = "3.38" # the least version; default: any
A tool needed on some OS families only says so, as a non-empty list of
linux, freebsd, darwin and windows, and is not checked on
others, so one configuration serves targets of several; on a target whose
family cannot be found, it is checked:
[tools.pciconf]
os = ["freebsd"]
core.tools_check checks the tools a step names, by default every one the
configuration describes, records what the target has, and fails, naming every
tool that is missing or too old:
- name: tools
uses: core.tools_check
with:
tools: "fio,nvme"
A script that needs tools declares them, and cijoe checks them just before
it runs the script, so a step fails with a message naming what it lacks rather
than with what the missing command printed; see
Declaring Tools. A version is the first dotted number in
what the version command prints, and versions compare number by number, so
10.2.1 is later than 9.0, with a pre-release such as 3.14.0rc1
before its release. A release with a bare letter, as OpenSSL’s 1.0.2a, is
read as its numbers, and its least version is given so, 1.0.2. A least
version is a string, min = "5.10", as a TOML number would lose a trailing
zero.
A tool whose facts already satisfy what is needed is not asked about again, so
the check runs once for a run, and not at all for a run started with
--facts from one that checked; core.tools_check asks again when given
refresh: true. The facts are as fresh as the run that recorded them.
Writing and reading facts#
A script writes facts on cijoe.facts, with dotted keys as for
getconf():
cijoe.facts.set("build.version", "1.2.3") # replace
cijoe.facts.update("build", {"commit": "c0ffee"}) # merge into a table
cijoe.facts.delete("build.commit") # remove
version = cijoe.facts.get("build.version", "unknown")
A key is names separated by dots, so a name cannot hold a dot itself; a fact
whose natural name has one, such as a PCI function’s BDF, goes in a list of
tables instead. A value is anything TOML can hold: strings, numbers, booleans,
lists and tables; a write of anything else, such as None, raises TypeError and
changes nothing. set() replaces what was at the key, which is how a script
brings a fact up to date when it observes again, for example after a device is
unplugged. get() returns a copy, so changing what it returned changes no
fact.
A task reads facts in a step’s with, as {{ facts.build.version }}, as
{{ target.os.id }} for the step’s target, or in a block such as
{% if target.gpu %}. Other variables in a task are
substituted when the task is loaded, before any step has run. A string that
refers to facts is substituted when its step starts, so the step sees what the
steps before it wrote; its syntax is still checked when the task is loaded, and
by cijoe -i. A step that names a fact nobody wrote fails; it does not run
with an empty value.
Tests run by core.testrunner read and write facts like any script, through
the cijoe fixture. They start with the run’s facts, and when they are done,
the step merges the facts they changed into the run’s, as written by the
testrunner step. A fact the tests delete stays in the run’s.
The facts of a run are one table, shared by all its steps. Facts about a machine
belong under its target, as above; others are organised by the scripts that
write them, under a key of their own, such as build.
Across runs#
Every write saves the whole set of facts to facts.toml in the output
directory. A later run starts from them with --facts:
cijoe provision.yaml --config machine.toml --output provisioned
cijoe benchmark.yaml --config machine.toml --facts provisioned/facts.toml
The file given to --facts is only where the run starts: it is copied into
this run’s facts.toml, which every write updates, and is itself never
written. A chain of runs passes each run’s facts.toml to the next.
When the earlier run’s configuration differs from this run’s, cijoe prints a warning, as the facts may then describe something else, and goes on.
Facts and configuration#
The two are kept apart, so that a run cannot rewrite its own intent:
getconf()never returns a fact, andcijoe.facts.get()never returns configuration.A configuration file with a
factstable is refused, and one that refers to{{ facts.* }}fails to load, as facts do not exist yet when it does.Only scripts write facts. A
run:step reads them, but does not write them.