Ctrl K
ComunidadGuías…

hack/run-kubernetes-block-io_test.bats

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bats
# Regression coverage for the tenant-worker block IO capture in
# hack/e2e-chainsaw/_lib/run-kubernetes.sh. cozytest.sh ends an @test block at
# the first bare closing brace, so command mocks stay at top level.
#
# What this capture answers, and why neither the guest nor the other collectors
# can. A worker that burns its whole vCPU while making no progress is either
# computing and getting nowhere or waiting on blocks, and from outside the Pod
# the two look identical: a busy vCPU thread with a clean cgroup quota. The
# guest's own /proc/diskstats would settle it, and the diagnostics credential
# minted for the guest is os:reader, which Talos does not allow to read file
# contents at all. The kubelet's view of the Pod's cgroup is what remains, and it
# is outside the guest entirely.
#
# The families are the subject of two tests here, because what arrives depends on
# the cgroup version and the artifact has to stay readable either way. Under
# cgroup v2 the kernel's io.stat carries transferred bytes and completed
# operations and nothing else, so the queue-depth and service-time families
# cAdvisor declares for this group are either absent or filled from the node
# filesystem's own counters, which are not this Pod's. A zero read as an idle
# disk is the wrong conclusion this suite pins against.

load test_helper

kubectl_calls=/dev/null
kubectl_node_names=
kubectl_list_rc=0
kubectl_list_stderr=
kubectl_raw_rc=0
kubectl_raw_stderr=

# Row shapes and label sets are upstream cAdvisor's own golden exposition
# (lib/metrics/testdata/prometheus_metrics) with the identifying labels rewritten
# to a worker Pod's; the values are illustrative of direction rather than
# measured. A virt-launcher Pod was not available to sample. Two properties are
# load-bearing and both come from that golden file: the blkio family carries its
# own operation label with Read as one value, and the device label on every one
# of these families is a HOST device rather than anything the guest names.
worker_pod='virt-launcher-kubernetes-test-latest-version-md0-abc12-xyz34'
worker_labels='container="compute",device="/dev/drbd1000",id="/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod0000000a_0000_0000_0000_00000000000b.slice/cri-containerd-0000000000000000000000000000000000000000000000000000000000000001.scope",image="quay.io/kubevirt/virt-launcher:v1.8.4",name="0000000000000000000000000000000000000000000000000000000000000001",namespace="tenant-test",pod="'"${worker_pod}"'"'
kubectl_raw_output=

# The divisor on its own, because it is also a shape the wire produces on its
# own: cAdvisor publishes it for any container it knows, so a worker whose disk
# counters never arrived still puts this row in the file.
worker_start_time_row() {
  printf 'container_start_time_seconds{%s} 1.786656e+09 1786657157281\n' "${worker_labels}"
}

# The two shapes a cgroup v2 node puts on the wire for one worker: the byte and
# operation counters, plus the blkio family split by operation. The start-time row
# rides along because it is the divisor the counters need, and cAdvisor publishes
# it for any container it knows rather than as part of the disk group. Built rather
# than pasted so a family cannot drift by a typo in one copy.
worker_v2_series() {
  local read_bytes=8.8080384e+07
  worker_start_time_row
  printf 'container_fs_reads_bytes_total{%s} %s 1786657157281\n' "${worker_labels}" "${read_bytes}"
  printf 'container_fs_reads_total{%s} 2148 1786657157281\n' "${worker_labels}"
  printf 'container_fs_writes_bytes_total{%s} 1.048576e+06 1786657157281\n' "${worker_labels}"
  printf 'container_fs_writes_total{%s} 64 1786657157281\n' "${worker_labels}"
  printf 'container_blkio_device_usage_total{container="compute",device="/dev/drbd1000",major="147",minor="1000",namespace="tenant-test",operation="Read",pod="%s"} %s 1786657157281\n' "${worker_pod}" "${read_bytes}"
  printf 'container_blkio_device_usage_total{container="compute",device="/dev/drbd1000",major="147",minor="1000",namespace="tenant-test",operation="Write",pod="%s"} 1.048576e+06 1786657157281\n' "${worker_pod}"
}

# The service-time and queue-depth families as a cgroup v1 node would carry them.
# Kept as a fixture of its own because their absence is what the note in the
# artifact explains, and a suite that only ever stages them present would leave
# that explanation unexercised.
worker_v1_extra_series() {
  printf 'container_fs_read_seconds_total{%s} 33.9 1786657157281\n' "${worker_labels}"
  printf 'container_fs_write_seconds_total{%s} 4.1 1786657157281\n' "${worker_labels}"
  printf 'container_fs_io_current{%s} 3 1786657157281\n' "${worker_labels}"
  printf 'container_fs_io_time_seconds_total{%s} 41.2 1786657157281\n' "${worker_labels}"
  printf 'container_fs_io_time_weighted_seconds_total{%s} 118.7 1786657157281\n' "${worker_labels}"
}

# The IO pressure families as the kubelet publishes them when its pressure gate
# is on. PSI is accounted per cgroup rather than per device, so these rows carry
# the worker's identifying labels with no device label; derived from the shared
# label set rather than pasted so the identity cannot drift between fixtures.
worker_psi_series() {
  local psi_labels
  psi_labels=$(printf '%s' "${worker_labels}" | sed 's|device="[^"]*",||')
  printf 'container_pressure_io_stalled_seconds_total{%s} 12.4 1786657157281\n' "${psi_labels}"
  printf 'container_pressure_io_waiting_seconds_total{%s} 15.9 1786657157281\n' "${psi_labels}"
}

# Two rows that must not reach a file headed "worker": the node's own filesystem,
# which arrives with both selector labels empty, and the Kamaji apiserver, which
# shares the namespace and has a disk of its own.
non_worker_series() {
  printf 'container_fs_reads_bytes_total{container="",device="/dev/sda1",id="/",image="",name="",namespace="",pod=""} 4.294967296e+09 1786657157281\n'
  printf 'container_fs_reads_bytes_total{container="kube-apiserver",device="/dev/sda1",id="/kubepods.slice/x.scope",image="registry.k8s.io/kube-apiserver:v1.33.0",name="x",namespace="tenant-test",pod="kubernetes-test-latest-version-7d9f8b6c5d-abcde"} 5.24288e+08 1786657157281\n'
}

timeout_calls=/dev/null
timeout_fail_node=

kubectl() {
  printf '%s\n' "$*" >>"${kubectl_calls}"

  if [ "${3:-}" = get ] && [ "${4:-}" = pods ]; then
    [ -z "${kubectl_list_stderr}" ] || printf '%s\n' "${kubectl_list_stderr}" >&2
    [ "${kubectl_list_rc}" -eq 0 ] || return "${kubectl_list_rc}"
    [ -z "${kubectl_node_names}" ] || printf '%s\n' ${kubectl_node_names}
    return 0
  fi

  if [ "${1:-}" = get ] && [ "${2:-}" = --raw ]; then
    [ -z "${kubectl_raw_stderr}" ] || printf '%s\n' "${kubectl_raw_stderr}" >&2
    # Output before status: a read cut off part way has already written what it
    # managed to read, and a stub that returns first makes the branch labelling a
    # partial capture unreachable in tests while it stays the likeliest one in
    # production.
    [ -z "${kubectl_raw_output}" ] || printf '%s\n' "${kubectl_raw_output}"
    return "${kubectl_raw_rc}"
  fi

  return 0
}

timeout() {
  local command_rc=0
  printf '%s\n' "$*" >>"${timeout_calls}"
  # Return 97 rather than running the command when the wrapper is not the bounded
  # form: a read that lost its ceiling must not pass as a read.
  [ "${1:-}" = -k ] || return 97
  shift 3
  "$@" || command_rc=$?
  if [ -n "${timeout_fail_node}" ]; then
    case " $* " in
      *"${timeout_fail_node}"*) return 124 ;;
    esac
  fi
  return "${command_rc}"
}

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
  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 label".
  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 into an `if`, both land in the passing
  # branch and the assertion "this is absent" is satisfied by the matcher giving
  # up. Every negative claim in this file rests on the three-way split.
  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
      return 1
      ;;
    1) return 0 ;;
    *)
      printf 'awk could not evaluate pattern %s against %s\n' "${pattern}" "${file}" >&2
      return 1
      ;;
  esac
}

# The function under test redirects a command's stderr into a report file and
# decides which artifact to write from it. cozytest.sh runs under `set -x`, so
# the tracer's own line for that command lands on the very stderr being
# redirected, and every assertion about those files would be reading the tracer.
# Call through this wrapper so the sinks hold what production writes.
run_capture() {
  local _rc=0
  ( set +x; cozy_capture_tenant_worker_block_io ) || _rc=$?
  return "${_rc}"
}

@test "a negative assertion fails when its own matcher cannot evaluate" {
  # The helper under every "must not appear" claim here. What rides on it is this
  # collector's contract stated negatively: that an unread kubelet was not
  # written up as an idle disk, and that the node's own filesystem did not land
  # under a worker heading. A matcher that fails open turns each of those into a
  # sentence nobody checked.
  tmp=$(mktemp -d)
  printf 'anything\n' >"$tmp/subject"

  if assert_file_lacks_pattern '*(' "$tmp/subject" 2>/dev/null; then
    echo "FAIL: an unparseable pattern was reported as absent" >&2
    rm -rf "$tmp"
    return 1
  fi
  assert_file_lacks_pattern 'absent-string' "$tmp/subject"
  if assert_file_lacks_pattern 'anything' "$tmp/subject" 2>/dev/null; then
    echo "FAIL: a present pattern was reported as absent" >&2
    rm -rf "$tmp"
    return 1
  fi
  rm -rf "$tmp"
}

@test "the byte, operation and blkio families of a worker Pod are captured" {
  # The positive control every negative claim below is measured against. Families
  # are named one by one rather than counted, because a count passes while one is
  # silently missing from the filter -- and the blkio family is the one a filter
  # written from the fs_* names alone loses, while it is the only one carrying the
  # device major that identifies which volume the reads went to.
  . hack/e2e-chainsaw/_lib/run-kubernetes.sh
  tmp=$(mktemp -d)
  export COZY_REPORT_DIR="$tmp"
  COZY_SNAPSHOT_NAME=kubernetes-latest
  kubectl_calls="$tmp/kubectl.calls"
  timeout_calls="$tmp/timeout.calls"
  kubectl_node_names="node-a"
  kubectl_raw_output="$(worker_v2_series)"

  run_capture

  out="$tmp/snapshots/kubernetes-latest/tenant-block-io/node-a.txt"
  assert_file_contains 'container_fs_reads_bytes_total{' "$out"
  assert_file_contains 'container_fs_reads_total{' "$out"
  assert_file_contains 'container_fs_writes_bytes_total{' "$out"
  assert_file_contains 'container_fs_writes_total{' "$out"
  assert_file_contains 'container_blkio_device_usage_total{' "$out"
  assert_file_contains 'operation="Read"' "$out"
  # The divisor, kept for a different reason from the rest and therefore the one
  # a later edit to the filter is most likely to drop: without it the totals in
  # this file cannot be read as an average at all.
  assert_file_contains 'container_start_time_seconds{' "$out"
  rm -rf "$tmp"
}

@test "one reading is called a total, with the row that turns it into an average" {
  # The claim the file may not make is that a single cumulative reading IS a rate,
  # and the claim it may only make when the row is present is how to divide. Both
  # directions are pinned here, because the note is the only thing standing between
  # a total and an invented throughput figure.
  . hack/e2e-chainsaw/_lib/run-kubernetes.sh
  tmp=$(mktemp -d)
  export COZY_REPORT_DIR="$tmp"
  COZY_SNAPSHOT_NAME=kubernetes-latest
  kubectl_calls="$tmp/kubectl.calls"
  timeout_calls="$tmp/timeout.calls"
  kubectl_node_names="node-a"
  kubectl_raw_output="$(worker_v2_series)"

  run_capture

  out="$tmp/snapshots/kubernetes-latest/tenant-block-io/node-a.txt"
  assert_file_contains 'totals and not a rate' "$out"
  assert_file_contains 'container_start_time_seconds above is the divisor' "$out"
  rm -rf "$tmp"
}

@test "a capture with no start time row does not tell the reader to divide by it" {
  # The same note with its divisor gone. A kubelet can answer with the disk
  # families and no start time -- a filter narrowed later is the likeliest way --
  # and an instruction to subtract a row that is not in the file sends the reader
  # after something that does not exist, which is the failure the note exists to
  # prevent rather than to cause.
  . hack/e2e-chainsaw/_lib/run-kubernetes.sh
  tmp=$(mktemp -d)
  export COZY_REPORT_DIR="$tmp"
  COZY_SNAPSHOT_NAME=kubernetes-latest
  kubectl_calls="$tmp/kubectl.calls"
  timeout_calls="$tmp/timeout.calls"
  kubectl_node_names="node-a"
  kubectl_raw_output="$(worker_v2_series | grep -v '^container_start_time_seconds')"

  run_capture

  out="$tmp/snapshots/kubernetes-latest/tenant-block-io/node-a.txt"
  assert_file_contains 'The divisor is missing from this file' "$out"
  assert_file_lacks_pattern 'above is the divisor' "$out"
  rm -rf "$tmp"
}

@test "a truncated capture gets no averaging instruction and no missing-family reading" {
  # Rows arrived and the read was then cut off, which is the shape a wedged
  # kubelet produces: the file holds a prefix. Dividing a prefix by the
  # container's age understates it, and a family absent from a prefix says nothing
  # about the kernel interface, so both readings are withheld -- the same rule the
  # CPU note follows for its pairing sentence and the sandbox capture for its
  # column legend.
  . hack/e2e-chainsaw/_lib/run-kubernetes.sh
  tmp=$(mktemp -d)
  export COZY_REPORT_DIR="$tmp"
  COZY_SNAPSHOT_NAME=kubernetes-latest
  kubectl_calls="$tmp/kubectl.calls"
  timeout_calls="$tmp/timeout.calls"
  kubectl_node_names="node-a"
  kubectl_raw_output="$(worker_v2_series)"
  timeout_fail_node=node-a

  run_capture

  out="$tmp/snapshots/kubernetes-latest/tenant-block-io/node-a.txt"
  assert_file_contains 'may be a prefix of what the kubelet had' "$out"
  assert_file_lacks_pattern 'above is the divisor' "$out"
  assert_file_lacks_pattern 'io.stat' "$out"
  assert_file_lacks_pattern 'is a setting rather than a quiet disk' "$out"
  # The device legend is the exception and stays: it explains a label on the rows
  # that did arrive, which is as true of a prefix as of a whole file, and a short
  # file is where a reader needs it most.
  assert_file_contains 'block major 147 for DRBD' "$out"
  timeout_fail_node=
  rm -rf "$tmp"
}

@test "a counter check that could not run claims nothing about the disk" {
  # grep exits 1 for "read it, no match" and 2 for "could not read it", and every
  # sentence past that check is a statement about what the kubelet reported, which
  # a check that never answered cannot support. Driven by handing the hook a path
  # that does not exist, which is the cheapest way to reach a status the walk does
  # not produce on purpose.
  . hack/e2e-chainsaw/_lib/run-kubernetes.sh
  tmp=$(mktemp -d)

  ( set +x; _cozy_block_io_tail_note "$tmp/absent.txt" 0 0 1 )

  assert_file_contains 'could not be checked' "$tmp/absent.txt"
  # The device legend still follows: it explains a label on rows, not the file
  # as a whole, and the arm's own sentence sends the reader to the rows above.
  assert_file_contains 'block major 147 for DRBD' "$tmp/absent.txt"
  assert_file_lacks_pattern 'above is the divisor' "$tmp/absent.txt"
  assert_file_lacks_pattern 'divisor is missing' "$tmp/absent.txt"
  assert_file_lacks_pattern 'no block IO counter reached' "$tmp/absent.txt"
  rm -rf "$tmp"
}

@test "the IO pressure families survive the filter where the kubelet publishes them" {
  # Asserted on rows rather than on the note text: the note names these families
  # on every capture, so only a brace after the family name proves a row of the
  # family itself came through the filter.
  . hack/e2e-chainsaw/_lib/run-kubernetes.sh
  tmp=$(mktemp -d)
  export COZY_REPORT_DIR="$tmp"
  COZY_SNAPSHOT_NAME=kubernetes-latest
  kubectl_calls="$tmp/kubectl.calls"
  timeout_calls="$tmp/timeout.calls"
  kubectl_node_names="node-a"
  kubectl_raw_output="$(worker_v2_series; worker_psi_series)"

  run_capture

  out="$tmp/snapshots/kubernetes-latest/tenant-block-io/node-a.txt"
  assert_file_contains 'container_pressure_io_stalled_seconds_total{' "$out"
  assert_file_contains 'container_pressure_io_waiting_seconds_total{' "$out"
  rm -rf "$tmp"
}

@test "the queue depth and service time families are captured where they exist" {
  # The cgroup v1 shape. The filter has to keep them, or a node that does publish
  # them produces an artifact identical to one that cannot -- and the difference
  # is the whole reason the note beside the rows explains which families the
  # cgroup version costs.
  . hack/e2e-chainsaw/_lib/run-kubernetes.sh
  tmp=$(mktemp -d)
  export COZY_REPORT_DIR="$tmp"
  COZY_SNAPSHOT_NAME=kubernetes-latest
  kubectl_calls="$tmp/kubectl.calls"
  timeout_calls="$tmp/timeout.calls"
  kubectl_node_names="node-a"
  kubectl_raw_output="$(worker_v2_series; worker_v1_extra_series)"

  run_capture

  out="$tmp/snapshots/kubernetes-latest/tenant-block-io/node-a.txt"
  assert_file_contains 'container_fs_read_seconds_total{' "$out"
  assert_file_contains 'container_fs_write_seconds_total{' "$out"
  assert_file_contains 'container_fs_io_current{' "$out"
  assert_file_contains 'container_fs_io_time_seconds_total{' "$out"
  assert_file_contains 'container_fs_io_time_weighted_seconds_total{' "$out"
  rm -rf "$tmp"
}

@test "a capture with no service time family says what the cgroup version costs" {
  # The reading this collector is most likely to be misread on, and the one a row
  # cannot state for itself: under cgroup v2 io.stat carries bytes and operations
  # only, so a missing service time is the kernel interface rather than a disk
  # that was never touched. Asserted on the cgroup v2 fixture, which is the shape
  # the sandbox actually produces.
  . hack/e2e-chainsaw/_lib/run-kubernetes.sh
  tmp=$(mktemp -d)
  export COZY_REPORT_DIR="$tmp"
  COZY_SNAPSHOT_NAME=kubernetes-latest
  kubectl_calls="$tmp/kubectl.calls"
  timeout_calls="$tmp/timeout.calls"
  kubectl_node_names="node-a"
  kubectl_raw_output="$(worker_v2_series)"

  run_capture

  out="$tmp/snapshots/kubernetes-latest/tenant-block-io/node-a.txt"
  assert_file_contains 'io.stat' "$out"
  # And the row that carries the attribution, because a reader looking for the
  # guest's system disk has several devices to choose between and the major is
  # what decides.
  assert_file_contains 'block major 147 for DRBD' "$out"
  # The families that would answer the question outright if the kubelet published
  # them. Their absence is a setting rather than a quiet disk, and a reader with
  # no sentence for that reads the silence as the answer.
  assert_file_contains 'container_pressure_io_stalled_seconds_total' "$out"
  rm -rf "$tmp"
}

@test "the capture does not send a reader after a sibling sample it never takes" {
  . hack/e2e-chainsaw/_lib/run-kubernetes.sh
  tmp=$(mktemp -d)
  export COZY_REPORT_DIR="$tmp"
  COZY_SNAPSHOT_NAME=kubernetes-latest
  kubectl_calls="$tmp/kubectl.calls"
  timeout_calls="$tmp/timeout.calls"
  kubectl_node_names="node-a"
  kubectl_raw_output="$(worker_v2_series)"

  run_capture

  out="$tmp/snapshots/kubernetes-latest/tenant-block-io/node-a.txt"
  # This capture is read once, and it shares its walk with a capture that is read
  # twice. A file telling the reader to subtract a sibling that was never written
  # costs them the time it takes to go looking for a directory that does not
  # exist, so the instruction here has to be the opposite one.
  assert_file_contains 'a second capture of the same stream' "$out"
  assert_file_lacks_pattern 'other sample directory' "$out"
  assert_file_contains 'read attempted from' "$out"
  rm -rf "$tmp"
}

@test "the node filesystem and the tenant control plane stay out of a worker heading" {
  # Both rows are what the wire actually carries. The node's own filesystem
  # arrives in the same families with both selector labels empty, and the Kamaji
  # apiserver shares the namespace with the workers and has a disk of its own --
  # under a heading that says worker, either one answers the question with the
  # wrong subject.
  . hack/e2e-chainsaw/_lib/run-kubernetes.sh
  tmp=$(mktemp -d)
  export COZY_REPORT_DIR="$tmp"
  COZY_SNAPSHOT_NAME=kubernetes-latest
  kubectl_calls="$tmp/kubectl.calls"
  timeout_calls="$tmp/timeout.calls"
  kubectl_node_names="node-a"
  kubectl_raw_output="$(non_worker_series; worker_v2_series)"

  run_capture

  out="$tmp/snapshots/kubernetes-latest/tenant-block-io/node-a.txt"
  assert_file_contains "pod=\"${worker_pod}\"" "$out"
  assert_file_lacks_pattern 'kube-apiserver' "$out"
  assert_file_lacks_pattern 'pod=""' "$out"
  rm -rf "$tmp"
}

@test "a start time with no counter beside it is not written up as an idle disk" {
  # The shape the divisor row makes possible, and the reason it is not free: the
  # walk decides "the kubelet reported nothing for this subject" from whether any
  # row survived, and this family survives for any container cAdvisor knows. So a
  # worker whose disk counters never arrived leaves a file that is not empty, the
  # walk's own "no series" sentence cannot fire, and without the note below the
  # artifact would carry a start time, an instruction for dividing counters, and
  # no counters.
  . hack/e2e-chainsaw/_lib/run-kubernetes.sh
  tmp=$(mktemp -d)
  export COZY_REPORT_DIR="$tmp"
  COZY_SNAPSHOT_NAME=kubernetes-latest
  kubectl_calls="$tmp/kubectl.calls"
  timeout_calls="$tmp/timeout.calls"
  kubectl_node_names="node-a"
  kubectl_raw_output="$(worker_start_time_row)"

  run_capture

  out="$tmp/snapshots/kubernetes-latest/tenant-block-io/node-a.txt"
  assert_file_contains 'no block IO counter reached this file' "$out"
  assert_file_lacks_pattern 'above is the divisor' "$out"
  assert_file_lacks_pattern 'io.stat' "$out"
  rm -rf "$tmp"
}

@test "a kubelet that knows nothing about the container is not written up as an idle disk" {
  # The other empty direction, and the one the walk does answer: cAdvisor
  # contributes none of these families for a container it does not know, so the
  # file is empty and the walk says which of its own arms fired. An empty file
  # reads as a worker that touched no disk, which is the conclusion the whole
  # capture was added to test.
  . hack/e2e-chainsaw/_lib/run-kubernetes.sh
  tmp=$(mktemp -d)
  export COZY_REPORT_DIR="$tmp"
  COZY_SNAPSHOT_NAME=kubernetes-latest
  kubectl_calls="$tmp/kubectl.calls"
  timeout_calls="$tmp/timeout.calls"
  kubectl_node_names="node-a"
  kubectl_raw_output='container_cpu_usage_seconds_total{container="compute",namespace="tenant-test",pod="'"${worker_pod}"'"} 412.5 1786657157281'

  run_capture

  out="$tmp/snapshots/kubernetes-latest/tenant-block-io/node-a.txt"
  # The walk builds this sentence from the subject word the capture passes, and
  # that word names the divisor family too, because the file can hold the divisor
  # with no counter beside it and a sentence naming only block IO would be false
  # about such a file.
  assert_file_contains 'no block IO or container start-time series' "$out"
  rm -rf "$tmp"
}

@test "a kubelet that never answered is not written up as an idle disk either" {
  # The other half of the same claim, and the one the status decides rather than
  # the output: timeout kills its child without a word, so this arrives non-zero
  # with an empty error log, and a collector keyed on stderr would land in the arm
  # that says the kubelet answered.
  . hack/e2e-chainsaw/_lib/run-kubernetes.sh
  tmp=$(mktemp -d)
  export COZY_REPORT_DIR="$tmp"
  COZY_SNAPSHOT_NAME=kubernetes-latest
  kubectl_calls="$tmp/kubectl.calls"
  timeout_calls="$tmp/timeout.calls"
  kubectl_node_names="node-a"
  kubectl_raw_output=
  timeout_fail_node=node-a

  run_capture

  out="$tmp/snapshots/kubernetes-latest/tenant-block-io/node-a.txt"
  assert_file_contains 'is unknown' "$out"
  assert_file_contains '[capture exit code: 124]' "$out"
  assert_file_lacks_pattern 'io.stat' "$out"
  timeout_fail_node=
  rm -rf "$tmp"
}

@test "every read this capture makes carries a wall clock bound" {
  # The property that keeps this collector from costing the artifacts behind it.
  # It runs on the failure path, where the apiserver and the kubelet are the two
  # components least likely to answer, and an unbounded read here holds the
  # chainsaw op until the op is killed -- which loses the tenant snapshot rather
  # than truncating it. The mock refuses any call that is not the bounded form, so
  # a read that lost its ceiling exits 97 and the file says so.
  . hack/e2e-chainsaw/_lib/run-kubernetes.sh
  tmp=$(mktemp -d)
  export COZY_REPORT_DIR="$tmp"
  COZY_SNAPSHOT_NAME=kubernetes-latest
  kubectl_calls="$tmp/kubectl.calls"
  timeout_calls="$tmp/timeout.calls"
  kubectl_node_names="node-a"
  kubectl_raw_output="$(worker_v2_series)"

  run_capture

  # Two reads: the virt-launcher listing and the node's metric stream. Both go
  # through the wrapper, and the count is asserted so a read added later without
  # one fails here rather than in a timed-out run.
  [ "$(grep -c 'kubectl' "$timeout_calls")" -eq 2 ]
  assert_file_lacks_pattern 'exit code: 97' "$tmp/snapshots/kubernetes-latest/tenant-block-io/node-a.txt"
  rm -rf "$tmp"
}