Skip to content
Draft
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

@tuunit tuunit Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the stupid and simple approach to automatically onboarding and managing clusters but maybe we should consider building a full operator / controller for this? 🤔

@tuunit tuunit Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But do we really need a runtime for this and do we want to built and distribute a custom container?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could consider an inbetween custom container with simple go tooling but still keeping it as a cronjob instead of a constantly running pod?

Original file line number Diff line number Diff line change
@@ -0,0 +1,386 @@
#!/bin/sh

set -u

RELEASE_NAMESPACE="{{ .Release.Namespace }}"
LABEL_KEY="{{ .Values.clusterCredentialRotator.secret.labelKey }}"
LABEL_VALUE="{{ .Values.clusterCredentialRotator.secret.labelValue }}"
REQUIRED_TYPE="{{ .Values.clusterCredentialRotator.secret.requiredType }}"
BOOTSTRAP_KUBECONFIG_KEY="{{ .Values.clusterCredentialRotator.secret.bootstrapKubeconfigKey }}"
GENERATED_CONFIG_KEY="{{ .Values.clusterCredentialRotator.secret.generatedConfigKey }}"
REMOTE_NAMESPACE="{{ .Values.clusterCredentialRotator.remoteAccess.namespace }}"
REMOTE_SERVICE_ACCOUNT_NAME="{{ .Values.clusterCredentialRotator.remoteAccess.serviceAccountName }}"
REMOTE_CLUSTER_ROLE_BINDING_NAME="{{ .Values.clusterCredentialRotator.remoteAccess.clusterRoleBindingName }}"
REMOTE_CLUSTER_ROLE_NAME="{{ .Values.clusterCredentialRotator.remoteAccess.clusterRoleName }}"
TOKEN_DURATION="{{ .Values.clusterCredentialRotator.token.duration }}"
REFRESH_BEFORE="{{ .Values.clusterCredentialRotator.token.refreshBefore }}"

ISSUED_AT_ANNOTATION="kubara.io/cluster-credential-issued-at"
EXPIRES_AT_ANNOTATION="kubara.io/cluster-credential-expires-at"
MANAGED_BY_ANNOTATION="kubara.io/cluster-credential-managed-by"
MANAGED_BY_VALUE="cluster-credential-rotator"

log() {
printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"
}

normalize_optional_value() {
if [ "${1:-}" = "<no value>" ] || [ "${1:-}" = "<nil>" ]; then
return 0
fi

printf '%s' "${1:-}"
}

duration_to_seconds() {
awk -v value="$1" '
function fail() { exit 1 }
BEGIN {
rest = value
total = 0

if (rest == "") {
fail()
}

while (rest != "") {
if (match(rest, /^[0-9]+[smhd]/) == 0) {
fail()
}

part = substr(rest, RSTART, RLENGTH)
amount = substr(part, 1, length(part) - 1) + 0
unit = substr(part, length(part), 1)

if (unit == "s") {
multiplier = 1
} else if (unit == "m") {
multiplier = 60
} else if (unit == "h") {
multiplier = 3600
} else if (unit == "d") {
multiplier = 86400
} else {
fail()
}

total += amount * multiplier
rest = substr(rest, RLENGTH + 1)
}

print total
}
' </dev/null
}

json_escape() {
printf '%s' "$1" | awk '
BEGIN { ORS = "" }
{
gsub(/\\/, "\\\\")
gsub(/"/, "\\\"")
gsub(/\r/, "\\r")
gsub(/\t/, "\\t")
printf "%s", $0
}
'
}

get_secret_annotation() {
template_string="$(printf '%s%s index .metadata.annotations "%s" %s%s' '{' '{' "$2" '}' '}')"
normalize_optional_value "$(kubectl -n "$RELEASE_NAMESPACE" get secret "$1" -o "go-template=${template_string}" 2>/dev/null || true)"
}

get_secret_data() {
template_string="$(printf '%s%s index .data "%s" %s%s' '{' '{' "$2" '}' '}')"
normalize_optional_value "$(kubectl -n "$RELEASE_NAMESPACE" get secret "$1" -o "go-template=${template_string}" 2>/dev/null || true)"
}

get_secret_value() {
encoded_value="$(get_secret_data "$1" "$2")"

if [ -z "$encoded_value" ]; then
return 0
fi

printf '%s' "$encoded_value" | base64 -d
}

get_secret_type() {
template_string="$(printf '%s%s .type %s%s' '{' '{' '}' '}')"
normalize_optional_value "$(kubectl -n "$RELEASE_NAMESPACE" get secret "$1" -o "go-template=${template_string}" 2>/dev/null || true)"
}

build_generated_kubeconfig() {
kubeconfig_path=$1
server=$2
ca_data=$3
insecure=$4
token=$5

if [ "$insecure" = "true" ]; then
cat >"$kubeconfig_path" <<EOF
apiVersion: v1
kind: Config
clusters:
- cluster:
insecure-skip-tls-verify: true
server: ${server}
name: managed-cluster
contexts:
- context:
cluster: managed-cluster
user: managed-service-account
name: managed-cluster
current-context: managed-cluster
users:
- name: managed-service-account
user:
token: ${token}
EOF
return 0
fi

cat >"$kubeconfig_path" <<EOF
apiVersion: v1
kind: Config
clusters:
- cluster:
certificate-authority-data: ${ca_data}
server: ${server}
name: managed-cluster
contexts:
- context:
cluster: managed-cluster
user: managed-service-account
name: managed-cluster
current-context: managed-cluster
users:
- name: managed-service-account
user:
token: ${token}
EOF
}

generated_config_token() {
printf '%s' "$1" | sed -n 's/.*"bearerToken":"\([^"]*\)".*/\1/p'
}

validate_generated_credentials() {
validation_kubeconfig=$1
server=$2
ca_data=$3
insecure=$4
token=$5

build_generated_kubeconfig "$validation_kubeconfig" "$server" "$ca_data" "$insecure" "$token"
kubectl --kubeconfig "$validation_kubeconfig" get namespace kube-system >/dev/null 2>&1
}

ensure_remote_access() {
bootstrap_kubeconfig=$1

cat <<EOF | kubectl --kubeconfig "$bootstrap_kubeconfig" apply -f - >/dev/null
apiVersion: v1
kind: Namespace
metadata:
name: ${REMOTE_NAMESPACE}
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: ${REMOTE_SERVICE_ACCOUNT_NAME}
namespace: ${REMOTE_NAMESPACE}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: ${REMOTE_CLUSTER_ROLE_BINDING_NAME}
subjects:
- kind: ServiceAccount
name: ${REMOTE_SERVICE_ACCOUNT_NAME}
namespace: ${REMOTE_NAMESPACE}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: ${REMOTE_CLUSTER_ROLE_NAME}
EOF
}

secret_needs_refresh() {
secret_name=$1
validation_kubeconfig=$2
server=$3
ca_data=$4
insecure=$5
refresh_after_epoch=$6

current_config="$(get_secret_value "$secret_name" "$GENERATED_CONFIG_KEY")"
if [ -z "$current_config" ]; then
return 0
fi

expires_at="$(get_secret_annotation "$secret_name" "$EXPIRES_AT_ANNOTATION")"
if [ -z "$expires_at" ]; then
return 0
fi

case "$expires_at" in
''|*[!0-9]*)
return 0
;;
esac

if [ "$refresh_after_epoch" -ge "$expires_at" ]; then
return 0
fi

current_token="$(generated_config_token "$current_config")"
if [ -z "$current_token" ]; then
return 0
fi

if ! validate_generated_credentials "$validation_kubeconfig" "$server" "$ca_data" "$insecure" "$current_token"; then
return 0
fi

return 1
}

patch_secret_with_credentials() {
secret_name=$1
patch_file=$2
cluster_name=$3
server=$4
generated_config=$5
issued_at=$6
expires_at=$7

escaped_name="$(json_escape "$cluster_name")"
escaped_server="$(json_escape "$server")"
escaped_config="$(json_escape "$generated_config")"

cat >"$patch_file" <<EOF
{
"metadata": {
"labels": {
"argocd.argoproj.io/secret-type": "cluster",
"${LABEL_KEY}": "${LABEL_VALUE}"
},
"annotations": {
"${ISSUED_AT_ANNOTATION}": "${issued_at}",
"${EXPIRES_AT_ANNOTATION}": "${expires_at}",
"${MANAGED_BY_ANNOTATION}": "${MANAGED_BY_VALUE}"
}
},
"stringData": {
"name": "${escaped_name}",
"server": "${escaped_server}",
"${GENERATED_CONFIG_KEY}": "${escaped_config}"
}
}
EOF

kubectl -n "$RELEASE_NAMESPACE" patch secret "$secret_name" --type merge --patch-file "$patch_file" >/dev/null
}

reconcile_secret() (
set -u

secret_name=$1
bootstrap_kubeconfig_file=$(mktemp)
validation_kubeconfig_file=$(mktemp)
patch_file=$(mktemp)
trap 'rm -f "$bootstrap_kubeconfig_file" "$validation_kubeconfig_file" "$patch_file"' EXIT INT TERM

secret_type="$(get_secret_type "$secret_name")"
if [ -n "$REQUIRED_TYPE" ] && [ "$secret_type" != "$REQUIRED_TYPE" ]; then
log "skipping ${secret_name}: expected secret type ${REQUIRED_TYPE}, found ${secret_type:-<empty>}"
return 0
fi

bootstrap_kubeconfig="$(get_secret_value "$secret_name" "$BOOTSTRAP_KUBECONFIG_KEY")"
if [ -z "$bootstrap_kubeconfig" ]; then
log "secret ${secret_name} is missing required key ${BOOTSTRAP_KUBECONFIG_KEY}"
return 1
fi

printf '%s' "$bootstrap_kubeconfig" >"$bootstrap_kubeconfig_file"

cluster_name="$(get_secret_value "$secret_name" "name")"
if [ -z "$cluster_name" ]; then
cluster_name="$secret_name"
fi

server="$(get_secret_value "$secret_name" "server")"
if [ -z "$server" ]; then
server="$(kubectl --kubeconfig "$bootstrap_kubeconfig_file" config view --raw --minify -o jsonpath='{.clusters[0].cluster.server}')"
fi

if [ -z "$server" ]; then
log "secret ${secret_name} did not yield a cluster server endpoint"
return 1
fi

ca_data="$(kubectl --kubeconfig "$bootstrap_kubeconfig_file" config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')"
insecure="$(kubectl --kubeconfig "$bootstrap_kubeconfig_file" config view --raw --minify -o jsonpath='{.clusters[0].cluster.insecure-skip-tls-verify}')"
if [ -z "$insecure" ]; then
insecure="false"
fi

current_epoch="$(date +%s)"
refresh_after_epoch=$((current_epoch + REFRESH_BEFORE_SECONDS))

if ! secret_needs_refresh "$secret_name" "$validation_kubeconfig_file" "$server" "$ca_data" "$insecure" "$refresh_after_epoch"; then
log "secret ${secret_name} already has valid generated credentials"
return 0
fi

log "refreshing credentials for ${secret_name}"

ensure_remote_access "$bootstrap_kubeconfig_file"
token="$(kubectl --kubeconfig "$bootstrap_kubeconfig_file" -n "$REMOTE_NAMESPACE" create token "$REMOTE_SERVICE_ACCOUNT_NAME" --duration "$TOKEN_DURATION")"

if [ -z "$token" ]; then
log "failed to mint a service account token for ${secret_name}"
return 1
fi

generated_config="$(printf '{"bearerToken":"%s","tlsClientConfig":{"caData":"%s","insecure":%s}}' "$token" "$ca_data" "$insecure")"
issued_at="$current_epoch"
expires_at=$((current_epoch + TOKEN_DURATION_SECONDS))

patch_secret_with_credentials "$secret_name" "$patch_file" "$cluster_name" "$server" "$generated_config" "$issued_at" "$expires_at"

if ! validate_generated_credentials "$validation_kubeconfig_file" "$server" "$ca_data" "$insecure" "$token"; then
log "generated credentials for ${secret_name} failed validation after refresh"
return 1
fi

log "successfully refreshed credentials for ${secret_name}"
)

TOKEN_DURATION_SECONDS="$(duration_to_seconds "$TOKEN_DURATION")"
REFRESH_BEFORE_SECONDS="$(duration_to_seconds "$REFRESH_BEFORE")"

managed_secrets="$(kubectl -n "$RELEASE_NAMESPACE" get secrets -l "${LABEL_KEY}=${LABEL_VALUE}" -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}')"

if [ -z "$managed_secrets" ]; then
log "no rotator-managed cluster secrets found in namespace ${RELEASE_NAMESPACE}"
exit 0
fi

failure_count=0

for secret_name in $managed_secrets; do
if ! reconcile_secret "$secret_name"; then
failure_count=$((failure_count + 1))
fi
done

if [ "$failure_count" -gt 0 ]; then
log "${failure_count} secret reconciliation(s) failed"
exit 1
fi

log "cluster credential rotation completed successfully"
Loading
Loading