Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions e2e/scenario/validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -3153,6 +3153,7 @@ func ValidateNodeExporter(ctx context.Context, s *Scenario) error {
errs = append(errs, err)
}
errs = append(errs, scrapeAndValidateNodeExporter(ctx, s, metricsURL, hasInfiniBand, requirePressure))
errs = append(errs, validateNodeExporterListenAddress(ctx, s))

if _, err := execScriptOnVMForScenarioValidateExitCode(ctx, s, fmt.Sprintf("systemctl is-active %s", serviceName), 0,
"node-exporter should remain active after scraping"); err != nil {
Expand Down Expand Up @@ -3222,6 +3223,27 @@ func nodeExporterRequiresPressure(ctx context.Context, s *Scenario) (bool, error
return available, nil
}

// validateNodeExporterListenAddress checks that the unauthenticated exporter listens
// only on the node IP that scrapers use, not on every address (secondary NICs, IPv6,
// bridges). The startup script is VHD-delivered; older VHDs used by standalone PR
// E2Es keep their previous behavior and skip this check.
func validateNodeExporterListenAddress(ctx context.Context, s *Scenario) error {
script := fmt.Sprintf(`if ! grep -q getNodeExporterListenIP /opt/bin/node-exporter-startup.sh; then
echo "SKIP: VHD predates node-IP-only listening"
exit 0
fi
listeners=$(ss -H -lnt 'sport = :19100' | awk '{print $4}' | sort -u | tr '\n' ' ')
echo "node-exporter listeners: ${listeners}"
[ "${listeners}" = "%s:19100 " ]`, s.Runtime.VM.PrivateIP)
result, err := execScriptOnVMForScenarioValidateExitCode(ctx, s, script, 0,
fmt.Sprintf("node-exporter must listen only on the node IP %s:19100", s.Runtime.VM.PrivateIP))
if err != nil {
return fmt.Errorf("validate node-exporter listen address: %w", err)
}
logging.Logf(ctx, "%s", strings.TrimSpace(result.stdout))
return nil
}

func scrapeAndValidateNodeExporter(ctx context.Context, s *Scenario, metricsURL string, requireInfiniBand, requirePressure bool) error {
// The boot-local marker only exists on VHDs with lifecycle-aware suppression;
// main/older VHDs used by standalone PR E2Es retain their existing checks.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,59 @@

PCI_DEVICES_PATH="${PCI_DEVICES_PATH:-/sys/bus/pci/devices}"
MANA_OBSERVED_FILE="${MANA_OBSERVED_FILE:-/run/node-exporter-mana-observed}"
KUBELET_DEFAULT_FILE="${KUBELET_DEFAULT_FILE:-/etc/default/kubelet}"
# Azure WireServer is always routed through the primary NIC.
NODE_EXPORTER_ROUTE_PROBE_IP="${NODE_EXPORTER_ROUTE_PROBE_IP:-168.63.129.16}"

isIPv4Address() {
printf '%s\n' "$1" | grep -Eqx '[0-9]{1,3}(\.[0-9]{1,3}){3}' || return 1
# Unspecified and loopback addresses are not the node IP; 0.0.0.0 would
# listen on every interface.
case "$1" in
0.0.0.0|127.*) return 1 ;;
esac
}

# Prints the value of kubelet's --node-ip flag. Only KUBELET_FLAGS tokens are
# considered, so a node label containing "--node-ip=" cannot match.
getKubeletNodeIPFlag() {
local flags token value=""
local -a tokens
flags=$(sed -n 's/^KUBELET_FLAGS=//p' "$KUBELET_DEFAULT_FILE" 2>/dev/null | tail -n 1)
flags="${flags#\"}"
flags="${flags%\"}"
read -ra tokens <<< "$flags"
for token in "${tokens[@]}"; do
case "$token" in
--node-ip=*) value="${token#--node-ip=}" ;;
esac
done
printf '%s\n' "$value"
}

# Prints the node IPv4 address node-exporter must listen on: the address scrapers
# use (Node InternalIP / status.hostIP). Never fall back to a wildcard address,
# which would also expose the unauthenticated endpoint on secondary NICs, IPv6,
# and bridge addresses.
getNodeExporterListenIP() {
local address
# kubelet registers --node-ip (set by CSE from IMDS) as the Node InternalIP.
for address in $(getKubeletNodeIPFlag | tr ',' ' '); do
if isIPv4Address "$address"; then
printf '%s\n' "$address"
return 0
fi
done
# Otherwise use the primary NIC's source address, which cloud-node-manager
# also assigns as the InternalIP.
address=$(ip -4 -o route get "$NODE_EXPORTER_ROUTE_PROBE_IP" 2>/dev/null |
awk '{for (i = 1; i < NF; i++) if ($i == "src") { print $(i + 1); exit }}')
if isIPv4Address "$address"; then
printf '%s\n' "$address"
return 0
fi
return 1
}

getNodeExporterHardwareArgs() {
if [ -f "$MANA_OBSERVED_FILE" ]; then
Expand Down Expand Up @@ -48,10 +101,10 @@ if [ "${1:-}" = "--mana-added" ]; then
exit $?
fi

if [ "$(grep ^ID= /etc/os-release | cut -c 4-)" = "flatcar" ]; then
NODE_IP=$(ip -o -4 addr show dev eth0 | awk '{print $4}' | cut -d '/' -f 1)
else
NODE_IP=$(hostname -I | awk '{print $1}')
if ! NODE_IP=$(getNodeExporterListenIP); then
# systemd restarts the service; never start listening on all interfaces.
echo "Could not determine the node IPv4 address; refusing to listen on all interfaces" >&2
exit 1
fi

TLS_CONFIG_PATH="/etc/node-exporter.d/web-config.yml"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Describe 'node-exporter-startup.sh hardware arguments'
PCI_DEVICES_PATH="$(mktemp -d)"
MANA_OBSERVED_FILE="${PCI_DEVICES_PATH}/mana-observed"
NODE_EXPORTER_EXTRA_ARGS=''
KUBELET_DEFAULT_FILE="${PCI_DEVICES_PATH}/kubelet-default"
printf '%s\n' 'KUBELET_FLAGS=--address=0.0.0.0 --node-ip=192.0.2.1 --pod-max-pids=-1' > "$KUBELET_DEFAULT_FILE"
}

add_pci_device() {
Expand Down Expand Up @@ -80,7 +82,6 @@ Describe 'node-exporter-startup.sh hardware arguments'
run_startup() {
NODE_EXPORTER_STARTUP_SOURCE_ONLY=false
NODE_EXPORTER_TLS_ENABLED=false
hostname() { printf '%s\n' '192.0.2.1'; }
exec() { printf '%s\n' "$@"; }
. ./parts/linux/cloud-init/artifacts/node-exporter/node-exporter-startup.sh
}
Expand Down Expand Up @@ -217,3 +218,127 @@ Describe 'node-exporter-startup.sh hardware arguments'
The stderr should equal ''
End
End

Describe 'node-exporter-startup.sh listen address'
NODE_EXPORTER_STARTUP_SOURCE_ONLY=true
Include './parts/linux/cloud-init/artifacts/node-exporter/node-exporter-startup.sh'

setup() {
TEST_DIR="$(mktemp -d)"
KUBELET_DEFAULT_FILE="${TEST_DIR}/kubelet-default"
PCI_DEVICES_PATH="${TEST_DIR}/pci"
MANA_OBSERVED_FILE="${TEST_DIR}/mana-observed"
NODE_EXPORTER_EXTRA_ARGS=''
mkdir "$PCI_DEVICES_PATH"
}
BeforeEach 'setup'
AfterEach 'rm -rf "$TEST_DIR"'

kubelet_flags() { printf 'KUBELET_FLAGS=--address=0.0.0.0 %s --pod-max-pids=-1\n' "$1" > "$KUBELET_DEFAULT_FILE"; }
# Route lookup used when kubelet has no --node-ip.
ip() {
[ "$*" = '-4 -o route get 168.63.129.16' ] || return 1
printf '%s\n' '168.63.129.16 via 10.224.0.1 dev eth0 src 10.224.0.9 uid 0 \ cache '
}

It 'uses the kubelet --node-ip address'
kubelet_flags '--node-ip=10.224.0.4'
When call getNodeExporterListenIP
The status should be success
The output should equal '10.224.0.4'
End

It 'uses the IPv4 address of a dual-stack --node-ip in either order'
kubelet_flags '--node-ip=fd00:10:224::4,10.224.0.4'
When call getNodeExporterListenIP
The status should be success
The output should equal '10.224.0.4'
End

It 'falls back to the primary NIC source address without --node-ip'
kubelet_flags ''
When call getNodeExporterListenIP
The status should be success
The output should equal '10.224.0.9'
End

It 'falls back to the route when --node-ip is IPv6 only'
kubelet_flags '--node-ip=fd00:10:224::4'
When call getNodeExporterListenIP
The status should be success
The output should equal '10.224.0.9'
End

It 'falls back to the route when the kubelet defaults file is missing'
When call getNodeExporterListenIP
The status should be success
The output should equal '10.224.0.9'
End

It 'ignores --node-ip text inside node labels'
printf '%s\n' 'KUBELET_FLAGS=--address=0.0.0.0 --node-ip=10.224.0.4' \
'KUBELET_NODE_LABELS=example.com/custom--node-ip=10.224.0.99,agentpool=x' > "$KUBELET_DEFAULT_FILE"
When call getNodeExporterListenIP
The status should be success
The output should equal '10.224.0.4'
End

It 'falls back to the route when only a label mentions --node-ip'
printf '%s\n' 'KUBELET_FLAGS=--address=0.0.0.0' \
'KUBELET_NODE_LABELS=example.com/custom--node-ip=10.224.0.99' > "$KUBELET_DEFAULT_FILE"
When call getNodeExporterListenIP
The status should be success
The output should equal '10.224.0.9'
End

It 'reads a quoted KUBELET_FLAGS value'
printf '%s\n' 'KUBELET_FLAGS="--address=0.0.0.0 --node-ip=10.224.0.4"' > "$KUBELET_DEFAULT_FILE"
When call getNodeExporterListenIP
The status should be success
The output should equal '10.224.0.4'
End

It 'never uses the unspecified or loopback address from --node-ip'
kubelet_flags '--node-ip=0.0.0.0'
When call getNodeExporterListenIP
The status should be success
The output should equal '10.224.0.9'
End

It 'rejects an unspecified route source address'
kubelet_flags '--node-ip=127.0.0.1'
ip() { printf '%s\n' '168.63.129.16 dev eth0 src 0.0.0.0 uid 0'; }
When call getNodeExporterListenIP
The status should be failure
The output should equal ''
End

It 'fails instead of returning an empty or wildcard address'
ip() { return 2; }
When call getNodeExporterListenIP
The status should be failure
The output should equal ''
End

run_startup() {
NODE_EXPORTER_STARTUP_SOURCE_ONLY=false
NODE_EXPORTER_TLS_ENABLED=false
exec() { printf '%s\n' "$@"; }
. ./parts/linux/cloud-init/artifacts/node-exporter/node-exporter-startup.sh
}

It 'listens only on the node IP'
kubelet_flags '--node-ip=10.224.0.4,fd00:10:224::4'
When run run_startup
The status should be success
The output should include '--web.listen-address=10.224.0.4:19100'
End

It 'exits without starting the exporter when no node IP is available'
ip() { return 2; }
When run run_startup
The status should equal 1
The output should equal ''
The stderr should include 'refusing to listen on all interfaces'
End
End
Loading