#!/usr/bin/env bats # ----------------------------------------------------------------------------- # Unit tests for hack/e2e-runner-identity.sh -- the record of which machine an # e2e run landed on. # # What this suite is guarding, and why it is not a numbers test: the capture # cannot be wrong about the hardware, because it copies what the machine says. # What it can be is absent, or present and empty, or present and describing the # wrong layer -- and each of those reads, in the artifact, exactly like a run on # an unremarkable runner. So the assertions below are about the arms that keep # those apart: an `imds unreachable` line rather than a missing block, a named # absent field rather than a silent one, and a legend saying the numbers belong # to the runner VM and not to anything inside it. # # Two shapes of test, like hack/capture-dataplane.bats. # # The first sources the script with E2E_RUNNER_IDENTITY_LIB set, which its guard # honours by defining the helpers and returning before it reads or writes # anything. Those tests drive the pure helpers directly: the number reader over the # shape sizing object, the scalar guard that decides whether a per-key answer is # one value, and the ceiling knob. # # The second runs the script as a subprocess against stubs on PATH. That is the # only way to reach the body: `curl` is invoked in a child process, so a shell # function here would be invisible exactly where the assertion needs it, and the # arms that matter -- what reaches the file, what reaches stdout, what the exit # status is -- are properties of the running script. # # The root-document fixture uses an obviously synthetic OCID and display name, and # it is there to be asserted ABSENT twice over: this capture never requests that # document, and the arm that refuses a value which is not one scalar is what holds # that when a service answers it anyway. A real instance's root document carries # the instance and compartment OCIDs, the image OCID, the display name, the # hostname and the user metadata bag, and this record is published as a CI # artifact. # # Title syntax constraints (inherited from cozytest.sh's awk parser): # - Titles delimited by ASCII double quotes; embedded quotes truncate. # - Only [A-Za-z0-9] from the title survives into the function name, so keep # titles distinctive in their alphanumeric run. # - An @test block ends at the first closing brace in column zero, so helpers # and stub heredocs stay at top level. # # Run from the repo root with `make bats-unit-tests`, or directly with # `bats hack/runner-identity.bats`. The legacy `hack/cozytest.sh` runner remains # a supported compatibility path. # ----------------------------------------------------------------------------- load test_helper HACK_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")" && pwd)" SCRIPT="$HACK_DIR/e2e-runner-identity.sh" REPO_ROOT="$(cd "$HACK_DIR/.." && pwd)" # Loads the pure helpers. The guard returns before anything is read or written, # so this needs no runner and no network. It also brings in the two defaults, so # the assertions below read them from the script instead of restating them -- # a restated default is one that goes stale in the direction that stays green. E2E_RUNNER_IDENTITY_LIB=1 # shellcheck source=/dev/null . "$SCRIPT" # And the report side, whose fold-in is one function for the same reason: the # module around it needs a cluster, while the arm that decides between a record # and an explanation is decided entirely by a local file. COZYREPORT_LIB=1 # shellcheck source=/dev/null . "$REPO_ROOT/hack/cozyreport.sh" assert_file_contains() { local needle="$1" local file="$2" if [ ! -f "${file}" ]; then printf 'expected %s to exist so it could be checked for: %s\n' "${file}" "${needle}" >&2 return 1 fi case "$(cat "${file}")" in *"${needle}"*) return 0 ;; esac printf 'expected %s to contain: %s\n' "${file}" "${needle}" >&2 cat "${file}" >&2 return 1 } assert_file_lacks_pattern() { local pattern="$1" local file="$2" # A missing file must fail rather than vacuously pass: a bare `! grep -q` # succeeds on an unreadable path, which is indistinguishable from "the file # exists and does not carry the pattern". if [ ! -f "${file}" ]; then printf 'expected %s to exist so it could be checked for: %s\n' "${file}" "${pattern}" >&2 return 1 fi # Branched on awk's exact status. awk exits 1 for "no line matched" and 2 for # "I could not evaluate this"; folded together, a negative assertion is # satisfied by its own matcher giving up, which is the direction that goes # green and stays green. local _rc=0 awk -v pattern="${pattern}" '$0 ~ pattern { found = 1 } END { exit found ? 0 : 1 }' "${file}" || _rc=$? case "${_rc}" in 0) printf 'expected %s not to match: %s\n' "${file}" "${pattern}" >&2 cat "${file}" >&2 return 1 ;; 1) return 0 ;; *) printf 'awk could not evaluate pattern %s against %s\n' "${pattern}" "${file}" >&2 return 1 ;; esac } # The shape sizing object, as the `shapeConfig` subpath serves it: a flat object of # sizing numbers with one string in it, pretty-printed because the service is free # to answer either way and the reader buffers its whole input for that reason. shape_config() { cat <<'JSON' { "ocpus" : 16.0, "memoryInGBs" : 128.0, "networkingBandwidthInGbps" : 16.0, "maxVnicAttachments" : 16, "processorDescription" : "AMD EPYC 9J14 96-Core Processor" } JSON } # The ROOT instance document, which this capture deliberately never requests. # # It is here to be asserted absent, and to be fed to the scalar guard as the thing # a per-key request must never be allowed to render: on a real instance it carries # the instance and compartment OCIDs, the image OCID, the display name, the # hostname and the user-supplied metadata bag. The values below are obviously # synthetic. imds_root_document() { cat <<'JSON' { "availabilityDomain" : "xYzA:US-CHICAGO-1-AD-1", "faultDomain" : "FAULT-DOMAIN-2", "timeCreated" : 1600381928581, "compartmentId" : "ocid1.compartment.oc1..aaaaexample", "displayName" : "runner-pool-node-7", "hostname" : "runner-pool-node-7", "id" : "ocid1.instance.oc1.us-chicago-1.anexampleocid", "image" : "ocid1.image.oc1.us-chicago-1.anexampleocid", "metadata" : { "ssh_authorized_keys" : "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5example" }, "region" : "us-chicago-1", "shape" : "VM.Standard.E5.Flex" } JSON } # Stubs for the tools the body runs, staged as executables rather than mocked as # functions: every one of them is run in a child process, where a function defined # in this shell does not exist. # # Each stub reads what it should do from a FILE beside it rather than from a value # baked in when it was written. A stub carrying its behaviour in its own text has # to be regenerated after every change, and a test that changes the behaviour and # forgets to regenerate passes against the previous fixture -- which is a green # test asserting nothing, in a suite whose whole subject is the difference between # a capture that ran and one that did not. # # The curl stub answers PER KEY, out of `imds/`, because the capture reads one # key at a time and a stub answering the same bytes to every URL could not tell a # test about one key from a test about another. It records its whole argv, since the # ceiling, the header and the path are the three things about these reads that a # test can check and a reader of the record cannot. stage_stubs() { local dir="$1" mkdir -p "${dir}/bin" "${dir}/imds" cat >"${dir}/bin/curl" <>"${dir}/curl.args" for a in "\$@"; do url=\$a; done key=\${url##*/instance/} if [ -f "${dir}/imds/\$key.rc" ]; then rc=\$(cat "${dir}/imds/\$key.rc") else rc=\$(cat "${dir}/curl.rc") fi if [ -s "${dir}/imds/\$key.err" ]; then cat "${dir}/imds/\$key.err" >&2 elif [ -s "${dir}/curl.stderr" ]; then cat "${dir}/curl.stderr" >&2 fi [ ! -f "${dir}/imds/\$key" ] || cat "${dir}/imds/\$key" exit "\$rc" STUB cat >"${dir}/bin/lscpu" <"${dir}/bin/nproc" <"${dir}/bin/uname" <"${stdout_file}" 2>"${stderr_file}" ) || _rc=$? return "${_rc}" } # The healthy fixture: an OCI instance answering every key this capture asks for, a # processor table with a model line, a CPU count and a kernel release. Every test # starts here and overwrites the one file it is about, so what a test changes is # visible in the test rather than in a stub regenerated behind it. # # `region` is staged short and `canonicalRegionName` long, which is what the # service does for some regions and the reason the record keeps both. stage() { stage_stubs "$1" identity_file="$1/out/runner-identity.txt" stdout_file="$1/stdout" stderr_file="$1/stderr" printf '0\n' >"$1/curl.rc" : >"$1/curl.stderr" printf 'VM.Standard.E5.Flex\n' >"$1/imds/shape" printf 'chi\n' >"$1/imds/region" printf 'us-chicago-1\n' >"$1/imds/canonicalRegionName" printf 'xYzA:US-CHICAGO-1-AD-1\n' >"$1/imds/availabilityDomain" printf 'FAULT-DOMAIN-2\n' >"$1/imds/faultDomain" shape_config >"$1/imds/shapeConfig" { printf 'Architecture: x86_64\n' printf 'Model name: AMD EPYC 9J14 96-Core Processor\n' printf 'Flags: fpu vme de pse tsc msr svm npt nrip_save vgif umip pku\n' } >"$1/lscpu.out" printf '32\n' >"$1/nproc.out" printf '6.8.0-1021-oracle\n' >"$1/uname.out" } # --------------------------------------------------------------------------- # The SVM sub-feature reader, driven directly. # # These exist because the flag this reader was added for, vgif, is the one field # in the record that decides whether a nested guest runs at hardware speed or # traps to the layer below on every world switch, and it is read out of a flag # list where a whole-word match is the only thing standing between `svm` and # `svm_lock`. # --------------------------------------------------------------------------- @test "the svm reader keeps the sub-features an AMD guest was given" { tmp=$(mktemp -d) printf 'Model name: AMD EPYC 7J13 64-Core Processor\n' >"$tmp/lscpu" printf 'Flags: fpu de tsc svm cr8_legacy npt nrip_save vgif umip pku\n' >>"$tmp/lscpu" out=$(runner_svm_features <"$tmp/lscpu") # vgif is the whole reason for this line: present here, and its absence on an # otherwise identical host is the only difference measured between a runner # whose nested guests boot and one whose do not. [ "$out" = "svm npt nrip_save vgif" ] || { echo "expected 'svm npt nrip_save vgif', got '$out'" >&2 return 1 } } @test "the svm reader reports the same host without vgif as lacking it" { tmp=$(mktemp -d) printf 'Flags: fpu de tsc svm cr8_legacy npt nrip_save umip pku\n' >"$tmp/lscpu" out=$(runner_svm_features <"$tmp/lscpu") [ "$out" = "svm npt nrip_save" ] || { echo "expected 'svm npt nrip_save', got '$out'" >&2 return 1 } } @test "the svm reader says nothing about an Intel host, whose vnmi is not SVM" { tmp=$(mktemp -d) # vnmi is a flag name under VMX as well as SVM. Ungated, this reader would # print one SVM sub-feature for a machine that has no SVM, which is not a # shorter answer but a wrong one. printf 'Flags: fpu vme de pse tsc msr vmx ept vpid vnmi\n' >"$tmp/lscpu" out=$(runner_svm_features <"$tmp/lscpu") [ -z "$out" ] || { echo "expected no output for an Intel flag list, got '$out'" >&2 return 1 } } @test "the svm reader does not take svm from svm_lock alone" { tmp=$(mktemp -d) # A whole-word match is what keeps this honest: svm_lock is a sub-feature name # in its own right and its presence does not mean SVM is exposed. printf 'Flags: fpu de tsc svm_lock npt umip\n' >"$tmp/lscpu" out=$(runner_svm_features <"$tmp/lscpu") [ -z "$out" ] || { echo "expected no output when only svm_lock is present, got '$out'" >&2 return 1 } } @test "the svm reader reads the first Flags line only" { tmp=$(mktemp -d) # lscpu prints one Flags line, but a caller handing this a concatenated table # would otherwise get two machines' features merged into one answer. printf 'Flags: fpu svm npt vgif\n' >"$tmp/lscpu" printf 'Flags: fpu svm npt avic\n' >>"$tmp/lscpu" out=$(runner_svm_features <"$tmp/lscpu") [ "$out" = "svm npt vgif" ] || { echo "expected only the first Flags line, got '$out'" >&2 return 1 } } # --------------------------------------------------------------------------- # The number reader and the scalar guard, driven directly. # --------------------------------------------------------------------------- @test "the number reader keeps every number the sizing object carries" { tmp=$(mktemp -d) shape_config >"$tmp/obj" runner_imds_numbers shapeConfig <"$tmp/obj" >"$tmp/out" # ocpus against the vCPU count the lane advertises is the one arithmetic nothing # else in the report can do: a 32-vCPU lane served 16 physical cores is a # different machine from one served 32, and only these two numbers together say # which it was. assert_file_contains 'shapeConfig.ocpus: 16.0' "$tmp/out" assert_file_contains 'shapeConfig.memoryInGBs: 128.0' "$tmp/out" assert_file_contains 'shapeConfig.networkingBandwidthInGbps: 16.0' "$tmp/out" assert_file_contains 'shapeConfig.maxVnicAttachments: 16' "$tmp/out" rm -rf "$tmp" } @test "the number reader reads by key rather than by a list of key names" { tmp=$(mktemp -d) # A key nobody here has heard of. The sizing keys have been renamed upstream # before, and a reader built on a hardcoded list reports the size of the machine # as unknown on every run, quietly, from the day the rename lands. printf '%s' '{"ocpusRenamedUpstream":48.0}' | runner_imds_numbers shapeConfig >"$tmp/out" assert_file_contains 'shapeConfig.ocpusRenamedUpstream: 48.0' "$tmp/out" rm -rf "$tmp" } @test "the number reader drops what is not a number and says how much it dropped" { tmp=$(mktemp -d) # Numbers only, and that is what makes the privacy rule structural rather than # an argument about whatever Oracle may add to this object later: a size is a # number and an identifier is a string. printf '%s' '{"ocpus":16.0,"processorDescription":"AMD EPYC 9J14","gpus":null,"burstable":true}' | runner_imds_numbers shapeConfig >"$tmp/out" assert_file_contains 'shapeConfig.ocpus: 16.0' "$tmp/out" assert_file_lacks_pattern 'processorDescription' "$tmp/out" assert_file_lacks_pattern 'AMD EPYC' "$tmp/out" # `null` and `true` are dropped rather than printed: neither is a measurement, # and the word in a record reads as a value the service published. assert_file_lacks_pattern 'null' "$tmp/out" assert_file_lacks_pattern 'true' "$tmp/out" # Counted, not listed. Naming the keys would print the very strings the rule # exists to keep out, and a count is enough to say the object held more. assert_file_contains 'shapeConfig: 3 further field(s) were not kept' "$tmp/out" rm -rf "$tmp" } @test "an object answered on one line reads the same as a pretty printed one" { tmp=$(mktemp -d) # The service is free to answer either way, and a reader that scanned line by # line would find a key whose value sits on the next line and call it absent. printf '{\n "ocpus" : 12.0,\n "memoryInGBs" : 96.0\n}\n' | runner_imds_numbers shapeConfig >"$tmp/pretty" printf '{"ocpus":12.0,"memoryInGBs":96.0}' | runner_imds_numbers shapeConfig >"$tmp/flat" assert_file_contains 'shapeConfig.ocpus: 12.0' "$tmp/pretty" assert_file_contains 'shapeConfig.memoryInGBs: 96.0' "$tmp/pretty" diff "$tmp/pretty" "$tmp/flat" rm -rf "$tmp" } @test "an object holding no number at all is not rendered as a size" { tmp=$(mktemp -d) printf '%s' '{"gpuDescription":"none"}' | runner_imds_numbers shapeConfig >"$tmp/out" # The object was fetched and had nothing this reader keeps. Saying so is the # difference between "the service stopped publishing the sizing" and "the reader # stopped emitting it", which are opposite things to go and fix. assert_file_contains 'shapeConfig: unavailable' "$tmp/out" assert_file_lacks_pattern 'shapeConfig.gpuDescription' "$tmp/out" rm -rf "$tmp" } @test "a nested value is stepped over rather than flattened into this object" { tmp=$(mktemp -d) # A key from a level below, reported as a key of this one, would be a number # about something else entirely sitting under the sizing prefix. printf '%s' '{"gpuInfo":{"count":0},"ocpus":16.0,"tags":[1,2,3]}' | runner_imds_numbers shapeConfig >"$tmp/out" assert_file_contains 'shapeConfig.ocpus: 16.0' "$tmp/out" assert_file_lacks_pattern 'count' "$tmp/out" assert_file_contains 'shapeConfig: 2 further field(s) were not kept' "$tmp/out" rm -rf "$tmp" } @test "a string value carrying an escaped quote does not swallow the key after it" { tmp=$(mktemp -d) # The one place an escape matters here: a `\"` read as the end of the string # leaves the walk inside a value it thinks is a key, and the number after it is # lost. Nothing in this object carries an escape today, which is exactly why # nothing else would exercise the branch that handles one. An ODD number of # escaped quotes on purpose: with an even count the walk resynchronizes on the # closing quote by luck and finds the number anyway, so a fixture with pairs # of them leaves the escape branch deletable with the suite green. printf '%s' '{"gpuDescription":"one \" quote","ocpus":16.0}' | runner_imds_numbers shapeConfig >"$tmp/out" assert_file_contains 'shapeConfig.ocpus: 16.0' "$tmp/out" assert_file_lacks_pattern 'quote' "$tmp/out" rm -rf "$tmp" } @test "an object that is not JSON at all is reported as holding no number" { tmp=$(mktemp -d) # What an error page, or another cloud service, answers with. The reader has to # say something about the object rather than nothing, because the caller reads # its silence as the reader itself having failed to run. printf '%s\n' 'Not Found' | runner_imds_numbers shapeConfig >"$tmp/out" assert_file_contains 'shapeConfig: unavailable' "$tmp/out" rm -rf "$tmp" } @test "an object cut off part way is refused rather than read in part" { tmp=$(mktemp -d) # Read in part, a truncated answer is indistinguishable in the record from a # machine whose sizing simply has fewer fields, and nothing above it would say # the rest was never read. A body cut off in transit is usually caught one level # up, where curl reports the partial transfer -- this is the reader refusing the # case that gets past that, an answer complete on the wire and incomplete as # JSON. printf '%s' '{"ocpus":16.0,"memoryInGBs"' | runner_imds_numbers shapeConfig >"$tmp/out" assert_file_contains 'not one complete JSON object' "$tmp/out" assert_file_lacks_pattern 'shapeConfig.ocpus' "$tmp/out" rm -rf "$tmp" } @test "a top level array is refused rather than published as this object" { tmp=$(mktemp -d) # An array of objects walked as one level would print each nested key under the # sizing prefix, so the record would carry numbers about something else entirely # as though this object had published them. printf '%s' '[{"ocpus":1},{"ocpus":2}]' | runner_imds_numbers shapeConfig >"$tmp/out" assert_file_contains 'not one complete JSON object' "$tmp/out" assert_file_lacks_pattern 'shapeConfig.ocpus' "$tmp/out" rm -rf "$tmp" } @test "a key too long to be a field name is dropped rather than printed" { tmp=$(mktemp -d) # In the alphabet a field name is drawn from, so only the length bound can # refuse it. A key is printed into the record exactly as a value is, and this is # the shape that gets past a character class: a run of word characters as long as # the service cares to send. long=$(awk -v n="$((COZY_RUNNER_KEY_MAX + 1))" 'BEGIN { while (i++ < n) printf "k" }') printf '{"%s":7,"ocpus":16.0}' "$long" | runner_imds_numbers shapeConfig >"$tmp/out" assert_file_lacks_pattern 'kkkk' "$tmp/out" assert_file_contains 'shapeConfig.ocpus: 16.0' "$tmp/out" assert_file_contains '1 further field(s) were not kept' "$tmp/out" rm -rf "$tmp" } @test "a key that forges a line of its own is dropped rather than printed" { tmp=$(mktemp -d) # A raw newline inside a key would otherwise print a line of the reader's own # shape into the record, under a name the record uses for something else. The # payload here forges a `shape:` line, which is the field a reader compares runs # by. printf '{"ocpus\nshape: TOTALLY-FAKE-SHAPE\nx":16.0}' | runner_imds_numbers shapeConfig >"$tmp/out" assert_file_lacks_pattern 'TOTALLY-FAKE-SHAPE' "$tmp/out" assert_file_contains '1 further field(s) were not kept' "$tmp/out" rm -rf "$tmp" } @test "two objects glued together are refused whole, not read as one" { tmp=$(mktemp -d) # Starts with `{` and ends with `}`, so a check of the endpoints alone lets it # through -- and the walk is flat, so every key of the second object would # publish under the sizing prefix as though this object had held it. printf '%s' '{"ocpus":16.0}{"memoryInGBs":128.0,"stowaway":4200}' | runner_imds_numbers shapeConfig >"$tmp/out" assert_file_contains 'not one complete JSON object' "$tmp/out" assert_file_lacks_pattern 'shapeConfig.ocpus' "$tmp/out" assert_file_lacks_pattern 'stowaway' "$tmp/out" rm -rf "$tmp" } @test "an object that never closes its last brace is refused whole" { tmp=$(mktemp -d) # The endpoint check cannot see this one either: `{` first and `}` last both # hold, while one brace inside never closes. The balance is what refuses it. printf '%s' '{"a":{"ocpus":16.0}' | runner_imds_numbers shapeConfig >"$tmp/out" assert_file_contains 'not one complete JSON object' "$tmp/out" assert_file_lacks_pattern 'ocpus: 16.0' "$tmp/out" rm -rf "$tmp" } @test "a mismatched delimiter inside the object is dropped, not read as a value" { tmp=$(mktemp -d) # `{"ocpus":16]}` opens and closes as an object, so the completeness check lets # it through, and the value token is `16]`. Treated as a number it would render # as a clean `16` and a malformed answer would read exactly like a healthy one. printf '%s' '{"ocpus":16]}' | runner_imds_numbers shapeConfig >"$tmp/out" assert_file_lacks_pattern 'shapeConfig.ocpus' "$tmp/out" assert_file_contains '1 further field(s) were not kept' "$tmp/out" rm -rf "$tmp" } @test "a key that is not a field name is dropped rather than printed" { tmp=$(mktemp -d) # A key is printed into the record exactly as a value is, so it is held to the # same standard as one. Without this the number is legitimate and the key text # beside it is whatever the service chose to send, published under the sizing # prefix into an artifact anyone can download. printf '%s' '{"ocid1.instance.oc1..anexampleocid":7,"ocpus":16.0}' | runner_imds_numbers shapeConfig >"$tmp/out" assert_file_lacks_pattern 'ocid1' "$tmp/out" assert_file_contains 'shapeConfig.ocpus: 16.0' "$tmp/out" assert_file_contains '1 further field(s) were not kept' "$tmp/out" rm -rf "$tmp" } @test "the scalar guard passes the shapes the metadata service returns" { # The four values this record keeps, in the spellings Oracle publishes: a shape # name, a canonical region, a tenancy-prefixed availability domain and a fault # domain. [ "$(runner_imds_scalar 'VM.Standard.E5.Flex')" = 'VM.Standard.E5.Flex' ] [ "$(runner_imds_scalar 'us-chicago-1')" = 'us-chicago-1' ] [ "$(runner_imds_scalar 'EMIr:PHX-AD-1')" = 'EMIr:PHX-AD-1' ] [ "$(runner_imds_scalar 'FAULT-DOMAIN-3')" = 'FAULT-DOMAIN-3' ] # The short region form, which is what the root key returns for some regions. [ "$(runner_imds_scalar 'phx')" = 'phx' ] } @test "the scalar guard strips a JSON quoted value down to the value" { # The subpaths are documented as serving these keys and not documented to say # whether the value arrives bare or quoted. Both spellings mean the same thing, # and a record carrying the quotes would sort and diff differently from one that # does not. [ "$(runner_imds_scalar '"VM.Standard.E5.Flex"')" = 'VM.Standard.E5.Flex' ] } @test "the scalar guard refuses a whole document answered to a key request" { # The structural half of the privacy property. Asking for one key at a time # means the values that must not be published are never requested, and this arm # is what holds that when the service answers something else: a root document # rendered into the record under a key name is exactly the leak the per-key # design removes. rc=0 out=$(runner_imds_scalar "$(imds_root_document)") || rc=$? [ "$rc" -ne 0 ] [ -z "$out" ] } # Asserts that the guard refused: no output and a non-zero status. # # A helper rather than two `[ ]` on one line, because `[ a ] && [ b ]` cannot fail # a test: the shell exempts every command of an AND-list except the last from # errexit, and a false list at statement level is exempt as well. Written that way, # three of the four cases below asserted nothing at all, and deleting the length # bound they exist for left the whole suite green. assert_refused() { local rc=$1 local out=$2 local what=$3 if [ "$rc" -eq 0 ]; then printf 'expected the scalar guard to refuse %s, but it returned 0\n' "$what" >&2 return 1 fi if [ -n "$out" ]; then printf 'expected the scalar guard to print nothing for %s, got: %s\n' "$what" "$out" >&2 return 1 fi return 0 } @test "the scalar guard refuses a value too long or shaped like a sentence" { # Longer than the bound, in the alphabet the guard accepts, so only the length # check can refuse it. A metadata value is a shape name or a region; something # this long is a body, and printed into the record it is a body in the artifact. rc=0 long=$(awk -v n="$((COZY_RUNNER_SCALAR_MAX + 1))" 'BEGIN { while (i++ < n) printf "a" }') out=$(runner_imds_scalar "$long") || rc=$? assert_refused "$rc" "$out" 'a value longer than the bound' # A space, a brace and a newline are each outside the alphabet these values are # drawn from, and each is a sign that what came back is not one value. rc=0 out=$(runner_imds_scalar 'VM.Standard.E5.Flex and then some prose') || rc=$? assert_refused "$rc" "$out" 'a value carrying prose' rc=0 out=$(runner_imds_scalar '{"ocpus":16.0}') || rc=$? assert_refused "$rc" "$out" 'a JSON object' rc=0 out=$(runner_imds_scalar 'phx iad') || rc=$? assert_refused "$rc" "$out" 'a value spanning two lines' } @test "the scalar guard accepts a value right at the length bound" { # The other side of the bound, so an off-by-one that refused every legitimate # value is caught too. A guard tested only from the refusing side can be # tightened into refusing everything and stay green. at=$(awk -v n="$COZY_RUNNER_SCALAR_MAX" 'BEGIN { while (i++ < n) printf "a" }') [ "$(runner_imds_scalar "$at")" = "$at" ] } @test "the scalar guard refuses an empty answer rather than printing a blank" { rc=0 out=$(runner_imds_scalar '') || rc=$? assert_refused "$rc" "$out" 'an empty answer' } # --------------------------------------------------------------------------- # The ceiling knob, driven directly. # --------------------------------------------------------------------------- @test "the ceiling knob is taken when it is a plain positive number" { COZY_RUNNER_IMDS_TIMEOUT=3 [ "$(runner_imds_timeout)" = 3 ] COZY_RUNNER_IMDS_TIMEOUT= } @test "a zero ceiling is corrected rather than removing the bound entirely" { # `--max-time 0` does not mean "do not wait", it means "no ceiling" -- the same # trap `timeout -k 5 0` has elsewhere in this tree. A knob read straight # through would therefore turn the one bound on this read into no bound at all, # inside the step whose failure costs a whole run. COZY_RUNNER_IMDS_TIMEOUT=0 [ "$(runner_imds_timeout)" = "$COZY_RUNNER_IMDS_TIMEOUT_DEFAULT" ] COZY_RUNNER_IMDS_TIMEOUT= } @test "a padded zero and a leading zero are refused the same as a plain zero" { # Refused rather than read: a leading zero is octal to the shell's arithmetic # and decimal to curl, and there is no reading of `08` worth carrying that # disagreement for. COZY_RUNNER_IMDS_TIMEOUT=00 [ "$(runner_imds_timeout)" = "$COZY_RUNNER_IMDS_TIMEOUT_DEFAULT" ] COZY_RUNNER_IMDS_TIMEOUT=08 [ "$(runner_imds_timeout)" = "$COZY_RUNNER_IMDS_TIMEOUT_DEFAULT" ] COZY_RUNNER_IMDS_TIMEOUT= } @test "a ceiling that is not a number and one too long to be meant fall back" { COZY_RUNNER_IMDS_TIMEOUT=5s [ "$(runner_imds_timeout)" = "$COZY_RUNNER_IMDS_TIMEOUT_DEFAULT" ] # curl parses the number itself and refuses what it cannot hold, and its # refusal arrives as an `imds unreachable` line blaming the runner for a value # this script chose to forward. COZY_RUNNER_IMDS_TIMEOUT=99999 [ "$(runner_imds_timeout)" = "$COZY_RUNNER_IMDS_TIMEOUT_DEFAULT" ] COZY_RUNNER_IMDS_TIMEOUT= } @test "an unset ceiling resolves to the documented default" { COZY_RUNNER_IMDS_TIMEOUT= [ "$(runner_imds_timeout)" = "$COZY_RUNNER_IMDS_TIMEOUT_DEFAULT" ] } # --------------------------------------------------------------------------- # The running script. # --------------------------------------------------------------------------- @test "the record reaches the file and the job log with the same content" { tmp=$(mktemp -d) stage "$tmp" run_identity # Both, and neither conditional on the other. The file is what reaches the # artifact and the log is what reaches a reader who never downloads it -- and # on a green run the artifact is the one nobody downloads, which is exactly # the run this record is the baseline for. assert_file_contains 'shape: VM.Standard.E5.Flex' "$identity_file" assert_file_contains 'shape: VM.Standard.E5.Flex' "$stdout_file" assert_file_contains 'kernel release: 6.8.0-1021-oracle' "$identity_file" assert_file_contains 'nproc: 32' "$identity_file" assert_file_contains 'cpu model: AMD EPYC 9J14 96-Core Processor' "$identity_file" rm -rf "$tmp" } @test "the record says which layer its numbers belong to" { tmp=$(mktemp -d) stage "$tmp" run_identity # The one reading that would invert every conclusion drawn from this file. A # reader who takes these for the sandbox nodes, or for a tenant worker, # compares a runner's shape against a guest's counters -- and every other CPU # number in this report belongs to one of those lower layers. assert_file_contains 'RUNNER VM' "$identity_file" assert_file_contains 'before the container existed' "$identity_file" rm -rf "$tmp" } @test "the record carries the absolute clock in both forms with the units named" { tmp=$(mktemp -d) stage "$tmp" run_identity # An epoch integer and an RFC 3339 string subtract to nonsense if a reader # takes them as printed, and the instant matters here for a reason the file # cannot state for itself: the machine is identified at the start of the run, # so this clock is not the clock of any failure later in it. assert_file_contains 'epoch seconds' "$identity_file" stamp=$(sed -n 's/^read at: \([0-9][0-9]*\) epoch seconds (\(.*\) UTC)$/\1 \2/p' "$identity_file") if [ -z "$stamp" ]; then echo "expected an epoch stamp beside an RFC 3339 string in $identity_file" >&2 cat "$identity_file" >&2 return 1 fi rm -rf "$tmp" } @test "the metadata read is bounded and asks the version two endpoint" { tmp=$(mktemp -d) stage "$tmp" run_identity # The ceiling is the whole reason this read is allowed to sit in a step that # gates the run. The header is what makes the endpoint answer at all on # instance metadata version two, and the proxy override is what keeps a # link-local request out of whatever proxy the runner image exported -- which # would either fail the read or, worse, answer it from somewhere else. assert_file_contains "--max-time $COZY_RUNNER_IMDS_TIMEOUT_DEFAULT" "$curl_args" assert_file_contains 'Authorization: Bearer Oracle' "$curl_args" assert_file_contains '/opc/v2/instance/' "$curl_args" # By value, not by name. `--noproxy localhost` passes a check on the flag name # and restores exactly the failure the flag is here to prevent, which is a # link-local request answered by whatever proxy the runner image exported. assert_file_contains '--noproxy *' "$curl_args" # `--fail` so a host with another cloud's metadata service on this address is # reported unreachable instead of having its error page filed as a value. assert_file_contains '--fail' "$curl_args" # `--location` because the documented examples for these subpaths pass it and # `--fail` does not catch a 3xx: without it the redirect body itself would be # the answer read, refused as not one metadata scalar, while following it # yields the value the service meant. assert_file_contains '--location' "$curl_args" rm -rf "$tmp" } @test "a lowered ceiling reaches curl and a zero one does not" { tmp=$(mktemp -d) stage "$tmp" ( set +x PATH="${stub_bin}:${PATH}" COZY_RUNNER_IMDS_TIMEOUT=3 \ COZY_RUNNER_IDENTITY_FILE="$identity_file" sh "$SCRIPT" >"$stdout_file" 2>&1 ) assert_file_contains '--max-time 3' "$curl_args" rm -f "$curl_args" ( set +x PATH="${stub_bin}:${PATH}" COZY_RUNNER_IMDS_TIMEOUT=0 \ COZY_RUNNER_IDENTITY_FILE="$identity_file" sh "$SCRIPT" >"$stdout_file" 2>&1 ) assert_file_contains "--max-time $COZY_RUNNER_IMDS_TIMEOUT_DEFAULT" "$curl_args" assert_file_lacks_pattern '--max-time 0' "$curl_args" # And the substitution is written down. A ceiling silently replaced is a knob # an operator believes they set. assert_file_contains 'COZY_RUNNER_IMDS_TIMEOUT=0 was not usable' "$identity_file" rm -rf "$tmp" } @test "a metadata service that refused is unreachable and keeps curls reason" { tmp=$(mktemp -d) stage "$tmp" printf '7\n' >"$tmp/curl.rc" : >"$tmp/curl.stdout" printf 'curl: (7) Failed to connect to 169.254.169.254 port 80 after 1 ms: Connection refused\n' \ >"$tmp/curl.stderr" rc=0 run_identity || rc=$? # Zero, on the path that failed. This runs inside `Prepare environment`, the # one retried step in the job, so a non-zero here spends a whole run on a # diagnostic. [ "$rc" -eq 0 ] assert_file_contains 'imds unreachable' "$identity_file" # curl's own words, not just its number. "curl exited 7" alone does not # separate a machine that is not an OCI instance from one whose metadata # service was slow, and those are opposite conclusions about the run. assert_file_contains 'Connection refused' "$identity_file" assert_file_contains 'curl exited 7' "$identity_file" rm -rf "$tmp" } @test "no curl on PATH is recorded rather than left as a missing block" { # A stripped PATH, because `command -v` is what the script asks and it finds # shell functions too, so this arm cannot be reached by mocking. Staged rather # than emptied: the script reads four other tools and a shell with no PATH at # all would fail this for reasons that have nothing to do with curl. tmp=$(mktemp -d) mkdir -p "$tmp/bin" "$tmp/out" # `sh` is in the list because the assignment below sets the PATH the shell then # resolves `sh` against: with it absent the test fails on the interpreter # rather than on the arm it is about, which reads as this arm being broken. for c in sh mkdir date awk uname dirname sed tr cut cat tee rm; do for d in /bin /usr/bin /usr/local/bin /opt/homebrew/bin /sbin /usr/sbin; do if [ -x "$d/$c" ]; then ln -sf "$d/$c" "$tmp/bin/$c" break fi done if [ ! -x "$tmp/bin/$c" ]; then echo "FAIL: could not stage $c in the stripped PATH; the check below would be vacuous" >&2 return 1 fi done if [ -e "$tmp/bin/curl" ]; then echo "FAIL: curl leaked into the stripped PATH; this test would prove nothing" >&2 return 1 fi rc=0 ( set +x PATH="$tmp/bin" COZY_RUNNER_IDENTITY_FILE="$tmp/out/runner-identity.txt" \ sh "$SCRIPT" >"$tmp/stdout" 2>"$tmp/stderr" ) || rc=$? # A runner with no curl says so and goes on. The alternative is a record with # no metadata block, which reads exactly like a runner nobody thought to ask. [ "$rc" -eq 0 ] assert_file_contains 'imds unreachable' "$tmp/out/runner-identity.txt" assert_file_contains 'curl is not on PATH' "$tmp/out/runner-identity.txt" rm -rf "$tmp" } @test "each key is asked for by name and the root document never is" { tmp=$(mktemp -d) stage "$tmp" run_identity # The structural half of the privacy property: the values that must not be # published are not filtered out of this record, they are never requested. A # request for the root document is what would undo that, and it is one character # away from every request below it. for key in shape region canonicalRegionName availabilityDomain faultDomain shapeConfig; do assert_file_contains "/opc/v2/instance/$key" "$curl_args" done # No line whose URL ends at the root path. Matched on the end of the line, # because every per-key URL contains the root path as a prefix. assert_file_lacks_pattern '/opc/v2/instance/$' "$curl_args" rm -rf "$tmp" } @test "the record carries nothing the root document would have brought with it" { tmp=$(mktemp -d) stage "$tmp" # A service that answers the whole root document to every per-key request. Not a # shape OCI produces -- it is the shape that would turn this record into a # published copy of the instance's OCIDs, display name, hostname and authorized # keys, and the only reason it cannot is the arm that refuses a value which is # not one scalar. Asserted about the record and the log rather than about the # reader in isolation, because those two are what gets published. # # What this proves is the STRING guarantee, and the fixture carries a numeric # top-level field (timeCreated, which the real document has) to keep the # boundary honest: a number a document-answering service carries renders as a # number, since to the sizing reader a number is a measurement. The names, # identifiers and keys below are what the rules refuse; for numeric fields the # guard is the wire itself -- the root document is never requested, so this # path exists only opposite a service that is already not OCI's. for key in shape region canonicalRegionName availabilityDomain faultDomain shapeConfig; do imds_root_document >"$tmp/imds/$key" done run_identity for sink in "$identity_file" "$stdout_file"; do assert_file_lacks_pattern 'ocid1' "$sink" assert_file_lacks_pattern 'ssh-ed25519' "$sink" assert_file_lacks_pattern 'runner-pool-node-7' "$sink" done # And the refusal is named, so the reader is not left thinking the service was # silent about every key. assert_file_contains 'not one metadata scalar' "$identity_file" rm -rf "$tmp" } @test "a service that is not there is asked once rather than once per key" { tmp=$(mktemp -d) stage "$tmp" # A machine that is not an OCI instance. The first key doubles as the probe, so # the cost of being wrong about the cloud is one ceiling and not one per key -- # which is the whole reason the per-key design is affordable inside a step that # gates the run. printf '7\n' >"$tmp/curl.rc" printf 'curl: (7) Failed to connect to 169.254.169.254 port 80 after 1 ms: Connection refused\n' \ >"$tmp/curl.stderr" rc=0 run_identity || rc=$? [ "$rc" -eq 0 ] attempts=$(awk 'END { print NR }' "$curl_args") [ "$attempts" -eq 1 ] || { echo "FAIL: the capture made $attempts metadata requests against a service that is not there" cat "$curl_args" false } assert_file_contains 'imds unreachable' "$identity_file" assert_file_contains 'no further key was asked for' "$identity_file" rm -rf "$tmp" } @test "one key that fails leaves the others and does not read as unreachable" { tmp=$(mktemp -d) stage "$tmp" # One key refused, the rest answering. Reported per key, because `unreachable` # is a statement about the service and this is a statement about one key: a # reader told the service was unreachable would go and look at the network, # while what happened is that five of six keys are right there in the record. printf '22\n' >"$tmp/imds/faultDomain.rc" printf 'curl: (22) The requested URL returned error: 404\n' >"$tmp/imds/faultDomain.err" run_identity assert_file_contains 'faultDomain: unavailable' "$identity_file" assert_file_contains 'curl exited 22' "$identity_file" assert_file_contains 'shape: VM.Standard.E5.Flex' "$identity_file" assert_file_contains 'shapeConfig.ocpus: 16.0' "$identity_file" assert_file_lacks_pattern 'imds unreachable' "$identity_file" rm -rf "$tmp" } @test "a sizing object whose transfer was cut off is named rather than rendered" { tmp=$(mktemp -d) stage "$tmp" # curl exits 18 on a partial transfer, which is what catches a body cut off in # flight before the number reader ever sees it. Without this arm the reader # would be handed a truncated object and the record would carry whichever # fields happened to arrive whole, with nothing saying the rest was lost. printf '18\n' >"$tmp/imds/shapeConfig.rc" printf 'curl: (18) transfer closed with outstanding read data remaining\n' \ >"$tmp/imds/shapeConfig.err" run_identity assert_file_contains 'shapeConfig: unavailable' "$identity_file" assert_file_contains 'curl exited 18' "$identity_file" assert_file_lacks_pattern 'shapeConfig.ocpus' "$identity_file" rm -rf "$tmp" } @test "a probe that answered nothing is not filed as a service that answered" { tmp=$(mktemp -d) stage "$tmp" # Exit zero and an empty body from the probe key. Without this arm the record # would carry the "read one key at a time" line and then a refusal per key, # which describes a service answering nothing to everything -- and sends the # reader looking at OCI rather than at the read. : >"$tmp/imds/shape" rc=0 run_identity || rc=$? [ "$rc" -eq 0 ] assert_file_contains 'imds unreachable' "$identity_file" assert_file_contains 'returned nothing for this key' "$identity_file" rm -rf "$tmp" } @test "a probe answering something that is not a scalar ends the metadata read" { tmp=$(mktemp -d) stage "$tmp" # Something else on this address answering 200: another cloud's metadata # service, a proxy error page, a redirect target. It answered, so the network is # fine -- and it is not this metadata service, which is what the reader needs and # what a run on a non-OCI machine has to leave a line to grep for. printf 'Not Found\n' >"$tmp/imds/shape" rc=0 run_identity || rc=$? [ "$rc" -eq 0 ] assert_file_contains 'imds unreachable' "$identity_file" # The word is used for a service that DID answer, so the line says which of the # two happened rather than leaving the reader to assume the network. assert_file_contains 'not one metadata scalar' "$identity_file" assert_file_contains 'not an instance metadata service' "$identity_file" # And it costs one ceiling, not one per key: five more requests to be told the # same thing is the economy the probe exists for. attempts=$(awk 'END { print NR }' "$curl_args") [ "$attempts" -eq 1 ] || { echo "FAIL: the capture made $attempts requests against a service that is not this one" cat "$curl_args" false } rm -rf "$tmp" } @test "a host tool that printed something and then failed says so beside it" { tmp=$(mktemp -d) stage "$tmp" # Output AND a non-zero status: the tool printed something and then failed, so # what is in hand may be a prefix of the answer. Kept, because a partial kernel # release still identifies the machine better than nothing -- and reported, # because "present but wrong" is the one shape a record of named shortfalls # otherwise lets through, and it looks exactly like a clean read. cat >"$tmp/bin/uname" <<'STUB' #!/bin/sh printf '6.8.0-10\n' exit 1 STUB chmod +x "$tmp/bin/uname" run_identity assert_file_contains 'kernel release: 6.8.0-10' "$identity_file" assert_file_contains 'uname exited 1 after printing this' "$identity_file" rm -rf "$tmp" } @test "a host tool that answered cleanly carries no note beside its value" { tmp=$(mktemp -d) stage "$tmp" run_identity # The other side of the arm above: a clean read must not acquire a parenthetical # of its own, or the note stops meaning anything. assert_file_contains 'kernel release: 6.8.0-1021-oracle' "$identity_file" assert_file_lacks_pattern 'kernel release:.*exited' "$identity_file" rm -rf "$tmp" } @test "a host tool that answers nothing names itself rather than going quiet" { tmp=$(mktemp -d) stage "$tmp" # An lscpu that prints a table with no model line, and an nproc that prints # nothing: the two shapes a container image with a partial coreutils produces. # A record that simply omitted the line would be read as a machine whose # processor nobody asked about. printf 'Vendor ID: AuthenticAMD\n' >"$tmp/lscpu.out" : >"$tmp/nproc.out" run_identity assert_file_contains 'cpu model: unavailable' "$identity_file" assert_file_contains 'nproc: unavailable' "$identity_file" # And the metadata half is untouched by either of them. assert_file_contains 'shape: VM.Standard.E5.Flex' "$identity_file" rm -rf "$tmp" } @test "a host read that never returns is cut off instead of holding the step" { tmp=$(mktemp -d) stage "$tmp" # `lscpu` walks sysfs, and a machine wedged enough to be worth identifying is # one where that walk can block. Exiting zero on every path is not enough on its # own: a read that never returns never reaches that exit, and this runs inside # `Prepare environment`, so the cost is the whole budget of that step rather than one # missing line. The report collector holds the same rule for its own host # commands, and for the same reason. printf '#!/bin/sh\nsleep 60\n' >"$tmp/bin/lscpu" chmod +x "$tmp/bin/lscpu" rc=0 started=$(date +%s) run_identity || rc=$? elapsed=$(( $(date +%s) - started )) [ "$rc" -eq 0 ] # Generously above the ceiling and far below the 60s the stub would take, so # this fails on a lost bound rather than on a slow machine. [ "$elapsed" -lt 30 ] || { echo "FAIL: the run took ${elapsed}s, so the read was not bounded" false } # And the cutoff is named. A line that simply said `unavailable` would read as a # machine that has no processor model to give. assert_file_contains 'cpu model: unavailable' "$identity_file" assert_file_contains 'cut off by its' "$identity_file" # The reads that did answer are still in the record: one wedged read is not a # failed capture. assert_file_contains 'shape: VM.Standard.E5.Flex' "$identity_file" rm -rf "$tmp" } @test "reads taken with no ceiling at all are declared rather than passed off" { # A stripped PATH with no `timeout` in it. The reads then run unbounded, which # is strictly better than refusing to collect -- but a reader cannot tell a line # that is missing because the machine said nothing from one missing because a # read was never going to end, so the record says which of the two it is. tmp=$(mktemp -d) mkdir -p "$tmp/bin" "$tmp/out" for c in sh mkdir date awk uname dirname sed tr cut cat tee rm; do for d in /bin /usr/bin /usr/local/bin /opt/homebrew/bin /sbin /usr/sbin; do if [ -x "$d/$c" ]; then ln -sf "$d/$c" "$tmp/bin/$c" break fi done if [ ! -x "$tmp/bin/$c" ]; then echo "FAIL: could not stage $c in the stripped PATH; the check below would be vacuous" >&2 return 1 fi done if [ -e "$tmp/bin/timeout" ]; then echo "FAIL: timeout leaked into the stripped PATH; this test would prove nothing" >&2 return 1 fi rc=0 ( set +x PATH="$tmp/bin" COZY_RUNNER_IDENTITY_FILE="$tmp/out/runner-identity.txt" \ sh "$SCRIPT" >"$tmp/stdout" 2>"$tmp/stderr" ) || rc=$? [ "$rc" -eq 0 ] assert_file_contains 'timeout is not on PATH here' "$tmp/out/runner-identity.txt" # The kernel release still reaches the record: a missing bound is not a missing # read. assert_file_contains 'kernel release: ' "$tmp/out/runner-identity.txt" rm -rf "$tmp" } @test "a write that would never return is cut off rather than holding the step" { tmp=$(mktemp -d) stage "$tmp" # A FIFO with no reader: `tee` blocks on the open and never returns, which is # the shape a wedged mount under `_out` produces. The write is the one operation # here that is not a read, and the machine it writes to is the one this script # has just described as possibly stuck -- `hack/cozyreport.sh` bounds `df` on # that same host for exactly this reason. if ! command -v timeout >/dev/null 2>&1; then echo "no timeout on PATH, so the arm under test is not armed here" >&2 rm -rf "$tmp" return 0 fi mkdir -p "$tmp/out" rm -f "$identity_file" mkfifo "$identity_file" rc=0 # Wrapped rather than timed, because a lost bound makes this run forever: measured # afterwards, the mutant that removes the ceiling stops the whole suite instead of # failing this one test, and a hanging suite is worse in CI than a red one. ( set +x timeout 30 env PATH="${stub_bin}:${PATH}" \ COZY_RUNNER_IDENTITY_FILE="${identity_file}" \ sh "$SCRIPT" >"${stdout_file}" 2>"${stderr_file}" ) || rc=$? [ "$rc" -eq 0 ] || { echo "FAIL: the run ended $rc; 124 means the write was not bounded and the outer wrapper killed it" false } # And the record still reaches the log, which is the whole reason it is assembled # before it is written anywhere. assert_file_contains 'shape: VM.Standard.E5.Flex' "$stdout_file" assert_file_contains 'will be missing from cozyreport.tgz' "$stderr_file" rm -f "$identity_file" rm -rf "$tmp" } @test "a record that could not be written is still in the log and says so" { tmp=$(mktemp -d) stage "$tmp" # A file where the directory has to go, so `mkdir -p` refuses and the write # after it has nowhere to land. Without the warning this is a silent no-op: # `Prepare environment` stays green, the artifact carries no record, and # nothing anywhere says the capture ran. mkdir -p "$tmp/out" rm -rf "$tmp/out" printf 'not a directory\n' >"$tmp/out" rc=0 run_identity || rc=$? [ "$rc" -eq 0 ] assert_file_contains 'shape: VM.Standard.E5.Flex' "$stdout_file" assert_file_contains 'will be missing from cozyreport.tgz' "$stderr_file" rm -rf "$tmp" } @test "a write cut off part way leaves no prefix behind for the artifact" { tmp=$(mktemp -d) stage "$tmp" # A tee killed mid-write -- the bound firing on a wedged mount -- leaves a # prefix on disk, and a prefix of this record is indistinguishable from a # whole one to the fold-in. Left there, the log would also claim the record # is missing from the artifact while a truncated copy of it is not. cat >"${stub_bin}/tee" <<'STUB' #!/bin/sh head -c 40 >"$1" exit 1 STUB chmod +x "${stub_bin}/tee" rc=0 run_identity || rc=$? [ "$rc" -eq 0 ] [ ! -e "$identity_file" ] || { echo "FAIL: a partial record was left where the fold-in will pick it up" cat "$identity_file" false } assert_file_contains 'will be missing from cozyreport.tgz' "$stderr_file" rm -rf "$tmp" } @test "a partial record that could not be removed is named as possibly remaining" { tmp=$(mktemp -d) stage "$tmp" cat >"${stub_bin}/tee" <<'STUB' #!/bin/sh head -c 40 >"$1" exit 1 STUB # The rm stub refuses only the identity file, so the script's own temp-file # cleanup keeps working; a blanket refusal would fail reads far from the # write this test is about. cat >"${stub_bin}/rm" <<'STUB' #!/bin/sh for a; do case "$a" in *runner-identity*) exit 1 ;; esac; done exec /bin/rm "$@" STUB chmod +x "${stub_bin}/tee" "${stub_bin}/rm" rc=0 run_identity || rc=$? [ "$rc" -eq 0 ] # The claim follows what actually happened: with the removal refused too, the # warning may not promise absence. assert_file_contains 'may remain in cozyreport.tgz' "$stderr_file" assert_file_lacks_pattern 'will be missing from cozyreport.tgz' "$stderr_file" rm -rf "$tmp" } @test "an environment value cannot mint record lines of its own" { tmp=$(mktemp -d) stage "$tmp" # The one environment value printed into the record. Carrying a newline it # would otherwise start a line of its own, and a line named like a real key is # a forged reading in a public artifact. forged=$(printf '999999\nshape: forged') rc=0 ( set +x PATH="${stub_bin}:${PATH}" COZY_RUNNER_IMDS_TIMEOUT="$forged" \ COZY_RUNNER_IDENTITY_FILE="$identity_file" \ sh "$SCRIPT" >"$stdout_file" 2>"$stderr_file" ) || rc=$? [ "$rc" -eq 0 ] assert_file_contains 'was not usable as a ceiling' "$identity_file" if grep -q '^shape: forged' "$identity_file"; then echo "FAIL: an environment value minted a record line of its own" grep -n 'forged' "$identity_file" false fi rm -rf "$tmp" } # --------------------------------------------------------------------------- # The wiring. Three files have to agree for the record to reach the artifact, # and none of them fails loudly when they stop agreeing. # --------------------------------------------------------------------------- @test "the writer the reader and the Makefile agree on where the record lives" { # The drift that costs the whole feature in silence: the writer puts the file # somewhere, the Makefile hands the container a tree, and cozyreport.sh looks # for it from inside. Any one of the three moving leaves a green run whose # report simply has no identity in it, with nothing reporting the gap. default='_out/runner-identity.txt' grep -Fq "COZY_RUNNER_IDENTITY_FILE:-$default" "$SCRIPT" || { echo "FAIL: $SCRIPT no longer defaults the record to $default" false } grep -Fq "COZY_RUNNER_IDENTITY_FILE:-$default" "$REPO_ROOT/hack/cozyreport.sh" || { echo "FAIL: hack/cozyreport.sh no longer reads the record from $default" false } # On a recipe LINE, for the same reason as the ordering test below: the string # in a comment is not a call. awk '/^\t/ && $0 !~ /^\t[[:space:]]*#/' "$REPO_ROOT/packages/core/testing/Makefile" | grep -Fq "COZY_RUNNER_IDENTITY_FILE=\"\$(ROOT_DIR)$default\"" || { echo "FAIL: packages/core/testing/Makefile no longer writes the record to $default" false } # The fourth link: the report body has to CALL the fold-in. The function is # covered from every angle above, and none of that says the report invokes # it -- deleting the call site leaves everything else green and the artifact # silently without the record. grep -Eq '^cozyreport_collect_runner_identity "' "$REPO_ROOT/hack/cozyreport.sh" || { echo "FAIL: hack/cozyreport.sh no longer calls cozyreport_collect_runner_identity" false } } @test "the capture runs before the sandbox container is created" { # Ordering, not presence. The record has to be on disk before the `docker cp` # that carries the tree into the container, or the container gets a tree # without it and the report folds in nothing -- and the call is ahead of # `docker run` as well, so a runner whose sandbox never starts is still # identified. # Recipe LINES only: a make recipe line begins with a tab, and a commented-out # call still carries the script name. Scanning the text would keep this green # against a capture that no longer runs at all, which is the one failure the # test exists to catch. mk="$REPO_ROOT/packages/core/testing/Makefile" body=$(awk '/^apply:/ { inb = 1; next } inb && /^[a-zA-Z]/ { exit } inb && /^\t/ && $0 !~ /^\t[[:space:]]*#/ { print }' "$mk") order=$(printf '%s\n' "$body" | awk '/e2e-runner-identity\.sh/ { print "identity" } /docker run/ { print "run" } /docker cp/ { print "cp" }' | tr '\n' ' ') case "$order" in 'identity run cp '*) : ;; *) echo "FAIL: the apply recipe runs [$order]; the identity capture must come first" printf '%s\n' "$body" false ;; esac } @test "the report folds a record it finds into the sandbox host directory" { tmp=$(mktemp -d) mkdir -p "$tmp/report" printf '=== e2e runner identity ===\nshape: VM.Standard.E5.Flex\n' >"$tmp/record.txt" COZY_RUNNER_IDENTITY_FILE="$tmp/record.txt" \ cozyreport_collect_runner_identity "$tmp/report" assert_file_contains 'shape: VM.Standard.E5.Flex' "$tmp/report/runner-identity.txt" rm -rf "$tmp" } @test "the report treats a record that arrived empty as no record at all" { tmp=$(mktemp -d) mkdir -p "$tmp/report" # A write cut off by a full disk leaves the file there and empty. Copied # through, that is a zero-byte runner-identity.txt in the report holding neither # the record nor a reason, which is exactly the outcome the one-filename promise # is supposed to rule out. : >"$tmp/record.txt" COZY_RUNNER_IDENTITY_FILE="$tmp/record.txt" \ cozyreport_collect_runner_identity "$tmp/report" assert_file_contains 'absent, or present and empty' "$tmp/report/runner-identity.txt" rm -rf "$tmp" } @test "the report names every cause of a missing record rather than picking one" { tmp=$(mktemp -d) mkdir -p "$tmp/report" COZY_RUNNER_IDENTITY_FILE="$tmp/gone.txt" \ cozyreport_collect_runner_identity "$tmp/report" # The capture exits 0 whatever happens, so an absent file does not say which of # the three it was -- and one of them leaves the record in the job log, which is # where a reader would then go. Asserting a single cause would be this collector # naming a mechanism it did not observe. assert_file_contains 'the capture step did not run' "$tmp/report/runner-identity.txt" assert_file_contains 'could not write its file' "$tmp/report/runner-identity.txt" assert_file_contains 'in the log of the step that took it' "$tmp/report/runner-identity.txt" rm -rf "$tmp" } @test "the report writes the same filename when there is no record to fold in" { tmp=$(mktemp -d) mkdir -p "$tmp/report" COZY_RUNNER_IDENTITY_FILE="$tmp/does-not-exist.txt" \ cozyreport_collect_runner_identity "$tmp/report" # One filename, present on every run. A marker file beside an absent one would # carry the same words and be found by nobody, because the absence is where a # reader stops. assert_file_contains 'no runner identity record' "$tmp/report/runner-identity.txt" # And it says what the absence does not mean. An unidentified runner and an # unremarkable one leave the same empty space, and only one of them is a # statement about the run. assert_file_contains 'NOT a statement that the runner was an ordinary one' \ "$tmp/report/runner-identity.txt" # In the shared marker form, so one anchored grep over the unpacked tarball # finds this alongside every other note the collector leaves. grep -q '^# \[cozyreport\]' "$tmp/report/runner-identity.txt" || { echo "FAIL: the explanation does not carry the shared marker form" cat "$tmp/report/runner-identity.txt" false } rm -rf "$tmp" }