Files
beer_data/scripts/kube-ca-refresh.sh
T
hermes bb071343c2 fix(ci): count the kubeconfig's clusters correctly
The cluster-count guard I added used `grep -c '"name"'`, which counts
LINES.  The jsonpath output is a single line, so the count was 1 for a
kubeconfig with clusters and 1 for one without - which made the guard
reject every kubeconfig:

    ERROR: the kubeconfig has no cluster entries.

The count is now done with `grep -o . | wc -l`, which counts occurrences
rather than lines.  Tested against both cases:

- a real kubeconfig with a stale CA -> the CA is refreshed and kubectl works
- a kubeconfig with no clusters        -> refuses with a clear message
2026-11-09 12:49:13 +10:00

127 lines
4.8 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Make sure kubectl can talk to the cluster API server.
#
# The kubeconfig embeds the k3s server CA. If the cluster's CA has been
# rotated since the secret was created, kubectl fails with:
#
# tls: failed to verify certificate: x509: certificate signed by unknown authority
#
# which reads like a network problem rather than a stale secret. This script
# refreshes the CA in place.
#
# The refresh is PINNED: the server's CA is fetched over an unverified
# connection, but it is only used if its SHA-256 fingerprint matches the value
# pinned in scripts/cluster-ca.sha256. An attacker able to intercept the
# fetch cannot use it - their CA will not hash to the pinned fingerprint.
# The runner has cluster-admin credentials, so trusting an unverified CA here
# would be a privilege-escalation hole.
#
# The fingerprint is a PUBLIC value - it can be read straight off the
# certificate - so it lives in the repo rather than in a secret. That way a
# change to it is a reviewable commit, and nobody with secret-write access
# can silently repoint the CI runner at a different CA.
#
# The expected fingerprint is read from scripts/cluster-ca.sha256 next to
# this script. It can be overridden with the first argument.
#
# Usage:
# kube-ca-refresh.sh [expected-fingerprint]
#
# Exit codes:
# 0 the cluster is reachable
# 1 the cluster is not reachable and could not be fixed safely
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
EXPECTED="${1:-}"
if [ -z "$EXPECTED" ] && [ -f "$SCRIPT_DIR/cluster-ca.sha256" ]; then
# The file may carry comments; take the last non-comment line.
EXPECTED=$(grep -vE '^[[:space:]]*(#|$)' "$SCRIPT_DIR/cluster-ca.sha256" | tail -1 | tr -d '[:space:]')
fi
if kubectl version --request-timeout=15s >/dev/null 2>&1; then
echo "cluster reachable"
exit 0
fi
if [ -z "$EXPECTED" ]; then
echo "ERROR: the kubeconfig cannot verify the API server, and no expected CA" >&2
echo " fingerprint was supplied, so the CA cannot be refreshed safely." >&2
echo " and no pinned CA fingerprint could be found." >&2
echo " Set one in scripts/cluster-ca.sha256:" >&2
echo " openssl x509 -in server-ca.crt -noout -fingerprint -sha256" >&2
exit 1
fi
# Guard against a kubeconfig with no clusters, or an empty one.
# `grep -c` counts LINES, and the jsonpath output is a single line, so the
# count is done with grep -o and wc -l instead.
CLUSTER_COUNT=$(kubectl config view --raw -o jsonpath='{.clusters[*].name}' 2>/dev/null \
| grep -o . | wc -l || true)
if [ "${CLUSTER_COUNT:-0}" -eq 0 ]; then
echo "ERROR: the kubeconfig has no cluster entries." >&2
echo " The KUBEC_CONFIG_BUILDX_NEW_2 secret looks wrong or empty." >&2
exit 1
fi
SERVER=$(kubectl config view --raw -o jsonpath='{.clusters[0].cluster.server}')
# The cluster entry's NAME - patching the CA into a *new* entry named
# "default" would leave the original entry (and its stale CA) in use.
CLUSTER_NAME=$(kubectl config view --raw -o jsonpath='{.clusters[0].name}')
if [ -z "$SERVER" ] || [ -z "$CLUSTER_NAME" ]; then
echo "ERROR: could not read the server or cluster name from the kubeconfig." >&2
exit 1
fi
echo "server: $SERVER cluster entry: $CLUSTER_NAME"
CA_FILE=$(mktemp)
trap 'rm -f "$CA_FILE"' EXIT
# Timeouts so an unreachable API server fails the job rather than hanging
# the runner indefinitely.
curl -sk --connect-timeout 10 --max-time 30 "$SERVER/cacerts" -o "$CA_FILE"
if [ ! -s "$CA_FILE" ]; then
echo "ERROR: could not fetch the cluster CA from $SERVER/cacerts" >&2
exit 1
fi
# Normalise both to lowercase hex without separators, so the comparison does
# not depend on the colon-separated format openssl prints.
normalise() {
tr -d ':' | tr '[:upper:]' '[:lower:]'
}
ACTUAL=$(openssl x509 -in "$CA_FILE" -noout -fingerprint -sha256 | cut -d= -f2 | normalise)
WANTED=$(printf '%s' "$EXPECTED" | normalise)
echo "expected CA fingerprint: $WANTED"
echo "actual CA fingerprint: $ACTUAL"
if [ "$ACTUAL" != "$WANTED" ]; then
echo "ERROR: the cluster CA does not match the pinned fingerprint." >&2
echo " expected: $WANTED" >&2
echo " actual: $ACTUAL" >&2
echo " Refusing to use it. Either the CA has been rotated and the" >&2
echo " pinned fingerprint needs updating, or the connection was" >&2
echo " intercepted." >&2
exit 1
fi
# The fingerprint matches the pinned value, so this is the CA we expect.
kubectl config set-cluster "$CLUSTER_NAME" \
--embed-certs=true \
--certificate-authority="$CA_FILE" >/dev/null
if kubectl version --request-timeout=15s >/dev/null 2>&1; then
echo "cluster reachable after refreshing the CA"
exit 0
fi
echo "ERROR: cannot reach the cluster API server even with a refreshed CA." >&2
echo " The kubeconfig secret may be stale or invalid." >&2
exit 1