# BST-TIMEOUT: 300 # one bounded wait of up to ~75 s for a minute boundary; see THE FUTURE JOB . "$BST_LIB" # at(1) is the one-shot half of job scheduling and the third DAEMON port on this # image, after sysklogd and cronie. 87-cronie.test beside it establishes the two # platform facts that shape any scheduler suite here -- relative timers are # unreliable (linux-in-the-browser#120) and signal delivery does not work at all, # SIGKILL included -- so the interesting question for this port is not whether it # can be tested but how much of it can be tested WITHOUT them. The answer is # nearly all of it, and that is worth stating up front because it is a property # of at(1), not of this suite's cleverness. # # 1. EXECUTION IS PROVEN SYNCHRONOUSLY, so the strongest case here is a real # green rather than a red held by the timer. atd's daemon mode sleeps on # atd_setalarm() until the next job is due, which is precisely the path # linux-in-the-browser#120 makes unreliable. But upstream ships -s, "process # the queue only once": main() takes `if (!run_as_daemon) { run_loop(); # exit(0); }` -- a straight-line call with no alarm, no fork into the # background, and no signal handlers in the path. run_file() then forks the # job and WAITS for it, so when `atd -s` returns, every due job has already # run to completion. That makes submit -> run -> artifact a deterministic # sequence with no polling at all. # # 2. THE ARTIFACT IS THE JOB'S OWN FILE, never its stdout, and never its absence # from the queue. run_file() redirects the job's output to a spool file and # then UNLINKS that file after handing it to SENDMAIL, which this recipe # configures as /bin/true -- so job output is discarded by design here. And a # job whose child DIES is dequeued exactly like one that succeeded: the parent # waits, then unlinks the job file either way. So "the job left the queue" # would have gone green against a port where nothing ever ran, which is the # failure mode this whole port was at risk of (see 3). Only a file the job # itself wrote separates the two. # # 3. WHAT THE PORT HAD TO FIX, and why the queue half needed nothing. at, atq and # atrm discard the return value of every credential call they make, so the # kernel's absent setuid/setgid family (linux#19) passes straight through # them. atd checks: its run_file() child calls initgroups/setgid/setuid before # exec'ing the job and perr()s -- log and exit -- on any failure, so unpatched # NO at job could ever run while atq, atrm and the spool all looked perfect. # at/exec-artifact below is that patch. at(1) also ships non-setuid here, # against upstream's 6755, and at/plain is that divergence. # # 4. WAITS ARE BOUNDED IN WALL CLOCK, never in iterations -- 87-cronie.test's # first lesson, and its measurements (30 consecutive `sleep 1` took 31 s while # a 40-iteration `sleep 1` poll finished in 0 s) apply unchanged. Only the # last block spends any, and every case that can be decided without it is # decided first, so a timeout there cannot convert the rest into one red. require at at/present # at -t parses its timespec through mktime(3), atq renders with strftime(3), and # this suite does its own arithmetic in date(1). Pinning all three to UTC is what # lets the expected atq line be compared literally instead of approximately. # UTC0 is the POSIX spelling and needs no zone file, so this does not depend on # tzdata being installed. TZ=UTC0; export TZ D=/tmp/.bst-at OUT=$D/fired FUT=$D/fired-future BAT=$D/fired-batch export HOME=$D/home rm -rf "$D"; mkdir -p "$D" "$HOME" JOBDIR=/var/spool/at/jobs SPOOLDIR=/var/spool/at/spool # epoch_fmt -- GNU date's @epoch spelling with # busybox's -D %s as the fallback, so the suite does not depend on which date(1) # won update-alternatives on this image. epoch_fmt() { date -u -d "@$1" "+$2" 2>/dev/null || date -u -D %s -d "$1" "+$2" 2>/dev/null } # wait_until -- poll a condition, bounded by REAL # seconds off date(1), which is the one clock that has proven trustworthy here. # Returns 1 on timeout so the caller reports the condition rather than hanging. wait_until() { _lim=$1; shift _t0=$(date -u +%s) while [ $(( $(date -u +%s) - _t0 )) -lt "$_lim" ]; do if "$@"; then return 0; fi sleep 1 done return 1 } # Drain anything a previous run (or an operator) left behind: /var/spool/at is # part of the rootfs and survives, and every queue assertion below states an # exact set rather than assuming an empty one. for _j in $(atq 2>/dev/null | cut -f1); do atrm "$_j" >/dev/null 2>&1; done # --- identity ----------------------------------------------------------------- # One binary, reached under three names. -V prints to STDERR and exits 0, so the # rc half of check_out is meaningful here rather than incidental. check_out at/version "at version 3.2.5" at -V check_out at/atq-alias "at version 3.2.5" atq -V check_out at/atrm-alias "at version 3.2.5" atrm -V # --- inventory ---------------------------------------------------------------- # Every path oe-core's recipe installs, minus datadir/at/batch-job which the # recipe deletes in do_install. inv="" for p in /usr/bin/at /usr/bin/atq /usr/bin/atrm /usr/bin/batch \ /usr/sbin/atd /usr/sbin/atrun /etc/at.deny /etc/init.d/atd; do [ -f "$p" ] || inv="$inv $p" done for p in "$JOBDIR" "$SPOOLDIR"; do [ -d "$p" ] || inv="$inv $p" done if [ -z "$inv" ]; then bst_ok at/inventory else bst_notok at/inventory "missing:$inv"; fi # atq and atrm are symlinks to at, not copies -- the recipe's LN_S, and the # reason the three -V cases above are one binary rather than three. if [ -L /usr/bin/atq ] && [ -L /usr/bin/atrm ]; then bst_ok at/aliases-symlinked else bst_notok at/aliases-symlinked "atq=$(ls -l /usr/bin/atq 2>&1) atrm=$(ls -l /usr/bin/atrm 2>&1)"; fi # busybox here has no at, atq, atrm or batch applet, so this port is the sole # provider of all four names and nothing had to be excluded to make room for it. # Absence is the only falsifiable form of that claim -- the same shape as # cronie/sole-provider. if [ ! -e /bin/at ] && [ ! -e /bin/atq ] && [ ! -e /bin/batch ] && [ "$(command -v at)" = /usr/bin/at ] && [ "$(command -v batch)" = /usr/bin/batch ]; then bst_ok at/sole-provider else bst_notok at/sole-provider "at=$(command -v at) batch=$(command -v batch) /bin: $(ls -d /bin/at* /bin/batch 2>&1)" fi # at(1) ships WITHOUT the setuid and setgid bits here -- a deliberate divergence # from upstream's 6755, and the one that has to hold for anything else in this # suite to run. A set-id at comes up with euid != uid on a rootfs that reports # the host's ownership, and at.c's first act is check_permission(), which returns # 1 immediately for geteuid() == 0 but otherwise feeds geteuid() to getpwuid() -- # NULL for a uid absent from this image's /etc/passwd, and the next line is # exit(EXIT_FAILURE). So the bits would not degrade at(1), they would stop it # starting. This case cannot fail under the local functional verify, whose rootfs # is a plain host directory that has already lost every set-id bit; it guards the # recipe on a mode-preserving deploy. What proves the divergence works HERE is # every submit case below -- exactly the calls that would have failed. if [ -u /usr/bin/at ] || [ -g /usr/bin/at ]; then bst_notok at/plain "still set-id: $(ls -l /usr/bin/at)" else bst_ok at/plain fi # The recipe ships at.deny and no at.allow, which is upstream's "everyone except # the listed" default. Worth pinning: with an at.allow present instead, an empty # one would deny every non-root user, and the two files are easy to confuse. if [ -f /etc/at.deny ] && [ ! -f /etc/at.allow ]; then bst_ok at/deny-default else bst_notok at/deny-default "at.deny=$([ -f /etc/at.deny ] && echo yes || echo no) at.allow=$([ -f /etc/at.allow ] && echo yes || echo no)"; fi # --- option contracts --------------------------------------------------------- # usage() exits EXIT_FAILURE, so a bad flag is rc=1 with the usage block -- unlike # sysklogd, whose bad flag exits 0. Pinned so a version that stops reporting # honestly is caught rather than assumed. out=$(at -Z 2>&1); rc=$? if [ "$rc" -eq 1 ] && { echo "$out" | grep -q "Usage: at"; }; then bst_ok at/badopt else bst_notok at/badopt "rc=$rc $(_bst_1line "$out")"; fi # atrm validates its job ids with strspn() before touching the spool, so a # non-numeric argument is rejected by name rather than silently ignored. out=$(atrm notanumber 2>&1); rc=$? if [ "$rc" -eq 1 ] && { echo "$out" | grep -q "unknown jobid: notanumber"; }; then bst_ok at/atrm-badjobid else bst_notok at/atrm-badjobid "rc=$rc $(_bst_1line "$out")"; fi # --- the queue round trip ----------------------------------------------------- # An exact absolute time, one hour out, on a minute boundary -- at stores # runtime/60, so a job's time is always whole minutes and the expected atq # rendering can be compared literally. -t is POSIX's [[CC]YY]MMDDhhmm form. NOWMIN=$(( $(date -u +%s) / 60 * 60 )) T=$(( NOWMIN + 3600 )) TS=$(epoch_fmt "$T" %Y%m%d%H%M) EXPECT=$(epoch_fmt "$T" '%a %b %e %T %Y') sub=$(printf 'echo QUEUED\n' | at -t "$TS" 2>&1); rc=$? JOB=$(printf '%s' "$sub" | sed -n 's/^job \([0-9][0-9]*\) at .*/\1/p') if [ "$rc" -eq 0 ] && [ -n "$JOB" ]; then bst_ok at/submit else bst_notok at/submit "rc=$rc $(_bst_1line "$sub")"; fi # at(1) announces the job on stderr with the SAME strftime format atq uses, so # the two renderings are cross-checked against one expected string. case "$sub" in *"$EXPECT"*) bst_ok at/submit-time ;; *) bst_notok at/submit-time "want:'$EXPECT' got:'$(_bst_1line "$sub")'" ;; esac # THE TICKET'S PASS CRITERION, first half: atq lists the job at the expected # time. Upstream's line is "\t