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
6 changes: 6 additions & 0 deletions kubernetes/headplane/Chart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
apiVersion: v2
appVersion: 0.6.1
description: Kubernetes Helm Chart for Headplane in integrated mode
name: headplane
type: application
version: 0.6.1
7 changes: 7 additions & 0 deletions kubernetes/headplane/templates/_helpers.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{{- define "headplane.cookieSecret" -}}
{{- if .Values.headplane.config.cookieSecret.value -}}
{{- .Values.headplane.config.cookieSecret.value -}}
{{- else -}}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end -}}
166 changes: 166 additions & 0 deletions kubernetes/headplane/templates/deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: headplane-integrated
spec:
replicas: 1
selector:
matchLabels:
app: headplane-integrated
strategy:
type: Recreate
template:
metadata:
labels:
app: headplane-integrated
annotations:
checksum/configmap-headplane: {{ include (print $.Template.BasePath "/headplane/configmap.yaml") . | sha256sum }}
checksum/secret-headplane: {{ include (print $.Template.BasePath "/headplane/secrets.yaml") . | sha256sum }}
checksum/secret-headscale: {{ include (print $.Template.BasePath "/headscale/secret.yaml") . | sha256sum }}
spec:
hostAliases: {{- toYaml .Values.hostAliases | nindent 8 }}
shareProcessNamespace: true
serviceAccountName: headplane-integrated
securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: headplane
image: "{{ required "headplane image repository required" .Values.headplane.image.repository }}:{{ .Values.headplane.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ required "headplane image pull policy required" .Values.headplane.image.pullPolicy }}
env:
- name: HEADPLANE_DEBUG_LOG
value: {{ .Values.headplane.config.debug | quote }}
- name: HEADPLANE_LOAD_ENV_OVERRIDES
value: "true"
- name: HEADPLANE_INTEGRATION__KUBERNETES__POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
ports:
- name: app
containerPort: 3000
protocol: TCP
securityContext: {{- toYaml .Values.headplane.securityContext | nindent 12 }}
volumeMounts:
- name: headplane-config
mountPath: /etc/headplane
- name: headplane-server-cookie-secret
mountPath: /var/secrets/headplane/server
{{- if .Values.headplane.config.oidc.enabled }}
- name: headplane-oidc-client-secret
mountPath: /var/secrets/headplane/oidc
- name: headplane-oidc-headscale-api-key
mountPath: /var/secrets/headplane/headscale
{{- end }}
- name: headplane-data
mountPath: /var/lib/headplane
readOnly: false
- name: headscale-config
mountPath: /etc/headscale
initContainers:
- name: headscale
image: "{{ required "headscale image repository required" .Values.headscale.image.repository }}:{{ required "headscale image tag required" .Values.headscale.image.tag }}"
imagePullPolicy: {{ required "headscale image pull policy required" .Values.headscale.image.pullPolicy }}
restartPolicy: Always
args:
- serve
ports:
- name: api
containerPort: 8080
protocol: TCP
- name: metrics
containerPort: 9090
protocol: TCP
- name: grpc
containerPort: 50443
protocol: TCP
- name: stun
containerPort: 3478
protocol: UDP
securityContext: {{- toYaml .Values.headscale.securityContext | nindent 12 }}
volumeMounts:
- name: headscale-config
mountPath: /etc/headscale
{{- if .Values.headscale.config.oidc.enabled }}
- name: headscale-oidc-client-secret
mountPath: /var/secrets/headscale/oidc
{{- end }}
- name: headscale-data
mountPath: /var/lib/headscale
readOnly: false
{{- if .Values.headplane.config.generateCredentials }}
- name: generate-headscale-token
image: alpine/k8s:1.33.1
env:
- name: NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: HEADSCALE_SECRET_NAME
value: {{ default "headscale-api-key" .Values.headplane.config.oidc.headscaleApiKey.secretName }}
- name: HEADSCALE_POD_SELECTOR
value: "app=headplane-integrated"
command: [ "bash", "-c" ]
args: [ "/etc/scripts/ensure-headscale-api-key.sh" ]
securityContext:
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
volumeMounts:
- name: headplane-scripts
mountPath: /etc/scripts
{{- end }}
volumes:
###
### Headplane Volumes ###
###
- name: headplane-config
configMap:
name: headplane
- name: headplane-server-cookie-secret
secret:
secretName: {{ default "headplane-cookie-secret" .Values.headplane.config.cookieSecret.secretName }}
{{- if .Values.headplane.config.oidc.enabled }}
- name: headplane-oidc-client-secret
secret:
secretName: {{ default "headplane-oidc-client-secret" .Values.headplane.config.oidc.clientSecret.secretName }}
- name: headplane-oidc-headscale-api-key
secret:
secretName: {{ default "headscale-api-key" .Values.headplane.config.oidc.headscaleApiKey.secretName }}
{{- end }}
- name: headplane-data
{{- if .Values.headplane.persistence.enabled }}
persistentVolumeClaim:
claimName: headplane-data
{{- else }}
emptyDir:
sizeLimit: 500Mi
{{- end }}
{{- if .Values.headplane.config.generateCredentials }}
- name: headplane-scripts
configMap:
name: headplane-scripts
defaultMode: 0777
{{- end }}
###
### Headscale Volumes ###
###
- name: headscale-config
configMap:
name: headscale
{{- if .Values.headscale.config.oidc.enabled }}
- name: headscale-oidc-client-secret
secret:
secretName: {{ default "headscale-oidc-client-secret" .Values.headscale.config.oidc.clientSecret.secretName }}
{{- end }}
- name: headscale-data
{{- if .Values.headscale.persistence.enabled }}
persistentVolumeClaim:
claimName: headscale-data
{{- else }}
emptyDir:
sizeLimit: 500Mi
{{- end }}
8 changes: 8 additions & 0 deletions kubernetes/headplane/templates/extra-objects.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{{ range .Values.extraObjects }}
---
{{ if typeIs "string" . }}
{{- tpl . $ }}
{{- else }}
{{- tpl (toYaml .) $ }}
{{- end }}
{{ end }}
132 changes: 132 additions & 0 deletions kubernetes/headplane/templates/headplane/configmap-scripts.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
{{- if .Values.headplane.config.generateCredentials }}
apiVersion: v1
kind: ConfigMap
metadata:
name: headplane-scripts
data:
ensure-headscale-api-key.sh: |
set -e

NAMESPACE="${NAMESPACE:?Error: NAMESPACE environment variable not set.}"
HEADSCALE_SECRET_NAME="${HEADSCALE_SECRET_NAME:?Error: HEADSCALE_SECRET_NAME environment variable not set.}"
HEADSCALE_POD_SELECTOR="${HEADSCALE_POD_SELECTOR:?Error: HEADSCALE_POD_SELECTOR environment variable not set.}"

check_api_key_validity() {
local key_to_check="$1"
local validation_url="$HEADSCALE_HOST:8080/api/v1/user"

local http_status
local curl_stderr_output
local curl_exit_status

curl_stderr_output=$(curl -sS --fail -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $key_to_check" "$validation_url" 2>&1)
curl_exit_status=$?

http_status=$(echo "$curl_stderr_output" | tail -n 1)
curl_stderr_output=$(echo "$curl_stderr_output" | head -n -1)

if [[ "$curl_exit_status" -eq 0 && "$http_status" =~ ^2 ]]; then
echo "Headscale API Key is valid (HTTP $http_status OK)."
return 0
else
echo "API Key validation failed."
echo "Debug Info:"
echo " Validation URL: $validation_url"
echo " Curl Exit Status: $curl_exit_status"
echo " HTTP Status Code: $http_status"
if [[ -n "$curl_stderr_output" ]]; then
echo " Curl Error Output: $curl_stderr_output"
fi
return 1
fi
}

echo "Finding headscale pod in namespace '$NAMESPACE' with selector '$HEADSCALE_POD_SELECTOR'..."
HEADPLANE_POD=$(kubectl get pod -n "$NAMESPACE" -l "$HEADSCALE_POD_SELECTOR" -o jsonpath="{.items[0].metadata.name}" --ignore-not-found)

if [[ -z "$HEADPLANE_POD" ]]; then
echo "Error: No headscale pod found matching selector '$HEADSCALE_POD_SELECTOR' in namespace '$NAMESPACE'."
exit 1
fi
echo "Success: Found headscale pod '$HEADPLANE_POD'"


echo "Checking 'headscale' container status in pod '$HEADPLANE_POD' for readiness..."

container_ready=$(kubectl get pod -n "$NAMESPACE" "$HEADPLANE_POD" -o jsonpath="{.status.initContainerStatuses[?(@.name==\"headscale\")].ready}" 2>/dev/null || echo "")

if [[ "$container_ready" == "true" ]]; then
echo "Success: 'headscale' container is ready"
else
echo "--- Headscale Container Readiness Check Failed ---"
echo "Error: 'headscale' container in pod '$HEADPLANE_POD' is NOT ready."
if [[ -z "$container_ready" ]]; then
echo " Reason: Container status not yet available (Pod might be starting or in a pending state)."
else
echo " Reason: Container status found, but reported as '$container_ready'."
fi
echo " Namespace: '$NAMESPACE'"
echo " Pod Name: '$HEADPLANE_POD'"
echo "---------------------------------------------------------"
exit 1
fi

if [[ -z "${HEADSCALE_HOST:-}" ]]; then
echo "HEADSCALE_HOST environment variable not provided. Attempting to determine pod IP for pod '$HEADPLANE_POD'..."
POD_IP=$(kubectl get pod -n $NAMESPACE $HEADPLANE_POD -o jsonpath='{.status.podIP}' --ignore-not-found)

if [[ -z "$POD_IP" ]]; then
POD_IP="127.0.0.1"
echo "Could not retrieve IP for pod '$HEADPLANE_POD'. Using default."
fi

HEADSCALE_HOST="http://$POD_IP"
echo "HEADSCALE_HOST set to: '$HEADSCALE_HOST'"
fi

API_KEY=""
echo "Checking for existing Kubernetes secret '$HEADSCALE_SECRET_NAME' in namespace '$NAMESPACE'..."

ENCODED_KEY_DATA=$(kubectl get secret "$HEADSCALE_SECRET_NAME" -n "$NAMESPACE" -o=jsonpath='{.data.api-key}' --ignore-not-found 2>/dev/null || echo "")

if [[ -n "$ENCODED_KEY_DATA" ]]; then
API_KEY=$(echo "$ENCODED_KEY_DATA" | base64 -d)
echo "Existing Headscale API Key found in secret '$HEADSCALE_SECRET_NAME'."

if check_api_key_validity "$API_KEY"; then
echo "Existing Headscale API Key is valid. No further action needed."
exit 0
else
echo "Existing Headscale API Key is invalid. A new API Key will be generated."
fi
else
echo "Kubernetes secret '$HEADSCALE_SECRET_NAME' not found or does not contain a 'api-key' field. A new API Key will be generated."
fi

echo "Generating a new Headscale API Key by executing CLI inside 'headscale' container..."
API_KEY=$(kubectl exec -n "$NAMESPACE" -c headscale "$HEADPLANE_POD" -- headscale apikeys create -e 100y)

if [[ -z "$API_KEY" ]]; then
echo "Error: Failed to create a new API Key via 'headscale apikeys create' command."
exit 1
fi
echo "Successfully generated a new Headscale API Key."

if kubectl get secret "$HEADSCALE_SECRET_NAME" -n "$NAMESPACE" &>/dev/null; then
echo "Updating existing secret '$HEADSCALE_SECRET_NAME' with the new API Key..."
kubectl patch secret "$HEADSCALE_SECRET_NAME" -n "$NAMESPACE" -p "{\"stringData\":{\"api-key\":\"$API_KEY\"}}" --type=merge
else
echo "Creating new secret '$HEADSCALE_SECRET_NAME' with the new API Key..."
kubectl create secret generic "$HEADSCALE_SECRET_NAME" -n "$NAMESPACE" --from-literal="api-key=$API_KEY"
fi
echo "Successfully ensured Headscale API Key in Kubernetes secret '$HEADSCALE_SECRET_NAME'."

echo "--- Performing final validation of the newly generated API Key ---"
if check_api_key_validity "$API_KEY"; then
echo "Final validation successful: The newly generated and stored Headscale API Key is valid."
exit 0
else
echo "Final validation failed: The newly generated/stored Headscale API Key is NOT valid. Please investigate."
exit 1
fi
{{- end }}
33 changes: 33 additions & 0 deletions kubernetes/headplane/templates/headplane/configmap.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: headplane
data:
config.yaml: |
server:
host: 0.0.0.0
port: 3000
cookie_secret_path: /var/secrets/headplane/server/{{ .Values.headplane.config.cookieSecret.secretKey }}
cookie_secure: true
headscale:
url: http://127.0.0.1:8080
{{- if .Values.headscale.config.url }}
public_url: {{ .Values.headscale.config.url }}
{{- end }}
config_path: /etc/headscale/config.yaml
config_strict: true
integration:
kubernetes:
enabled: true
validate_manifest: true
pod_name: replaced-by-environment-variable
{{- if .Values.headplane.config.oidc.enabled }}
oidc:
issuer: {{ .Values.headplane.config.oidc.issuerUrl }}
client_id: {{ .Values.headplane.config.oidc.clientId }}
client_secret_path: /var/secrets/headplane/oidc/{{ .Values.headplane.config.oidc.clientSecret.secretKey }}
disable_api_key_login: {{ .Values.headplane.config.oidc.disableApiKeyLogin }}
token_endpoint_auth_method: {{ .Values.headplane.config.oidc.tokenEndpointAuthMethod }}
headscale_api_key_path: /var/secrets/headplane/headscale/{{ .Values.headplane.config.oidc.headscaleApiKey.secretKey }}
redirect_uri: {{ .Values.headplane.config.url }}/admin/oidc/callback
{{- end }}
23 changes: 23 additions & 0 deletions kubernetes/headplane/templates/headplane/pvc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{{- if .Values.headplane.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: headplane-data
{{- with .Values.headplane.persistence.pvc.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.headplane.persistence.pvc.labels }}
labels:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
{{- toYaml .Values.headplane.persistence.pvc.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.headplane.persistence.pvc.storage }}
{{- if .Values.headplane.persistence.pvc.storageClassName }}
storageClassName: {{ .Values.headplane.persistence.pvc.storageClassName }}
{{- end }}
{{- end }}
Loading