diff --git a/.github/workflows/helm-release.yaml b/.github/workflows/helm-release.yaml new file mode 100644 index 00000000..0af21f6c --- /dev/null +++ b/.github/workflows/helm-release.yaml @@ -0,0 +1,36 @@ +name: Release Helm Chart + +on: + push: + branches: + - main + paths: + - "kubernetes/headplane/**" + +jobs: + release: + permissions: + contents: read + packages: write + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + with: + determinate: true + + - name: Cache Nix store + uses: DeterminateSystems/magic-nix-cache-action@main + + - name: Login to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | nix shell nixpkgs#kubernetes-helm -c helm registry login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Package and Push Chart + run: | + nix shell nixpkgs#kubernetes-helm -c helm package kubernetes/headplane -d .helm-pkg + for pkg in .helm-pkg/*.tgz; do + nix shell nixpkgs#kubernetes-helm -c helm push "$pkg" oci://ghcr.io/${{ github.repository_owner }} + done diff --git a/.github/workflows/helm-test.yaml b/.github/workflows/helm-test.yaml new file mode 100644 index 00000000..67a25a0a --- /dev/null +++ b/.github/workflows/helm-test.yaml @@ -0,0 +1,148 @@ +name: Helm Lint and Test + +on: + pull_request: + paths: + - "kubernetes/headplane/**" + - ".github/workflows/helm-test.yaml" + push: + branches: + - main + paths: + - "kubernetes/headplane/**" + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + with: + determinate: true + + - name: Cache Nix store + uses: DeterminateSystems/magic-nix-cache-action@main + + - name: Lint Chart + run: nix shell nixpkgs#kubernetes-helm -c helm lint kubernetes/headplane + + - name: Template Chart + run: nix shell nixpkgs#kubernetes-helm -c helm template headplane kubernetes/headplane --set headscale.url=http://headscale:8080 + + integration: + runs-on: ubuntu-latest + needs: lint + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + with: + determinate: true + + - name: Cache Nix store + uses: DeterminateSystems/magic-nix-cache-action@main + + - name: Create Kind cluster + run: nix shell nixpkgs#kind -c kind create cluster --name helm-test --wait 60s + + - name: Deploy standalone Headscale + run: | + nix shell nixpkgs#kubectl -c kubectl apply -f - <<'EOF' + apiVersion: v1 + kind: ConfigMap + metadata: + name: headscale-config + data: + config.yaml: | + server_url: http://headscale:8080 + listen_addr: 0.0.0.0:8080 + database: + type: sqlite + sqlite: + path: /var/lib/headscale/db.sqlite + prefixes: + v4: 100.64.0.0/10 + v6: fd7a:115c:a1e0::/48 + noise: + private_key_path: /var/lib/headscale/noise_private.key + dns: + base_domain: headscale.local + nameservers: + global: + - 1.1.1.1 + derp: + server: + enabled: false + urls: + - https://controlplane.tailscale.com/derpmap/default + --- + apiVersion: apps/v1 + kind: Deployment + metadata: + name: headscale + spec: + replicas: 1 + selector: + matchLabels: + app: headscale + template: + metadata: + labels: + app: headscale + spec: + containers: + - name: headscale + image: headscale/headscale:0.25.1 + args: ["serve"] + ports: + - containerPort: 8080 + volumeMounts: + - name: config + mountPath: /etc/headscale + - name: data + mountPath: /var/lib/headscale + volumes: + - name: config + configMap: + name: headscale-config + - name: data + emptyDir: {} + --- + apiVersion: v1 + kind: Service + metadata: + name: headscale + spec: + selector: + app: headscale + ports: + - port: 8080 + targetPort: 8080 + EOF + nix shell nixpkgs#kubectl -c kubectl wait --for=condition=available deployment/headscale --timeout=90s + + - name: Install Chart + run: | + nix shell nixpkgs#kubernetes-helm -c helm install headplane kubernetes/headplane \ + --set headscale.url=http://headscale:8080 \ + --set integration.kubernetes.enabled=false \ + --wait --timeout 120s + + - name: Debug on failure + if: failure() + run: | + nix shell nixpkgs#kubectl -c kubectl get pods -o wide + nix shell nixpkgs#kubectl -c kubectl describe pods -l app.kubernetes.io/name=headplane + nix shell nixpkgs#kubectl -c kubectl logs -l app.kubernetes.io/name=headplane --tail=100 + + - name: Run Helm Tests + run: nix shell nixpkgs#kubernetes-helm -c helm test headplane + + - name: Cleanup + if: always() + run: nix shell nixpkgs#kind -c kind delete cluster --name helm-test diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index fb369153..727d7294 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -31,6 +31,7 @@ export default defineConfig({ { text: "Limited Mode", link: "/install/limited-mode" }, { text: "Native Mode", link: "/install/native-mode" }, { text: "Docker", link: "/install/docker" }, + { text: "Helm Chart", link: "/install/kubernetes-helm" }, ], }, { diff --git a/docs/install/kubernetes-helm.md b/docs/install/kubernetes-helm.md new file mode 100644 index 00000000..9a9ca267 --- /dev/null +++ b/docs/install/kubernetes-helm.md @@ -0,0 +1,101 @@ +# Helm Chart Installation + +Headplane provides an official Helm chart distributed as an OCI artifact via GitHub Container Registry. + +## Pre-requisites + +- A Kubernetes cluster (K3s, Talos, vanilla Kubernetes, etc.) +- Helm 3.0+ installed locally +- A running Headscale instance (deploy it separately using the [upstream Headscale chart](https://github.com/juanfont/headscale) or your own method) + +## Installation + +Pull the default values and customize them for your environment: + +```bash +helm show values oci://ghcr.io/tale/headplane > my-values.yaml +``` + +At minimum, set `headscale.url` to point to your Headscale instance: + +```yaml +headscale: + url: "http://headscale.headscale.svc.cluster.local:8080" + publicUrl: "https://headscale.example.com" +``` + +Then install the chart: + +```bash +helm install headplane oci://ghcr.io/tale/headplane -f my-values.yaml -n headplane --create-namespace +``` + +## Configuration + +The `values.yaml` file includes grouped comments explaining each section. Key configuration areas: + +- **headscale**: Connection URL to your existing Headscale instance (required) +- **server**: Bind address, port, and cookie settings +- **oidc**: Optional OIDC authentication provider configuration +- **persistence**: Toggle persistent storage for Headplane's data directory +- **serviceAccount / rbac**: Toggle creation of ServiceAccount and RBAC resources, or provide your own +- **probes**: Liveness and readiness probe configuration +- **autoscaling**: HPA with CPU/memory utilization targets +- **resources**: Container CPU/memory requests and limits +- **nodeSelector / tolerations / affinity**: Pod scheduling constraints +- **securityContext / podSecurityContext**: Container and pod security policies + +### OIDC Example + +```yaml +oidc: + enabled: true + issuerUrl: "https://your.oidc.issuer.url" + clientId: "headplane-client" + clientSecret: + value: "my-oidc-secret" +``` + +### Using Existing Secrets (GitOps) + +If you manage secrets externally (e.g., via Sealed Secrets, External Secrets Operator, or SOPS), reference them with `existingSecret` instead of providing plain values: + +```yaml +server: + cookieSecret: + existingSecret: "my-cookie-secret" + secretKey: "cookie-secret" + +oidc: + enabled: true + clientSecret: + existingSecret: "my-oidc-secret" + secretKey: "client-secret" + headscaleApiKey: + existingSecret: "my-headscale-apikey" + secretKey: "api-key" +``` + +### Autoscaling + +```yaml +autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 75 +``` + +## Upgrades + +```bash +helm upgrade headplane oci://ghcr.io/tale/headplane -f my-values.yaml -n headplane +``` + +## Verification + +After installation, verify the deployment with: + +```bash +helm test headplane -n headplane +``` diff --git a/kubernetes/headplane/Chart.yaml b/kubernetes/headplane/Chart.yaml new file mode 100644 index 00000000..49475c89 --- /dev/null +++ b/kubernetes/headplane/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +appVersion: 0.6.1 +description: Helm chart for Headplane, a web UI for managing Headscale +name: headplane +type: application +version: 0.6.1 diff --git a/kubernetes/headplane/README.md b/kubernetes/headplane/README.md new file mode 100644 index 00000000..7b5fdc58 --- /dev/null +++ b/kubernetes/headplane/README.md @@ -0,0 +1,190 @@ +# Headplane Helm Chart + +Official Helm chart for deploying Headplane into Kubernetes. Distributed as an OCI artifact via GitHub Container Registry. + +Headplane connects to an external Headscale instance. Deploy Headscale separately using its upstream chart or your own method. + +## Getting Started + +1. Fetch the default values: + +```bash +helm show values oci://ghcr.io/tale/headplane > my-values.yaml +``` + +2. Set `headscale.url` to point to your Headscale service, configure OIDC if needed, and adjust any other overrides. + +3. Install: + +```bash +helm install headplane oci://ghcr.io/tale/headplane -f my-values.yaml -n headplane --create-namespace +``` + +## Features + +- Dynamic naming via `_helpers.tpl` for multi-instance deployments +- Standard `app.kubernetes.io/*` labels on all resources +- `existingSecret` support for GitOps-managed credentials +- Toggleable ServiceAccount and RBAC creation +- HPA with CPU/memory utilization targets +- Liveness and readiness probes +- Resource requests/limits, nodeSelector, tolerations, affinity, topologySpreadConstraints + +## Configuration + +The following table lists the configurable parameters of the Headplane chart and their default values. + +### General + +| Parameter | Description | Default | +| ------------------ | ---------------------------------------------- | ------- | +| `nameOverride` | Override the chart name used in resource names | `""` | +| `fullnameOverride` | Override the fully qualified release name | `""` | +| `debug` | Enable debug logging | `false` | + +### Image + +| Parameter | Description | Default | +| ------------------ | ------------------------------------------ | ------------------------ | +| `image.repository` | Container image repository | `ghcr.io/tale/headplane` | +| `image.pullPolicy` | Image pull policy | `IfNotPresent` | +| `image.tag` | Image tag (defaults to chart `appVersion`) | `""` | +| `imagePullSecrets` | Docker registry secret names as an array | `[]` | + +### Headscale + +| Parameter | Description | Default | +| --------------------- | -------------------------------------------------------------- | ------- | +| `headscale.url` | **Required.** Internal URL of your Headscale gRPC/API endpoint | `""` | +| `headscale.publicUrl` | Public-facing Headscale URL shown in the UI | `""` | + +### Server + +| Parameter | Description | Default | +| ------------------------------------ | ------------------------------------------------------------ | ----------------- | +| `server.host` | Address the Headplane server binds to | `"0.0.0.0"` | +| `server.port` | Port the Headplane server listens on | `3000` | +| `server.cookieSecure` | Set the `Secure` flag on session cookies | `true` | +| `server.cookieSecret.value` | Plaintext cookie signing secret (not recommended for GitOps) | `""` | +| `server.cookieSecret.existingSecret` | Name of an existing Secret containing the cookie secret | `""` | +| `server.cookieSecret.secretKey` | Key within the existing Secret | `"cookie-secret"` | + +### Kubernetes Integration + +| Parameter | Description | Default | +| ----------------------------------------- | ------------------------------------------------------- | ------- | +| `integration.kubernetes.enabled` | Enable the Kubernetes integration in Headplane's config | `true` | +| `integration.kubernetes.validateManifest` | Validate Kubernetes manifests on startup | `true` | + +### OIDC Authentication + +| Parameter | Description | Default | +| ------------------------------------- | ----------------------------------------------- | -------------------- | +| `oidc.enabled` | Enable OIDC authentication | `false` | +| `oidc.issuerUrl` | OIDC issuer URL | `""` | +| `oidc.clientId` | OIDC client ID | `""` | +| `oidc.disableApiKeyLogin` | Disable API key login when OIDC is enabled | `false` | +| `oidc.tokenEndpointAuthMethod` | Token endpoint auth method | `client_secret_post` | +| `oidc.redirectUri` | OIDC redirect URI | `""` | +| `oidc.clientSecret.value` | Plaintext OIDC client secret | `""` | +| `oidc.clientSecret.existingSecret` | Existing Secret name for the OIDC client secret | `""` | +| `oidc.clientSecret.secretKey` | Key within the existing Secret | `"client-secret"` | +| `oidc.headscaleApiKey.value` | Plaintext Headscale API key for OIDC flows | `""` | +| `oidc.headscaleApiKey.existingSecret` | Existing Secret name for the Headscale API key | `""` | +| `oidc.headscaleApiKey.secretKey` | Key within the existing Secret | `"api-key"` | + +### Persistence + +| Parameter | Description | Default | +| ------------------------------- | -------------------------------------------------- | ------------------- | +| `persistence.enabled` | Enable persistent storage | `false` | +| `persistence.accessModes` | PVC access modes | `["ReadWriteOnce"]` | +| `persistence.storage` | PVC size | `1Gi` | +| `persistence.storageClassName` | StorageClass name (empty uses cluster default) | `""` | +| `persistence.annotations` | Additional PVC annotations | `{}` | +| `persistence.emptyDirSizeLimit` | Size limit when persistence is disabled (emptyDir) | `500Mi` | + +### ServiceAccount & RBAC + +| Parameter | Description | Default | +| ---------------------------- | ------------------------------------------------------ | ------- | +| `serviceAccount.create` | Create a ServiceAccount | `true` | +| `serviceAccount.name` | ServiceAccount name (generated from fullname if empty) | `""` | +| `serviceAccount.annotations` | ServiceAccount annotations | `{}` | +| `rbac.create` | Create Role and RoleBinding for the ServiceAccount | `true` | + +### Security Context + +| Parameter | Description | Default | +| ---------------------------------------- | -------------------------------------- | --------- | +| `securityContext.capabilities.drop` | Linux capabilities to drop | `["ALL"]` | +| `securityContext.readOnlyRootFilesystem` | Mount the root filesystem as read-only | `true` | +| `podSecurityContext` | Pod-level security context | `{}` | + +### Resources & Autoscaling + +| Parameter | Description | Default | +| -------------------------------------------- | ---------------------------------- | ------- | +| `resources` | CPU/memory requests and limits | `{}` | +| `autoscaling.enabled` | Enable Horizontal Pod Autoscaler | `false` | +| `autoscaling.minReplicas` | Minimum replica count | `1` | +| `autoscaling.maxReplicas` | Maximum replica count | `5` | +| `autoscaling.targetCPUUtilizationPercentage` | Target CPU utilization for scaling | `80` | + +### Probes + +| Parameter | Description | Default | +| -------------------------------------- | ------------------------------------------- | ---------------- | +| `probes.liveness.enabled` | Enable liveness probe | `true` | +| `probes.liveness.path` | HTTP path for liveness checks | `/admin/healthz` | +| `probes.liveness.port` | Named port for liveness checks | `app` | +| `probes.liveness.initialDelaySeconds` | Delay before first liveness check | `10` | +| `probes.liveness.periodSeconds` | Interval between liveness checks | `30` | +| `probes.liveness.timeoutSeconds` | Timeout for each liveness check | `5` | +| `probes.liveness.failureThreshold` | Consecutive failures before restart | `3` | +| `probes.readiness.enabled` | Enable readiness probe | `true` | +| `probes.readiness.path` | HTTP path for readiness checks | `/admin/healthz` | +| `probes.readiness.port` | Named port for readiness checks | `app` | +| `probes.readiness.initialDelaySeconds` | Delay before first readiness check | `5` | +| `probes.readiness.periodSeconds` | Interval between readiness checks | `10` | +| `probes.readiness.timeoutSeconds` | Timeout for each readiness check | `3` | +| `probes.readiness.failureThreshold` | Consecutive failures before marking unready | `3` | + +### Scheduling + +| Parameter | Description | Default | +| --------------------------- | ------------------------------------------------ | ------- | +| `nodeSelector` | Node labels for pod assignment | `{}` | +| `tolerations` | Tolerations for pod scheduling | `[]` | +| `affinity` | Affinity rules for pod scheduling | `{}` | +| `topologySpreadConstraints` | Topology spread constraints for pod distribution | `[]` | + +### Ingress + +| Parameter | Description | Default | +| --------------------- | -------------------------------- | ------- | +| `ingress.enabled` | Enable Ingress resource creation | `false` | +| `ingress.className` | IngressClass name | `""` | +| `ingress.annotations` | Ingress annotations | `{}` | +| `ingress.hosts` | Ingress host rules | `[]` | +| `ingress.tls` | Ingress TLS configuration | `[]` | + +### Miscellaneous + +| Parameter | Description | Default | +| ---------------- | ------------------------------------------------------------------ | ------- | +| `podAnnotations` | Additional annotations added to pods | `{}` | +| `hostAliases` | Host aliases injected into the pod's `/etc/hosts` | `[]` | +| `extraObjects` | Arbitrary extra Kubernetes manifests to deploy alongside the chart | `[]` | + +## Storage + +Persistent storage is disabled by default. Enable `persistence.enabled` in your values for production environments to avoid data loss on pod restarts. + +## Testing + +After installation, verify the deployment with: + +```bash +helm test headplane -n headplane +``` diff --git a/kubernetes/headplane/templates/_helpers.tpl b/kubernetes/headplane/templates/_helpers.tpl new file mode 100644 index 00000000..86b3a35c --- /dev/null +++ b/kubernetes/headplane/templates/_helpers.tpl @@ -0,0 +1,72 @@ +{{/* +Chart name, truncated to 63 chars. +*/}} +{{- define "headplane.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Fully qualified app name. Uses release name + chart name unless overridden. +*/}} +{{- define "headplane.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Chart label value. +*/}} +{{- define "headplane.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Standard metadata labels applied to every resource. +*/}} +{{- define "headplane.labels" -}} +helm.sh/chart: {{ include "headplane.chart" . }} +{{ include "headplane.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels used for pod matching in Services and Deployments. +*/}} +{{- define "headplane.selectorLabels" -}} +app.kubernetes.io/name: {{ include "headplane.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +ServiceAccount name. Uses the fullname if creation is enabled, otherwise +falls back to a user-provided name or the default SA. +*/}} +{{- define "headplane.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "headplane.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Generate a random cookie secret if one is not provided. +*/}} +{{- define "headplane.cookieSecret" -}} +{{- if .Values.server.cookieSecret.value -}} +{{- .Values.server.cookieSecret.value -}} +{{- else -}} +{{- randAlphaNum 32 -}} +{{- end -}} +{{- end }} diff --git a/kubernetes/headplane/templates/configmap.yaml b/kubernetes/headplane/templates/configmap.yaml new file mode 100644 index 00000000..1a8a2514 --- /dev/null +++ b/kubernetes/headplane/templates/configmap.yaml @@ -0,0 +1,36 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "headplane.fullname" . }} + labels: + {{- include "headplane.labels" . | nindent 4 }} +data: + config.yaml: | + server: + host: {{ .Values.server.host | default "0.0.0.0" }} + port: {{ .Values.server.port | default 3000 }} + cookie_secret_path: /var/secrets/headplane/server/{{ .Values.server.cookieSecret.secretKey | default "cookie-secret" }} + cookie_secure: {{ .Values.server.cookieSecure | default true }} + headscale: + url: {{ required "headscale.url is required" .Values.headscale.url }} + config_strict: false + {{- if .Values.headscale.publicUrl }} + public_url: {{ .Values.headscale.publicUrl }} + {{- end }} + integration: + kubernetes: + enabled: {{ .Values.integration.kubernetes.enabled }} + validate_manifest: {{ .Values.integration.kubernetes.validateManifest }} + pod_name: replaced-by-environment-variable + {{- if .Values.oidc.enabled }} + oidc: + issuer: {{ .Values.oidc.issuerUrl }} + client_id: {{ .Values.oidc.clientId }} + client_secret_path: /var/secrets/headplane/oidc/{{ .Values.oidc.clientSecret.secretKey | default "client-secret" }} + disable_api_key_login: {{ .Values.oidc.disableApiKeyLogin | default false }} + token_endpoint_auth_method: {{ .Values.oidc.tokenEndpointAuthMethod | default "client_secret_post" }} + headscale_api_key_path: /var/secrets/headplane/headscale/{{ .Values.oidc.headscaleApiKey.secretKey | default "api-key" }} + {{- if .Values.oidc.redirectUri }} + redirect_uri: {{ .Values.oidc.redirectUri }} + {{- end }} + {{- end }} diff --git a/kubernetes/headplane/templates/deployment.yaml b/kubernetes/headplane/templates/deployment.yaml new file mode 100644 index 00000000..95a53a91 --- /dev/null +++ b/kubernetes/headplane/templates/deployment.yaml @@ -0,0 +1,140 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "headplane.fullname" . }} + labels: + {{- include "headplane.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: 1 + {{- end }} + selector: + matchLabels: + {{- include "headplane.selectorLabels" . | nindent 6 }} + strategy: + type: Recreate + template: + metadata: + labels: + {{- include "headplane.selectorLabels" . | nindent 8 }} + annotations: + checksum/configmap: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/secret: {{ include (print $.Template.BasePath "/secrets.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "headplane.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.hostAliases }} + hostAliases: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.topologySpreadConstraints }} + topologySpreadConstraints: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + env: + - name: HEADPLANE_DEBUG_LOG + value: {{ .Values.debug | default false | quote }} + - name: HEADPLANE_LOAD_ENV_OVERRIDES + value: "true" + - name: HEADPLANE_INTEGRATION__KUBERNETES__POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + ports: + - name: app + containerPort: {{ .Values.server.port | default 3000 }} + protocol: TCP + {{- if .Values.probes.liveness.enabled }} + livenessProbe: + httpGet: + path: {{ .Values.probes.liveness.path }} + port: {{ .Values.probes.liveness.port }} + initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.liveness.failureThreshold }} + {{- end }} + {{- if .Values.probes.readiness.enabled }} + readinessProbe: + httpGet: + path: {{ .Values.probes.readiness.path }} + port: {{ .Values.probes.readiness.port }} + initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.readiness.failureThreshold }} + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: tmp + mountPath: /tmp + - name: config + mountPath: /etc/headplane + - name: cookie-secret + mountPath: /var/secrets/headplane/server + {{- if .Values.oidc.enabled }} + - name: oidc-client-secret + mountPath: /var/secrets/headplane/oidc + - name: headscale-api-key + mountPath: /var/secrets/headplane/headscale + {{- end }} + - name: data + mountPath: /var/lib/headplane + volumes: + - name: tmp + emptyDir: {} + - name: config + configMap: + name: {{ include "headplane.fullname" . }} + - name: cookie-secret + secret: + secretName: {{ .Values.server.cookieSecret.existingSecret | default (printf "%s-cookie" (include "headplane.fullname" .)) }} + {{- if .Values.oidc.enabled }} + - name: oidc-client-secret + secret: + secretName: {{ .Values.oidc.clientSecret.existingSecret | default (printf "%s-oidc" (include "headplane.fullname" .)) }} + - name: headscale-api-key + secret: + secretName: {{ .Values.oidc.headscaleApiKey.existingSecret | default (printf "%s-apikey" (include "headplane.fullname" .)) }} + {{- end }} + - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "headplane.fullname" . }} + {{- else }} + emptyDir: + sizeLimit: {{ .Values.persistence.emptyDirSizeLimit | default "500Mi" }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/kubernetes/headplane/templates/extra-objects.yaml b/kubernetes/headplane/templates/extra-objects.yaml new file mode 100644 index 00000000..fc9a76b8 --- /dev/null +++ b/kubernetes/headplane/templates/extra-objects.yaml @@ -0,0 +1,8 @@ +{{ range .Values.extraObjects }} +--- +{{ if typeIs "string" . }} + {{- tpl . $ }} +{{- else }} + {{- tpl (toYaml .) $ }} +{{- end }} +{{ end }} diff --git a/kubernetes/headplane/templates/hpa.yaml b/kubernetes/headplane/templates/hpa.yaml new file mode 100644 index 00000000..f868693d --- /dev/null +++ b/kubernetes/headplane/templates/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "headplane.fullname" . }} + labels: + {{- include "headplane.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "headplane.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/kubernetes/headplane/templates/pvc.yaml b/kubernetes/headplane/templates/pvc.yaml new file mode 100644 index 00000000..f2d884ec --- /dev/null +++ b/kubernetes/headplane/templates/pvc.yaml @@ -0,0 +1,21 @@ +{{- if .Values.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "headplane.fullname" . }} + labels: + {{- include "headplane.labels" . | nindent 4 }} + {{- with .Values.persistence.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 4 }} + resources: + requests: + storage: {{ .Values.persistence.storage }} + {{- if .Values.persistence.storageClassName }} + storageClassName: {{ .Values.persistence.storageClassName }} + {{- end }} +{{- end }} diff --git a/kubernetes/headplane/templates/role.yaml b/kubernetes/headplane/templates/role.yaml new file mode 100644 index 00000000..6174d0a2 --- /dev/null +++ b/kubernetes/headplane/templates/role.yaml @@ -0,0 +1,21 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "headplane.fullname" . }} + labels: + {{- include "headplane.labels" . | nindent 4 }} +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["pods/exec"] + verbs: ["create"] + - apiGroups: [""] + resources: ["secrets"] + verbs: ["create", "patch", "get"] +{{- end }} diff --git a/kubernetes/headplane/templates/rolebinding.yaml b/kubernetes/headplane/templates/rolebinding.yaml new file mode 100644 index 00000000..10da731f --- /dev/null +++ b/kubernetes/headplane/templates/rolebinding.yaml @@ -0,0 +1,16 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "headplane.fullname" . }} + labels: + {{- include "headplane.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "headplane.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "headplane.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/kubernetes/headplane/templates/secrets.yaml b/kubernetes/headplane/templates/secrets.yaml new file mode 100644 index 00000000..e2ea4669 --- /dev/null +++ b/kubernetes/headplane/templates/secrets.yaml @@ -0,0 +1,23 @@ +{{- if not .Values.server.cookieSecret.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "headplane.fullname" . }}-cookie + labels: + {{- include "headplane.labels" . | nindent 4 }} +stringData: + {{ .Values.server.cookieSecret.secretKey | default "cookie-secret" }}: {{ include "headplane.cookieSecret" . }} +{{- end }} +{{- if .Values.oidc.enabled }} +{{- if not .Values.oidc.clientSecret.existingSecret }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "headplane.fullname" . }}-oidc + labels: + {{- include "headplane.labels" . | nindent 4 }} +stringData: + {{ .Values.oidc.clientSecret.secretKey | default "client-secret" }}: {{ required "oidc.clientSecret.value is required when not using existingSecret" .Values.oidc.clientSecret.value }} +{{- end }} +{{- end }} diff --git a/kubernetes/headplane/templates/service.yaml b/kubernetes/headplane/templates/service.yaml new file mode 100644 index 00000000..745fb2fa --- /dev/null +++ b/kubernetes/headplane/templates/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "headplane.fullname" . }} + labels: + {{- include "headplane.labels" . | nindent 4 }} +spec: + selector: + {{- include "headplane.selectorLabels" . | nindent 4 }} + ports: + - protocol: TCP + port: 80 + name: http + targetPort: app diff --git a/kubernetes/headplane/templates/serviceaccount.yaml b/kubernetes/headplane/templates/serviceaccount.yaml new file mode 100644 index 00000000..d76ebd76 --- /dev/null +++ b/kubernetes/headplane/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "headplane.serviceAccountName" . }} + labels: + {{- include "headplane.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/kubernetes/headplane/templates/tests/test-connection.yaml b/kubernetes/headplane/templates/tests/test-connection.yaml new file mode 100644 index 00000000..61035fc3 --- /dev/null +++ b/kubernetes/headplane/templates/tests/test-connection.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "headplane.fullname" . }}-test-connection + labels: + {{- include "headplane.labels" . | nindent 4 }} + annotations: + helm.sh/hook: test +spec: + restartPolicy: Never + containers: + - name: wget + image: busybox:1.37 + command: ["wget", "--spider", "--timeout=5", "http://{{ include "headplane.fullname" . }}:80/admin/healthz"] diff --git a/kubernetes/headplane/values.yaml b/kubernetes/headplane/values.yaml new file mode 100644 index 00000000..f2f229fe --- /dev/null +++ b/kubernetes/headplane/values.yaml @@ -0,0 +1,145 @@ +# Headplane Helm Chart +# Override any of these values in your own values file. +# Only headscale.url requires explicit configuration for a working setup. + +# -- Override the chart name or fully qualified release name +nameOverride: "" +fullnameOverride: "" + +# -- Container image +image: + repository: ghcr.io/tale/headplane + pullPolicy: IfNotPresent + tag: "" + +imagePullSecrets: [] + +# -- Headscale connection. Deploy Headscale separately using its upstream chart. +headscale: + url: "" + publicUrl: "" + +# -- Server bind address, port, and cookie settings +server: + host: "0.0.0.0" + port: 3000 + cookieSecure: true + cookieSecret: + value: "" + existingSecret: "" + secretKey: "cookie-secret" + +# -- Kubernetes integration settings written into Headplane's config.yaml +integration: + kubernetes: + enabled: true + validateManifest: true + +# -- OIDC authentication +oidc: + enabled: false + issuerUrl: "" + clientId: "" + disableApiKeyLogin: false + tokenEndpointAuthMethod: client_secret_post + redirectUri: "" + clientSecret: + value: "" + existingSecret: "" + secretKey: "client-secret" + headscaleApiKey: + value: "" + existingSecret: "" + secretKey: "api-key" + +# -- Persistent storage for Headplane data +persistence: + enabled: false + accessModes: + - ReadWriteOnce + storage: 1Gi + storageClassName: "" + annotations: {} + emptyDirSizeLimit: 500Mi + +# -- ServiceAccount configuration +serviceAccount: + create: true + name: "" + annotations: {} + +# -- RBAC role and binding +rbac: + create: true + +# -- Container-level security context +securityContext: + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + +# -- Pod-level security context +podSecurityContext: {} + +# -- Container resource requests and limits +resources: {} + # requests: + # cpu: 100m + # memory: 128Mi + # limits: + # cpu: 500m + # memory: 256Mi + +# -- Liveness and readiness probes +probes: + liveness: + enabled: true + path: /admin/healthz + port: app + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readiness: + enabled: true + path: /admin/healthz + port: app + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + +# -- Horizontal Pod Autoscaler +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +# -- Scheduling constraints +nodeSelector: {} +tolerations: [] +affinity: {} +topologySpreadConstraints: [] + +# -- Debug logging +debug: false + +# -- Additional pod annotations +podAnnotations: {} + +# -- Ingress configuration +ingress: + enabled: false + className: "" + annotations: {} + hosts: [] + tls: [] + +# -- Additional host aliases injected into the pod +hostAliases: [] + +# -- Arbitrary extra Kubernetes objects to deploy alongside the chart +extraObjects: []