Compare commits

..

2 Commits

284 changed files with 316 additions and 16439 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -1,67 +0,0 @@
# Copy to **.env** in this repository root (`.env` is gitignored).
# Ansible **noble_cert_manager** role sources `.env` after cert-manager Helm install and creates
# **cert-manager/cloudflare-dns-api-token** when **CLOUDFLARE_DNS_API_TOKEN** is set.
#
# Cloudflare: Zone → DNS → Edit + Zone → Read for **pcenicni.dev** (see clusters/noble/bootstrap/cert-manager/README.md).
CLOUDFLARE_DNS_API_TOKEN=
# --- Optional: other deploy-time values (documented for manual use or future automation) ---
# Pangolin / Newt — with **noble_newt_install=true**, Ansible creates **newt/newt-pangolin-auth** when all are set (see clusters/noble/bootstrap/newt/README.md).
PANGOLIN_ENDPOINT=
NEWT_ID=
NEWT_SECRET=
# Optional: Pangolin Integration API — automate public HTTP resources + Traefik targets (**noble_pangolin_sync_http_resources=true** in **group_vars**; see **clusters/noble/bootstrap/newt/README.md** §4).
# NOBLE_PANGOLIN_API_BASE=https://api.example.com/v1 # Integration API — separate host from the main Pangolin UI; see clusters/noble/bootstrap/newt/README.md §4
# NOBLE_PANGOLIN_ORG_ID=
# NOBLE_PANGOLIN_API_TOKEN= # **apiKeyId.apiKeySecret** (one value, dot in the middle) from Organization → API keys — **not** login password; browser cookies do not apply. Alternatively: secret only here + **NOBLE_PANGOLIN_API_KEY_ID** below.
# NOBLE_PANGOLIN_API_KEY_ID= # optional; if set, **NOBLE_PANGOLIN_API_TOKEN** may be the secret half only
# NOBLE_PANGOLIN_SITE_ID= # numeric siteId, or Pangolin **niceId** (Sites UI slug, e.g. unruly-asian-badger)
# NOBLE_PANGOLIN_TRAEFIK_IP=192.168.50.211
# NOBLE_PANGOLIN_TRAEFIK_PORT=443
# Self-signed Integration API TLS: either trust your CA (preferred) or homelab-only skip verify:
# NOBLE_PANGOLIN_CA_BUNDLE=/path/to/ca.pem
# NOBLE_PANGOLIN_INSECURE_SKIP_TLS_VERIFY=true
# Velero — when **noble_velero_install=true**, set bucket + S3 API URL and credentials (see clusters/noble/bootstrap/velero/README.md).
NOBLE_VELERO_S3_BUCKET=
NOBLE_VELERO_S3_URL=
NOBLE_VELERO_AWS_ACCESS_KEY_ID=
NOBLE_VELERO_AWS_SECRET_ACCESS_KEY=
# Authentik + OIDC — when **noble_authentik_install=true**, Ansible installs Authentik and reconfigures Argo CD, Grafana, Headlamp, **Open WebUI** (native OIDC) and Prometheus/Alertmanager/Longhorn via oauth2-proxy (OIDC to Authentik + Traefik ForwardAuth). See **ansible/roles/noble_authentik/README.md**.
NOBLE_AUTHENTIK_SECRET_KEY=
NOBLE_AUTHENTIK_POSTGRES_PASSWORD=
NOBLE_AUTHENTIK_BOOTSTRAP_TOKEN=
NOBLE_AUTHENTIK_BOOTSTRAP_EMAIL=
NOBLE_AUTHENTIK_BOOTSTRAP_PASSWORD=
NOBLE_AUTHENTIK_CLIENT_SECRET_ARGOCD=
NOBLE_AUTHENTIK_CLIENT_SECRET_GRAFANA=
NOBLE_AUTHENTIK_CLIENT_SECRET_HEADLAMP=
NOBLE_AUTHENTIK_CLIENT_SECRET_OAUTH2_PROXY=
# Open WebUI (Argo app **clusters/noble/apps/open-webui**) — OIDC client secret + app secrets (see **clusters/noble/apps/open-webui/values.yaml**).
NOBLE_AUTHENTIK_CLIENT_SECRET_OPEN_WEBUI=
NOBLE_OPEN_WEBUI_OPENAI_API_KEY=
# e.g. openssl rand -hex 32
NOBLE_OPEN_WEBUI_WEBUI_SECRET_KEY=
# Random secret for oauth2-proxy session cookie (see oauth2-proxy Helm chart docs; e.g. openssl rand -base64 32 | head -c 32 | base64)
NOBLE_AUTHENTIK_OAUTH2_PROXY_COOKIE_SECRET=
# S3 media — **separate** bucket from Velero backups (**NOBLE_VELERO_S3_BUCKET**). Endpoint and keys default to the Velero vars above unless you set the Authentik-specific overrides.
NOBLE_AUTHENTIK_MEDIA_S3_BUCKET=
# Optional overrides (otherwise **NOBLE_VELERO_S3_URL** and Velero AWS keys are used):
# NOBLE_AUTHENTIK_S3_URL=
# NOBLE_AUTHENTIK_S3_ACCESS_KEY=
# NOBLE_AUTHENTIK_S3_SECRET_KEY=
# NOBLE_AUTHENTIK_S3_REGION=
# NOBLE_AUTHENTIK_S3_ADDRESSING_STYLE=
#
# Optional outbound email (password recovery, invites, etc.) — maps to Authentik **AUTHENTIK_EMAIL__*** (see https://docs.goauthentik.io/install-config/configuration/#email-settings ). Omit **NOBLE_AUTHENTIK_SMTP_HOST** to leave email unset in Helm.
# NOBLE_AUTHENTIK_SMTP_HOST=
# NOBLE_AUTHENTIK_SMTP_FROM=
# NOBLE_AUTHENTIK_SMTP_PORT=587
# NOBLE_AUTHENTIK_SMTP_USERNAME=
# NOBLE_AUTHENTIK_SMTP_PASSWORD=
# NOBLE_AUTHENTIK_SMTP_USE_TLS=true
# NOBLE_AUTHENTIK_SMTP_USE_SSL=false
# NOBLE_AUTHENTIK_SMTP_TIMEOUT=30

12
.gitignore vendored
View File

@@ -1,12 +0,0 @@
ansible/inventory/hosts.ini
# Talos generated
talos/out/
talos/kubeconfig
# Local secrets
age-key.txt
.env
.tmp*
# Generated by ansible noble_landing_urls
ansible/output/noble-lab-ui-urls.md

View File

@@ -1,7 +0,0 @@
# Mozilla SOPS — encrypt/decrypt Kubernetes Secret manifests under clusters/noble/secrets/
# Generate a key: age-keygen -o age-key.txt (age-key.txt is gitignored)
# Add the printed public key below (one recipient per line is supported).
creation_rules:
- path_regex: clusters/noble/secrets/.*\.yaml$
age: >-
age1juym5p3ez3dkt0dxlznydgfgqvaujfnyk9hpdsssf50hsxeh3p4sjpf3gn

View File

@@ -180,12 +180,6 @@ Shared services used across multiple applications.
**Configuration:** Requires Pangolin endpoint URL, Newt ID, and Newt secret. **Configuration:** Requires Pangolin endpoint URL, Newt ID, and Newt secret.
### versitygw/ (`komodo/s3/versitygw/`)
- **[Versity S3 Gateway](https://github.com/versity/versitygw)** — S3 API on port **10000** by default; optional **WebUI** on **8080** (not the same listener—enable `VERSITYGW_WEBUI_PORT` / `VGW_WEBUI_GATEWAYS` per `.env.sample`). Behind **Pangolin**, expose the API and WebUI separately (or you will see **404** browsing the API URL).
**Configuration:** Set either `ROOT_ACCESS_KEY` / `ROOT_SECRET_KEY` or `ROOT_ACCESS_KEY_ID` / `ROOT_SECRET_ACCESS_KEY`. Optional `VERSITYGW_PORT`. For a browser UI on another host (for example `https://s3-ui.pcenicni.dev` calling the API through Pangolin), set **`VGW_CORS_ALLOW_ORIGIN`** to that UI origin; VersityGW reflects requested headers on preflight when bucket CORS is unset (see `.env.sample`). If that is already set and CORS still fails, **Pangolin** may be handling **OPTIONS** before Versity (for example auth on the public resource blocking preflight; see `komodo/s3/versitygw/.env.sample` § Pangolin). Compose uses `${VAR}` interpolation so credentials work with Komodos `docker compose --env-file <run_directory>/.env` (avoid `env_file:` in the service when `run_directory` is not the same folder as `compose.yaml`, or the written `.env` will not be found).
--- ---
## 📊 Monitoring (`komodo/monitor/`) ## 📊 Monitoring (`komodo/monitor/`)

1
ansible/.gitignore vendored
View File

@@ -1 +0,0 @@
.ansible-tmp/

View File

@@ -1,163 +1,84 @@
# Ansible — noble cluster # Home Server Ansible Configuration
**Narrative walkthrough (Proxmox → Talos → platform → Argo):** [`docs/ansible-getting-started.md`](../docs/ansible-getting-started.md). This directory contains Ansible playbooks for managing the Proxmox home server environment.
Automates [`talos/CLUSTER-BUILD.md`](../talos/CLUSTER-BUILD.md): optional **Talos Phase A** (genconfig → apply → bootstrap → kubeconfig), then **Phase B+** (CNI → add-ons → ingress → Argo CD → Kyverno → observability, etc.). **Trivy Operator** is installed via Argo (**`noble-trivy-operator`** app-of-apps), not **`noble.yml`**. **Argo CD** does not reconcile core charts first — optional add-on **`Application`** manifests live under [`clusters/noble/apps/`](../clusters/noble/apps/) and are included when **`noble_platform`** runs **`kubectl apply -k clusters/noble/bootstrap`** (see [`clusters/noble/apps/README.md`](../clusters/noble/apps/README.md)). ## Directory Structure
## Order of operations - `inventory/`: Contains the inventory file `hosts.ini` where you define your servers.
- `playbooks/`: Contains the actual Ansible playbooks.
- `ansible.cfg`: Local Ansible configuration.
- `requirements.yml`: List of Ansible collections required.
1. **From `talos/`:** `talhelper gensecret` / `talsecret` as in [`talos/README.md`](../talos/README.md) §1 (if not already done). ## Setup
2. **Talos Phase A (automated):** run [`playbooks/talos_phase_a.yml`](playbooks/talos_phase_a.yml) **or** the full pipeline [`playbooks/deploy.yml`](playbooks/deploy.yml). This runs **`talhelper genconfig -o out`**, **`talosctl apply-config`** on each node, **`talosctl bootstrap`**, and **`talosctl kubeconfig`** → **`talos/kubeconfig`**.
3. **Platform stack:** [`playbooks/noble.yml`](playbooks/noble.yml) (included at the end of **`deploy.yml`**).
Your workstation must be able to reach **node IPs on the lab LAN** (Talos API **:50000** for `talosctl`, Kubernetes **:6443** for `kubectl` / Helm). If `kubectl` cannot reach the VIP (`192.168.50.230`), use `-e 'noble_k8s_api_server_override=https://<control-plane-ip>:6443'` on **`noble.yml`** (see `inventory/group_vars/all.yml`). 1. **Install Requirements**:
```bash
ansible-galaxy install -r requirements.yml
```
**One-shot full deploy** (after nodes are booted and reachable): 2. **Configure Inventory**:
Edit `inventory/hosts.ini` and update the following:
- `ansible_host`: The IP address of your Proxmox node.
- `ansible_user`: The SSH user (usually root).
- `proxmox_api_*`: Variables if you plan to use API-based modules in the future.
*Note: Ensure you have SSH key access to your Proxmox node for passwordless login, or uncomment `ansible_ssh_pass`.*
## Available Playbooks
### Create Ubuntu Cloud Template (`playbooks/create_ubuntu_template.yml`)
This playbook downloads a generic Ubuntu 22.04 Cloud Image and converts it into a Proxmox VM Template.
**Usage:**
```bash ```bash
cd ansible # Run the playbook
ansible-playbook playbooks/deploy.yml ansible-playbook playbooks/create_ubuntu_template.yml
``` ```
## Deploy secrets (`.env`) **Variables:**
You can override variables at runtime or by editing the playbook:
Copy **`.env.sample`** to **`.env`** at the repository root (`.env` is gitignored). At minimum set **`CLOUDFLARE_DNS_API_TOKEN`** for cert-manager DNS-01. The **cert-manager** role applies it automatically during **`noble.yml`**. See **`.env.sample`** for optional placeholders (e.g. Newt/Pangolin). - `template_id`: Default `9000`
- `template_name`: Default `ubuntu-2204-cloud`
## Prerequisites - `storage_pool`: Default `local-lvm`
- `talosctl` (matches node Talos version), `talhelper`, `helm`, `kubectl`.
- **SOPS secrets:** `sops` and `age` on the control host if you use **`clusters/noble/secrets/`** with **`age-key.txt`** (see **`clusters/noble/secrets/README.md`**).
- **Phase A:** same LAN/VPN as nodes so **Talos :50000** and **Kubernetes :6443** are reachable (see [`talos/README.md`](../talos/README.md) §3).
- **noble.yml:** bootstrapped cluster and **`talos/kubeconfig`** (or `KUBECONFIG`).
## Playbooks
| Playbook | Purpose |
|----------|---------|
| [`playbooks/deploy.yml`](playbooks/deploy.yml) | **Talos Phase A** then **`noble.yml`** (full automation). |
| [`playbooks/talos_phase_a.yml`](playbooks/talos_phase_a.yml) | `genconfig``apply-config``bootstrap``kubeconfig` only. |
| [`playbooks/noble.yml`](playbooks/noble.yml) | Helm + `kubectl` platform (after Phase A). |
| [`playbooks/post_deploy.yml`](playbooks/post_deploy.yml) | SOPS reminders and optional Argo root Application note. |
| [`playbooks/talos_bootstrap.yml`](playbooks/talos_bootstrap.yml) | **`talhelper genconfig` only** (legacy shortcut; prefer **`talos_phase_a.yml`**). |
| [`playbooks/debian_harden.yml`](playbooks/debian_harden.yml) | Baseline hardening for Debian servers (SSH/sysctl/fail2ban/unattended-upgrades). |
| [`playbooks/debian_maintenance.yml`](playbooks/debian_maintenance.yml) | Debian maintenance run (apt upgrades, autoremove/autoclean, reboot when required). |
| [`playbooks/debian_rotate_ssh_keys.yml`](playbooks/debian_rotate_ssh_keys.yml) | Rotate managed users' `authorized_keys`. |
| [`playbooks/debian_ops.yml`](playbooks/debian_ops.yml) | Convenience pipeline: harden then maintenance for Debian servers. |
| [`playbooks/proxmox_prepare.yml`](playbooks/proxmox_prepare.yml) | Configure Proxmox community repos and disable no-subscription UI warning. |
| [`playbooks/proxmox_upgrade.yml`](playbooks/proxmox_upgrade.yml) | Proxmox maintenance run (apt dist-upgrade, cleanup, reboot when required). |
| [`playbooks/proxmox_cluster.yml`](playbooks/proxmox_cluster.yml) | Create a Proxmox cluster on the master and join additional hosts. |
| [`playbooks/proxmox_ops.yml`](playbooks/proxmox_ops.yml) | Convenience pipeline: prepare, upgrade, then cluster Proxmox hosts. |
Example overriding variables:
```bash ```bash
cd ansible ansible-playbook playbooks/create_ubuntu_template.yml -e "template_id=9001 template_name=my-custom-template"
export KUBECONFIG=/absolute/path/to/home-server/talos/kubeconfig
# noble.yml only — if VIP is unreachable from this host:
# ansible-playbook playbooks/noble.yml -e 'noble_k8s_api_server_override=https://192.168.50.20:6443'
ansible-playbook playbooks/noble.yml
ansible-playbook playbooks/post_deploy.yml
``` ```
### Talos Phase A variables (role `talos_phase_a` defaults) ### Manage VM Playbook (`playbooks/manage_vm.yml`)
Override with `-e` when needed, e.g. **`-e noble_talos_skip_bootstrap=true`** if etcd is already initialized. This unified playbook allows you to manage VMs (create from template, delete, backup, create template) across your Proxmox hosts.
| Variable | Default | Meaning | **Usage:**
|----------|---------|---------|
| `noble_talos_genconfig` | `true` | Run **`talhelper genconfig -o out`** first. |
| `noble_talos_apply_mode` | `auto` | **`auto`** — **`talosctl apply-config --dry-run`** on the first node picks maintenance (**`--insecure`**) vs joined (**`TALOSCONFIG`**). **`insecure`** / **`secure`** force talos/README §2 A or B. |
| `noble_talos_skip_bootstrap` | `false` | Skip **`talosctl bootstrap`**. If etcd is **already** initialized, bootstrap is treated as a no-op (same as **`talosctl`** “etcd data directory is not empty”). |
| `noble_talos_apid_wait_delay` / `noble_talos_apid_wait_timeout` | `20` / `900` | Seconds to wait for **apid :50000** on the bootstrap node after **apply-config** (nodes reboot). Increase if bootstrap hits **connection refused** to `:50000`. |
| `noble_talos_nodes` | neon/argon/krypton/helium | IP + **`out/*.yaml`** filename — align with **`talos/talconfig.yaml`**. |
### Tags (partial runs) The playbook target defaults to the `proxmox` group, but you should usually specify a specific host using `target_host` variable or `-l` limit.
```bash 1. **Create a New Template**:
ansible-playbook playbooks/noble.yml --tags cilium,metallb ```bash
ansible-playbook playbooks/noble.yml --skip-tags newt ansible-playbook playbooks/manage_vm.yml -e "proxmox_action=create_template vmid=9003 template_name=my-ubuntu-template"
ansible-playbook playbooks/noble.yml --tags velero -e noble_velero_install=true -e noble_velero_s3_bucket=... -e noble_velero_s3_url=... ```
ansible-playbook playbooks/noble.yml --tags authentik -e noble_authentik_install=true
```
### Variables — `inventory/group_vars/` and role defaults 2. **Create a VM from Template**:
```bash
ansible-playbook playbooks/manage_vm.yml -e "proxmox_action=create_vm vmid=9002 new_vmid=105 new_vm_name=my-new-vm"
```
- **`inventory/group_vars/all.yml`:** **`noble_newt_install`**, **`noble_velero_install`**, **`noble_authentik_install`**, **`noble_cert_manager_require_cloudflare_secret`**, **`noble_argocd_apply_bootstrap_root_application`**, **`noble_k8s_api_server_override`**, **`noble_k8s_api_server_auto_fallback`**, **`noble_k8s_api_server_fallback`**, **`noble_skip_k8s_health_check`** 3. **Delete a VM**:
- **`roles/noble_platform/defaults/main.yml`:** **`noble_apply_sops_secrets`**, **`noble_sops_age_key_file`**, **`noble_platform_loki_helm_wait_timeout`**, **`noble_platform_wait_longhorn_csi_before_loki`**, **`noble_platform_longhorn_csi_rollout_timeout`** ```bash
ansible-playbook playbooks/manage_vm.yml -e "proxmox_action=delete_vm vmid=105"
```
## Roles 4. **Backup a VM**:
```bash
ansible-playbook playbooks/manage_vm.yml -e "proxmox_action=backup_vm vmid=105"
```
| Role | Contents | **Variables:**
|------|----------| - `proxmox_action`: One of `create_template`, `create_vm`, `delete_vm`, `backup_vm` (Default: `create_vm`)
| `talos_phase_a` | Talos genconfig, apply-config, bootstrap, kubeconfig | - `target_host`: The host to run on (Default: `proxmox` group). Example: `-e "target_host=mercury"`
| `helm_repos` | `helm repo add` / `update` |
| `noble_*` | Cilium, CSI Volume Snapshot CRDs + controller, metrics-server, Longhorn, MetalLB (20m Helm wait), kube-vip, Traefik, cert-manager, Newt, Argo CD, Kyverno, platform stack, **Authentik** (optional OIDC), Velero (optional). **Trivy Operator:** Argo leaf **`noble-trivy-operator`** (see `clusters/noble/bootstrap/argocd/app-of-apps/`); role **`noble_trivy`** is not invoked by **`noble.yml`**. |
| `noble_landing_urls` | Writes **`ansible/output/noble-lab-ui-urls.md`** — URLs, service names, and (optional) Argo/Grafana passwords from Secrets |
| `noble_post_deploy` | Post-install reminders |
| `talos_bootstrap` | Genconfig-only (used by older playbook) |
| `debian_baseline_hardening` | Baseline Debian hardening (SSH policy, sysctl profile, fail2ban, unattended upgrades) |
| `debian_maintenance` | Routine Debian maintenance tasks (updates, cleanup, reboot-on-required) |
| `debian_ssh_key_rotation` | Declarative `authorized_keys` rotation for server users |
| `proxmox_baseline` | Proxmox repo prep (community repos) and no-subscription warning suppression |
| `proxmox_maintenance` | Proxmox package maintenance (dist-upgrade, cleanup, reboot-on-required) |
| `proxmox_cluster` | Proxmox cluster bootstrap/join automation using `pvecm` |
## Debian server ops quick start *See `roles/proxmox_vm/defaults/main.yml` for all available configuration options.*
These playbooks are separate from the Talos/noble flow and target hosts in `debian_servers`.
1. Copy `inventory/debian.example.yml` to `inventory/debian.yml` and update hosts/users.
2. Update `inventory/group_vars/debian_servers.yml` with your allowed SSH users and real public keys.
3. Run with the Debian inventory:
```bash
cd ansible
ansible-playbook -i inventory/debian.yml playbooks/debian_harden.yml
ansible-playbook -i inventory/debian.yml playbooks/debian_rotate_ssh_keys.yml
ansible-playbook -i inventory/debian.yml playbooks/debian_maintenance.yml
```
Or run the combined maintenance pipeline:
```bash
cd ansible
ansible-playbook -i inventory/debian.yml playbooks/debian_ops.yml
```
## Proxmox host + cluster quick start
These playbooks are separate from the Talos/noble flow and target hosts in `proxmox_hosts`.
1. Copy `inventory/proxmox.example.yml` to `inventory/proxmox.yml` and update hosts/users.
2. Update `inventory/group_vars/proxmox_hosts.yml` with your cluster name (`proxmox_cluster_name`), chosen cluster master, and root public key file paths to install.
3. First run (no SSH keys yet): use `--ask-pass` **or** set `ansible_password` (prefer Ansible Vault). Keep `ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new"` in inventory for first-contact hosts.
4. Run prepare first to install your public keys on each host, then continue:
```bash
cd ansible
ansible-playbook -i inventory/proxmox.yml playbooks/proxmox_prepare.yml --ask-pass
ansible-playbook -i inventory/proxmox.yml playbooks/proxmox_upgrade.yml
ansible-playbook -i inventory/proxmox.yml playbooks/proxmox_cluster.yml
```
After `proxmox_prepare.yml` finishes, SSH key auth should work for root (keys from `proxmox_root_authorized_key_files`), so `--ask-pass` is usually no longer needed.
If `pvecm add` still prompts for the master root password during join, set `proxmox_cluster_master_root_password` (prefer Vault) to run join non-interactively.
Changing `proxmox_cluster_name` only affects new cluster creation; it does not rename an already-created cluster.
Or run the full Proxmox pipeline:
```bash
cd ansible
ansible-playbook -i inventory/proxmox.yml playbooks/proxmox_ops.yml
```
## Migrating from Argo-managed `noble-platform`
```bash
kubectl delete application -n argocd noble-platform noble-kyverno noble-kyverno-policies --ignore-not-found
kubectl apply -f clusters/noble/bootstrap/argocd/bootstrap-root-application.yaml
```
Then run `playbooks/noble.yml` so Helm state matches git values.

View File

@@ -1,12 +1,5 @@
[defaults] [defaults]
# Inventory lives under **inventory/** — place **group_vars/** next to the inventory file inventory = inventory/hosts.ini
# (e.g. **inventory/group_vars/all.yml**) so variables apply to playbooks under **playbooks/**. host_key_checking = False
inventory = inventory/localhost.yml
roles_path = roles
retry_files_enabled = False retry_files_enabled = False
stdout_callback = default interpreter_python = auto_silent
callback_result_format = yaml
local_tmp = .ansible-tmp
[privilege_escalation]
become = False

View File

@@ -1,11 +0,0 @@
---
all:
children:
debian_servers:
hosts:
debian-01:
ansible_host: 192.168.50.101
ansible_user: admin
debian-02:
ansible_host: 192.168.50.102
ansible_user: admin

View File

@@ -1,36 +0,0 @@
---
# noble_repo_root / noble_kubeconfig are set in playbooks (use **playbook_dir** magic var).
# When kubeconfig points at the API VIP but this workstation cannot reach the lab LAN (VPN off, etc.),
# set a reachable control-plane URL — same as: kubectl config set-cluster noble --server=https://<cp-ip>:6443
# Example: ansible-playbook playbooks/noble.yml -e 'noble_k8s_api_server_override=https://192.168.50.20:6443'
noble_k8s_api_server_override: ""
# When /healthz fails with **network unreachable** to the VIP and **override** is empty, retry using this URL (neon).
noble_k8s_api_server_auto_fallback: true
noble_k8s_api_server_fallback: "https://192.168.50.20:6443"
# Only if you must skip the kubectl /healthz preflight (not recommended).
noble_skip_k8s_health_check: false
# Pangolin / Newt — set true only after newt-pangolin-auth Secret exists (SOPS: clusters/noble/secrets/ or imperative — see clusters/noble/bootstrap/newt/README.md)
noble_newt_install: true
noble_pangolin_sync_http_resources: true
# cert-manager needs Secret cloudflare-dns-api-token in cert-manager namespace before ClusterIssuers work
noble_cert_manager_require_cloudflare_secret: true
# Velero — set **noble_velero_install: true** plus S3 bucket/URL (and credentials — see clusters/noble/bootstrap/velero/README.md)
noble_velero_install: true
# Bootstrap kustomize in Argo (**noble-bootstrap-root** → **clusters/noble/bootstrap**, includes **clusters/noble/apps**). Applied with manual sync; enable automation after **noble.yml** (see **clusters/noble/bootstrap/argocd/README.md** §5).
noble_argocd_apply_bootstrap_root_application: true
# Authentik (OIDC IdP) + oauth2-proxy ForwardAuth — set **true** after **.env** has NOBLE_AUTHENTIK_* (see ansible/roles/noble_authentik/README.md).
noble_authentik_install: true
# Optional: public (or extra) Authentik hostnames on the same IdP — list of FQDNs. Pangolin: CNAME + resource → Newt → Traefik (see noble_authentik README).
noble_authentik_ingress_extra_hosts:
- auth.nikflix.ca
# Open WebUI — public Ingress host (Pangolin → Newt → Traefik). Must match **clusters/noble/apps/open-webui/values.yaml** **ingress.host** and Authentik redirect URI (Ansible uses this var).
noble_open_webui_public_host: webui.nikflix.ca
noble_authentik_blueprints_enabled: true

View File

@@ -1,12 +0,0 @@
---
# Hardened SSH settings
debian_baseline_ssh_allow_users:
- admin
# Example key rotation entries. Replace with your real users and keys.
debian_ssh_rotation_users:
- name: admin
home: /home/admin
state: present
keys:
- "ssh-ed25519 AAAAEXAMPLE_REPLACE_ME admin@workstation"

View File

@@ -1,37 +0,0 @@
---
# Proxmox repositories
proxmox_repo_debian_codename: trixie
proxmox_repo_disable_enterprise: true
proxmox_repo_disable_ceph_enterprise: true
proxmox_repo_enable_pve_no_subscription: true
proxmox_repo_enable_ceph_no_subscription: true
# Suppress "No valid subscription" warning in UI
proxmox_no_subscription_notice_disable: true
# Public keys to install for root on each Proxmox host.
proxmox_root_authorized_key_files:
- "{{ lookup('env', 'HOME') }}/.ssh/id_ed25519.pub"
- "{{ lookup('env', 'HOME') }}/.ssh/ansible.pub"
# Package upgrade/reboot policy
proxmox_upgrade_apt_cache_valid_time: 3600
proxmox_upgrade_autoremove: true
proxmox_upgrade_autoclean: true
proxmox_upgrade_reboot_if_required: true
proxmox_upgrade_reboot_timeout: 1800
# Cluster settings
proxmox_cluster_enabled: true
proxmox_cluster_name: atomic-hub
# Bootstrap host name from inventory (first host by default if empty)
proxmox_cluster_master: ""
# Optional explicit IP/FQDN for joining; leave empty to use ansible_host of master
proxmox_cluster_master_ip: ""
proxmox_cluster_force: false
# Optional: use only for first cluster joins when inter-node SSH trust is not established.
# Prefer storing with Ansible Vault if you set this.
proxmox_cluster_master_root_password: "Hemroid8"

View File

@@ -0,0 +1,14 @@
[proxmox]
# Replace pve1 with your proxmox node hostname or IP
mercury ansible_host=192.168.50.100 ansible_user=root
[proxmox:vars]
# If using password auth (ssh key recommended though):
# ansible_ssh_pass=yourpassword
# Connection variables for the proxmox modules (api)
proxmox_api_user=root@pam
proxmox_api_password=Hemroid8
proxmox_api_host=192.168.50.100
# proxmox_api_token_id=
# proxmox_api_token_secret=

View File

@@ -1,6 +0,0 @@
---
all:
hosts:
localhost:
ansible_connection: local
ansible_python_interpreter: "{{ ansible_playbook_python }}"

View File

@@ -1,24 +0,0 @@
---
all:
children:
proxmox_hosts:
vars:
ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new"
hosts:
helium:
ansible_host: 192.168.1.100
ansible_user: root
# First run without SSH keys:
# ansible_password: "{{ vault_proxmox_root_password }}"
neon:
ansible_host: 192.168.1.90
ansible_user: root
# ansible_password: "{{ vault_proxmox_root_password }}"
argon:
ansible_host: 192.168.1.80
ansible_user: root
# ansible_password: "{{ vault_proxmox_root_password }}"
krypton:
ansible_host: 192.168.1.70
ansible_user: root
# ansible_password: "{{ vault_proxmox_root_password }}"

View File

@@ -1,24 +0,0 @@
---
all:
children:
proxmox_hosts:
vars:
ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new"
hosts:
helium:
ansible_host: 192.168.1.100
ansible_user: root
# First run without SSH keys:
# ansible_password: "{{ vault_proxmox_root_password }}"
neon:
ansible_host: 192.168.1.90
ansible_user: root
# ansible_password: "{{ vault_proxmox_root_password }}"
argon:
ansible_host: 192.168.1.80
ansible_user: root
# ansible_password: "{{ vault_proxmox_root_password }}"
krypton:
ansible_host: 192.168.1.70
ansible_user: root
# ansible_password: "{{ vault_proxmox_root_password }}"

View File

@@ -0,0 +1,72 @@
---
- name: Create Ubuntu Cloud-Init Template
hosts: proxmox
become: yes
vars:
template_id: 9000
template_name: ubuntu-2204-cloud
# URL for Ubuntu 22.04 Cloud Image (Jammy)
image_url: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img"
image_name: "ubuntu-22.04-server-cloudimg-amd64.img"
storage_pool: "local-lvm"
memory: 2048
cores: 2
tasks:
- name: Check if template already exists
command: "qm status {{ template_id }}"
register: vm_status
failed_when: false
changed_when: false
- name: Fail if template ID exists
fail:
msg: "VM ID {{ template_id }} already exists. Please choose a different ID or delete the existing VM."
when: vm_status.rc == 0
- name: Download Ubuntu Cloud Image
get_url:
url: "{{ image_url }}"
dest: "/tmp/{{ image_name }}"
mode: '0644'
- name: Install libguestfs-tools (required for virt-customize if needed, optional)
apt:
name: libguestfs-tools
state: present
ignore_errors: yes
- name: Create VM with hardware config
command: >
qm create {{ template_id }}
--name "{{ template_name }}"
--memory {{ memory }}
--core {{ cores }}
--net0 virtio,bridge=vmbr0
--scsihw virtio-scsi-pci
--ostype l26
--serial0 socket --vga serial0
- name: Import Disk
command: "qm importdisk {{ template_id }} /tmp/{{ image_name }} {{ storage_pool }}"
- name: Attach Disk to SCSI
command: "qm set {{ template_id }} --scsi0 {{ storage_pool }}:vm-{{ template_id }}-disk-0"
- name: Add Cloud-Init Drive
command: "qm set {{ template_id }} --ide2 {{ storage_pool }}:cloudinit"
- name: Set Boot Order
command: "qm set {{ template_id }} --boot c --bootdisk scsi0"
- name: Resize Disk (Optional, e.g. 10G)
command: "qm resize {{ template_id }} scsi0 10G"
ignore_errors: yes
- name: Convert to Template
command: "qm template {{ template_id }}"
- name: Remove Downloaded Image
file:
path: "/tmp/{{ image_name }}"
state: absent

View File

@@ -1,8 +0,0 @@
---
- name: Debian server baseline hardening
hosts: debian_servers
become: true
gather_facts: true
roles:
- role: debian_baseline_hardening
tags: [hardening, baseline]

View File

@@ -1,8 +0,0 @@
---
- name: Debian maintenance (updates + reboot handling)
hosts: debian_servers
become: true
gather_facts: true
roles:
- role: debian_maintenance
tags: [maintenance, updates]

View File

@@ -1,3 +0,0 @@
---
- import_playbook: debian_harden.yml
- import_playbook: debian_maintenance.yml

View File

@@ -1,8 +0,0 @@
---
- name: Debian SSH key rotation
hosts: debian_servers
become: true
gather_facts: false
roles:
- role: debian_ssh_key_rotation
tags: [ssh, ssh_keys, rotation]

View File

@@ -1,5 +0,0 @@
---
# Full bring-up: Talos Phase A then platform stack.
# Run from **ansible/**: ansible-playbook playbooks/deploy.yml
- import_playbook: talos_phase_a.yml
- import_playbook: noble.yml

View File

@@ -0,0 +1,6 @@
---
- name: Manage Proxmox VMs
hosts: "{{ target_host | default('proxmox') }}"
become: yes
roles:
- proxmox_vm

View File

@@ -1,250 +0,0 @@
---
# Full platform install — **after** Talos bootstrap (`talosctl bootstrap` + working kubeconfig).
# Do not run until `kubectl get --raw /healthz` returns ok (see talos/README.md §3, CLUSTER-BUILD Phase A).
# Run from repo **ansible/** directory: ansible-playbook playbooks/noble.yml
#
# Tags: repos, cilium, csi_snapshot, metrics, longhorn, metallb, kube_vip, traefik, cert_manager, newt,
# argocd, kyverno, kyverno_policies, platform, authentik, velero, landing, all (default)
# Argo leaf **Application** CRs are applied in play **tasks:** after **noble_velero** (Ansible Helm first, then GitOps).
# Trivy Operator is **not** installed here — sync **noble-trivy-operator** from Argo (app-of-apps) after deploy.
- name: Noble cluster — platform stack (Ansible-managed)
hosts: localhost
connection: local
gather_facts: false
vars:
noble_repo_root: "{{ playbook_dir | dirname | dirname }}"
noble_kubeconfig: "{{ lookup('env', 'KUBECONFIG') | default(noble_repo_root + '/talos/kubeconfig', true) }}"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
pre_tasks:
# Helm/kubectl use $KUBECONFIG; a missing file yields "connection refused" to localhost:8080.
- name: Stat kubeconfig path from KUBECONFIG or default
ansible.builtin.stat:
path: "{{ noble_kubeconfig }}"
register: noble_kubeconfig_stat
tags: [always]
- name: Fall back to repo talos/kubeconfig when $KUBECONFIG is unset or not a file
ansible.builtin.set_fact:
noble_kubeconfig: "{{ noble_repo_root }}/talos/kubeconfig"
when: not noble_kubeconfig_stat.stat.exists | default(false)
tags: [always]
- name: Stat kubeconfig after fallback
ansible.builtin.stat:
path: "{{ noble_kubeconfig }}"
register: noble_kubeconfig_stat2
tags: [always]
- name: Require a real kubeconfig file
ansible.builtin.assert:
that:
- noble_kubeconfig_stat2.stat.exists | default(false)
- noble_kubeconfig_stat2.stat.isreg | default(false)
fail_msg: >-
No kubeconfig file at {{ noble_kubeconfig }}.
Fix: export KUBECONFIG=/actual/path/from/talosctl-kubeconfig (see talos/README.md),
or copy the admin kubeconfig to {{ noble_repo_root }}/talos/kubeconfig.
Do not use documentation placeholders as the path.
tags: [always]
- name: Ensure temp dir for kubeconfig API override
ansible.builtin.file:
path: "{{ noble_repo_root }}/ansible/.ansible-tmp"
state: directory
mode: "0700"
when: noble_k8s_api_server_override | default('') | length > 0
tags: [always]
- name: Copy kubeconfig for API server override (original file unchanged)
ansible.builtin.copy:
src: "{{ noble_kubeconfig }}"
dest: "{{ noble_repo_root }}/ansible/.ansible-tmp/kubeconfig.patched"
mode: "0600"
when: noble_k8s_api_server_override | default('') | length > 0
tags: [always]
- name: Resolve current cluster name (for set-cluster)
ansible.builtin.command:
argv:
- kubectl
- config
- view
- --minify
- -o
- jsonpath={.clusters[0].name}
environment:
KUBECONFIG: "{{ noble_repo_root }}/ansible/.ansible-tmp/kubeconfig.patched"
register: noble_k8s_cluster_name
changed_when: false
when: noble_k8s_api_server_override | default('') | length > 0
tags: [always]
- name: Point patched kubeconfig at reachable apiserver
ansible.builtin.command:
argv:
- kubectl
- config
- set-cluster
- "{{ noble_k8s_cluster_name.stdout }}"
- --server={{ noble_k8s_api_server_override }}
- --kubeconfig={{ noble_repo_root }}/ansible/.ansible-tmp/kubeconfig.patched
when: noble_k8s_api_server_override | default('') | length > 0
changed_when: true
tags: [always]
- name: Use patched kubeconfig for this play
ansible.builtin.set_fact:
noble_kubeconfig: "{{ noble_repo_root }}/ansible/.ansible-tmp/kubeconfig.patched"
when: noble_k8s_api_server_override | default('') | length > 0
tags: [always]
- name: Verify Kubernetes API is reachable from this host
ansible.builtin.command:
argv:
- kubectl
- get
- --raw
- /healthz
- --request-timeout=15s
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_k8s_health_first
failed_when: false
changed_when: false
tags: [always]
# talosctl kubeconfig often sets server to the VIP; off-LAN you can reach a control-plane IP but not 192.168.50.230.
# kubectl stderr is often "The connection to the server ... was refused" (no substring "connection refused").
- name: Auto-fallback API server when VIP is unreachable (temp kubeconfig)
tags: [always]
when:
- noble_k8s_api_server_auto_fallback | default(true) | bool
- noble_k8s_api_server_override | default('') | length == 0
- not (noble_skip_k8s_health_check | default(false) | bool)
- (noble_k8s_health_first.rc | default(1)) != 0 or (noble_k8s_health_first.stdout | default('') | trim) != 'ok'
- (((noble_k8s_health_first.stderr | default('')) ~ (noble_k8s_health_first.stdout | default(''))) | lower) is search('network is unreachable|no route to host|connection refused|was refused', multiline=False)
block:
- name: Ensure temp dir for kubeconfig auto-fallback
ansible.builtin.file:
path: "{{ noble_repo_root }}/ansible/.ansible-tmp"
state: directory
mode: "0700"
- name: Copy kubeconfig for API auto-fallback
ansible.builtin.copy:
src: "{{ noble_kubeconfig }}"
dest: "{{ noble_repo_root }}/ansible/.ansible-tmp/kubeconfig.auto-fallback"
mode: "0600"
- name: Resolve cluster name for kubectl set-cluster
ansible.builtin.command:
argv:
- kubectl
- config
- view
- --minify
- -o
- jsonpath={.clusters[0].name}
environment:
KUBECONFIG: "{{ noble_repo_root }}/ansible/.ansible-tmp/kubeconfig.auto-fallback"
register: noble_k8s_cluster_fb
changed_when: false
- name: Point temp kubeconfig at fallback apiserver
ansible.builtin.command:
argv:
- kubectl
- config
- set-cluster
- "{{ noble_k8s_cluster_fb.stdout }}"
- --server={{ noble_k8s_api_server_fallback | default('https://192.168.50.20:6443', true) }}
- --kubeconfig={{ noble_repo_root }}/ansible/.ansible-tmp/kubeconfig.auto-fallback
changed_when: true
- name: Use kubeconfig with fallback API server for this play
ansible.builtin.set_fact:
noble_kubeconfig: "{{ noble_repo_root }}/ansible/.ansible-tmp/kubeconfig.auto-fallback"
- name: Re-verify Kubernetes API after auto-fallback
ansible.builtin.command:
argv:
- kubectl
- get
- --raw
- /healthz
- --request-timeout=15s
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_k8s_health_after_fallback
failed_when: false
changed_when: false
- name: Mark that API was re-checked after kubeconfig fallback
ansible.builtin.set_fact:
noble_k8s_api_fallback_used: true
- name: Normalize API health result for preflight (scalars; avoids dict merge / set_fact stringification)
ansible.builtin.set_fact:
noble_k8s_health_rc: "{{ noble_k8s_health_after_fallback.rc | default(1) if (noble_k8s_api_fallback_used | default(false) | bool) else (noble_k8s_health_first.rc | default(1)) }}"
noble_k8s_health_stdout: "{{ noble_k8s_health_after_fallback.stdout | default('') if (noble_k8s_api_fallback_used | default(false) | bool) else (noble_k8s_health_first.stdout | default('')) }}"
noble_k8s_health_stderr: "{{ noble_k8s_health_after_fallback.stderr | default('') if (noble_k8s_api_fallback_used | default(false) | bool) else (noble_k8s_health_first.stderr | default('')) }}"
tags: [always]
- name: Fail when API check did not return ok
ansible.builtin.fail:
msg: "{{ lookup('template', 'templates/api_health_hint.j2') }}"
when:
- not (noble_skip_k8s_health_check | default(false) | bool)
- (noble_k8s_health_rc | int) != 0 or (noble_k8s_health_stdout | default('') | trim) != 'ok'
tags: [always]
roles:
- role: helm_repos
tags: [repos, helm]
- role: noble_cilium
tags: [cilium, cni]
- role: noble_csi_snapshot_controller
tags: [csi_snapshot, snapshot, storage]
- role: noble_metrics_server
tags: [metrics, metrics_server]
# Kyverno before Longhorn: Longhorn post-upgrade Job is admitted through Kyverno; policies use
# failurePolicy Ignore so webhook transport timeouts do not fail Helm (see policies-values.yaml).
- role: noble_kyverno
tags: [kyverno, policy]
- role: noble_kyverno_policies
tags: [kyverno_policies, policy]
- role: noble_longhorn
tags: [longhorn, storage]
- role: noble_metallb
tags: [metallb, lb]
- role: noble_kube_vip
tags: [kube_vip, vip]
- role: noble_traefik
tags: [traefik, ingress]
- role: noble_cert_manager
tags: [cert_manager, certs]
- role: noble_newt
tags: [newt, pangolin]
- role: noble_argocd
tags: [argocd, gitops]
- role: noble_platform
tags: [platform, observability, apps]
- role: noble_authentik
tags: [authentik, sso, oauth, oidc]
- role: noble_velero
tags: [velero, backups]
tasks:
# Leaf Application CRs must exist only after all Ansible Helm in this play (platform, authentik, velero, …)
# so argocd-controller does not SSA resources before Helm owns them; then Argo can take over (manual → auto).
- name: Apply Argo CD root / bootstrap / leaf Application manifests (postAnsible Helm)
ansible.builtin.include_role:
name: noble_argocd
tasks_from: applications_post_platform
tags: [argocd, gitops, platform, apps, observability, all]
- name: Noble landing URLs (+ optional token fetch)
ansible.builtin.include_role:
name: noble_landing_urls
tags: [landing, platform, observability, apps, all]

View File

@@ -1,7 +0,0 @@
---
# Manual follow-ups after **noble.yml**: SOPS key backup, optional Argo root Application.
- hosts: localhost
connection: local
gather_facts: false
roles:
- noble_post_deploy

View File

@@ -1,9 +0,0 @@
---
- name: Proxmox cluster bootstrap/join
hosts: proxmox_hosts
become: true
gather_facts: false
serial: 1
roles:
- role: proxmox_cluster
tags: [proxmox, cluster]

View File

@@ -1,4 +0,0 @@
---
- import_playbook: proxmox_prepare.yml
- import_playbook: proxmox_upgrade.yml
- import_playbook: proxmox_cluster.yml

View File

@@ -1,8 +0,0 @@
---
- name: Proxmox host preparation (community repos + no-subscription notice)
hosts: proxmox_hosts
become: true
gather_facts: true
roles:
- role: proxmox_baseline
tags: [proxmox, prepare, repos, ui]

View File

@@ -1,9 +0,0 @@
---
- name: Proxmox host maintenance (upgrade to latest)
hosts: proxmox_hosts
become: true
gather_facts: true
serial: 1
roles:
- role: proxmox_maintenance
tags: [proxmox, maintenance, updates]

View File

@@ -1,11 +0,0 @@
---
# Genconfig only — for full Talos Phase A (apply, bootstrap, kubeconfig) use **playbooks/talos_phase_a.yml**
# or **playbooks/deploy.yml**. Run: ansible-playbook playbooks/talos_bootstrap.yml -e noble_talos_genconfig=true
- name: Talos — optional genconfig helper
hosts: localhost
connection: local
gather_facts: false
vars:
noble_repo_root: "{{ playbook_dir | dirname | dirname }}"
roles:
- role: talos_bootstrap

View File

@@ -1,15 +0,0 @@
---
# Talos Phase A — **talhelper genconfig** → **apply-config** (all nodes) → **bootstrap** → **kubeconfig**.
# Requires: **talosctl**, **talhelper**, reachable node IPs (same LAN as nodes for Talos API :50000).
# See **talos/README.md** §1§3. Then run **playbooks/noble.yml** or **deploy.yml**.
- name: Talos — genconfig, apply, bootstrap, kubeconfig
hosts: localhost
connection: local
gather_facts: false
vars:
noble_repo_root: "{{ playbook_dir | dirname | dirname }}"
noble_talos_dir: "{{ noble_repo_root }}/talos"
noble_talos_kubeconfig_out: "{{ noble_repo_root }}/talos/kubeconfig"
roles:
- role: talos_phase_a
tags: [talos, phase_a]

View File

@@ -1,22 +0,0 @@
{# Error output for noble.yml API preflight when kubectl /healthz fails #}
Cannot use the Kubernetes API from this host (kubectl get --raw /healthz).
rc={{ noble_k8s_health_rc | default('n/a') }}
stderr: {{ noble_k8s_health_stderr | default('') | trim }}
{% set err = (noble_k8s_health_stderr | default('')) | lower %}
{% if 'connection refused' in err %}
Connection refused: the TCP path to that host works, but nothing is accepting HTTPS on port 6443 there.
• **Not bootstrapped yet?** Finish Talos first: `talosctl bootstrap` (once on a control plane), then `talosctl kubeconfig`, then confirm `kubectl get nodes`. See talos/README.md §2§3 and CLUSTER-BUILD.md Phase A. **Do not run this playbook before the Kubernetes API exists.**
• If bootstrap is done: try another control-plane IP (CLUSTER-BUILD inventory: neon 192.168.50.20, argon .30, krypton .40), or the VIP if kube-vip is up and you are on the LAN:
-e 'noble_k8s_api_server_override=https://192.168.50.230:6443'
• Do not point the API URL at a worker-only node.
• `talosctl health` / `kubectl get nodes` from a working client.
{% elif 'network is unreachable' in err or 'no route to host' in err %}
Network unreachable / no route: this machine cannot route to the API IP. Join the lab LAN or VPN, or set a reachable API server URL (talos/README.md §3).
{% else %}
If kubeconfig used the VIP from off-LAN, try a reachable control-plane IP, e.g.:
-e 'noble_k8s_api_server_override=https://192.168.50.20:6443'
See talos/README.md §3.
{% endif %}
To skip this check (not recommended): -e noble_skip_k8s_health_check=true

2
ansible/requirements.yml Normal file
View File

@@ -0,0 +1,2 @@
collections:
- name: community.general

View File

@@ -1,39 +0,0 @@
---
# Update apt metadata only when stale (seconds)
debian_baseline_apt_cache_valid_time: 3600
# Core host hardening packages
debian_baseline_packages:
- unattended-upgrades
- apt-listchanges
- fail2ban
- needrestart
- sudo
- ca-certificates
# SSH hardening controls
debian_baseline_ssh_permit_root_login: "no"
debian_baseline_ssh_password_authentication: "no"
debian_baseline_ssh_pubkey_authentication: "yes"
debian_baseline_ssh_x11_forwarding: "no"
debian_baseline_ssh_max_auth_tries: 3
debian_baseline_ssh_client_alive_interval: 300
debian_baseline_ssh_client_alive_count_max: 2
debian_baseline_ssh_allow_users: []
# unattended-upgrades controls
debian_baseline_enable_unattended_upgrades: true
debian_baseline_unattended_auto_upgrade: "1"
debian_baseline_unattended_update_lists: "1"
# Kernel and network hardening sysctls
debian_baseline_sysctl_settings:
net.ipv4.conf.all.accept_redirects: "0"
net.ipv4.conf.default.accept_redirects: "0"
net.ipv4.conf.all.send_redirects: "0"
net.ipv4.conf.default.send_redirects: "0"
net.ipv4.conf.all.log_martians: "1"
net.ipv4.conf.default.log_martians: "1"
net.ipv4.tcp_syncookies: "1"
net.ipv6.conf.all.accept_redirects: "0"
net.ipv6.conf.default.accept_redirects: "0"

View File

@@ -1,12 +0,0 @@
---
- name: Restart ssh
ansible.builtin.service:
name: ssh
state: restarted
- name: Reload sysctl
ansible.builtin.command:
argv:
- sysctl
- --system
changed_when: true

View File

@@ -1,52 +0,0 @@
---
- name: Refresh apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: "{{ debian_baseline_apt_cache_valid_time }}"
- name: Install baseline hardening packages
ansible.builtin.apt:
name: "{{ debian_baseline_packages }}"
state: present
- name: Configure unattended-upgrades auto settings
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
mode: "0644"
content: |
APT::Periodic::Update-Package-Lists "{{ debian_baseline_unattended_update_lists }}";
APT::Periodic::Unattended-Upgrade "{{ debian_baseline_unattended_auto_upgrade }}";
when: debian_baseline_enable_unattended_upgrades | bool
- name: Configure SSH hardening options
ansible.builtin.copy:
dest: /etc/ssh/sshd_config.d/99-hardening.conf
mode: "0644"
content: |
PermitRootLogin {{ debian_baseline_ssh_permit_root_login }}
PasswordAuthentication {{ debian_baseline_ssh_password_authentication }}
PubkeyAuthentication {{ debian_baseline_ssh_pubkey_authentication }}
X11Forwarding {{ debian_baseline_ssh_x11_forwarding }}
MaxAuthTries {{ debian_baseline_ssh_max_auth_tries }}
ClientAliveInterval {{ debian_baseline_ssh_client_alive_interval }}
ClientAliveCountMax {{ debian_baseline_ssh_client_alive_count_max }}
{% if debian_baseline_ssh_allow_users | length > 0 %}
AllowUsers {{ debian_baseline_ssh_allow_users | join(' ') }}
{% endif %}
notify: Restart ssh
- name: Configure baseline sysctls
ansible.builtin.copy:
dest: /etc/sysctl.d/99-hardening.conf
mode: "0644"
content: |
{% for key, value in debian_baseline_sysctl_settings.items() %}
{{ key }} = {{ value }}
{% endfor %}
notify: Reload sysctl
- name: Ensure fail2ban service is enabled
ansible.builtin.service:
name: fail2ban
enabled: true
state: started

View File

@@ -1,7 +0,0 @@
---
debian_maintenance_apt_cache_valid_time: 3600
debian_maintenance_upgrade_type: dist
debian_maintenance_autoremove: true
debian_maintenance_autoclean: true
debian_maintenance_reboot_if_required: true
debian_maintenance_reboot_timeout: 1800

View File

@@ -1,30 +0,0 @@
---
- name: Refresh apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: "{{ debian_maintenance_apt_cache_valid_time }}"
- name: Upgrade Debian packages
ansible.builtin.apt:
upgrade: "{{ debian_maintenance_upgrade_type }}"
- name: Remove orphaned packages
ansible.builtin.apt:
autoremove: "{{ debian_maintenance_autoremove }}"
- name: Clean apt package cache
ansible.builtin.apt:
autoclean: "{{ debian_maintenance_autoclean }}"
- name: Check if reboot is required
ansible.builtin.stat:
path: /var/run/reboot-required
register: debian_maintenance_reboot_required_file
- name: Reboot when required by package updates
ansible.builtin.reboot:
reboot_timeout: "{{ debian_maintenance_reboot_timeout }}"
msg: "Reboot initiated by Ansible maintenance playbook"
when:
- debian_maintenance_reboot_if_required | bool
- debian_maintenance_reboot_required_file.stat.exists | default(false)

View File

@@ -1,10 +0,0 @@
---
# List of users to manage keys for.
# Example:
# debian_ssh_rotation_users:
# - name: deploy
# home: /home/deploy
# state: present
# keys:
# - "ssh-ed25519 AAAA... deploy@laptop"
debian_ssh_rotation_users: []

View File

@@ -1,50 +0,0 @@
---
- name: Validate SSH key rotation inputs
ansible.builtin.assert:
that:
- item.name is defined
- item.home is defined
- (item.state | default('present')) in ['present', 'absent']
- (item.state | default('present')) == 'absent' or (item.keys is defined and item.keys | length > 0)
fail_msg: >-
Each entry in debian_ssh_rotation_users must include name, home, and either:
state=absent, or keys with at least one SSH public key.
loop: "{{ debian_ssh_rotation_users }}"
loop_control:
label: "{{ item.name | default('unknown') }}"
- name: Ensure ~/.ssh exists for managed users
ansible.builtin.file:
path: "{{ item.home }}/.ssh"
state: directory
owner: "{{ item.name }}"
group: "{{ item.name }}"
mode: "0700"
loop: "{{ debian_ssh_rotation_users }}"
loop_control:
label: "{{ item.name }}"
when: (item.state | default('present')) == 'present'
- name: Rotate authorized_keys for managed users
ansible.builtin.copy:
dest: "{{ item.home }}/.ssh/authorized_keys"
owner: "{{ item.name }}"
group: "{{ item.name }}"
mode: "0600"
content: |
{% for key in item.keys %}
{{ key }}
{% endfor %}
loop: "{{ debian_ssh_rotation_users }}"
loop_control:
label: "{{ item.name }}"
when: (item.state | default('present')) == 'present'
- name: Remove authorized_keys for users marked absent
ansible.builtin.file:
path: "{{ item.home }}/.ssh/authorized_keys"
state: absent
loop: "{{ debian_ssh_rotation_users }}"
loop_control:
label: "{{ item.name }}"
when: (item.state | default('present')) == 'absent'

View File

@@ -1,18 +0,0 @@
---
noble_helm_repos:
- { name: cilium, url: "https://helm.cilium.io/" }
- { name: metallb, url: "https://metallb.github.io/metallb" }
- { name: longhorn, url: "https://charts.longhorn.io" }
- { name: traefik, url: "https://traefik.github.io/charts" }
- { name: jetstack, url: "https://charts.jetstack.io" }
- { name: fossorial, url: "https://charts.fossorial.io" }
- { name: argo, url: "https://argoproj.github.io/argo-helm" }
- { name: metrics-server, url: "https://kubernetes-sigs.github.io/metrics-server/" }
- { name: prometheus-community, url: "https://prometheus-community.github.io/helm-charts" }
- { name: grafana, url: "https://grafana.github.io/helm-charts" }
- { name: fluent, url: "https://fluent.github.io/helm-charts" }
- { name: headlamp, url: "https://kubernetes-sigs.github.io/headlamp/" }
- { name: kyverno, url: "https://kyverno.github.io/kyverno/" }
- { name: vmware-tanzu, url: "https://vmware-tanzu.github.io/helm-charts" }
- { name: goauthentik, url: "https://charts.goauthentik.io" }
- { name: oauth2-proxy, url: "https://oauth2-proxy.github.io/manifests" }

View File

@@ -1,16 +0,0 @@
---
- name: Add Helm repositories
ansible.builtin.command:
cmd: "helm repo add {{ item.name }} {{ item.url }}"
loop: "{{ noble_helm_repos }}"
loop_control:
label: "{{ item.name }}"
register: helm_repo_add
changed_when: helm_repo_add.rc == 0
failed_when: >-
helm_repo_add.rc != 0 and
('already exists' not in (helm_repo_add.stderr | default('')))
- name: helm repo update
ansible.builtin.command: helm repo update
changed_when: true

View File

@@ -1,3 +0,0 @@
---
# When true, applies clusters/noble/bootstrap/argocd/bootstrap-root-application.yaml (noble-bootstrap-root; manual sync until README §5).
noble_argocd_apply_bootstrap_root_application: true

View File

@@ -1,27 +0,0 @@
---
# Run from **ansible/playbooks/noble.yml** *after* roles **noble_platform**, **noble_authentik**, **noble_velero**
# (see play **tasks:**). Leaf **Application** CRs must not be reconciled before Ansible Helm finishes, or
# **argocd-controller** can SSA resources without Helm release metadata (e.g. chart-owned ServiceAccounts).
- name: Apply Argo CD bootstrap root Application
ansible.builtin.command:
argv:
- kubectl
- apply
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/argocd/bootstrap-root-application.yaml"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
when: noble_argocd_apply_bootstrap_root_application | default(false) | bool
changed_when: true
- name: Apply Argo CD leaf Application definitions (argocd/app-of-apps — post-Helm)
ansible.builtin.command:
argv:
- kubectl
- apply
- -k
- "{{ noble_repo_root }}/clusters/noble/bootstrap/argocd/app-of-apps"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
when: noble_argocd_apply_bootstrap_root_application | default(false) | bool
changed_when: true

View File

@@ -1,23 +0,0 @@
---
- name: Install Argo CD
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- argocd
- argo/argo-cd
- --namespace
- argocd
- --create-namespace
- --version
- "9.5.14"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/argocd/values.yaml"
- --force-conflicts
- --wait
- --timeout
- 15m
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true

View File

@@ -1,149 +0,0 @@
# noble_authentik — Authentik + OIDC for the noble stack
Installs **Authentik** (Helm `goauthentik/authentik`) as the cluster IdP, **oauth2-proxy** as an **OIDC** client to Authentik for Traefik **ForwardAuth** (Prometheus, Alertmanager, Longhorn UI), and re-applies Helm values so **Argo CD**, **Grafana**, and **Headlamp** use **native OIDC** to Authentik (not HTTP BasicAuth).
## Enable
1. Copy repository **`.env.sample`** to **`.env`** and set all **required** **`NOBLE_AUTHENTIK_*`** values (see comments there; SMTP keys are optional).
2. Set **`noble_authentik_install: true`** in **`ansible/inventory/group_vars/all.yml`** (or pass **`-e noble_authentik_install=true`**).
3. Run **`ansible-playbook playbooks/noble.yml --tags authentik`** (or a full **`noble.yml`**) from **`ansible/`** with a working **`KUBECONFIG`**.
`noble_authentik` runs **after** **`noble_platform`** so Grafana / Headlamp / Prometheus exist before SSO Helm upgrades.
## Variables
See **`defaults/main.yml`**. Hostnames default to **`auth.apps.noble.lab.pcenicni.dev`** and **`oauth2.apps.noble.lab.pcenicni.dev`**. **`noble_authentik_ensure_admin_ui_access`** (default **true**) re-applies **authentik Admins** superuser membership via the worker on each **`--tags authentik`** run so the admin UI keeps working under **2026+** RBAC.
### S3 media (avatars, flows, uploads)
Authentik stores file-backed data in **S3** (not a shared PVC on **`authentik-worker`**). Set **`NOBLE_AUTHENTIK_MEDIA_S3_BUCKET`** in **`.env`** to a **dedicated** bucket name (do **not** reuse the Velero backup bucket). **`NOBLE_VELERO_S3_URL`**, **`NOBLE_VELERO_AWS_ACCESS_KEY_ID`**, and **`NOBLE_VELERO_AWS_SECRET_ACCESS_KEY`** are reused automatically when the Authentik-specific S3 variables are unset; override with **`NOBLE_AUTHENTIK_S3_URL`** / **`NOBLE_AUTHENTIK_S3_ACCESS_KEY`** / **`NOBLE_AUTHENTIK_S3_SECRET_KEY`** if needed. Optional: **`NOBLE_AUTHENTIK_S3_REGION`** (defaults to **`us-east-1`** in Ansible), **`NOBLE_AUTHENTIK_S3_ADDRESSING_STYLE`** (**`path`** vs **`virtual`** for some gateways). Create the bucket and grant the same credentials **read/write** to that bucket only. For browser uploads and public assets, follow [Authentik — S3 storage](https://docs.goauthentik.io/sys-mgmt/ops/storage-s3/) (CORS and policies). If you previously used a PVC for **`/data`**, sync into the new bucket (for example **`aws s3 sync`** from a volume snapshot or old mount) before relying on S3-only.
### Outbound email (SMTP)
Optional. Set **`NOBLE_AUTHENTIK_SMTP_HOST`** and **`NOBLE_AUTHENTIK_SMTP_FROM`** in repository **`.env`**; Ansible adds **`AUTHENTIK_EMAIL__HOST`**, **`AUTHENTIK_EMAIL__FROM`**, and related variables to Helm **`global.env`** (see [Authentik configuration — email](https://docs.goauthentik.io/install-config/configuration/#email-settings)). Omit **`NOBLE_AUTHENTIK_SMTP_HOST`** to skip SMTP env vars entirely. Optional overrides: **`NOBLE_AUTHENTIK_SMTP_PORT`** (default **587** in **`defaults/main.yml`**), **`NOBLE_AUTHENTIK_SMTP_USERNAME`**, **`NOBLE_AUTHENTIK_SMTP_PASSWORD`**, **`NOBLE_AUTHENTIK_SMTP_USE_TLS`** / **`USE_SSL`** / **`TIMEOUT`**. Re-run **`ansible-playbook playbooks/noble.yml --tags authentik`** after changes.
### Extra public hostname (Pangolin + Newt, same Authentik)
To expose the **same** Authentik instance on an **internet-facing** FQDN (while keeping the lab name on Traefik), set **`noble_authentik_ingress_extra_hosts`** in **`ansible/inventory/group_vars/all.yml`** (or **`-e`**) to a list of extra FQDNs, for example **`auth.example.com`**. Re-run **`ansible-playbook playbooks/noble.yml --tags authentik`**. Ansible extends **`server.ingress.hosts`** and **`tls[0].hosts`** so **cert-manager** issues one certificate with SANs for the primary **`noble_authentik_host`** plus those names (DNS must resolve for your issuer — often **Cloudflare** for public names, split horizon for lab).
Then in **Pangolin**: link the domain, create an **HTTP** resource for that hostname, and set the **target** to your **Newt** site with **`ip:port`** pointing at the cluster **Traefik** HTTPS entry (same pattern as **`clusters/noble/bootstrap/newt/README.md`** — typically the MetalLB / LAN VIP and **443**). One Newt tunnel can front many hostnames.
**Open WebUI (public hostname only):** Set **`noble_open_webui_public_host`** in **`group_vars`** to the same FQDN as **`clusters/noble/apps/open-webui/values.yaml`** **`ingress.host`** (for example **`webui.example.com`**). Add a **Pangolin** HTTP resource for that hostname to the same Newt target. Point **`OPENID_PROVIDER_URL`** in **`values.yaml`** at your **public** Authentik host (usually the first **`noble_authentik_ingress_extra_hosts`** entry, e.g. **`https://auth.example.com/application/o/open-webui/.well-known/openid-configuration`**), and set **`OPENID_REDIRECT_URI`** / **`WEBUI_URL`** to **`https://<noble_open_webui_public_host>/…`**. After changing **`noble_open_webui_public_host`**, re-run **`ansible-playbook playbooks/noble.yml --tags authentik`** so Authentiks OAuth2 redirect URI matches.
### Split routing, two Brands, and optional blueprints
This role supports a **single Authentik deployment** with **two hostnames** (lab + public) and **different Brands** per **`Host`**, without Authentiks separate-database **Tenancy** feature (see [Tenancy](https://docs.goauthentik.io/sys-mgmt/tenancy) — alpha / licensing). [Brands](https://docs.goauthentik.io/brands/) choose default **authentication** (and related) **flows** and branding for each FQDN.
**Split routing (recommended):**
- **Lab / operator URL** — **`noble_authentik_host`** (default **`auth.apps.noble.lab.pcenicni.dev`**): keep DNS **internal-only** (split horizon, VPN, or LAN DNS). Do **not** publish this hostname as a Pangolin HTTP resource toward the internet unless you intentionally want it reachable off-LAN.
- **Public URL** — entries in **`noble_authentik_ingress_extra_hosts`**: use Pangolin (or another edge) only for these names so casual users never need the lab FQDN.
Network isolation is enforced at **DNS and the tunnel**, not inside Authentik. Optionally add firewall / Traefik entrypoint rules for defense in depth.
**Two-Brand model:**
- **Lab Brand** — domain equals **`noble_authentik_host`**: restricted authentication flow (**`noble_authentik_blueprint_lab_flow_slug`**) so only operator groups (defaults: **`noble-admins`**, **`authentik Admins`**) can continue past identification; dedicated password stage with **`noble_authentik_blueprint_lab_password_failed_attempts`**, expression-based password strength (**length / character classes / optional zxcvbn**), and MFA stage **`noble-lab-authenticator-validate-strict`** with **`noble_authentik_blueprint_lab_mfa_not_configured_action`** (**`configure`** injects default TOTP setup when no device; **`deny`** blocks; **`skip`** matches stock). WebAuthn passwordless does **not** skip MFA on this flow. Your bootstrap / break-glass account must remain in one of those groups (see **`noble_authentik_ensure_admin_ui_access`** and **`configure_authentik.py`** group membership).
- **Nikflix Brand(s)** — one Brand per FQDN in **`noble_authentik_ingress_extra_hosts`**: authentication flow **`noble_authentik_blueprint_public_auth_flow_slug`** (default **`noble-public-authentication-flow`**) mirrors stock **`default-authentication-flow`** (optional password / MFA skip for WebAuthn passwordless). Directory groups default to **`nikflix-users`** / **`nikflix-admins`** only; add optional groups via **`noble_authentik_blueprint_extra_directory_groups`** or the optional **`noble_authentik_blueprint_public_groups`** list. **`noble-admins`** / **`noble-editors`** remain for cluster / Argo / Grafana as today.
**OAuth note:** Redirect URIs and **`iss`** must stay consistent with the hostname clients use (internal issuer for in-cluster apps vs public issuer is a deliberate choice — avoid mixing both for the same app).
**Mounted blueprints (optional):** set **`noble_authentik_blueprints_enabled: true`** in **`group_vars`** (or **`-e`**). On each **`--tags authentik`** run, Ansible renders Jinja templates under **`templates/blueprints/`** into a ConfigMap **`noble_authentik_blueprints_configmap_name`** (default **`authentik-noble-blueprints`**) and sets Helm **`blueprints.configMaps`** so **authentik-worker** loads them from **`/blueprints/mounted/cm-authentik-noble-blueprints/`** (see [Blueprints](https://docs.goauthentik.io/customize/blueprints/)). Files (apply in lexical order):
| Key | Purpose |
| --- | --- |
| **`10-noble-public-groups.yaml.j2`** | **`noble_authentik_blueprint_public_groups`** (optional, default empty) **`noble_authentik_blueprint_extra_directory_groups`** **`noble_authentik_blueprint_nikflix_groups`** → **Group** objects (defaults: **Nikflix** only — see **Blueprint: directory groups**). |
| **`20-noble-lab-operator-authentication-flow.yaml.j2`** | Flow **`noble_authentik_blueprint_lab_flow_slug`**: operator policy **`noble_authentik_blueprint_operator_policy_name`**, lab password/MFA tunables (see **`defaults/main.yml`**). |
| **`21-noble-public-authentication-flow.yaml.j2`** | Flow **`noble_authentik_blueprint_public_auth_flow_slug`** — public sign-in (same optional policies as stock default authentication). |
| **`22-noble-invitation-enrollment-flows.yaml.j2`** | Two **enrollment** flows + **Invitation** stages: Nikflix / extra_hosts (**`noble_authentik_blueprint_public_invitation_enrollment_flow_slug`**) vs lab (**`noble_authentik_blueprint_lab_invitation_enrollment_flow_slug`**); see **Invitations** below. |
| **`30-noble-brands-domain-split.yaml.j2`** | Brand for **`noble_authentik_host`** → lab flow; one Brand per **`noble_authentik_ingress_extra_hosts`** → public flow slug above. |
Tune titles via **`noble_authentik_blueprint_lab_brand_title`** and **`noble_authentik_blueprint_public_brand_title_prefix`**. After the worker applies blueprints, confirm **System → Brands** and **Flows** in the admin UI; fix any **`!Find`** failures if upstream default stage **names** change between Authentik versions.
#### Invitations (Nikflix vs lab)
[Brands](https://docs.goauthentik.io/brands/) do **not** expose a default “enrollment” or “invitation” flow: onboarding is driven by **Directory → Invitations**, where each invitation row selects an **enrollment** flow. The link Authentik shows is:
`https://<host>/if/flow/<enrollment-flow-slug>/?itoken=<invitation-uuid>`
Use **`<host>`** that matches the experience you want:
- **Nikflix / internet hostname** — an FQDN from **`noble_authentik_ingress_extra_hosts`**: use flow slug **`noble_authentik_blueprint_public_invitation_enrollment_flow_slug`** (default **`nikflix-invitation-enrollment`**). New users are added to **`noble_authentik_blueprint_public_invitation_user_group`** (default **`nikflix-users`**). **`noble_authentik_blueprint_public_invitation_user_type`** defaults to **`internal`**. If you previously used **`noble-public-invitation-enrollment`** or **`noble-public-users`**, update **Directory → Invitations** to the new flow slug and re-run **`--tags authentik`**; remove obsolete **`noble-public-*`** groups in the admin UI if they are empty (Ansible no longer defines them).
- **Lab** — **`noble_authentik_host`** only when you intend to onboard someone who will later get **`noble_authentik_blueprint_lab_operator_groups`** access: use **`noble_authentik_blueprint_lab_invitation_enrollment_flow_slug`** (default **`noble-lab-invitation-enrollment`**). The blueprint creates **`noble_authentik_blueprint_lab_invitee_group_name`** (default **`noble-lab-invited`**) and assigns new enrollments there; **promote** people to **`noble-admins`** / **`authentik Admins`** (or your configured operator groups) in the admin UI when they should sign in on the lab URL.
Blueprint **22** does **not** create sample **Invitation** rows (no placeholder emails). Create invitations in the UI after blueprints apply. For richer patterns (prefilled attributes, extra policies), see [Invitations](https://docs.goauthentik.io/users-sources/user/invitations/) and the upstream example blueprint **`flows-invitation-enrollment.yaml`** ([download](https://goauthentik.io/blueprints/example/flows-invitation-enrollment.yaml)). Password strength for enrollment prompts is **not** duplicated from the lab **authentication** flow here; add **Prompt** validation policies or a dedicated policy if you need parity.
**Users already created as `external`:** change **User type** to **Internal** under **Directory → Users** (or edit the **User write** stage in **Flows** and re-run the playbook so future invitees use **`noble_authentik_blueprint_public_invitation_user_type: internal`** in **`group_vars`**).
#### Blueprint: directory groups
Three inventory lists are concatenated **in this order** into **`10-noble-public-groups.yaml.j2`**:
1. **`noble_authentik_blueprint_public_groups`** — optional extra groups merged first (default **empty**; use for legacy names or shared groups that must exist before **`extra`** / Nikflix).
2. **`noble_authentik_blueprint_extra_directory_groups`** — any other groups (empty by default).
3. **`noble_authentik_blueprint_nikflix_groups`** — Nikflix-facing groups (defaults **`nikflix-users`** / **`nikflix-admins`** with **`noble.ak/brand: nikflix`**). Listed last so **`parents`** can reference groups from (1) or (2) if you choose.
Each item may be:
| Form | Example |
| --- | --- |
| **String** | **`my-app-operators`** — creates a group with that **name** only. |
| **Mapping** | **`name`** (required), optional **`is_superuser`**: **`true`** (use sparingly), **`attributes`**: dict (JSON on the group; useful in expression policies), **`parents`**: list of **existing** group **names** (resolved with **`!Find`**). |
Order matters for **`parents`**: every parent must already exist when the child row is applied — list parents **above** children in the merged list, or reference groups Authentik already created (for example **`nikflix-users`** before **`nikflix-admins`** with **`parents: [nikflix-users]`**). See [Group properties and attributes](https://docs.goauthentik.io/users-sources/groups/group_ref/).
##### Audience groups vs per-service groups
For Nikflix (and similar brands), prefer **one broad “users” group and a small “admins” group** (`nikflix-users` / `nikflix-admins`), then bind **OAuth providers**, **policies**, and **app access** to those groups. Add **per-service** groups (for example **`nikflix-media-readonly`**) only when a service truly needs a **different** membership set than the rest of the brand; every extra group is another object to keep in sync with enrollment and IdP claims. Optional pattern: make a service group a **child** of **`nikflix-users`** via **`parents`** so members inherit the parent for generic “logged in to Nikflix” checks.
**Confirming blueprints on the cluster:** the Ansible task **Install Authentik (Helm)** uses **`changed_when: true`**, so a **“changed”** line there does **not** prove Helm mutated the release. When **`noble_authentik_blueprints_enabled`** is true, the role asserts the **worker** Deployment has a volumeMount named **`blueprints-cm-<noble_authentik_blueprints_configmap_name>`** (default **`blueprints-cm-authentik-noble-blueprints`**). You can also run:
```bash
kubectl -n authentik get configmap authentik-noble-blueprints -o yaml
helm get values authentik -n authentik -o yaml | grep -A2 blueprints
kubectl -n authentik get deploy -l app.kubernetes.io/component=worker -o yaml | grep blueprints-cm
```
Mounted files are applied asynchronously by **authentik-worker**; check **System → Blueprints** (or **Customization → Blueprints** depending on version) for instances sourced from **`/blueprints/mounted/cm-authentik-noble-blueprints/`**, and **`kubectl logs -n authentik deploy/authentik-worker`** if a blueprint shows **Error** / failed apply.
### “Secondary tenant” (separate PostgreSQL schema — alpha)
Authentik **tenancy** (multiple isolated tenants in one deployment, **`AUTHENTIK_TENANTS__ENABLED`**) is **alpha**, requires **per-tenant Enterprise licensing**, **`AUTHENTIK_TENANTS__API_KEY`**, and **`AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true`** (embedded outposts are unsupported with tenancy). It is **not** wired in this repo by default. See [Tenancy](https://docs.goauthentik.io/sys-mgmt/tenancy). For most homelabs, **one tenant** plus **`noble_authentik_ingress_extra_hosts`** is the right split.
## IdP configuration
When **`noble_authentik_configure_idp`** is true, Ansible creates/updates OAuth2 providers and applications for **argocd**, **grafana**, **headlamp**, **open-webui**, and **oauth2-proxy** using either the **worker ORM path** (default **`noble_authentik_oidc_provision_via: worker`**: **`kubectl exec`** + **`ak shell`** + **`files/worker_upsert_oauth_oidc.py`**, which avoids **2026+** REST **403** on **`GET …/providers/oauth2/**`) or the **REST-only path** (**`noble_authentik_oidc_provision_via: rest`**: **`files/configure_authentik.py`** needs a token that can list/patch OAuth2 providers). With the worker path and a bootstrap email, it also runs **`files/worker_add_bootstrap_user_groups.py`** so **`User.groups.add`** does not depend on **`GET …/core/users/**`. It then runs **`configure_authentik.py`** with **`AUTHENTIK_SKIP_OIDC_REST`** / **`AUTHENTIK_SKIP_USER_GROUP_REST`** when those worker steps ran, so the script only calls **`ensure_group`** over the API (skipped when **`AUTHENTIK_NOBLE_*_GROUP_PK`** are set).
## RBAC notes
- **Argo CD:** `noble-admins` group → `role:admin` (see **`clusters/noble/bootstrap/argocd/values-authentik-oidc.yaml`**).
- **Grafana:** `noble-admins` → Admin, `noble-editors` → Editor (see **`values-authentik-oidc.yaml`**).
## Troubleshooting
- **Blueprints from Ansible fail to apply** (worker logs / **System → Blueprints**): confirm the ConfigMap exists (**`kubectl -n authentik get cm authentik-noble-blueprints`** unless you changed **`noble_authentik_blueprints_configmap_name`**), that Helm mounts it (**`blueprints.configMaps`** in the rendered extra values), and that every **`!Find`** in **`templates/blueprints/20-*.j2`** still matches your Authentik versions default stage **names**. For **`10-noble-public-groups.yaml.j2`**, **`parents`** must reference groups that appear **earlier** in the merged list (or already exist in Authentik). Re-run **`--tags authentik`** after editing templates.
- **oauth2-proxy shows 500** on **`oauth2.apps…/oauth2/callback`** (logs: `email in id_token (...) isn't verified`): Authentiks id_token often lacks **`email_verified: true`** for bootstrap users. **`clusters/noble/bootstrap/oauth2-proxy/values.yaml`** sets **`insecure-oidc-allow-unverified-email`** for the lab; otherwise verify the users email in Authentik, then **`helm upgrade oauth2-proxy`** (or **`--tags authentik`**).
- Re-run **`configure_authentik.py`** only by executing **`noble.yml`** with **`--tags authentik`** after fixing `.env`.
- If Authentik API calls fail, check flows exist (slug **`default-provider-authorization-implicit-consent`**) and TLS reaches **`AUTHENTIK_API_BASE`**.
- **`GET …/flows/instances/…` → HTTP 403** with **`Token invalid/expired`**: the bootstrap API token is not accepted yet (common right after install: worker still creating it) or **`NOBLE_AUTHENTIK_BOOTSTRAP_TOKEN`** in `.env` does not match the value Helm applied. Re-run **`--tags authentik`** (the role waits for **`GET …/core/applications/`** to return **200** with your token). If you rotated the token in `.env` only, run the play again so Helm picks up the new value, or mint a new API token for **`akadmin`** in the admin UI.
- **`GET …/flows/instances/…` → HTTP 403** with **permission** errors (Authentik **2026+** RBAC): the bootstrap API token often cannot **view flows**. The role reads flow UUIDs from the **worker** database (`kubectl exec` + **`ak shell`**) when **`noble_authentik_oauth_authorization_flow_pk`** / **`noble_authentik_oauth_invalidation_flow_pk`** are unset. The same pattern applies to **`/crypto/certificatekeypairs/`**, **`/propertymappings/…`**, **`/core/groups/`**, and the matching **`noble_authentik_*`** inventory variables. If a lookup fails, fix **`akadmin`** / **authentik Admins** / token, or set the UUID variables manually (see below).
- **`GET …/crypto/certificatekeypairs/…` → HTTP 403** (permission): same RBAC issue as flows. When **`noble_authentik_oauth_signing_key_pk`** is unset, the role resolves the first **CertificateKeyPair** UUID from the **worker** DB. You can also set **`noble_authentik_oauth_signing_key_pk`** manually (Admin → **System****Certificates**).
- **`GET …/propertymappings/…` → HTTP 403** (permission): when **`noble_authentik_oauth_scope_mapping_pks`** is unset, the role resolves **ScopeMapping** UUIDs from the **worker** DB: **openid**, **email**, **profile**, **offline_access**, and **groups** only if a separate **`groups`** mapping exists (Authentik **2026.x** defaults put **groups** inside **profile** only).
- **`GET …/core/groups/…` → HTTP 403** (permission): when **`noble_authentik_group_pk_noble_admins`** and **`noble_authentik_group_pk_noble_editors`** are unset, the role runs **`resolve_noble_group_pks.py`** in the worker (**`get_or_create`** for **noble-admins** / **noble-editors**), then passes **`AUTHENTIK_NOBLE_*_GROUP_PK`** into **`configure_authentik.py`** so it skips group list/create via REST.
- **`GET …/providers/oauth2/…` → HTTP 403** (permission): bootstrap tokens often cannot list OAuth2 providers. With the default **`noble_authentik_oidc_provision_via: worker`**, the role upserts providers and applications in **`authentik-worker`** via Django ORM (**`worker_upsert_oauth_oidc.py`**) instead of **`configure_authentik.py`** REST. Set **`noble_authentik_oidc_provision_via: rest`** only if your API token has **view_oauth2provider** / provider edit permissions (e.g. a full **akadmin** token from the UI).
- **`GET …/core/users/…` → HTTP 403** when adding the bootstrap user to **noble-admins** / **noble-editors**: with **`noble_authentik_oidc_provision_via: worker`** and a non-empty bootstrap email, the role runs **`worker_add_bootstrap_user_groups.py`** in the worker (ORM **`User.groups.add`**) and sets **`AUTHENTIK_SKIP_USER_GROUP_REST`** so **`configure_authentik.py`** does not call the users API for membership.
- **Manual flow / signing / scope / group UUIDs (optional):** set **`noble_authentik_oauth_authorization_flow_pk`** and **`noble_authentik_oauth_invalidation_flow_pk`** (both together), optionally **`noble_authentik_oauth_signing_key_pk`**, **`noble_authentik_oauth_scope_mapping_pks`**, **`noble_authentik_group_pk_noble_admins`**, and **`noble_authentik_group_pk_noble_editors`**, from the admin UI or `-e` / `group_vars`; **`configure_authentik.py`** then skips the matching REST discovery calls.
- **`/if/admin/` redirects to `/if/user/`** (lost admin panel): in **2026.x**, **`canAccessAdmin`** follows **`isSuperuser`**, which is true only when the user belongs to a group with the **superuser** flag (**`authentik Admins`** by default). **`noble_authentik_ensure_admin_ui_access`** (default **true**) makes **`--tags authentik`** run **`files/worker_ensure_authentik_admin_access.py`** in **authentik-worker** (adds **akadmin** or the **bootstrap email** user to **authentik Admins** and forces **`is_superuser`** on that group). **Log out** of Authentik (private window is fine) and sign in again. Set **`noble_authentik_ensure_admin_ui_access: false`** to skip. Without Ansible, you can fix it in **Directory → Groups → authentik Admins** (superuser flag + membership) or run **`ak shell`** with the same logic as that script.
- **Grafana / Headlamp / ForwardAuth “Unauthorized” or Authentik “Not found”** (Authentik **2026.x**): OAuth endpoints are no longer under **`/application/o/<app>/oauth2/...`**. Use **issuer discovery** (Grafana **`server_url`** at **`…/application/o/<slug>/`**; oauth2-proxy **`oidc-issuer-url`**; Headlamp **`-oidc-idp-issuer-url`**). Re-apply **Traefik** (**`allowCrossNamespace`** so Ingresses can use Middleware in **`oauth2-proxy`**), **kube-prometheus-stack**, and **Headlamp** after updating values (e.g. **`ansible-playbook playbooks/noble.yml --tags authentik`**).
- **Headlamp OIDC authorize fails / `invalid_scope`**: Authentik often has no separate **`groups`** ScopeMapping (groups live under **`profile`**). Default **`noble_authentik_headlamp_oidc_scopes`** omits **`groups`**; add a **`groups`** mapping to the provider in Authentik and set **`noble_authentik_headlamp_oidc_scopes`** to include **`groups`** if you need that scope by name.
- **Headlamp OIDC: Authentik flashes then back at login / page refresh**: Headlamp **does** support normal browser OAuth (redirect to Authentik and return to **`/oidc-callback`**). If the callback fails, the UI looks like it “drops” auth. Common causes: **`X-Forwarded-Proto`** not reaching Headlamp (callback built as **`http`** — see [Headlamp OIDC docs](https://headlamp.dev/docs/latest/installation/in-cluster/oidc)); **Traefik ForwardAuth** on the same Ingress (do not combine with native OIDC); **PKCE** state issues — this role defaults **`noble_authentik_headlamp_oidc_use_pkce: false`** for Authentik confidential clients (set **`true`** in **`group_vars`** if you need PKCE).
- **Headlamp UI: `/me` works but `/clusters/main/version` (and other K8s calls) return 401**: Headlamp forwards your **OIDC id_token** to **kube-apiserver**. The API server must be configured with **OIDC** flags for the same **issuer** and **`oidc-client-id`** as Headlamp (see **`talos/talconfig.yaml`** patch and [Kubernetes OIDC authentication](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens)). Apply regenerated Talos configs to control plane nodes, then **`kubectl apply -k clusters/noble/bootstrap/headlamp`** (or **`--tags authentik`**) for **`oidc-noble-admins-clusterrolebinding.yaml`**. Ensure your user is in Authentik group **`noble-admins`** and the id_token includes a **`groups`** claim if you rely on that binding.
- **Headlamp + Traefik ForwardAuth (`oauth2-proxy-forward-auth`)**: Do **not** put ForwardAuth on the **Headlamp** Ingress while using **native Headlamp OIDC**. Auth runs on **`/oidc-callback`** before Headlamp can finish the code exchange; ForwardAuth returns **401** and breaks login. Use **either** native OIDC (this repos **`values-authentik-oidc.yaml`**) **or** terminate auth at oauth2-proxy only (no **`config.oidc`**), not both. The same applies to **Open WebUI** native OIDC (**`/oauth/oidc/callback`** in **`clusters/noble/apps/open-webui/values.yaml`**).
### Fix admin access manually (worker shell, no Ansible)
```bash
kubectl exec -it deploy/authentik-worker -n authentik -- ak shell -c "from authentik.core.models import User, Group; u=User.objects.get(username='akadmin'); adm,_=Group.objects.get_or_create(name='authentik Admins', defaults={'is_superuser': True}); adm.is_superuser=True; adm.save(update_fields=['is_superuser']); u.groups.add(adm); u=User.objects.get(pk=u.pk); print('is_superuser', u.is_superuser)"
```
Then **log out** of Authentik and sign in again.

View File

@@ -1,184 +0,0 @@
---
# Set **noble_authentik_install: true** after filling **.env** (see role README and repository **.env.sample**).
noble_authentik_install: false
# When true, run **configure_authentik.py** against the Authentik API (requires bootstrap token + client secrets).
noble_authentik_configure_idp: true
# **worker** — upsert OAuth2 providers + applications via **ak shell** + Django ORM (avoids **GET …/providers/oauth2/** 403
# for bootstrap tokens). **rest** — use the Authentik API only (needs a token that can list/patch OAuth2 providers).
# When true (default), run **worker_ensure_authentik_admin_access.py** so **akadmin** / bootstrap email is in
# **authentik Admins** with **is_superuser** on the group (fixes **/if/admin/** redirecting to user UI in 2026+).
noble_authentik_ensure_admin_ui_access: true
noble_authentik_chart_version: "2026.2.3"
noble_authentik_namespace: authentik
# Helm release name (deployments: **{release}-server**, **{release}-worker**).
noble_authentik_release_name: authentik
noble_authentik_oauth2_proxy_chart_version: "10.4.3"
# Helm **--wait** timeout for **oauth2-proxy** (first pull / API checks can exceed 10m).
noble_authentik_oauth2_proxy_helm_wait_timeout: 10m
noble_authentik_host: auth.apps.noble.lab.pcenicni.dev
noble_authentik_public_url: "https://{{ noble_authentik_host }}"
noble_authentik_api_base: "{{ noble_authentik_public_url }}/api/v3"
# Optional extra Ingress hostnames (FQDN strings) for the **same** Authentik release — e.g. a **public** name
# (Pangolin HTTP resource → Newt site → Traefik) while **`noble_authentik_host`** stays the in-lab name.
# Ansible merges these into **server.ingress.hosts** / **tls** (one cert Secret with multiple SANs).
noble_authentik_ingress_extra_hosts: []
# Mounted **blueprints** (ConfigMap → worker `/blueprints/mounted/cm-*`). See README § split routing / two-Brand.
noble_authentik_blueprints_enabled: false
noble_authentik_blueprints_configmap_name: authentik-noble-blueprints
# Directory groups blueprint (**`10-noble-public-groups.yaml.j2`**): merges **`noble_authentik_blueprint_public_groups`**
# (optional — often empty), **`noble_authentik_blueprint_extra_directory_groups`**, and **`noble_authentik_blueprint_nikflix_groups`**
# (defaults: **`nikflix-users`** / **`nikflix-admins`**). Lab onboarding uses **`noble_authentik_blueprint_lab_invitee_group_name`**
# from blueprint **22**, not this list. Each item may be:
# - a **string** (group name only), or
# - a **dict** with **`name`** (required) and optional **`is_superuser`** (bool), **`attributes`** (dict → JSON in blueprint),
# **`parents`** (list of **existing** group names — list parents *before* children in these lists, or use built-in groups).
noble_authentik_blueprint_public_groups: []
# Additional directory groups (same entry shape as **`noble_authentik_blueprint_public_groups`**); merged into blueprint **10** after **`public_groups`**.
noble_authentik_blueprint_extra_directory_groups: []
# Nikflix (e.g. **auth.nikflix.ca**) directory groups — merged **after** optional **`public_groups`** + **`extra_directory_groups`**
# so **`parents`** can reference those. Prefer **`nikflix-users`** / **`nikflix-admins`** for the internet-facing Brand.
noble_authentik_blueprint_nikflix_groups:
- name: nikflix-users
attributes:
"noble.ak/brand": nikflix
"noble.ak/audience": public
- name: nikflix-admins
parents:
- nikflix-users
attributes:
"noble.ak/brand": nikflix
"noble.ak/audience": public
# Lab-only authentication flow slug (Brand for **`noble_authentik_host`** points here).
noble_authentik_blueprint_lab_flow_slug: noble-lab-operator-authentication-flow
noble_authentik_blueprint_operator_policy_name: noble-lab-operators-only
# Who may sign in on the **lab** hostname (`noble_authentik_host`). Bootstrap user should be in **noble-admins**
# and/or **authentik Admins** (see **`noble_authentik_ensure_admin_ui_access`**).
noble_authentik_blueprint_lab_operator_groups:
- noble-admins
- authentik Admins
noble_authentik_blueprint_lab_brand_title: Noble lab (operators)
noble_authentik_blueprint_public_brand_title_prefix: Nikflix
# Public hostname Brand(s) → dedicated authentication flow (**21-noble-public-…** blueprint).
noble_authentik_blueprint_public_auth_flow_slug: noble-public-authentication-flow
# Lab flow: password stage (**failed_attempts_before_cancel**) and strength checks (expression policy; skips when **password** not yet in request context).
noble_authentik_blueprint_lab_password_failed_attempts: 3
noble_authentik_blueprint_lab_password_policy_length_min: 16
noble_authentik_blueprint_lab_password_policy_amount_uppercase: 1
noble_authentik_blueprint_lab_password_policy_amount_lowercase: 1
noble_authentik_blueprint_lab_password_policy_amount_digits: 1
noble_authentik_blueprint_lab_password_policy_amount_symbols: 1
noble_authentik_blueprint_lab_password_policy_check_zxcvbn: true
noble_authentik_blueprint_lab_password_policy_zxcvbn_score_threshold: 3
noble_authentik_blueprint_lab_password_policy_error_message: >-
Lab password policy: at least 16 characters with upper, lower, digit, symbol, and sufficient strength.
# Lab MFA when user has no compatible device: **skip** (like stock), **deny** (block), **configure** (TOTP setup via default stage).
noble_authentik_blueprint_lab_mfa_not_configured_action: configure
# Invitation-based **enrollment** flows (blueprint **22**). Brands do not select enrollment; each **Invitation** picks a flow.
# Link shape: **`https://<host>/if/flow/<slug>/?itoken=<uuid>`** — use your **Nikflix / extra_hosts** FQDN for this flows invites.
noble_authentik_blueprint_public_invitation_enrollment_flow_slug: nikflix-invitation-enrollment
noble_authentik_blueprint_lab_invitation_enrollment_flow_slug: noble-lab-invitation-enrollment
noble_authentik_blueprint_public_invitation_flow_name: Nikflix invitation enrollment
noble_authentik_blueprint_public_invitation_flow_title: Complete your signup
noble_authentik_blueprint_lab_invitation_flow_name: Noble lab invitation enrollment
noble_authentik_blueprint_lab_invitation_flow_title: Lab access — complete enrollment
# **User write** for Nikflix (internet) invites: must match a **Group** created in blueprint **10** (default **`nikflix-users`**).
noble_authentik_blueprint_public_invitation_user_group: nikflix-users
# **`internal`** — normal directory users (default). Use **`external`** only when you intentionally isolate invitees from admin / “internal-only” surfaces (see [Invitations troubleshooting](https://docs.goauthentik.io/users-sources/user/invitations/)).
noble_authentik_blueprint_public_invitation_user_type: internal
noble_authentik_blueprint_public_invitation_user_path: users/noble/nikflix
# Lab invites: blueprint creates **`noble_authentik_blueprint_lab_invitee_group_name`**; add members to **`noble_authentik_blueprint_lab_operator_groups`** manually when they should use the lab URL.
noble_authentik_blueprint_lab_invitee_group_name: noble-lab-invited
noble_authentik_blueprint_lab_invitation_user_type: internal
noble_authentik_blueprint_lab_invitation_user_path: users/noble/lab
noble_authentik_oauth2_proxy_host: oauth2.apps.noble.lab.pcenicni.dev
# Media: **S3** via Ansible **`global.env`** (same S3 **URL** + **access keys** as **Velero** when you omit Authentik-specific overrides).
# Set **`NOBLE_AUTHENTIK_MEDIA_S3_BUCKET`** to a **dedicated** bucket (do not use the Velero backup bucket).
noble_authentik_media_s3_bucket: ""
noble_authentik_s3_endpoint: ""
noble_authentik_s3_access_key: ""
noble_authentik_s3_secret_key: ""
noble_authentik_s3_region: "us-east-1"
noble_authentik_s3_addressing_style: "path"
# Optional outbound SMTP (maps to **AUTHENTIK_EMAIL__*** in Helm **global.env**). Leave **noble_authentik_smtp_host**
# empty to omit email env vars; set **NOBLE_AUTHENTIK_SMTP_HOST** (and **NOBLE_AUTHENTIK_SMTP_FROM**) in **.env** to enable.
noble_authentik_smtp_host: ""
noble_authentik_smtp_port: "587"
noble_authentik_smtp_username: ""
noble_authentik_smtp_password: ""
noble_authentik_smtp_use_tls: "true"
noble_authentik_smtp_use_ssl: "false"
noble_authentik_smtp_timeout: "30"
noble_authentik_smtp_from: ""
# OIDC client ids (must match Authentik providers created by configure script)
noble_authentik_client_id_argocd: argocd
noble_authentik_client_id_grafana: grafana
noble_authentik_client_id_headlamp: headlamp
noble_authentik_client_id_oauth2_proxy: oauth2-proxy
noble_authentik_client_id_open_webui: open-webui
# Headlamp **OIDC_SCOPES** for Secret **headlamp-oidc**. Omit **groups** unless the Authentik OAuth2 provider
# includes a separate **groups** ScopeMapping (2026.x defaults often embed groups in **profile** only; requesting
# **groups** then yields **invalid_scope** on authorize). Override if your IdP exposes **groups** explicitly.
noble_authentik_headlamp_oidc_scopes: "openid profile email offline_access"
# PKCE for Headlamp OIDC. **false** is the default for Authentik **confidential** clients: auth still uses the
# standard browser OAuth code flow; PKCE is optional and some users see the callback “flash” then login reset
# when PKCE state/cookies do not survive the redirect. Set **true** if you require PKCE.
noble_authentik_headlamp_oidc_use_pkce: false
# Secrets / bootstrap — prefer **lookup('env', ...)** set via repository **.env** (see from_env.yml).
noble_authentik_secret_key: ""
noble_authentik_postgresql_password: ""
noble_authentik_bootstrap_token: ""
noble_authentik_bootstrap_email: ""
noble_authentik_bootstrap_password: ""
noble_authentik_client_secret_argocd: ""
noble_authentik_client_secret_grafana: ""
noble_authentik_client_secret_headlamp: ""
noble_authentik_client_secret_oauth2_proxy: ""
noble_authentik_client_secret_open_webui: ""
noble_authentik_oauth2_proxy_cookie_secret: ""
# **open-webui** namespace — Secret **open-webui-secrets** (Ansible **--tags authentik**). See **clusters/noble/apps/open-webui/values.yaml**.
noble_open_webui_openai_api_key: ""
noble_open_webui_webui_secret_key: ""
# Public FQDN for Open WebUI (Ingress + OIDC **redirect_uri**). Set in **group_vars** (e.g. **webui.example.com**); must match GitOps **values.yaml** **ingress.host** and **OPENID_REDIRECT_URI** / **WEBUI_URL**.
noble_open_webui_public_host: ""
# Optional: OAuth2 provider flow PKs (UUID strings). When **both** are set, **configure_authentik.py**
# skips **GET /flows/instances/** (avoids 403 if the API token cannot view flows). If unset, the role
# tries **kubectl exec** into **authentik-worker** + **ak shell** to read the same slugs from the DB.
noble_authentik_oauth_authorization_flow_pk: ""
noble_authentik_oauth_invalidation_flow_pk: ""
# Optional: OAuth2 signing key (**CertificateKeyPair** UUID). When set, **configure_authentik.py** skips
# **GET /crypto/certificatekeypairs/** (often 403 for bootstrap tokens). If unset, the role resolves it
# from the worker DB when possible (see **resolve_oauth_signing_key_pk.py**).
noble_authentik_oauth_signing_key_pk: ""
# Optional: comma-separated **ScopeMapping** UUIDs (openid, email, profile, offline_access; optional **groups**
# if you created a separate mapping — 2026.x defaults embed groups in **profile** only).
# When set, **configure_authentik.py** skips **GET /propertymappings/...** (often 403 for bootstrap tokens).
noble_authentik_oauth_scope_mapping_pks: ""
# Optional: **Group** UUIDs for **noble-admins** / **noble-editors** (skip **GET /core/groups/** when set).
noble_authentik_group_pk_noble_admins: ""
noble_authentik_group_pk_noble_editors: ""
noble_authentik_helm_wait_timeout: 25m
# After Helm --wait, the worker still creates the bootstrap API token; poll the public API before configure_authentik.py.
noble_authentik_bootstrap_api_wait_retries: 36
noble_authentik_bootstrap_api_wait_delay: 5
# Re-apply the same chart versions as the rest of noble.yml when flipping SSO on.
noble_authentik_argocd_chart_version: "9.5.14"
noble_authentik_kube_prometheus_chart_version: "85.0.3"
noble_authentik_headlamp_chart_version: "0.42.0"
noble_authentik_longhorn_chart_version: "1.11.2"
noble_authentik_kube_prometheus_helm_wait_timeout: 60m

View File

@@ -1,520 +0,0 @@
#!/usr/bin/env python3
"""
Create / update Authentik OAuth2/OpenID providers + applications + groups (stdlib only).
Environment:
AUTHENTIK_API_BASE e.g. https://auth.apps.example.com/api/v3
AUTHENTIK_TOKEN bootstrap API token (Bearer) for user **akadmin** (see README if flows return 403)
BOOTSTRAP_EMAIL initial admin email (added to noble-admins)
CLIENT_JSON path to JSON file: { "argocd": {"client_id","client_secret","redirect_uri"}, ... }
Optional (skip API discovery; Authentik 2026+ RBAC — use UUID strings from worker shell or Admin UI):
AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK
AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK
AUTHENTIK_OAUTH_SIGNING_KEY_PK
AUTHENTIK_OAUTH_SCOPE_MAPPING_PKS comma-separated ScopeMapping UUIDs (openid,email,profile,offline_access[,groups])
AUTHENTIK_NOBLE_ADMINS_GROUP_PK noble-admins Group UUID (skip GET /core/groups/)
AUTHENTIK_NOBLE_EDITORS_GROUP_PK noble-editors Group UUID
AUTHENTIK_SKIP_OIDC_REST if 1/true/yes, skip OAuth2 provider + application REST calls (Ansible
uses **worker_upsert_oauth_oidc.py** when bootstrap tokens cannot **GET …/providers/oauth2/**)
AUTHENTIK_SKIP_USER_GROUP_REST if 1/true/yes, skip **GET/PATCH …/core/users/** for bootstrap group membership (Ansible uses
**worker_add_bootstrap_user_groups.py** when the token cannot **view_user**)
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
# List endpoints for OAuth2 scope PropertyMapping (Authentik version-dependent).
_OAUTH_SCOPE_MAPPING_LIST_PATHS = (
"/propertymappings/provider/scope/?page_size=500",
"/propertymappings/scope/?page_size=500",
"/propertymappings/oauthscope/?page_size=500",
)
def primary_key(value) -> int | str:
"""Authentik API pk: integer (legacy) or UUID string (2026+)."""
if isinstance(value, bool):
raise ValueError(f"invalid pk (bool): {value!r}")
if isinstance(value, int):
return value
if isinstance(value, str):
return value
raise ValueError(f"invalid pk type {type(value).__name__}: {value!r}")
def dedupe_pks_preserve_order(items: list[int | str]) -> list[int | str]:
out: list[int | str] = []
seen: set[str] = set()
for item in items:
key = str(item)
if key not in seen:
seen.add(key)
out.append(item)
return out
def req(method: str, url: str, body: dict | None = None) -> tuple[int, dict | list]:
data = None
headers = {"Authorization": f"Bearer {os.environ['AUTHENTIK_TOKEN']}", "Accept": "application/json"}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
r = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(r, timeout=120) as resp:
raw = resp.read().decode("utf-8")
code = resp.getcode()
if not raw:
return code, {}
return code, json.loads(raw)
except urllib.error.HTTPError as e:
err = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"{method} {url} -> HTTP {e.code}: {err}") from e
def req_any(method: str, url: str, body: dict | None = None) -> tuple[int, dict | list | str]:
"""Like req() but returns HTTP status and body (or error text) instead of raising on HTTP errors."""
data = None
headers = {"Authorization": f"Bearer {os.environ['AUTHENTIK_TOKEN']}", "Accept": "application/json"}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
r = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(r, timeout=120) as resp:
raw = resp.read().decode("utf-8")
code = resp.getcode()
if not raw:
return code, {}
return code, json.loads(raw)
except urllib.error.HTTPError as e:
err = e.read().decode("utf-8", errors="replace")
try:
return e.code, json.loads(err)
except json.JSONDecodeError:
return e.code, err
def collect(base: str, path: str) -> list:
out: list = []
url = base + path
origin = base.split("/api/v3", 1)[0] if "/api/v3" in base else base
while url:
code, payload = req("GET", url)
if code != 200:
raise RuntimeError(f"GET {url} unexpected {code}")
if isinstance(payload, dict) and "results" in payload:
out.extend(payload["results"])
nxt = payload.get("next") or ""
if not nxt:
url = ""
elif nxt.startswith("http"):
url = nxt
else:
url = origin + nxt
else:
break
return out
def find_flow_pk(base: str, slug: str) -> str:
"""Resolve a Flow primary key. Prefer detail-by-slug (REST); fall back to list filter."""
slug_path = urllib.parse.quote(slug, safe="")
attempts = (
f"{base}/flows/instances/{slug_path}/",
f"{base}/flows/instances/?slug={urllib.parse.quote(slug)}",
)
errors: list[str] = []
for url in attempts:
code, payload = req_any("GET", url)
if code != 200:
errors.append(f"{url} -> HTTP {code}: {payload!r}")
continue
if not isinstance(payload, dict):
errors.append(f"{url} -> unexpected body type")
continue
if "results" in payload:
results = payload.get("results") or []
if results:
return str(primary_key(results[0]["pk"]))
elif payload.get("pk") is not None:
return str(primary_key(payload["pk"]))
errors.append(f"{url} -> empty")
joined = "; ".join(errors)
extra = ""
low = joined.lower()
if (
"token invalid" in low
or "invalid/expired" in low
or ("invalid" in low and "expired" in low)
):
extra = (
" The API rejected the bearer token (invalid/expired). After a fresh install, wait until "
"the worker has finished bootstrap (re-run this playbook; Ansible now waits for HTTP 200). "
"Confirm NOBLE_AUTHENTIK_BOOTSTRAP_TOKEN in .env matches what Helm was given. "
"If you changed the token in .env without a matching helm upgrade, run the authentik tag again "
"or create a new API token for akadmin."
)
else:
extra = (
" Authentik 2026+ RBAC: the API user must be a superuser (member of **authentik Admins**) "
"or use a token for **akadmin** created from that account. "
"Alternatively set AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK and "
"AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK (flow UUIDs from Admin → Flows → open flow → URL / API)."
)
raise RuntimeError(f"cannot resolve flow slug {slug!r} ({joined}).{extra}")
def find_flow_pk_first(base: str, slugs: list[str]) -> str:
last_err: Exception | None = None
for s in slugs:
try:
return find_flow_pk(base, s)
except RuntimeError as e:
last_err = e
raise RuntimeError(f"no flow matched {slugs}: {last_err}")
def signing_key_pk(base: str):
raw = (os.environ.get("AUTHENTIK_OAUTH_SIGNING_KEY_PK") or "").strip()
if raw:
return primary_key(raw)
code, payload = req_any("GET", f"{base}/crypto/certificatekeypairs/?ordering=pk&page_size=1")
if code != 200:
raise RuntimeError(
f"GET certificatekeypairs -> HTTP {code}: {payload!r}. "
"Authentik 2026+ RBAC: set AUTHENTIK_OAUTH_SIGNING_KEY_PK (CertificateKeyPair UUID) or let "
"Ansible resolve it from the worker DB (noble_authentik_oauth_signing_key_pk)."
)
if not isinstance(payload, dict) or not payload.get("results"):
raise RuntimeError("no signing certificate keypairs returned")
return primary_key(payload["results"][0]["pk"])
def _collect_scope_mappings(base: str) -> list:
"""Try known Authentik API paths for OAuth2 scope property mappings (version-dependent)."""
last_err: Exception | None = None
for path in _OAUTH_SCOPE_MAPPING_LIST_PATHS:
try:
return collect(base, path)
except RuntimeError as exc:
err = str(exc)
if " 404" in err or "404:" in err or " 403" in err or "403:" in err:
last_err = exc
continue
raise
raise RuntimeError(f"could not list OAuth scope property mappings; last error: {last_err}")
def scope_mapping_pks(base: str) -> list[int | str]:
raw = (os.environ.get("AUTHENTIK_OAUTH_SCOPE_MAPPING_PKS") or "").strip()
if raw:
parts = [p.strip() for p in raw.split(",") if p.strip()]
uniq = dedupe_pks_preserve_order([primary_key(p) for p in parts])
if len(uniq) < 3:
raise RuntimeError(
f"AUTHENTIK_OAUTH_SCOPE_MAPPING_PKS must list at least 3 UUIDs (got {len(uniq)}); "
"use comma-separated ScopeMapping primary keys in order "
"openid,email,profile,offline_access,groups from the worker helper or Admin UI."
)
return uniq
mappings = _collect_scope_mappings(base)
want_scopes = {"openid", "email", "profile", "offline_access", "groups"}
pks: list[int | str] = []
for m in mappings:
sn = (m.get("scope_name") or "").strip()
if sn in want_scopes:
pks.append(primary_key(m["pk"]))
# de-dupe preserve order (str key: int 1 vs str "1" unlikely in one payload)
seen: set[str] = set()
uniq: list[int | str] = []
for p in pks:
key = str(p)
if key not in seen:
seen.add(key)
uniq.append(p)
if len(uniq) >= 3:
return uniq
# Fallback: include managed OpenID mappings by name heuristics
for m in mappings:
name = (m.get("name") or "").lower()
if "openid" in name and any(x in name for x in ("openid", "email", "profile", "groups", "offline")):
pk_val = primary_key(m["pk"])
key = str(pk_val)
if key not in seen:
seen.add(key)
uniq.append(pk_val)
if len(uniq) < 3:
hints = ", ".join(
f"GET {base}{p.split('?', 1)[0]}/" for p in _OAUTH_SCOPE_MAPPING_LIST_PATHS
)
raise RuntimeError(
f"too few oauth scope mappings resolved ({uniq}); inspect one of: {hints}. "
"Authentik 2026+ RBAC: set AUTHENTIK_OAUTH_SCOPE_MAPPING_PKS (comma-separated UUIDs) or "
"let Ansible resolve them from the worker DB."
)
return uniq
def find_oauth2_provider_by_client_id(base: str, client_id: str) -> dict | None:
"""OAuth2 provider list has no reliable **slug** filter; match on **client_id**."""
url = f"{base}/providers/oauth2/?page_size=100"
origin = base.split("/api/v3", 1)[0] if "/api/v3" in base else base
while url:
code, payload = req_any("GET", url)
if code != 200 or not isinstance(payload, dict):
raise RuntimeError(
f"provider list failed: GET {url} -> {code}: {payload!r}. "
"Authentik 2026+ RBAC: the token may not list OAuth2 providers; use an **akadmin** API token "
"with provider permissions, or grant **view_oauth2provider** (see Admin → Roles)."
)
for row in payload.get("results") or []:
if row.get("client_id") == client_id:
return row
nxt = payload.get("next") or ""
if not nxt:
return None
if nxt.startswith("http"):
url = nxt
else:
url = origin + nxt
return None
def upsert_oauth_provider(
base: str,
slug: str,
name: str,
client_id: str,
client_secret: str,
redirect_uri: str,
auth_flow: str,
invalidation_flow: str,
signing_key,
property_mappings: list[int | str],
) -> int | str:
# Authentik 2025+ expects redirect_uris as structured JSON (not newline-separated text).
redirect_uris = [
{
"matching_mode": "strict",
"url": redirect_uri.strip(),
"redirect_uri_type": "authorization",
}
]
existing = find_oauth2_provider_by_client_id(base, client_id)
body = {
"name": name,
"authorization_flow": auth_flow,
"invalidation_flow": invalidation_flow,
"property_mappings": property_mappings,
"client_type": "confidential",
"client_id": client_id,
"client_secret": client_secret,
"redirect_uris": redirect_uris,
"signing_key": signing_key,
"grant_types": ["authorization_code"],
}
if existing:
pk = primary_key(existing["pk"])
code2, _ = req("PATCH", f"{base}/providers/oauth2/{pk}/", body)
if code2 not in (200, 204):
raise RuntimeError(f"PATCH provider client_id={client_id} -> {code2}")
return pk
code3, created = req("POST", f"{base}/providers/oauth2/", body)
if code3 not in (200, 201) or not isinstance(created, dict):
raise RuntimeError(f"POST provider client_id={client_id} -> {code3} {created}")
return primary_key(created["pk"])
def upsert_application(base: str, slug: str, name: str, provider_pk: int | str) -> None:
# Detail URL is /core/applications/{slug}/ (lookup_field slug, 2026+). Do not trust list
# ordering: ?slug= filters can still return unrelated rows first → PATCH wrong app + 400.
slug = str(slug).strip()
seg = urllib.parse.quote(slug, safe="")
detail = f"{base}/core/applications/{seg}/"
code, payload = req_any("GET", detail)
if code == 200:
if not isinstance(payload, dict):
raise RuntimeError(f"GET application {slug}: unexpected response")
# Omit slug on update — sending the same slug/provider can trip UniqueValidator on PATCH.
code2, err = req_any("PATCH", detail, {"name": name, "provider": provider_pk})
if code2 not in (200, 204):
raise RuntimeError(f"PATCH application {slug} -> {code2}: {err!r}")
return
if code == 404:
body = {"name": name, "slug": slug, "provider": provider_pk}
code3, err = req_any("POST", f"{base}/core/applications/", body)
if code3 not in (200, 201):
raise RuntimeError(f"POST application {slug} -> {code3}: {err!r}")
return
raise RuntimeError(f"GET application {slug} -> {code}: {payload!r}")
def _group_pk_from_env(name: str) -> str | None:
env_keys = {
"noble-admins": "AUTHENTIK_NOBLE_ADMINS_GROUP_PK",
"noble-editors": "AUTHENTIK_NOBLE_EDITORS_GROUP_PK",
}
key = env_keys.get(name)
if not key:
return None
raw = (os.environ.get(key) or "").strip()
return raw or None
def ensure_group(base: str, name: str) -> int | str:
env_pk = _group_pk_from_env(name)
if env_pk:
return primary_key(env_pk)
code, payload = req_any("GET", f"{base}/core/groups/?name={urllib.parse.quote(name)}")
if code != 200:
raise RuntimeError(
f"GET groups name={name!r} -> HTTP {code}: {payload!r}. "
"Authentik 2026+ RBAC: set AUTHENTIK_NOBLE_ADMINS_GROUP_PK and "
"AUTHENTIK_NOBLE_EDITORS_GROUP_PK (Group UUIDs), or let Ansible resolve/create them via the worker DB."
)
if not isinstance(payload, dict):
raise RuntimeError("group list failed")
results = payload.get("results") or []
if results:
return primary_key(results[0]["pk"])
code2, created = req_any("POST", f"{base}/core/groups/", {"name": name})
if code2 not in (200, 201) or not isinstance(created, dict):
raise RuntimeError(
f"POST group {name} -> {code2}: {created!r}. "
"If the API user cannot create groups, run the noble_authentik worker helper or create the group in Admin."
)
return primary_key(created["pk"])
def add_user_to_groups(base: str, email: str, group_pks: list[int | str]) -> None:
code, payload = req_any("GET", f"{base}/core/users/?email={urllib.parse.quote(email)}")
if code != 200:
raise RuntimeError(
f"GET users email={email!r} -> HTTP {code}: {payload!r}. "
"Authentik 2026+ RBAC: the token may not list users; add the bootstrap user to **noble-admins** / "
"**noble-editors** in Admin, or use a token with **view_user** permission."
)
if not isinstance(payload, dict):
raise RuntimeError("user list failed")
results = payload.get("results") or []
if not results:
print(f"WARN: no user with email {email}; skip group membership", file=sys.stderr)
return
user = results[0]
upk = primary_key(user["pk"])
existing = [
primary_key(g["pk"])
for g in user.get("groups", [])
if isinstance(g, dict) and "pk" in g
]
merged = dedupe_pks_preserve_order([*existing, *group_pks])
if {str(x) for x in merged} == {str(x) for x in existing}:
return
code2, err = req_any("PATCH", f"{base}/core/users/{upk}/", {"groups": merged})
if code2 not in (200, 204):
raise RuntimeError(f"PATCH user groups -> {code2}: {err!r}")
def _truthy_env(name: str) -> bool:
return (os.environ.get(name) or "").strip().lower() in ("1", "true", "yes")
def main() -> int:
base = os.environ.get("AUTHENTIK_API_BASE", "").rstrip("/")
tok = os.environ.get("AUTHENTIK_TOKEN", "")
cfg_path = os.environ.get("CLIENT_JSON", "")
email = os.environ.get("BOOTSTRAP_EMAIL", "")
skip_oidc_rest = _truthy_env("AUTHENTIK_SKIP_OIDC_REST")
skip_user_group_rest = _truthy_env("AUTHENTIK_SKIP_USER_GROUP_REST")
if not base or not tok:
print("AUTHENTIK_API_BASE and AUTHENTIK_TOKEN required", file=sys.stderr)
return 2
if not skip_oidc_rest and not cfg_path:
print("CLIENT_JSON required unless AUTHENTIK_SKIP_OIDC_REST is set", file=sys.stderr)
return 2
clients: dict = {}
if not skip_oidc_rest:
with open(cfg_path, encoding="utf-8") as f:
clients = json.load(f)
if not isinstance(clients, dict):
print("CLIENT_JSON must be an object keyed by provider slug", file=sys.stderr)
return 2
auth_flow: str | None = None
invalidation_flow: str | None = None
signing_key = None
pmap: list[int | str] = []
if not skip_oidc_rest:
auth_pk = (os.environ.get("AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK") or "").strip()
inv_pk = (os.environ.get("AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK") or "").strip()
if auth_pk and inv_pk:
auth_flow, invalidation_flow = auth_pk, inv_pk
elif auth_pk or inv_pk:
print(
"Set both AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK and AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK "
"or neither",
file=sys.stderr,
)
return 2
else:
auth_flow = find_flow_pk(base, "default-provider-authorization-implicit-consent")
invalidation_flow = find_flow_pk_first(
base,
["default-invalidation-flow", "default-provider-invalidation-flow"],
)
signing_key = signing_key_pk(base)
pmap = scope_mapping_pks(base)
admins_pk = ensure_group(base, "noble-admins")
editors_pk = ensure_group(base, "noble-editors")
try:
if not skip_oidc_rest:
assert auth_flow is not None and invalidation_flow is not None
for slug, meta in clients.items():
name = meta.get("name") or slug
cid = meta["client_id"]
csec = meta["client_secret"]
redir = meta["redirect_uri"]
ppk = upsert_oauth_provider(
base,
slug,
name,
cid,
csec,
redir,
auth_flow,
invalidation_flow,
signing_key,
pmap,
)
upsert_application(base, slug, name, ppk)
if email and not skip_user_group_rest:
add_user_to_groups(base, email, [admins_pk, editors_pk])
if skip_oidc_rest and skip_user_group_rest:
print("authentik: noble groups verified; OAuth2 apps + bootstrap group membership via worker", flush=True)
elif skip_oidc_rest:
print("authentik: groups verified; OAuth2 apps via worker (user membership via API)", flush=True)
else:
print("authentik: providers + applications configured", flush=True)
return 0
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
return 1
except Exception as exc:
print(f"{type(exc).__name__}: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,15 +0,0 @@
# Run inside the Authentik worker image (see noble_authentik Ansible role).
# Ensures **noble-admins** and **noble-editors** exist, then prints their UUIDs (one per line).
# Order: noble-admins, noble-editors — matches **configure_authentik.py** usage.
from authentik.core.models import Group
_NAMES = ("noble-admins", "noble-editors")
def main() -> None:
for name in _NAMES:
g, _ = Group.objects.get_or_create(name=name, defaults={"is_superuser": False})
print(str(g.pk))
main()

View File

@@ -1,23 +0,0 @@
# Run inside the Authentik worker image: `ak shell -c "exec(open('/tmp/...').read())"`.
# Prints two lines: authorization flow UUID, invalidation flow UUID (for configure_authentik.py).
from authentik.flows.models import Flow
def _pk(slug: str) -> str:
return str(Flow.objects.get(slug=slug).pk)
def main() -> None:
auth = _pk("default-provider-authorization-implicit-consent")
inv_slug = None
for candidate in ("default-invalidation-flow", "default-provider-invalidation-flow"):
if Flow.objects.filter(slug=candidate).exists():
inv_slug = candidate
break
if not inv_slug:
raise SystemExit("no default invalidation flow (expected one of: default-invalidation-flow, default-provider-invalidation-flow)")
print(auth)
print(_pk(inv_slug))
main()

View File

@@ -1,22 +0,0 @@
# Run inside the Authentik worker image (see noble_authentik Ansible role).
# Prints one UUID per line for **ScopeMapping** rows used by configure_authentik.py.
#
# Authentik 2026.x default blueprint puts **groups** inside the **profile** mapping expression;
# there is often no separate scope_name **groups** — so **groups** is optional here.
from authentik.providers.oauth2.models import ScopeMapping
_REQUIRED = ("openid", "email", "profile", "offline_access")
def main() -> None:
for scope in _REQUIRED:
m = ScopeMapping.objects.filter(scope_name=scope).order_by("name").first()
if not m:
raise SystemExit(f"no ScopeMapping for scope_name={scope!r}")
print(str(m.pk))
groups = ScopeMapping.objects.filter(scope_name="groups").order_by("name").first()
if groups:
print(str(groups.pk))
main()

View File

@@ -1,13 +0,0 @@
# Run inside the Authentik worker image (see noble_authentik Ansible role).
# Prints one line: default **CertificateKeyPair** UUID (same ordering as REST: by pk).
from authentik.crypto.models import CertificateKeyPair
def main() -> None:
ckp = CertificateKeyPair.objects.order_by("kp_uuid").first()
if not ckp:
raise SystemExit("no CertificateKeyPair in database")
print(str(ckp.pk))
main()

View File

@@ -1,54 +0,0 @@
# Run inside the Authentik worker image (**ak shell**; see noble_authentik Ansible role).
# Adds the bootstrap user to the given **Group** rows by primary key (ORM; bypasses **GET /core/users/** RBAC).
#
# Env:
# AUTHENTIK_WORKER_USER_GROUPS_SPEC absolute path inside the container to JSON:
# {"email": "<bootstrap email>", "group_pks": ["<uuid>", ...]}
from __future__ import annotations
import json
import os
import sys
from typing import Any
from django.db import transaction
from authentik.core.models import Group, User
def _load_spec() -> dict[str, Any]:
path = (os.environ.get("AUTHENTIK_WORKER_USER_GROUPS_SPEC") or "").strip()
if not path:
print("AUTHENTIK_WORKER_USER_GROUPS_SPEC must point to the JSON spec file", file=sys.stderr)
raise SystemExit(2)
with open(path, encoding="utf-8") as f:
spec = json.load(f)
if not isinstance(spec, dict):
raise SystemExit("spec root must be an object")
return spec
def main() -> None:
spec = _load_spec()
email = (spec.get("email") or "").strip()
pks = spec.get("group_pks")
if not email:
print("spec.email is required", file=sys.stderr)
raise SystemExit(2)
if not isinstance(pks, list) or not pks:
print("spec.group_pks must be a non-empty list of UUIDs", file=sys.stderr)
raise SystemExit(2)
user = User.objects.filter(email__iexact=email).first()
if user is None:
print(f"WARN: no user with email {email!r}; skip group membership (ORM)", file=sys.stderr)
return
groups = [Group.objects.get(pk=pk) for pk in pks]
with transaction.atomic():
user.groups.add(*groups)
print("worker: bootstrap user group membership updated", flush=True)
main()

View File

@@ -1,72 +0,0 @@
# Run inside the Authentik worker image (**ak shell**; see noble_authentik Ansible role).
# Restores **Admin interface** access for the bootstrap account: ensures the **authentik Admins** group
# exists with **is_superuser** set and adds **akadmin** (or the bootstrap user by email) to that group.
# Authentik 2026+ derives **canAccessAdmin** from **User.is_superuser**, which requires membership in a
# group with **is_superuser=True** (see role README).
#
# Env:
# AUTHENTIK_WORKER_ADMIN_ACCESS_SPEC path inside the container to JSON: {"bootstrap_email": "..."}
# (email may be empty — then only **username=akadmin** is considered)
from __future__ import annotations
import json
import os
import sys
from typing import Any
from django.db import transaction
from authentik.core.models import Group, User
_ADMINS = "authentik Admins"
def _load_spec() -> dict[str, Any]:
path = (os.environ.get("AUTHENTIK_WORKER_ADMIN_ACCESS_SPEC") or "").strip()
if not path:
print("AUTHENTIK_WORKER_ADMIN_ACCESS_SPEC must point to the JSON spec file", file=sys.stderr)
raise SystemExit(2)
with open(path, encoding="utf-8") as f:
spec = json.load(f)
if not isinstance(spec, dict):
raise SystemExit("spec root must be an object")
return spec
def _resolve_user(spec: dict[str, Any]) -> User | None:
u = User.objects.filter(username="akadmin").first()
if u is not None:
return u
email = (spec.get("bootstrap_email") or "").strip()
if not email:
return None
return User.objects.filter(email__iexact=email).first()
def main() -> None:
spec = _load_spec()
user = _resolve_user(spec)
if user is None:
print(
"WARN: no user with username=akadmin and no bootstrap_email match; skip authentik Admins repair",
file=sys.stderr,
)
return
with transaction.atomic():
adm, _ = Group.objects.get_or_create(
name=_ADMINS,
defaults={"is_superuser": True},
)
if not adm.is_superuser:
adm.is_superuser = True
adm.save(update_fields=["is_superuser"])
user.groups.add(adm)
print(
f"worker: {user.username!r} is in {_ADMINS!r} (superuser group); log out of Authentik and sign in again",
flush=True,
)
main()

View File

@@ -1,109 +0,0 @@
# Run inside the Authentik worker image (**ak shell**; see noble_authentik Ansible role).
# Upserts **OAuth2Provider** + **Application** from a JSON spec (Django ORM; bypasses OAuth2 provider REST RBAC).
#
# Env:
# AUTHENTIK_WORKER_OIDC_SPEC absolute path inside the container to the JSON spec file
#
# Spec JSON:
# {
# "authorization_flow": "<uuid>",
# "invalidation_flow": "<uuid>",
# "signing_key": "<uuid>",
# "property_mappings": ["<uuid>", ...],
# "clients": { "<slug>": {"name","client_id","client_secret","redirect_uri"}, ... }
# }
from __future__ import annotations
import json
import os
import sys
from typing import Any
from django.db import transaction
from authentik.core.models import Application, PropertyMapping
from authentik.flows.models import Flow
from authentik.crypto.models import CertificateKeyPair
from authentik.providers.oauth2.models import (
ClientTypes,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
)
def _load_spec() -> dict[str, Any]:
path = (os.environ.get("AUTHENTIK_WORKER_OIDC_SPEC") or "").strip()
if not path:
print("AUTHENTIK_WORKER_OIDC_SPEC must point to the JSON spec file", file=sys.stderr)
raise SystemExit(2)
with open(path, encoding="utf-8") as f:
spec = json.load(f)
if not isinstance(spec, dict):
raise SystemExit("spec root must be an object")
return spec
def main() -> None:
spec = _load_spec()
auth_id = spec.get("authorization_flow")
inv_id = spec.get("invalidation_flow")
sk_id = spec.get("signing_key")
pmap_ids = spec.get("property_mappings")
clients = spec.get("clients")
if not auth_id or not inv_id or not sk_id:
print("spec requires authorization_flow, invalidation_flow, signing_key", file=sys.stderr)
raise SystemExit(2)
if not isinstance(pmap_ids, list) or len(pmap_ids) < 1:
print("spec.property_mappings must be a non-empty list of UUIDs", file=sys.stderr)
raise SystemExit(2)
if not isinstance(clients, dict) or not clients:
print("spec.clients must be a non-empty object", file=sys.stderr)
raise SystemExit(2)
auth_flow = Flow.objects.get(pk=auth_id)
inv_flow = Flow.objects.get(pk=inv_id)
signing_key = CertificateKeyPair.objects.get(pk=sk_id)
mappings = list(PropertyMapping.objects.filter(pk__in=pmap_ids))
if len(mappings) != len(pmap_ids):
found = {str(m.pk) for m in mappings}
missing = [p for p in pmap_ids if str(p) not in found]
print(f"property_mappings: unknown pk(s): {missing}", file=sys.stderr)
raise SystemExit(1)
with transaction.atomic():
for slug, meta in clients.items():
if not isinstance(meta, dict):
print(f"clients[{slug!r}] must be an object", file=sys.stderr)
raise SystemExit(2)
name = (meta.get("name") or slug).strip()
cid = meta["client_id"]
csec = meta["client_secret"]
redir = meta["redirect_uri"].strip()
redirect = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT, url=redir)]
prov = OAuth2Provider.objects.filter(client_id=cid).first()
if prov is None:
prov = OAuth2Provider(client_id=cid)
prov.name = name
prov.client_secret = csec
prov.client_type = ClientTypes.CONFIDENTIAL
prov.authorization_flow = auth_flow
prov.invalidation_flow = inv_flow
prov.signing_key = signing_key
prov.redirect_uris = redirect
prov.save()
prov.property_mappings.set(mappings)
app = Application.objects.filter(slug=slug).first()
if app is None:
Application.objects.create(name=name, slug=slug, provider=prov)
else:
app.name = name
app.provider = prov
app.save()
print("worker: OAuth2 providers + applications upserted", flush=True)
main()

View File

@@ -1,579 +0,0 @@
---
# **.env** is shell `KEY=value` syntax (not YAML). Source it like **noble_velero** does.
- name: Stat repository .env for Authentik
ansible.builtin.stat:
path: "{{ noble_repo_root }}/.env"
register: noble_authentik_dotenv_stat
changed_when: false
- name: Load NOBLE_AUTHENTIK_SECRET_KEY from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_SECRET_KEY:-}"
register: noble_authentik_secret_key_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_secret_key | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_SECRET_KEY from .env
ansible.builtin.set_fact:
noble_authentik_secret_key: "{{ noble_authentik_secret_key_from_env.stdout | trim }}"
when:
- noble_authentik_secret_key_from_env is defined
- (noble_authentik_secret_key_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_POSTGRES_PASSWORD from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_POSTGRES_PASSWORD:-}"
register: noble_authentik_pg_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_postgresql_password | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_POSTGRES_PASSWORD from .env
ansible.builtin.set_fact:
noble_authentik_postgresql_password: "{{ noble_authentik_pg_from_env.stdout | trim }}"
when:
- noble_authentik_pg_from_env is defined
- (noble_authentik_pg_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_BOOTSTRAP_TOKEN from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_BOOTSTRAP_TOKEN:-}"
register: noble_authentik_bt_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_bootstrap_token | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_BOOTSTRAP_TOKEN from .env
ansible.builtin.set_fact:
noble_authentik_bootstrap_token: "{{ noble_authentik_bt_from_env.stdout | trim }}"
when:
- noble_authentik_bt_from_env is defined
- (noble_authentik_bt_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_BOOTSTRAP_EMAIL from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_BOOTSTRAP_EMAIL:-}"
register: noble_authentik_be_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_bootstrap_email | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_BOOTSTRAP_EMAIL from .env
ansible.builtin.set_fact:
noble_authentik_bootstrap_email: "{{ noble_authentik_be_from_env.stdout | trim }}"
when:
- noble_authentik_be_from_env is defined
- (noble_authentik_be_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_BOOTSTRAP_PASSWORD from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_BOOTSTRAP_PASSWORD:-}"
register: noble_authentik_bp_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_bootstrap_password | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_BOOTSTRAP_PASSWORD from .env
ansible.builtin.set_fact:
noble_authentik_bootstrap_password: "{{ noble_authentik_bp_from_env.stdout | trim }}"
when:
- noble_authentik_bp_from_env is defined
- (noble_authentik_bp_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_CLIENT_SECRET_ARGOCD from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_CLIENT_SECRET_ARGOCD:-}"
register: noble_authentik_cs_argo_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_client_secret_argocd | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_CLIENT_SECRET_ARGOCD from .env
ansible.builtin.set_fact:
noble_authentik_client_secret_argocd: "{{ noble_authentik_cs_argo_from_env.stdout | trim }}"
when:
- noble_authentik_cs_argo_from_env is defined
- (noble_authentik_cs_argo_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_CLIENT_SECRET_GRAFANA from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_CLIENT_SECRET_GRAFANA:-}"
register: noble_authentik_cs_graf_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_client_secret_grafana | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_CLIENT_SECRET_GRAFANA from .env
ansible.builtin.set_fact:
noble_authentik_client_secret_grafana: "{{ noble_authentik_cs_graf_from_env.stdout | trim }}"
when:
- noble_authentik_cs_graf_from_env is defined
- (noble_authentik_cs_graf_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_CLIENT_SECRET_HEADLAMP from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_CLIENT_SECRET_HEADLAMP:-}"
register: noble_authentik_cs_hl_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_client_secret_headlamp | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_CLIENT_SECRET_HEADLAMP from .env
ansible.builtin.set_fact:
noble_authentik_client_secret_headlamp: "{{ noble_authentik_cs_hl_from_env.stdout | trim }}"
when:
- noble_authentik_cs_hl_from_env is defined
- (noble_authentik_cs_hl_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_CLIENT_SECRET_OAUTH2_PROXY from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_CLIENT_SECRET_OAUTH2_PROXY:-}"
register: noble_authentik_cs_o2_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_client_secret_oauth2_proxy | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_CLIENT_SECRET_OAUTH2_PROXY from .env
ansible.builtin.set_fact:
noble_authentik_client_secret_oauth2_proxy: "{{ noble_authentik_cs_o2_from_env.stdout | trim }}"
when:
- noble_authentik_cs_o2_from_env is defined
- (noble_authentik_cs_o2_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_CLIENT_SECRET_OPEN_WEBUI from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_CLIENT_SECRET_OPEN_WEBUI:-}"
register: noble_authentik_cs_ow_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_client_secret_open_webui | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_CLIENT_SECRET_OPEN_WEBUI from .env
ansible.builtin.set_fact:
noble_authentik_client_secret_open_webui: "{{ noble_authentik_cs_ow_from_env.stdout | trim }}"
when:
- noble_authentik_cs_ow_from_env is defined
- (noble_authentik_cs_ow_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_OPEN_WEBUI_OPENAI_API_KEY from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_OPEN_WEBUI_OPENAI_API_KEY:-}"
register: noble_open_webui_openai_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_open_webui_openai_api_key | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_OPEN_WEBUI_OPENAI_API_KEY from .env
ansible.builtin.set_fact:
noble_open_webui_openai_api_key: "{{ noble_open_webui_openai_from_env.stdout | trim }}"
when:
- noble_open_webui_openai_from_env is defined
- (noble_open_webui_openai_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_OPEN_WEBUI_WEBUI_SECRET_KEY from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_OPEN_WEBUI_WEBUI_SECRET_KEY:-}"
register: noble_open_webui_webui_secret_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_open_webui_webui_secret_key | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_OPEN_WEBUI_WEBUI_SECRET_KEY from .env
ansible.builtin.set_fact:
noble_open_webui_webui_secret_key: "{{ noble_open_webui_webui_secret_from_env.stdout | trim }}"
when:
- noble_open_webui_webui_secret_from_env is defined
- (noble_open_webui_webui_secret_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_OAUTH2_PROXY_COOKIE_SECRET from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_OAUTH2_PROXY_COOKIE_SECRET:-}"
register: noble_authentik_cs_cookie_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_oauth2_proxy_cookie_secret | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_OAUTH2_PROXY_COOKIE_SECRET from .env
ansible.builtin.set_fact:
noble_authentik_oauth2_proxy_cookie_secret: "{{ noble_authentik_cs_cookie_from_env.stdout | trim }}"
when:
- noble_authentik_cs_cookie_from_env is defined
- (noble_authentik_cs_cookie_from_env.stdout | default('') | trim | length) > 0
no_log: true
# --- S3 media (reuse Velero endpoint + AWS keys from .env unless Authentik-specific vars are set) ---
- name: Load NOBLE_AUTHENTIK_MEDIA_S3_BUCKET from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_MEDIA_S3_BUCKET:-}"
register: noble_authentik_media_s3_bucket_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_media_s3_bucket | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_MEDIA_S3_BUCKET from .env
ansible.builtin.set_fact:
noble_authentik_media_s3_bucket: "{{ noble_authentik_media_s3_bucket_from_env.stdout | trim }}"
when:
- noble_authentik_media_s3_bucket_from_env is defined
- (noble_authentik_media_s3_bucket_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Resolve Authentik S3 endpoint from .env (Authentik-specific URL or Velero S3 URL)
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
if [ -n "${NOBLE_AUTHENTIK_S3_URL:-}" ]; then printf '%s' "${NOBLE_AUTHENTIK_S3_URL}"
elif [ -n "${NOBLE_VELERO_S3_URL:-}" ]; then printf '%s' "${NOBLE_VELERO_S3_URL}"
else printf ''
fi
register: noble_authentik_s3_endpoint_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_s3_endpoint | default('') | length == 0
changed_when: false
no_log: true
- name: Apply resolved Authentik S3 endpoint from .env
ansible.builtin.set_fact:
noble_authentik_s3_endpoint: "{{ noble_authentik_s3_endpoint_from_env.stdout | trim }}"
when:
- noble_authentik_s3_endpoint_from_env is defined
- (noble_authentik_s3_endpoint_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Resolve Authentik S3 access key from .env (override or Velero AWS key)
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
if [ -n "${NOBLE_AUTHENTIK_S3_ACCESS_KEY:-}" ]; then printf '%s' "${NOBLE_AUTHENTIK_S3_ACCESS_KEY}"
elif [ -n "${NOBLE_VELERO_AWS_ACCESS_KEY_ID:-}" ]; then printf '%s' "${NOBLE_VELERO_AWS_ACCESS_KEY_ID}"
else printf ''
fi
register: noble_authentik_s3_access_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_s3_access_key | default('') | length == 0
changed_when: false
no_log: true
- name: Apply resolved Authentik S3 access key from .env
ansible.builtin.set_fact:
noble_authentik_s3_access_key: "{{ noble_authentik_s3_access_from_env.stdout | trim }}"
when:
- noble_authentik_s3_access_from_env is defined
- (noble_authentik_s3_access_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Resolve Authentik S3 secret key from .env (override or Velero AWS secret)
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
if [ -n "${NOBLE_AUTHENTIK_S3_SECRET_KEY:-}" ]; then printf '%s' "${NOBLE_AUTHENTIK_S3_SECRET_KEY}"
elif [ -n "${NOBLE_VELERO_AWS_SECRET_ACCESS_KEY:-}" ]; then printf '%s' "${NOBLE_VELERO_AWS_SECRET_ACCESS_KEY}"
else printf ''
fi
register: noble_authentik_s3_secret_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_s3_secret_key | default('') | length == 0
changed_when: false
no_log: true
- name: Apply resolved Authentik S3 secret key from .env
ansible.builtin.set_fact:
noble_authentik_s3_secret_key: "{{ noble_authentik_s3_secret_from_env.stdout | trim }}"
when:
- noble_authentik_s3_secret_from_env is defined
- (noble_authentik_s3_secret_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_S3_REGION from .env when set
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_S3_REGION:-}"
register: noble_authentik_s3_region_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_S3_REGION from .env
ansible.builtin.set_fact:
noble_authentik_s3_region: "{{ noble_authentik_s3_region_from_env.stdout | trim }}"
when:
- noble_authentik_s3_region_from_env is defined
- (noble_authentik_s3_region_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_S3_ADDRESSING_STYLE from .env when set
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_S3_ADDRESSING_STYLE:-}"
register: noble_authentik_s3_addr_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_S3_ADDRESSING_STYLE from .env
ansible.builtin.set_fact:
noble_authentik_s3_addressing_style: "{{ noble_authentik_s3_addr_from_env.stdout | trim }}"
when:
- noble_authentik_s3_addr_from_env is defined
- (noble_authentik_s3_addr_from_env.stdout | default('') | trim | length) > 0
no_log: true
# --- Optional SMTP (AUTHENTIK_EMAIL__* via Helm global.env) ---
- name: Load NOBLE_AUTHENTIK_SMTP_HOST from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_SMTP_HOST:-}"
register: noble_authentik_smtp_host_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_smtp_host | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_SMTP_HOST from .env
ansible.builtin.set_fact:
noble_authentik_smtp_host: "{{ noble_authentik_smtp_host_from_env.stdout | trim }}"
when:
- noble_authentik_smtp_host_from_env is defined
- (noble_authentik_smtp_host_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_SMTP_FROM from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_SMTP_FROM:-}"
register: noble_authentik_smtp_from_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_smtp_from | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_SMTP_FROM from .env
ansible.builtin.set_fact:
noble_authentik_smtp_from: "{{ noble_authentik_smtp_from_from_env.stdout | trim }}"
when:
- noble_authentik_smtp_from_from_env is defined
- (noble_authentik_smtp_from_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_SMTP_USERNAME from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_SMTP_USERNAME:-}"
register: noble_authentik_smtp_username_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_smtp_username | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_SMTP_USERNAME from .env
ansible.builtin.set_fact:
noble_authentik_smtp_username: "{{ noble_authentik_smtp_username_from_env.stdout | trim }}"
when:
- noble_authentik_smtp_username_from_env is defined
- (noble_authentik_smtp_username_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_SMTP_PASSWORD from .env when unset
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_SMTP_PASSWORD:-}"
register: noble_authentik_smtp_password_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
- noble_authentik_smtp_password | default('') | length == 0
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_SMTP_PASSWORD from .env
ansible.builtin.set_fact:
noble_authentik_smtp_password: "{{ noble_authentik_smtp_password_from_env.stdout | trim }}"
when:
- noble_authentik_smtp_password_from_env is defined
- (noble_authentik_smtp_password_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_SMTP_PORT from .env
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_SMTP_PORT:-}"
register: noble_authentik_smtp_port_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_SMTP_PORT from .env
ansible.builtin.set_fact:
noble_authentik_smtp_port: "{{ noble_authentik_smtp_port_from_env.stdout | trim }}"
when:
- noble_authentik_smtp_port_from_env is defined
- (noble_authentik_smtp_port_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_SMTP_USE_TLS from .env
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_SMTP_USE_TLS:-}"
register: noble_authentik_smtp_use_tls_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_SMTP_USE_TLS from .env
ansible.builtin.set_fact:
noble_authentik_smtp_use_tls: "{{ noble_authentik_smtp_use_tls_from_env.stdout | trim }}"
when:
- noble_authentik_smtp_use_tls_from_env is defined
- (noble_authentik_smtp_use_tls_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_SMTP_USE_SSL from .env
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_SMTP_USE_SSL:-}"
register: noble_authentik_smtp_use_ssl_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_SMTP_USE_SSL from .env
ansible.builtin.set_fact:
noble_authentik_smtp_use_ssl: "{{ noble_authentik_smtp_use_ssl_from_env.stdout | trim }}"
when:
- noble_authentik_smtp_use_ssl_from_env is defined
- (noble_authentik_smtp_use_ssl_from_env.stdout | default('') | trim | length) > 0
no_log: true
- name: Load NOBLE_AUTHENTIK_SMTP_TIMEOUT from .env
ansible.builtin.shell: |
set -a
. "{{ noble_repo_root }}/.env"
set +a
printf '%s' "${NOBLE_AUTHENTIK_SMTP_TIMEOUT:-}"
register: noble_authentik_smtp_timeout_from_env
when:
- noble_authentik_dotenv_stat.stat.exists | default(false)
changed_when: false
no_log: true
- name: Apply NOBLE_AUTHENTIK_SMTP_TIMEOUT from .env
ansible.builtin.set_fact:
noble_authentik_smtp_timeout: "{{ noble_authentik_smtp_timeout_from_env.stdout | trim }}"
when:
- noble_authentik_smtp_timeout_from_env is defined
- (noble_authentik_smtp_timeout_from_env.stdout | default('') | trim | length) > 0
no_log: true

View File

@@ -1,760 +0,0 @@
---
- name: Authentik disabled (set noble_authentik_install=true and .env — see role README)
ansible.builtin.debug:
msg: "Skipping noble_authentik (noble_authentik_install is false)."
when: not (noble_authentik_install | default(false) | bool)
- name: Authentik + OIDC stack
when: noble_authentik_install | default(false) | bool
block:
- name: Include Authentik secrets from .env
ansible.builtin.include_tasks: from_env.yml
- name: Require Authentik secrets and bootstrap settings
ansible.builtin.assert:
that:
- noble_authentik_secret_key | default('') | length > 0
- noble_authentik_postgresql_password | default('') | length > 0
- noble_authentik_bootstrap_token | default('') | length > 0
- noble_authentik_bootstrap_email | default('') | length > 0
- noble_authentik_bootstrap_password | default('') | length > 0
- noble_authentik_client_secret_argocd | default('') | length > 0
- noble_authentik_client_secret_grafana | default('') | length > 0
- noble_authentik_client_secret_headlamp | default('') | length > 0
- noble_authentik_client_secret_oauth2_proxy | default('') | length > 0
- noble_authentik_client_secret_open_webui | default('') | length > 0
- noble_authentik_oauth2_proxy_cookie_secret | default('') | length > 0
- noble_open_webui_openai_api_key | default('') | length > 0
- noble_open_webui_webui_secret_key | default('') | length > 0
- noble_open_webui_public_host | default('') | trim | length > 0
fail_msg: >-
Authentik requires secrets in .env (see ansible/roles/noble_authentik/README.md) or matching -e extra-vars.
Includes Open WebUI: NOBLE_AUTHENTIK_CLIENT_SECRET_OPEN_WEBUI, NOBLE_OPEN_WEBUI_OPENAI_API_KEY,
NOBLE_OPEN_WEBUI_WEBUI_SECRET_KEY (see .env.sample). Set **noble_open_webui_public_host** (must match
**clusters/noble/apps/open-webui/values.yaml** ingress host; see README Pangolin section).
- name: Require Authentik S3 media settings (same endpoint/keys as Velero; dedicated bucket)
ansible.builtin.assert:
that:
- noble_authentik_media_s3_bucket | default('') | length > 0
- noble_authentik_s3_endpoint | default('') | length > 0
- noble_authentik_s3_access_key | default('') | length > 0
- noble_authentik_s3_secret_key | default('') | length > 0
fail_msg: >-
Set NOBLE_AUTHENTIK_MEDIA_S3_BUCKET (dedicated bucket for media, not the Velero backup bucket).
For S3 URL and keys, set NOBLE_AUTHENTIK_S3_URL / NOBLE_AUTHENTIK_S3_ACCESS_KEY / NOBLE_AUTHENTIK_S3_SECRET_KEY,
or reuse Velero's NOBLE_VELERO_S3_URL and NOBLE_VELERO_AWS_ACCESS_KEY_ID / NOBLE_VELERO_AWS_SECRET_ACCESS_KEY
in .env (see .env.sample and clusters/noble/bootstrap/velero/README.md).
- name: Require Authentik SMTP From when SMTP host is set
ansible.builtin.assert:
that:
- noble_authentik_smtp_from | default('') | trim | length > 0
fail_msg: >-
When NOBLE_AUTHENTIK_SMTP_HOST is set, set NOBLE_AUTHENTIK_SMTP_FROM (sender address).
See repository .env.sample and https://docs.goauthentik.io/install-config/configuration/#email-settings
when: noble_authentik_smtp_host | default('') | trim | length > 0
- name: Ensure Ansible temp dir for rendered Helm values
ansible.builtin.file:
path: "{{ noble_repo_root }}/ansible/.ansible-tmp"
state: directory
mode: "0700"
- name: Render Authentik Helm extra values (secrets)
ansible.builtin.template:
src: authentik-extra-values.yaml.j2
dest: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-extra-values.yaml"
mode: "0600"
no_log: true
- name: Apply Authentik and oauth2-proxy namespaces
ansible.builtin.command:
argv:
- kubectl
- apply
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/authentik/namespace.yaml"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/oauth2-proxy/namespace.yaml"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Ensure dir for rendered Authentik blueprints
ansible.builtin.file:
path: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-blueprints"
state: directory
mode: "0700"
when: noble_authentik_blueprints_enabled | default(false) | bool
- name: Assert noble Authentik blueprint variables (when blueprints enabled)
ansible.builtin.assert:
that:
- >-
((noble_authentik_blueprint_public_groups | default([])) | length
+ (noble_authentik_blueprint_extra_directory_groups | default([])) | length
+ (noble_authentik_blueprint_nikflix_groups | default([])) | length) > 0
- noble_authentik_blueprint_lab_operator_groups | default([]) | length > 0
- noble_authentik_blueprint_lab_flow_slug | default('') | trim | length > 0
- noble_authentik_blueprint_public_auth_flow_slug | default('') | trim | length > 0
- (noble_authentik_blueprint_lab_mfa_not_configured_action | default('configure') | trim | lower)
in ['skip', 'deny', 'configure']
- noble_authentik_blueprint_public_invitation_enrollment_flow_slug | default('') | trim | length > 0
- noble_authentik_blueprint_lab_invitation_enrollment_flow_slug | default('') | trim | length > 0
- noble_authentik_blueprint_public_invitation_user_group | default('') | trim | length > 0
- noble_authentik_blueprint_lab_invitee_group_name | default('') | trim | length > 0
- (noble_authentik_blueprint_public_invitation_user_type | default('internal') | trim | lower) in ['external', 'internal']
- (noble_authentik_blueprint_lab_invitation_user_type | default('internal') | trim | lower) in ['external', 'internal']
fail_msg: >-
When noble_authentik_blueprints_enabled is true, set at least one entry across
noble_authentik_blueprint_public_groups, noble_authentik_blueprint_extra_directory_groups,
and/or noble_authentik_blueprint_nikflix_groups,
plus noble_authentik_blueprint_lab_operator_groups (non-empty), noble_authentik_blueprint_lab_flow_slug,
noble_authentik_blueprint_public_auth_flow_slug, noble_authentik_blueprint_lab_mfa_not_configured_action
(skip, deny, or configure), invitation enrollment flow slugs, noble_authentik_blueprint_public_invitation_user_group,
noble_authentik_blueprint_lab_invitee_group_name, and invitation user_type values (external or internal).
See ansible/roles/noble_authentik/defaults/main.yml and README.
when: noble_authentik_blueprints_enabled | default(false) | bool
- name: Validate noble Authentik blueprint directory group entries (when blueprints enabled)
ansible.builtin.assert:
that:
- (item is string and (item | trim | length) > 0) or (item is mapping and (item.name | default('') | trim | length) > 0)
fail_msg: >-
Each noble_authentik_blueprint_*_groups entry must be a non-empty string or a dict with key **name** (string).
Invalid entry: {{ item }}
loop: "{{ (noble_authentik_blueprint_public_groups | default([])) + (noble_authentik_blueprint_extra_directory_groups | default([])) + (noble_authentik_blueprint_nikflix_groups | default([])) }}"
loop_control:
label: "{{ item if item is string else item.name }}"
when: noble_authentik_blueprints_enabled | default(false) | bool
- name: Render Authentik noble blueprint YAML files
ansible.builtin.template:
src: "blueprints/{{ item }}.j2"
dest: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-blueprints/{{ item }}"
mode: "0600"
loop:
- 10-noble-public-groups.yaml
- 20-noble-lab-operator-authentication-flow.yaml
- 21-noble-public-authentication-flow.yaml
- 22-noble-invitation-enrollment-flows.yaml
- 30-noble-brands-domain-split.yaml
when: noble_authentik_blueprints_enabled | default(false) | bool
- name: Apply Authentik noble blueprints ConfigMap (worker mounts under /blueprints/mounted/cm-*)
ansible.builtin.shell: |
set -euo pipefail
kubectl -n "{{ noble_authentik_namespace }}" create configmap "{{ noble_authentik_blueprints_configmap_name }}" \
--from-file="{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-blueprints" \
--dry-run=client -o yaml | kubectl apply -f -
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
when: noble_authentik_blueprints_enabled | default(false) | bool
changed_when: true
- name: Install Authentik (Helm)
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- authentik
- goauthentik/authentik
- --namespace
- authentik
- --version
- "{{ noble_authentik_chart_version }}"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/authentik/values.yaml"
- -f
- "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-extra-values.yaml"
- --force-conflicts
- --wait
- --timeout
- "{{ noble_authentik_helm_wait_timeout }}"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Verify authentik-worker mounts noble blueprints volume (Helm blueprints.configMaps)
ansible.builtin.shell: |
set -euo pipefail
WANT="blueprints-cm-{{ noble_authentik_blueprints_configmap_name }}"
D="$(kubectl get deploy -n "{{ noble_authentik_namespace }}" \
-l app.kubernetes.io/name=authentik,app.kubernetes.io/component=worker \
-o jsonpath='{.items[0].metadata.name}')"
MOUNTS="$(kubectl get deploy -n "{{ noble_authentik_namespace }}" "$D" \
-o jsonpath='{.spec.template.spec.containers[0].volumeMounts[*].name}')"
if ! echo "$MOUNTS" | tr ' ' '\n' | grep -Fxq "$WANT"; then
echo "Expected volumeMount ${WANT} on ${D}; got: ${MOUNTS}" >&2
exit 1
fi
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
when: noble_authentik_blueprints_enabled | default(false) | bool
changed_when: false
- name: Wait for authentik server rollout
ansible.builtin.command:
argv:
- kubectl
- rollout
- status
- deployment/authentik-server
- -n
- authentik
- --timeout=15m
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: false
- name: Wait for authentik worker rollout
ansible.builtin.command:
argv:
- kubectl
- rollout
- status
- deployment/authentik-worker
- -n
- authentik
- --timeout=15m
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: false
when: noble_authentik_configure_idp | default(true) | bool
- name: Wait until Authentik API accepts bootstrap token (worker finished bootstrap)
ansible.builtin.uri:
url: "{{ noble_authentik_api_base }}/core/applications/?page_size=1"
method: GET
headers:
Authorization: "Bearer {{ noble_authentik_bootstrap_token }}"
Accept: application/json
status_code: [200, 401, 403, 500, 502, 503]
timeout: 30
register: noble_authentik_api_bootstrap_ready
until: noble_authentik_api_bootstrap_ready.status == 200
retries: "{{ noble_authentik_bootstrap_api_wait_retries }}"
delay: "{{ noble_authentik_bootstrap_api_wait_delay }}"
when: noble_authentik_configure_idp | default(true) | bool
changed_when: false
no_log: true
- name: Render Authentik API client descriptor (JSON)
ansible.builtin.template:
src: authentik-clients.json.j2
dest: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-clients.json"
mode: "0600"
no_log: true
# Authentik 2026+ RBAC: bootstrap tokens often cannot **GET /flows/instances/** (403). Resolve UUIDs
# from the worker DB via **ak shell** when inventory PKs are unset (same slugs as configure_authentik.py).
- name: Resolve OAuth provider flow UUIDs from authentik-worker (DB; bypasses flows API RBAC)
ansible.builtin.shell: |
set -euo pipefail
NS="{{ noble_authentik_namespace }}"
POD="$(kubectl get pods -n "$NS" \
-l "app.kubernetes.io/name=authentik,app.kubernetes.io/component=worker" \
-o jsonpath='{.items[0].metadata.name}')"
REM=/tmp/ansible_resolve_oauth_flow_pks.py
kubectl cp "{{ role_path }}/files/resolve_oauth_flow_pks.py" "${NS}/${POD}:${REM}"
kubectl exec -n "$NS" "$POD" -- ak shell -c "exec(compile(open('${REM}').read(), 'ansible_resolve_oauth_flow_pks.py', 'exec'))"
kubectl exec -n "$NS" "$POD" -- rm -f "$REM" || true
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_authentik_oauth_flow_pk_resolve
changed_when: false
when:
- noble_authentik_configure_idp | default(true) | bool
- (noble_authentik_oauth_authorization_flow_pk | default('') | trim | length) == 0
- (noble_authentik_oauth_invalidation_flow_pk | default('') | trim | length) == 0
- name: Apply OAuth flow PKs from worker resolution for configure_authentik.py
ansible.builtin.set_fact:
noble_authentik_oauth_authorization_flow_pk: "{{ (noble_authentik_oauth_flow_pk_resolve.stdout_lines | select('match', '^[0-9a-fA-F-]{36}$') | list)[0] }}"
noble_authentik_oauth_invalidation_flow_pk: "{{ (noble_authentik_oauth_flow_pk_resolve.stdout_lines | select('match', '^[0-9a-fA-F-]{36}$') | list)[1] }}"
when:
- noble_authentik_oauth_flow_pk_resolve is defined
- not (noble_authentik_oauth_flow_pk_resolve.skipped | default(false))
- (noble_authentik_oauth_flow_pk_resolve.rc | default(-1)) == 0
- (noble_authentik_oauth_flow_pk_resolve.stdout_lines | default([]) | select('match', '^[0-9a-fA-F-]{36}$') | list | length) >= 2
# Bootstrap tokens often cannot list **/crypto/certificatekeypairs/** (403).
- name: Resolve OAuth signing key UUID from authentik-worker (DB; bypasses crypto API RBAC)
ansible.builtin.shell: |
set -euo pipefail
NS="{{ noble_authentik_namespace }}"
POD="$(kubectl get pods -n "$NS" \
-l "app.kubernetes.io/name=authentik,app.kubernetes.io/component=worker" \
-o jsonpath='{.items[0].metadata.name}')"
REM=/tmp/ansible_resolve_oauth_signing_key_pk.py
kubectl cp "{{ role_path }}/files/resolve_oauth_signing_key_pk.py" "${NS}/${POD}:${REM}"
kubectl exec -n "$NS" "$POD" -- ak shell -c "exec(compile(open('${REM}').read(), 'ansible_resolve_oauth_signing_key_pk.py', 'exec'))"
kubectl exec -n "$NS" "$POD" -- rm -f "$REM" || true
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_authentik_oauth_signing_key_resolve
changed_when: false
when:
- noble_authentik_configure_idp | default(true) | bool
- (noble_authentik_oauth_signing_key_pk | default('') | trim | length) == 0
- name: Apply OAuth signing key PK from worker resolution for configure_authentik.py
ansible.builtin.set_fact:
noble_authentik_oauth_signing_key_pk: "{{ (noble_authentik_oauth_signing_key_resolve.stdout_lines | select('match', '^[0-9a-fA-F-]{36}$') | list)[0] }}"
when:
- noble_authentik_oauth_signing_key_resolve is defined
- not (noble_authentik_oauth_signing_key_resolve.skipped | default(false))
- (noble_authentik_oauth_signing_key_resolve.rc | default(-1)) == 0
- (noble_authentik_oauth_signing_key_resolve.stdout_lines | default([]) | select('match', '^[0-9a-fA-F-]{36}$') | list | length) >= 1
# Bootstrap tokens often cannot list **/propertymappings/provider/scope/** (403).
- name: Resolve OAuth scope mapping UUIDs from authentik-worker (DB; bypasses propertymappings API RBAC)
ansible.builtin.shell: |
set -euo pipefail
NS="{{ noble_authentik_namespace }}"
POD="$(kubectl get pods -n "$NS" \
-l "app.kubernetes.io/name=authentik,app.kubernetes.io/component=worker" \
-o jsonpath='{.items[0].metadata.name}')"
REM=/tmp/ansible_resolve_oauth_scope_mapping_pks.py
kubectl cp "{{ role_path }}/files/resolve_oauth_scope_mapping_pks.py" "${NS}/${POD}:${REM}"
kubectl exec -n "$NS" "$POD" -- ak shell -c "exec(compile(open('${REM}').read(), 'ansible_resolve_oauth_scope_mapping_pks.py', 'exec'))"
kubectl exec -n "$NS" "$POD" -- rm -f "$REM" || true
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_authentik_oauth_scope_mapping_resolve
changed_when: false
when:
- noble_authentik_configure_idp | default(true) | bool
- (noble_authentik_oauth_scope_mapping_pks | default('') | trim | length) == 0
- name: Apply OAuth scope mapping PKs from worker resolution for configure_authentik.py
ansible.builtin.set_fact:
noble_authentik_oauth_scope_mapping_pks: "{{ (noble_authentik_oauth_scope_mapping_resolve.stdout_lines | default([]) | select('match', '^[0-9a-fA-F-]{36}$') | list) | join(',') }}"
when:
- noble_authentik_oauth_scope_mapping_resolve is defined
- not (noble_authentik_oauth_scope_mapping_resolve.skipped | default(false))
- (noble_authentik_oauth_scope_mapping_resolve.rc | default(-1)) == 0
- (noble_authentik_oauth_scope_mapping_resolve.stdout_lines | default([]) | select('match', '^[0-9a-fA-F-]{36}$') | list | length) >= 4
# Bootstrap tokens often cannot **GET /core/groups/** (403). Worker **get_or_create** ensures groups exist.
- name: Resolve noble-admins / noble-editors group UUIDs from authentik-worker (DB; bypasses groups API RBAC)
ansible.builtin.shell: |
set -euo pipefail
NS="{{ noble_authentik_namespace }}"
POD="$(kubectl get pods -n "$NS" \
-l "app.kubernetes.io/name=authentik,app.kubernetes.io/component=worker" \
-o jsonpath='{.items[0].metadata.name}')"
REM=/tmp/ansible_resolve_noble_group_pks.py
kubectl cp "{{ role_path }}/files/resolve_noble_group_pks.py" "${NS}/${POD}:${REM}"
kubectl exec -n "$NS" "$POD" -- ak shell -c "exec(compile(open('${REM}').read(), 'ansible_resolve_noble_group_pks.py', 'exec'))"
kubectl exec -n "$NS" "$POD" -- rm -f "$REM" || true
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_authentik_noble_group_resolve
changed_when: false
when:
- noble_authentik_configure_idp | default(true) | bool
- (noble_authentik_group_pk_noble_admins | default('') | trim | length) == 0
- (noble_authentik_group_pk_noble_editors | default('') | trim | length) == 0
- name: Apply noble group PKs from worker resolution for configure_authentik.py
ansible.builtin.set_fact:
noble_authentik_group_pk_noble_admins: "{{ (noble_authentik_noble_group_resolve.stdout_lines | default([]) | select('match', '^[0-9a-fA-F-]{36}$') | list)[0] }}"
noble_authentik_group_pk_noble_editors: "{{ (noble_authentik_noble_group_resolve.stdout_lines | default([]) | select('match', '^[0-9a-fA-F-]{36}$') | list)[1] }}"
when:
- noble_authentik_noble_group_resolve is defined
- not (noble_authentik_noble_group_resolve.skipped | default(false))
- (noble_authentik_noble_group_resolve.rc | default(-1)) == 0
- (noble_authentik_noble_group_resolve.stdout_lines | default([]) | select('match', '^[0-9a-fA-F-]{36}$') | list | length) >= 2
- name: Render Authentik worker admin-access spec (JSON for ak shell)
ansible.builtin.template:
src: authentik-worker-admin-access-spec.json.j2
dest: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-worker-admin-access-spec.json"
mode: "0600"
no_log: true
when:
- noble_authentik_configure_idp | default(true) | bool
- noble_authentik_ensure_admin_ui_access | default(true) | bool
- name: Ensure authentik Admins + superuser group flag (worker ORM; restores admin UI access)
ansible.builtin.shell: |
set -euo pipefail
NS="{{ noble_authentik_namespace }}"
POD="$(kubectl get pods -n "$NS" \
-l "app.kubernetes.io/name=authentik,app.kubernetes.io/component=worker" \
-o jsonpath='{.items[0].metadata.name}')"
SPEC=/tmp/ansible_authentik_worker_admin_access_spec.json
REM=/tmp/ansible_worker_ensure_authentik_admin_access.py
kubectl cp "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-worker-admin-access-spec.json" "${NS}/${POD}:${SPEC}"
kubectl cp "{{ role_path }}/files/worker_ensure_authentik_admin_access.py" "${NS}/${POD}:${REM}"
kubectl exec -n "$NS" "$POD" -- env AUTHENTIK_WORKER_ADMIN_ACCESS_SPEC="${SPEC}" ak shell -c "exec(compile(open('${REM}').read(), 'ansible_worker_ensure_authentik_admin_access.py', 'exec'))"
kubectl exec -n "$NS" "$POD" -- rm -f "$SPEC" "$REM" || true
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_authentik_worker_admin_access
changed_when: >-
"worker:" in (noble_authentik_worker_admin_access.stdout | default(""))
and "authentik Admins" in (noble_authentik_worker_admin_access.stdout | default(""))
failed_when: >-
(noble_authentik_worker_admin_access.rc | default(-1)) != 0
or (
"worker:" not in (noble_authentik_worker_admin_access.stdout | default(""))
or "authentik Admins" not in (noble_authentik_worker_admin_access.stdout | default(""))
)
when:
- noble_authentik_configure_idp | default(true) | bool
- noble_authentik_ensure_admin_ui_access | default(true) | bool
- name: Require OAuth PKs for worker OIDC upsert (ORM path)
ansible.builtin.assert:
that:
- (noble_authentik_oauth_authorization_flow_pk | default('') | trim | length) > 0
- (noble_authentik_oauth_invalidation_flow_pk | default('') | trim | length) > 0
- (noble_authentik_oauth_signing_key_pk | default('') | trim | length) > 0
- (noble_authentik_oauth_scope_mapping_pks | default('') | trim | length) > 0
fail_msg: >-
Worker OIDC provisioning needs flow UUIDs, signing key UUID, and comma-separated scope-mapping UUIDs.
Ensure worker resolution tasks ran, or set noble_authentik_oauth_* inventory vars (see role README).
when:
- noble_authentik_configure_idp | default(true) | bool
- (noble_authentik_oidc_provision_via | default('worker') | lower) == 'worker'
- name: Render Authentik worker OIDC spec (JSON for ak shell upsert)
ansible.builtin.template:
src: authentik-worker-oidc-spec.json.j2
dest: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-worker-oidc-spec.json"
mode: "0600"
no_log: true
when:
- noble_authentik_configure_idp | default(true) | bool
- (noble_authentik_oidc_provision_via | default('worker') | lower) == 'worker'
- name: Upsert OAuth2 providers + applications in authentik-worker (ORM; bypasses provider REST RBAC)
ansible.builtin.shell: |
set -euo pipefail
NS="{{ noble_authentik_namespace }}"
POD="$(kubectl get pods -n "$NS" \
-l "app.kubernetes.io/name=authentik,app.kubernetes.io/component=worker" \
-o jsonpath='{.items[0].metadata.name}')"
SPEC=/tmp/ansible_authentik_worker_oidc_spec.json
REM=/tmp/ansible_worker_upsert_oauth_oidc.py
kubectl cp "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-worker-oidc-spec.json" "${NS}/${POD}:${SPEC}"
kubectl cp "{{ role_path }}/files/worker_upsert_oauth_oidc.py" "${NS}/${POD}:${REM}"
kubectl exec -n "$NS" "$POD" -- env AUTHENTIK_WORKER_OIDC_SPEC="${SPEC}" ak shell -c "exec(compile(open('${REM}').read(), 'ansible_worker_upsert_oauth_oidc.py', 'exec'))"
kubectl exec -n "$NS" "$POD" -- rm -f "$SPEC" "$REM" || true
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_authentik_worker_oidc_upsert
changed_when: >-
"worker: OAuth2 providers + applications upserted"
in (noble_authentik_worker_oidc_upsert.stdout | default(""))
failed_when: >-
(noble_authentik_worker_oidc_upsert.rc | default(-1)) != 0
or (
"worker: OAuth2 providers + applications upserted"
not in (noble_authentik_worker_oidc_upsert.stdout | default(""))
)
when:
- noble_authentik_configure_idp | default(true) | bool
- (noble_authentik_oidc_provision_via | default('worker') | lower) == 'worker'
- name: Require noble group PKs for worker bootstrap group membership
ansible.builtin.assert:
that:
- (noble_authentik_group_pk_noble_admins | default('') | trim | length) > 0
- (noble_authentik_group_pk_noble_editors | default('') | trim | length) > 0
fail_msg: >-
Worker bootstrap group membership needs noble-admins / noble-editors UUIDs (worker DB resolve or inventory).
See noble_authentik_group_pk_noble_* in defaults/README.
when:
- noble_authentik_configure_idp | default(true) | bool
- (noble_authentik_oidc_provision_via | default('worker') | lower) == 'worker'
- (noble_authentik_bootstrap_email | default('') | trim | length) > 0
- name: Render Authentik worker user-groups spec (JSON for ak shell)
ansible.builtin.template:
src: authentik-worker-user-groups-spec.json.j2
dest: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-worker-user-groups-spec.json"
mode: "0600"
no_log: true
when:
- noble_authentik_configure_idp | default(true) | bool
- (noble_authentik_oidc_provision_via | default('worker') | lower) == 'worker'
- (noble_authentik_bootstrap_email | default('') | trim | length) > 0
- name: Add bootstrap user to noble groups in authentik-worker (ORM; bypasses users API RBAC)
ansible.builtin.shell: |
set -euo pipefail
NS="{{ noble_authentik_namespace }}"
POD="$(kubectl get pods -n "$NS" \
-l "app.kubernetes.io/name=authentik,app.kubernetes.io/component=worker" \
-o jsonpath='{.items[0].metadata.name}')"
SPEC=/tmp/ansible_authentik_worker_user_groups_spec.json
REM=/tmp/ansible_worker_add_bootstrap_user_groups.py
kubectl cp "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-worker-user-groups-spec.json" "${NS}/${POD}:${SPEC}"
kubectl cp "{{ role_path }}/files/worker_add_bootstrap_user_groups.py" "${NS}/${POD}:${REM}"
kubectl exec -n "$NS" "$POD" -- env AUTHENTIK_WORKER_USER_GROUPS_SPEC="${SPEC}" ak shell -c "exec(compile(open('${REM}').read(), 'ansible_worker_add_bootstrap_user_groups.py', 'exec'))"
kubectl exec -n "$NS" "$POD" -- rm -f "$SPEC" "$REM" || true
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_authentik_worker_user_groups
changed_when: >-
"worker: bootstrap user group membership updated"
in (noble_authentik_worker_user_groups.stdout | default(""))
failed_when: (noble_authentik_worker_user_groups.rc | default(-1)) != 0
when:
- noble_authentik_configure_idp | default(true) | bool
- (noble_authentik_oidc_provision_via | default('worker') | lower) == 'worker'
- (noble_authentik_bootstrap_email | default('') | trim | length) > 0
- name: Configure Authentik OAuth2/OIDC providers (API)
ansible.builtin.command:
argv:
- python3
- "{{ role_path }}/files/configure_authentik.py"
environment:
AUTHENTIK_API_BASE: "{{ noble_authentik_api_base }}"
AUTHENTIK_TOKEN: "{{ noble_authentik_bootstrap_token }}"
BOOTSTRAP_EMAIL: "{{ noble_authentik_bootstrap_email }}"
CLIENT_JSON: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-clients.json"
AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK: "{{ noble_authentik_oauth_authorization_flow_pk | default('') }}"
AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK: "{{ noble_authentik_oauth_invalidation_flow_pk | default('') }}"
AUTHENTIK_OAUTH_SIGNING_KEY_PK: "{{ noble_authentik_oauth_signing_key_pk | default('') }}"
AUTHENTIK_OAUTH_SCOPE_MAPPING_PKS: "{{ noble_authentik_oauth_scope_mapping_pks | default('') }}"
AUTHENTIK_NOBLE_ADMINS_GROUP_PK: "{{ noble_authentik_group_pk_noble_admins | default('') }}"
AUTHENTIK_NOBLE_EDITORS_GROUP_PK: "{{ noble_authentik_group_pk_noble_editors | default('') }}"
AUTHENTIK_SKIP_OIDC_REST: "{{ '1' if (noble_authentik_oidc_provision_via | default('worker') | lower) == 'worker' else '' }}"
AUTHENTIK_SKIP_USER_GROUP_REST: "{{ '1' if ((noble_authentik_oidc_provision_via | default('worker') | lower) == 'worker' and (noble_authentik_bootstrap_email | default('') | trim | length) > 0) else '' }}"
when: noble_authentik_configure_idp | default(true) | bool
changed_when: true
- name: Create argocd namespace Secret for OIDC client (Argo CD $authentik-oidc:clientSecret)
ansible.builtin.shell: |
set -euo pipefail
kubectl -n argocd create secret generic authentik-oidc \
--from-literal=clientSecret="${ARGOCD_OIDC_SECRET}" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n argocd label secret authentik-oidc app.kubernetes.io/part-of=argocd --overwrite
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
ARGOCD_OIDC_SECRET: "{{ noble_authentik_client_secret_argocd }}"
no_log: true
changed_when: true
- name: Create Grafana OIDC client secret (GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET)
ansible.builtin.shell: |
set -euo pipefail
kubectl -n monitoring create secret generic authentik-grafana-oauth \
--from-literal=GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET="${GRAFANA_OIDC_SECRET}" \
--dry-run=client -o yaml | kubectl apply -f -
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
GRAFANA_OIDC_SECRET: "{{ noble_authentik_client_secret_grafana }}"
no_log: true
changed_when: true
- name: Create Headlamp OIDC env secret (OIDC_* env vars)
ansible.builtin.shell: |
set -euo pipefail
kubectl -n headlamp create secret generic headlamp-oidc \
--from-literal=OIDC_CLIENT_ID="{{ noble_authentik_client_id_headlamp }}" \
--from-literal=OIDC_CLIENT_SECRET="${HEADLAMP_OIDC_SECRET}" \
--from-literal=OIDC_ISSUER_URL="{{ noble_authentik_public_url }}/application/o/headlamp/" \
--from-literal=OIDC_SCOPES="{{ noble_authentik_headlamp_oidc_scopes }}" \
--from-literal=OIDC_CALLBACK_URL="https://headlamp.apps.noble.lab.pcenicni.dev/oidc-callback" \
--from-literal=OIDC_USE_PKCE="{{ 'true' if (noble_authentik_headlamp_oidc_use_pkce | bool) else 'false' }}" \
--dry-run=client -o yaml | kubectl apply -f -
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
HEADLAMP_OIDC_SECRET: "{{ noble_authentik_client_secret_headlamp }}"
no_log: true
changed_when: true
- name: Ensure open-webui namespace exists (Secret before Argo first sync)
ansible.builtin.shell: |
set -euo pipefail
kubectl create namespace open-webui --dry-run=client -o yaml | kubectl apply -f -
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
when: noble_authentik_configure_idp | default(true) | bool
changed_when: true
- name: Create Open WebUI secrets (OpenAI + WEBUI + OIDC client secret)
ansible.builtin.shell: |
set -euo pipefail
kubectl -n open-webui create secret generic open-webui-secrets \
--from-literal=OPENAI_API_KEY="${OPENAI_API_KEY}" \
--from-literal=WEBUI_SECRET_KEY="${WEBUI_SECRET_KEY}" \
--from-literal=OAUTH_CLIENT_SECRET="${OAUTH_CLIENT_SECRET}" \
--dry-run=client -o yaml | kubectl apply -f -
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
OPENAI_API_KEY: "{{ noble_open_webui_openai_api_key }}"
WEBUI_SECRET_KEY: "{{ noble_open_webui_webui_secret_key }}"
OAUTH_CLIENT_SECRET: "{{ noble_authentik_client_secret_open_webui }}"
no_log: true
when: noble_authentik_configure_idp | default(true) | bool
changed_when: true
- name: Create oauth2-proxy credentials Secret (OIDC to Authentik; not BasicAuth)
ansible.builtin.shell: |
set -euo pipefail
kubectl -n oauth2-proxy create secret generic oauth2-proxy-credentials \
--from-literal=client-id="{{ noble_authentik_client_id_oauth2_proxy }}" \
--from-literal=client-secret="${O2_CLIENT_SECRET}" \
--from-literal=cookie-secret="${O2_COOKIE_SECRET}" \
--dry-run=client -o yaml | kubectl apply -f -
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
O2_CLIENT_SECRET: "{{ noble_authentik_client_secret_oauth2_proxy }}"
O2_COOKIE_SECRET: "{{ noble_authentik_oauth2_proxy_cookie_secret }}"
no_log: true
changed_when: true
- name: Install oauth2-proxy (Helm) — OIDC provider Authentik
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- oauth2-proxy
- oauth2-proxy/oauth2-proxy
- --namespace
- oauth2-proxy
- --version
- "{{ noble_authentik_oauth2_proxy_chart_version }}"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/oauth2-proxy/values.yaml"
- --force-conflicts
- --wait
- --timeout
- "{{ noble_authentik_oauth2_proxy_helm_wait_timeout }}"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Apply Traefik ForwardAuth Middleware (references oauth2-proxy OIDC session)
ansible.builtin.command:
argv:
- kubectl
- apply
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/oauth2-proxy/middleware-forwardauth.yaml"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Helm upgrade Argo CD with Authentik OIDC values
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- argocd
- argo/argo-cd
- --namespace
- argocd
- --version
- "{{ noble_authentik_argocd_chart_version }}"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/argocd/values.yaml"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/argocd/values-authentik-oidc.yaml"
- --force-conflicts
- --wait
- --timeout
- 15m
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Helm upgrade kube-prometheus-stack (Grafana OIDC + ForwardAuth on Prom/AM)
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- kube-prometheus
- prometheus-community/kube-prometheus-stack
- -n
- monitoring
- --version
- "{{ noble_authentik_kube_prometheus_chart_version }}"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/kube-prometheus-stack/values.yaml"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/kube-prometheus-stack/values-authentik-oidc.yaml"
- --force-conflicts
- --wait
- --timeout
- "{{ noble_authentik_kube_prometheus_helm_wait_timeout }}"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Helm upgrade Headlamp with Authentik OIDC values
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- headlamp
- headlamp/headlamp
- --version
- "{{ noble_authentik_headlamp_chart_version }}"
- -n
- headlamp
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/headlamp/values.yaml"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/headlamp/values-authentik-oidc.yaml"
- --set=config.oidc.usePKCE={{ 'true' if (noble_authentik_headlamp_oidc_use_pkce | bool) else 'false' }}
- --force-conflicts
- --wait
- --timeout
- 10m
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Apply Headlamp static manifests (metrics RBAC + OIDC group binding)
ansible.builtin.command:
argv:
- kubectl
- apply
- -k
- "{{ noble_repo_root }}/clusters/noble/bootstrap/headlamp"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Helm upgrade Longhorn with ForwardAuth (oauth2-proxy OIDC)
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- longhorn
- longhorn/longhorn
- -n
- longhorn-system
- --version
- "{{ noble_authentik_longhorn_chart_version }}"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/longhorn/values.yaml"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/longhorn/values-authentik-forwardauth.yaml"
- --force-conflicts
- --wait
- --timeout
- "{{ noble_helm_longhorn_wait_timeout | default('20m') }}"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_authentik_longhorn_helm
retries: "{{ noble_helm_longhorn_retries | default(8) | int }}"
delay: "{{ noble_helm_longhorn_retry_delay | default(25) | int }}"
until: noble_authentik_longhorn_helm.rc == 0
changed_when: true

View File

@@ -1,32 +0,0 @@
{
"argocd": {
"name": "Argo CD",
"client_id": {{ noble_authentik_client_id_argocd | to_json }},
"client_secret": {{ noble_authentik_client_secret_argocd | to_json }},
"redirect_uri": "https://argo.apps.noble.lab.pcenicni.dev/auth/callback"
},
"grafana": {
"name": "Grafana",
"client_id": {{ noble_authentik_client_id_grafana | to_json }},
"client_secret": {{ noble_authentik_client_secret_grafana | to_json }},
"redirect_uri": "https://grafana.apps.noble.lab.pcenicni.dev/login/generic_oauth"
},
"headlamp": {
"name": "Headlamp",
"client_id": {{ noble_authentik_client_id_headlamp | to_json }},
"client_secret": {{ noble_authentik_client_secret_headlamp | to_json }},
"redirect_uri": "https://headlamp.apps.noble.lab.pcenicni.dev/oidc-callback"
},
"oauth2-proxy": {
"name": "oauth2-proxy (ForwardAuth)",
"client_id": {{ noble_authentik_client_id_oauth2_proxy | to_json }},
"client_secret": {{ noble_authentik_client_secret_oauth2_proxy | to_json }},
"redirect_uri": "https://{{ noble_authentik_oauth2_proxy_host }}/oauth2/callback"
},
"open-webui": {
"name": "Open WebUI",
"client_id": {{ noble_authentik_client_id_open_webui | to_json }},
"client_secret": {{ noble_authentik_client_secret_open_webui | to_json }},
"redirect_uri": "https://{{ noble_open_webui_public_host | trim }}/oauth/oidc/callback"
}
}

View File

@@ -1,71 +0,0 @@
---
authentik:
secret_key: "{{ noble_authentik_secret_key }}"
postgresql:
password: "{{ noble_authentik_postgresql_password }}"
global:
env:
- name: AUTHENTIK_BOOTSTRAP_TOKEN
value: "{{ noble_authentik_bootstrap_token }}"
- name: AUTHENTIK_BOOTSTRAP_EMAIL
value: "{{ noble_authentik_bootstrap_email }}"
- name: AUTHENTIK_BOOTSTRAP_PASSWORD
value: "{{ noble_authentik_bootstrap_password }}"
- name: AUTHENTIK_STORAGE__BACKEND
value: "s3"
- name: AUTHENTIK_STORAGE__S3__BUCKET_NAME
value: "{{ noble_authentik_media_s3_bucket }}"
- name: AUTHENTIK_STORAGE__S3__ENDPOINT
value: "{{ noble_authentik_s3_endpoint }}"
- name: AUTHENTIK_STORAGE__S3__ACCESS_KEY
value: "{{ noble_authentik_s3_access_key }}"
- name: AUTHENTIK_STORAGE__S3__SECRET_KEY
value: "{{ noble_authentik_s3_secret_key }}"
- name: AUTHENTIK_STORAGE__S3__REGION
value: "{{ noble_authentik_s3_region }}"
- name: AUTHENTIK_STORAGE__S3__ADDRESSING_STYLE
value: "{{ noble_authentik_s3_addressing_style }}"
{% if noble_authentik_smtp_host | default('') | trim | length > 0 %}
- name: AUTHENTIK_EMAIL__HOST
value: {{ noble_authentik_smtp_host | trim | to_json }}
- name: AUTHENTIK_EMAIL__PORT
value: {{ (noble_authentik_smtp_port | default('587') | string) | to_json }}
- name: AUTHENTIK_EMAIL__USERNAME
value: {{ noble_authentik_smtp_username | default('') | to_json }}
- name: AUTHENTIK_EMAIL__PASSWORD
value: {{ noble_authentik_smtp_password | default('') | to_json }}
- name: AUTHENTIK_EMAIL__USE_TLS
value: {{ (noble_authentik_smtp_use_tls | default('true') | string) | to_json }}
- name: AUTHENTIK_EMAIL__USE_SSL
value: {{ (noble_authentik_smtp_use_ssl | default('false') | string) | to_json }}
- name: AUTHENTIK_EMAIL__TIMEOUT
value: {{ (noble_authentik_smtp_timeout | default('30') | string) | to_json }}
- name: AUTHENTIK_EMAIL__FROM
value: {{ noble_authentik_smtp_from | trim | to_json }}
{% endif %}
postgresql:
auth:
password: "{{ noble_authentik_postgresql_password }}"
{% if noble_authentik_ingress_extra_hosts | default([]) | length > 0 %}
# Extra SANs on the same Authentik server (e.g. public FQDN behind Pangolin → Newt → Traefik). Helms last -f
# replaces **server.ingress.hosts** / **tls[0].hosts**; primary lab host stays first.
server:
ingress:
hosts:
- {{ noble_authentik_host }}
{% for h in noble_authentik_ingress_extra_hosts %}
- {{ h }}
{% endfor %}
tls:
- secretName: authentik-apps-noble-tls
hosts:
- {{ noble_authentik_host }}
{% for h in noble_authentik_ingress_extra_hosts %}
- {{ h }}
{% endfor %}
{% endif %}
{% if noble_authentik_blueprints_enabled | default(false) | bool %}
blueprints:
configMaps:
- {{ noble_authentik_blueprints_configmap_name }}
{% endif %}

View File

@@ -1,3 +0,0 @@
{
"bootstrap_email": {{ noble_authentik_bootstrap_email | default('') | trim | to_json }}
}

View File

@@ -1,38 +0,0 @@
{
"authorization_flow": {{ noble_authentik_oauth_authorization_flow_pk | trim | to_json }},
"invalidation_flow": {{ noble_authentik_oauth_invalidation_flow_pk | trim | to_json }},
"signing_key": {{ noble_authentik_oauth_signing_key_pk | trim | to_json }},
"property_mappings": {{ (noble_authentik_oauth_scope_mapping_pks | default('')).split(',') | map('trim') | reject('equalto', '') | list | to_json }},
"clients": {
"argocd": {
"name": "Argo CD",
"client_id": {{ noble_authentik_client_id_argocd | to_json }},
"client_secret": {{ noble_authentik_client_secret_argocd | to_json }},
"redirect_uri": "https://argo.apps.noble.lab.pcenicni.dev/auth/callback"
},
"grafana": {
"name": "Grafana",
"client_id": {{ noble_authentik_client_id_grafana | to_json }},
"client_secret": {{ noble_authentik_client_secret_grafana | to_json }},
"redirect_uri": "https://grafana.apps.noble.lab.pcenicni.dev/login/generic_oauth"
},
"headlamp": {
"name": "Headlamp",
"client_id": {{ noble_authentik_client_id_headlamp | to_json }},
"client_secret": {{ noble_authentik_client_secret_headlamp | to_json }},
"redirect_uri": "https://headlamp.apps.noble.lab.pcenicni.dev/oidc-callback"
},
"oauth2-proxy": {
"name": "oauth2-proxy (ForwardAuth)",
"client_id": {{ noble_authentik_client_id_oauth2_proxy | to_json }},
"client_secret": {{ noble_authentik_client_secret_oauth2_proxy | to_json }},
"redirect_uri": "https://{{ noble_authentik_oauth2_proxy_host }}/oauth2/callback"
},
"open-webui": {
"name": "Open WebUI",
"client_id": {{ noble_authentik_client_id_open_webui | to_json }},
"client_secret": {{ noble_authentik_client_secret_open_webui | to_json }},
"redirect_uri": "https://{{ noble_open_webui_public_host | trim }}/oauth/oidc/callback"
}
}
}

View File

@@ -1,7 +0,0 @@
{
"email": {{ noble_authentik_bootstrap_email | trim | to_json }},
"group_pks": [
{{ noble_authentik_group_pk_noble_admins | trim | to_json }},
{{ noble_authentik_group_pk_noble_editors | trim | to_json }}
]
}

View File

@@ -1,38 +0,0 @@
# Noble — directory groups (blueprint). Merges (in order): **`noble_authentik_blueprint_public_groups`** (optional),
# **`noble_authentik_blueprint_extra_directory_groups`**, **`noble_authentik_blueprint_nikflix_groups`** (see role **defaults** / README).
# Each entry: a string (**name** only), or a mapping with **name** and optional **is_superuser**, **attributes**, **parents**.
# **parents** must reference groups that already exist: list those entries *before* children in the merged list, or rely on built-in groups.
version: 1
metadata:
name: noble-directory-groups
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
{% set _all = (noble_authentik_blueprint_public_groups | default([]))
+ (noble_authentik_blueprint_extra_directory_groups | default([]))
+ (noble_authentik_blueprint_nikflix_groups | default([])) %}
{% for g in _all %}
{% set gn = (g.name if (g is mapping) else g) | trim %}
- model: authentik_core.group
identifiers:
name: {{ gn | to_json }}
{% if g is mapping and (
(g.get('is_superuser') | default(false) | bool)
or ((g.get('attributes') or {}) | length > 0)
or ((g.get('parents') or []) | length > 0)
) %}
attrs:
{% if g.get('is_superuser') | default(false) | bool %}
is_superuser: true
{% endif %}
{% if (g.get('attributes') or {}) | length > 0 %}
attributes: {{ g.attributes | to_json }}
{% endif %}
{% if (g.get('parents') or []) | length > 0 %}
parents:
{% for p in g.parents %}
- !Find [authentik_core.group, [name, {{ p | trim | to_json }}]]
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}

View File

@@ -1,148 +0,0 @@
# Noble — **lab** hostname authentication: operator-only, stricter password checks, MFA required (no WebAuthn-PWL skip).
version: 1
metadata:
name: noble-lab-operator-authentication
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_blueprints.metaapplyblueprint
attrs:
identifiers:
name: Default - Password change flow
required: false
- model: authentik_stages_password.passwordstage
id: noble-lab-password-stage
identifiers:
name: noble-lab-authentication-password
attrs:
backends:
- authentik.core.auth.InbuiltBackend
- authentik.sources.kerberos.auth.KerberosBackend
- authentik.sources.ldap.auth.LDAPBackend
- authentik.core.auth.TokenBackend
configure_flow: !Find [authentik_flows.flow, [slug, default-password-change]]
failed_attempts_before_cancel: {{ noble_authentik_blueprint_lab_password_failed_attempts | int }}
- model: authentik_stages_authenticator_validate.authenticatorvalidatestage
id: noble-lab-authenticator-validate-strict
identifiers:
name: noble-lab-authenticator-validate-strict
attrs:
not_configured_action: {{ noble_authentik_blueprint_lab_mfa_not_configured_action | trim | lower | to_json }}
{% if noble_authentik_blueprint_lab_mfa_not_configured_action | trim | lower == 'configure' %}
configuration_stages:
- !Find [authentik_stages_authenticator_totp.authenticatortotpstage, [name, default-authenticator-totp-setup]]
{% endif %}
- model: authentik_flows.flow
id: flow
identifiers:
slug: {{ noble_authentik_blueprint_lab_flow_slug | trim | to_json }}
attrs:
name: Noble lab (operators)
title: Noble lab — operators only
designation: authentication
authentication: none
- id: noble-lab-identification-binding
model: authentik_flows.flowstagebinding
identifiers:
order: 10
stage: !Find [authentik_stages_identification.identificationstage, [name, default-authentication-identification]]
target: !KeyOf flow
- id: noble-lab-password-binding
model: authentik_flows.flowstagebinding
identifiers:
order: 20
stage: !KeyOf noble-lab-password-stage
target: !KeyOf flow
attrs:
re_evaluate_policies: true
- id: noble-lab-authenticator-binding
model: authentik_flows.flowstagebinding
identifiers:
order: 30
stage: !KeyOf noble-lab-authenticator-validate-strict
target: !KeyOf flow
- model: authentik_flows.flowstagebinding
identifiers:
order: 100
stage: !Find [authentik_stages_user_login.userloginstage, [name, default-authentication-login]]
target: !KeyOf flow
- model: authentik_policies_expression.expressionpolicy
id: noble-lab-password-optional
identifiers:
name: noble-lab-password-optional
attrs:
expression: |
flow_plan = request.context.get("flow_plan")
if not flow_plan:
return True
return not hasattr(flow_plan.context.get("pending_user"), "backend")
- model: authentik_policies_expression.expressionpolicy
id: noble-lab-password-strength
identifiers:
name: noble-lab-password-strength
attrs:
expression: |
import re
_zxc = {{ noble_authentik_blueprint_lab_password_policy_check_zxcvbn | bool }}
_zxc_thr = {{ noble_authentik_blueprint_lab_password_policy_zxcvbn_score_threshold | int }}
pwd = request.context.get("password")
if not pwd:
return True
msg = {{ noble_authentik_blueprint_lab_password_policy_error_message | trim | to_json }}
if len(pwd) < {{ noble_authentik_blueprint_lab_password_policy_length_min | int }}:
ak_message(msg)
return False
if len(re.findall(r"[A-Z]", pwd)) < {{ noble_authentik_blueprint_lab_password_policy_amount_uppercase | int }}:
ak_message(msg)
return False
if len(re.findall(r"[a-z]", pwd)) < {{ noble_authentik_blueprint_lab_password_policy_amount_lowercase | int }}:
ak_message(msg)
return False
if len(re.findall(r"[0-9]", pwd)) < {{ noble_authentik_blueprint_lab_password_policy_amount_digits | int }}:
ak_message(msg)
return False
sym = sum(1 for c in pwd if (not c.isalnum()) and (not c.isspace()))
if sym < {{ noble_authentik_blueprint_lab_password_policy_amount_symbols | int }}:
ak_message(msg)
return False
if _zxc:
try:
from zxcvbn import zxcvbn
if zxcvbn(pwd[:72])["score"] <= _zxc_thr:
ak_message("Password is too weak for the lab policy.")
return False
except Exception:
pass
return True
- model: authentik_policies_expression.expressionpolicy
id: noble-lab-operators-only
identifiers:
name: {{ noble_authentik_blueprint_operator_policy_name | trim | to_json }}
attrs:
expression: |
u = context.get("pending_user")
if u is None:
return False
{% for g in noble_authentik_blueprint_lab_operator_groups | default([]) %}
if ak_is_group_member(u, name={{ g | trim | to_json }}):
return True
{% endfor %}
ak_message("This login URL is for administrators only. Use the public Authentik hostname instead.")
return False
- model: authentik_policies.policybinding
identifiers:
order: 5
target: !KeyOf noble-lab-password-binding
policy: !KeyOf noble-lab-operators-only
- model: authentik_policies.policybinding
identifiers:
order: 10
target: !KeyOf noble-lab-password-binding
policy: !KeyOf noble-lab-password-optional
attrs:
failure_result: true
- model: authentik_policies.policybinding
identifiers:
order: 15
target: !KeyOf noble-lab-password-binding
policy: !KeyOf noble-lab-password-strength

View File

@@ -1,80 +0,0 @@
# Noble — **public** hostname(s): same behaviour as stock **default-authentication-flow** (optional password / MFA skip for WebAuthn PWL), isolated slug for Brand binding.
version: 1
metadata:
name: noble-public-authentication-flow
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_blueprints.metaapplyblueprint
attrs:
identifiers:
name: Default - Password change flow
required: false
- model: authentik_flows.flow
id: flow
identifiers:
slug: {{ noble_authentik_blueprint_public_auth_flow_slug | trim | to_json }}
attrs:
name: Noble public sign-in
title: Sign in
designation: authentication
authentication: none
- id: noble-public-identification-binding
model: authentik_flows.flowstagebinding
identifiers:
order: 10
stage: !Find [authentik_stages_identification.identificationstage, [name, default-authentication-identification]]
target: !KeyOf flow
- id: noble-public-password-binding
model: authentik_flows.flowstagebinding
identifiers:
order: 20
stage: !Find [authentik_stages_password.passwordstage, [name, default-authentication-password]]
target: !KeyOf flow
attrs:
re_evaluate_policies: true
- id: noble-public-authenticator-binding
model: authentik_flows.flowstagebinding
identifiers:
order: 30
stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, default-authentication-mfa-validation]]
target: !KeyOf flow
- model: authentik_flows.flowstagebinding
identifiers:
order: 100
stage: !Find [authentik_stages_user_login.userloginstage, [name, default-authentication-login]]
target: !KeyOf flow
- model: authentik_policies_expression.expressionpolicy
id: noble-public-password-optional
identifiers:
name: noble-public-password-optional
attrs:
expression: |
flow_plan = request.context.get("flow_plan")
if not flow_plan:
return True
return not hasattr(flow_plan.context.get("pending_user"), "backend")
- model: authentik_policies_expression.expressionpolicy
id: noble-public-authenticator-validate-optional
identifiers:
name: noble-public-authenticator-validate-optional
attrs:
expression: |
flow_plan = request.context.get("flow_plan")
if not flow_plan:
return True
return not (flow_plan.context.get("auth_method") == "auth_webauthn_pwl")
- model: authentik_policies.policybinding
identifiers:
order: 10
target: !KeyOf noble-public-password-binding
policy: !KeyOf noble-public-password-optional
attrs:
failure_result: true
- model: authentik_policies.policybinding
identifiers:
order: 10
target: !KeyOf noble-public-authenticator-binding
policy: !KeyOf noble-public-authenticator-validate-optional
attrs:
failure_result: true

View File

@@ -1,227 +0,0 @@
# Noble — two **enrollment** flows (public vs lab) with separate **Invitation** stages (invitation token required).
# Create rows under **Directory → Invitations** in the admin UI and pick the matching flow; share links with the
# correct **Host** so the right Brand applies. Does **not** ship example **Invitation** objects (no prefilled emails).
version: 1
metadata:
name: noble-invitation-enrollment-flows
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_core.group
id: noble-lab-invited-group
identifiers:
name: {{ noble_authentik_blueprint_lab_invitee_group_name | trim | to_json }}
attrs:
is_superuser: false
attributes:
"noble.ak/audience": lab
"noble.ak/role": lab-invited
- model: authentik_flows.flow
id: noble-inv-flow-public
identifiers:
slug: {{ noble_authentik_blueprint_public_invitation_enrollment_flow_slug | trim | to_json }}
attrs:
name: {{ noble_authentik_blueprint_public_invitation_flow_name | trim | to_json }}
title: {{ noble_authentik_blueprint_public_invitation_flow_title | trim | to_json }}
designation: enrollment
authentication: require_unauthenticated
- model: authentik_flows.flow
id: noble-inv-flow-lab
identifiers:
slug: {{ noble_authentik_blueprint_lab_invitation_enrollment_flow_slug | trim | to_json }}
attrs:
name: {{ noble_authentik_blueprint_lab_invitation_flow_name | trim | to_json }}
title: {{ noble_authentik_blueprint_lab_invitation_flow_title | trim | to_json }}
designation: enrollment
authentication: require_unauthenticated
- model: authentik_stages_invitation.invitationstage
id: noble-inv-stage-public
identifiers:
name: noble-invitation-enrollment-invitation-public
attrs:
continue_flow_without_invitation: false
- model: authentik_stages_invitation.invitationstage
id: noble-inv-stage-lab
identifiers:
name: noble-invitation-enrollment-invitation-lab
attrs:
continue_flow_without_invitation: false
- id: noble-inv-prompt-field-username
model: authentik_stages_prompt.prompt
identifiers:
name: noble-inv-enroll-field-username
attrs:
field_key: username
label: Username
type: username
required: true
placeholder: Username
placeholder_expression: false
order: 0
- id: noble-inv-prompt-field-password
model: authentik_stages_prompt.prompt
identifiers:
name: noble-inv-enroll-field-password
attrs:
field_key: password
label: Password
type: password
required: true
placeholder: Password
placeholder_expression: false
order: 1
- id: noble-inv-prompt-field-password-repeat
model: authentik_stages_prompt.prompt
identifiers:
name: noble-inv-enroll-field-password-repeat
attrs:
field_key: password_repeat
label: Password (repeat)
type: password
required: true
placeholder: Password (repeat)
placeholder_expression: false
order: 2
- id: noble-inv-prompt-field-name
model: authentik_stages_prompt.prompt
identifiers:
name: noble-inv-enroll-field-name
attrs:
field_key: name
label: Name
type: text
required: true
placeholder: Name
placeholder_expression: false
order: 0
- id: noble-inv-prompt-field-email
model: authentik_stages_prompt.prompt
identifiers:
name: noble-inv-enroll-field-email
attrs:
field_key: email
label: Email
type: email
required: true
placeholder: Email
placeholder_expression: false
order: 1
- id: noble-inv-prompt-stage-credentials
model: authentik_stages_prompt.promptstage
identifiers:
name: noble-inv-enroll-prompt-credentials
attrs:
fields:
- !KeyOf noble-inv-prompt-field-username
- !KeyOf noble-inv-prompt-field-password
- !KeyOf noble-inv-prompt-field-password-repeat
- id: noble-inv-prompt-stage-details
model: authentik_stages_prompt.promptstage
identifiers:
name: noble-inv-enroll-prompt-details
attrs:
fields:
- !KeyOf noble-inv-prompt-field-name
- !KeyOf noble-inv-prompt-field-email
- id: noble-inv-user-write-public
model: authentik_stages_user_write.userwritestage
identifiers:
name: noble-inv-enroll-user-write-public
attrs:
user_creation_mode: always_create
user_type: {{ noble_authentik_blueprint_public_invitation_user_type | trim | lower | to_json }}
user_path_template: {{ noble_authentik_blueprint_public_invitation_user_path | trim | to_json }}
create_users_group: !Find [authentik_core.group, [name, {{ noble_authentik_blueprint_public_invitation_user_group | trim | to_json }}]]
- id: noble-inv-user-write-lab
model: authentik_stages_user_write.userwritestage
identifiers:
name: noble-inv-enroll-user-write-lab
attrs:
user_creation_mode: always_create
user_type: {{ noble_authentik_blueprint_lab_invitation_user_type | trim | lower | to_json }}
user_path_template: {{ noble_authentik_blueprint_lab_invitation_user_path | trim | to_json }}
create_users_group: !KeyOf noble-lab-invited-group
- id: noble-inv-user-login
model: authentik_stages_user_login.userloginstage
identifiers:
name: noble-inv-enroll-user-login
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf noble-inv-flow-public
stage: !KeyOf noble-inv-stage-public
order: 5
attrs:
evaluate_on_plan: true
re_evaluate_policies: true
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf noble-inv-flow-public
stage: !KeyOf noble-inv-prompt-stage-credentials
order: 10
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf noble-inv-flow-public
stage: !KeyOf noble-inv-prompt-stage-details
order: 15
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf noble-inv-flow-public
stage: !KeyOf noble-inv-user-write-public
order: 20
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf noble-inv-flow-public
stage: !KeyOf noble-inv-user-login
order: 100
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf noble-inv-flow-lab
stage: !KeyOf noble-inv-stage-lab
order: 5
attrs:
evaluate_on_plan: true
re_evaluate_policies: true
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf noble-inv-flow-lab
stage: !KeyOf noble-inv-prompt-stage-credentials
order: 10
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf noble-inv-flow-lab
stage: !KeyOf noble-inv-prompt-stage-details
order: 15
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf noble-inv-flow-lab
stage: !KeyOf noble-inv-user-write-lab
order: 20
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf noble-inv-flow-lab
stage: !KeyOf noble-inv-user-login
order: 100

View File

@@ -1,27 +0,0 @@
# Noble — Brands so **Host** selects authentication flow: lab hostname → operator-only hardened flow; extra hosts → public flow (**21**).
version: 1
metadata:
name: noble-brands-domain-split
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_brands.brand
identifiers:
domain: {{ noble_authentik_host | trim | to_json }}
attrs:
default: false
title: {{ noble_authentik_blueprint_lab_brand_title | trim | to_json }}
flow_authentication: !Find [authentik_flows.flow, [slug, {{ noble_authentik_blueprint_lab_flow_slug | trim | to_json }}]]
flow_invalidation: !Find [authentik_flows.flow, [slug, default-invalidation-flow]]
flow_user_settings: !Find [authentik_flows.flow, [slug, default-user-settings-flow]]
{% for host in noble_authentik_ingress_extra_hosts | default([]) %}
- model: authentik_brands.brand
identifiers:
domain: {{ host | trim | to_json }}
attrs:
default: false
title: {{ ((noble_authentik_blueprint_public_brand_title_prefix | default('Noble public')) ~ ' (' ~ (host | trim) ~ ')') | to_json }}
flow_authentication: !Find [authentik_flows.flow, [slug, {{ noble_authentik_blueprint_public_auth_flow_slug | trim | to_json }}]]
flow_invalidation: !Find [authentik_flows.flow, [slug, default-invalidation-flow]]
flow_user_settings: !Find [authentik_flows.flow, [slug, default-user-settings-flow]]
{% endfor %}

View File

@@ -1,6 +0,0 @@
---
# Warn when **cloudflare-dns-api-token** is missing after apply (also set in **inventory/group_vars/all.yml** when loaded).
noble_cert_manager_require_cloudflare_secret: true
# Helm --wait default (~5m) can expire while startupapicheck waits on webhooks / API (busy or slow pulls).
noble_helm_cert_manager_wait_timeout: 15m

View File

@@ -1,28 +0,0 @@
---
# See repository **.env.sample** — copy to **.env** (gitignored).
- name: Stat repository .env for deploy secrets
ansible.builtin.stat:
path: "{{ noble_repo_root }}/.env"
register: noble_deploy_env_file
changed_when: false
- name: Create cert-manager Cloudflare DNS secret from .env
ansible.builtin.shell: |
set -euo pipefail
set -a
. "{{ noble_repo_root }}/.env"
set +a
if [ -z "${CLOUDFLARE_DNS_API_TOKEN:-}" ]; then
echo NO_TOKEN
exit 0
fi
kubectl -n cert-manager create secret generic cloudflare-dns-api-token \
--from-literal=api-token="${CLOUDFLARE_DNS_API_TOKEN}" \
--dry-run=client -o yaml | kubectl apply -f -
echo APPLIED
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
when: noble_deploy_env_file.stat.exists | default(false)
no_log: true
register: noble_cf_secret_from_env
changed_when: "'APPLIED' in (noble_cf_secret_from_env.stdout | default(''))"

View File

@@ -1,71 +0,0 @@
---
- name: Create cert-manager namespace
ansible.builtin.command:
argv:
- kubectl
- apply
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/cert-manager/namespace.yaml"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Install cert-manager
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- cert-manager
- jetstack/cert-manager
- --namespace
- cert-manager
- --version
- v1.20.2
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/cert-manager/values.yaml"
- --force-conflicts
- --wait
- --timeout
- "{{ noble_helm_cert_manager_wait_timeout }}"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Apply secrets from repository .env (optional)
ansible.builtin.include_tasks: from_env.yml
- name: Check Cloudflare DNS API token Secret (required for ClusterIssuers)
ansible.builtin.command:
argv:
- kubectl
- -n
- cert-manager
- get
- secret
- cloudflare-dns-api-token
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_cf_secret
failed_when: false
changed_when: false
- name: Warn when Cloudflare Secret is missing
ansible.builtin.debug:
msg: >-
Secret cert-manager/cloudflare-dns-api-token not found.
Create it per clusters/noble/bootstrap/cert-manager/README.md before ClusterIssuers can succeed.
when:
- noble_cert_manager_require_cloudflare_secret | default(true) | bool
- noble_cf_secret.rc != 0
- name: Apply ClusterIssuers (staging + prod)
ansible.builtin.command:
argv:
- kubectl
- apply
- -k
- "{{ noble_repo_root }}/clusters/noble/bootstrap/cert-manager"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true

View File

@@ -1,8 +0,0 @@
---
# When true, delete **kube-system/hubble-server-certs** if **managedFields** show **argocd-controller**
# (recover from Helm SSA conflicts after Argo synced Cilium before Ansible). Requires **kubectl** with
# **--show-managed-fields** on the pre-check (see tasks).
noble_cilium_repair_argo_ssa_on_hubble_secret: true
# When true, delete **hubble-server-certs** whenever it exists (before Helm). Use only if the Argo check
# still does not fire (older kubectl) or you need a one-shot cleanup.
noble_cilium_delete_hubble_server_certs_if_present: false

View File

@@ -1,67 +0,0 @@
---
# Argo may have server-side-applied chart-owned Secrets during earlier runs; Helm then fails with
# "conflict with argocd-controller". **kubectl** omits **managedFields** unless **--show-managed-fields=true**.
# We delete the Secret only when **argocd-controller** appears there (or set **noble_cilium_delete_hubble_server_certs_if_present**).
- name: Read hubble-server-certs Secret (if any) for SSA repair
ansible.builtin.command:
argv:
- kubectl
- get
- secret
- hubble-server-certs
- -n
- kube-system
- --show-managed-fields=true
- -o
- json
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_cilium_hubble_secret_json
failed_when: false
changed_when: false
when: noble_cilium_repair_argo_ssa_on_hubble_secret | default(true) | bool
- name: Remove hubble-server-certs when Argo is a field manager (Helm SSA conflict recovery)
ansible.builtin.command:
argv:
- kubectl
- delete
- secret
- hubble-server-certs
- -n
- kube-system
- --wait=false
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
when:
- noble_cilium_repair_argo_ssa_on_hubble_secret | default(true) | bool
- not (noble_cilium_hubble_secret_json.skipped | default(false))
- noble_cilium_hubble_secret_json.rc | default(-1) | int == 0
- (noble_cilium_delete_hubble_server_certs_if_present | default(false) | bool) or ("argocd-controller" in (noble_cilium_hubble_secret_json.stdout | default("")))
changed_when: true
- name: Install Cilium (required CNI for Talos cni:none)
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- cilium
- cilium/cilium
- --namespace
- kube-system
- --version
- "1.19.4"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/cilium/values.yaml"
- --force-conflicts
- --wait
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Wait for Cilium DaemonSet
ansible.builtin.command: kubectl -n kube-system rollout status ds/cilium --timeout=300s
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: false

View File

@@ -1,2 +0,0 @@
---
noble_csi_snapshot_kubectl_timeout: 120s

View File

@@ -1,39 +0,0 @@
---
# Volume Snapshot CRDs + snapshot-controller (Velero CSI / Longhorn snapshots).
- name: Apply Volume Snapshot CRDs (snapshot.storage.k8s.io)
ansible.builtin.command:
argv:
- kubectl
- apply
- "--request-timeout={{ noble_csi_snapshot_kubectl_timeout | default('120s') }}"
- -k
- "{{ noble_repo_root }}/clusters/noble/bootstrap/csi-snapshot-controller/crd"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Apply snapshot-controller in kube-system
ansible.builtin.command:
argv:
- kubectl
- apply
- "--request-timeout={{ noble_csi_snapshot_kubectl_timeout | default('120s') }}"
- -k
- "{{ noble_repo_root }}/clusters/noble/bootstrap/csi-snapshot-controller/controller"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Wait for snapshot-controller Deployment
ansible.builtin.command:
argv:
- kubectl
- -n
- kube-system
- rollout
- status
- deploy/snapshot-controller
- --timeout=120s
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: false

View File

@@ -1,11 +0,0 @@
---
- name: Apply kube-vip (Kubernetes API VIP)
ansible.builtin.command:
argv:
- kubectl
- apply
- -k
- "{{ noble_repo_root }}/clusters/noble/bootstrap/kube-vip"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true

View File

@@ -1,6 +0,0 @@
---
# When true, delete **FlowSchema/kyverno-admission-controller** if **managedFields** show **argocd-controller**
# (Helm SSA conflict after Argo synced Kyverno before Ansible).
noble_kyverno_repair_argo_ssa_on_flowschema: true
# When true, delete that FlowSchema whenever it exists (before Helm). One-shot escape hatch.
noble_kyverno_delete_kyverno_admission_flowschema_if_present: false

View File

@@ -1,68 +0,0 @@
---
# Argo may have server-side-applied cluster FlowSchemas; Helm then fails with "conflict with argocd-controller".
- name: Read kyverno-admission-controller FlowSchema (if any) for SSA repair
ansible.builtin.command:
argv:
- kubectl
- get
- flowschemas.flowcontrol.apiserver.k8s.io
- kyverno-admission-controller
- --show-managed-fields=true
- -o
- json
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_kyverno_flowschema_json
failed_when: false
changed_when: false
when: noble_kyverno_repair_argo_ssa_on_flowschema | default(true) | bool
- name: Remove kyverno-admission-controller FlowSchema when Argo is a field manager (Helm SSA conflict recovery)
ansible.builtin.command:
argv:
- kubectl
- delete
- flowschemas.flowcontrol.apiserver.k8s.io
- kyverno-admission-controller
- --wait=false
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
when:
- noble_kyverno_repair_argo_ssa_on_flowschema | default(true) | bool
- not (noble_kyverno_flowschema_json.skipped | default(false))
- noble_kyverno_flowschema_json.rc | default(-1) | int == 0
- (noble_kyverno_delete_kyverno_admission_flowschema_if_present | default(false) | bool) or ("argocd-controller" in (noble_kyverno_flowschema_json.stdout | default("")))
changed_when: true
- name: Create Kyverno namespace
ansible.builtin.command:
argv:
- kubectl
- apply
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/kyverno/namespace.yaml"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Install Kyverno operator
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- kyverno
- kyverno/kyverno
- -n
- kyverno
- --version
- "3.8.0"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/kyverno/values.yaml"
- --force-conflicts
- --wait
- --timeout
- 15m
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true

View File

@@ -1,8 +0,0 @@
---
# After the operator chart, the validating webhook can still be a few seconds behind Helm --wait.
noble_kyverno_policies_endpoint_wait_retries: 60
noble_kyverno_policies_endpoint_wait_delay: 5
# Transient "failed calling webhook ... context deadline exceeded" while admission warms up.
noble_kyverno_policies_helm_retries: 12
noble_kyverno_policies_helm_delay: 20

View File

@@ -1,60 +0,0 @@
---
# Helm --wait on the operator does not guarantee the first policyvalidate call from apiserver succeeds.
- name: Wait for Kyverno admission controller Deployment rollout
ansible.builtin.command:
argv:
- kubectl
- rollout
- status
- deployment/kyverno-admission-controller
- -n
- kyverno
- --timeout=300s
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: false
- name: Wait for Kyverno webhook Service (kyverno-svc) to have endpoints
ansible.builtin.command:
argv:
- kubectl
- get
- endpoints
- kyverno-svc
- -n
- kyverno
- -o
- 'jsonpath={range .subsets[*].addresses[*]}{.ip}{"\n"}{end}'
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_kyverno_policies_ep
until: (noble_kyverno_policies_ep.stdout | default('') | trim | length) > 0
retries: "{{ noble_kyverno_policies_endpoint_wait_retries }}"
delay: "{{ noble_kyverno_policies_endpoint_wait_delay }}"
changed_when: false
- name: Install Kyverno policy chart (PSS baseline, Audit)
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- kyverno-policies
- kyverno/kyverno-policies
- -n
- kyverno
- --version
- "3.8.0"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/kyverno/policies-values.yaml"
- --force-conflicts
- --wait
- --timeout
- 10m
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_kyverno_policies_helm
retries: "{{ noble_kyverno_policies_helm_retries }}"
delay: "{{ noble_kyverno_policies_helm_delay }}"
until: noble_kyverno_policies_helm.rc == 0
changed_when: true

View File

@@ -1,61 +0,0 @@
---
# Regenerated when **noble_landing_urls** runs (after platform stack). Paths match Traefik + cert-manager Ingresses.
noble_landing_urls_dest: "{{ noble_repo_root }}/ansible/output/noble-lab-ui-urls.md"
# When true, run kubectl to fill Argo CD / Grafana secrets and a bounded Headlamp SA token in the markdown (requires working kubeconfig).
noble_landing_urls_fetch_credentials: true
# Headlamp: bounded token for UI sign-in (`kubectl create token`); cluster may cap max duration.
noble_landing_urls_headlamp_token_duration: 48h
noble_lab_ui_entries:
- name: Authentik
description: OIDC IdP (admin UI, OAuth2/OIDC for cluster apps)
namespace: authentik
service: authentik-server
url: https://auth.apps.noble.lab.pcenicni.dev
- name: oauth2-proxy
description: OIDC to Authentik + Traefik ForwardAuth (Prometheus, Alertmanager, Longhorn)
namespace: oauth2-proxy
service: oauth2-proxy
url: https://oauth2.apps.noble.lab.pcenicni.dev
- name: Argo CD
description: GitOps UI (sync, apps, repos)
namespace: argocd
service: argocd-server
url: https://argo.apps.noble.lab.pcenicni.dev
- name: Grafana
description: Dashboards, Loki explore (logs)
namespace: monitoring
service: kube-prometheus-grafana
url: https://grafana.apps.noble.lab.pcenicni.dev
- name: Prometheus
description: Prometheus UI (queries, targets) — lab; protect in production
namespace: monitoring
service: kube-prometheus-kube-prome-prometheus
url: https://prometheus.apps.noble.lab.pcenicni.dev
- name: Alertmanager
description: Alertmanager UI (silences, status)
namespace: monitoring
service: kube-prometheus-kube-prome-alertmanager
url: https://alertmanager.apps.noble.lab.pcenicni.dev
- name: Headlamp
description: Kubernetes UI (cluster resources)
namespace: headlamp
service: headlamp
url: https://headlamp.apps.noble.lab.pcenicni.dev
- name: Longhorn
description: Storage volumes, nodes, backups
namespace: longhorn-system
service: longhorn-frontend
url: https://longhorn.apps.noble.lab.pcenicni.dev
- name: Velero
description: Cluster backups — no web UI (velero CLI / kubectl CRDs)
namespace: velero
service: velero
url: ""
- name: Homepage
description: App dashboard (links to lab UIs)
namespace: homepage
service: homepage
url: https://homepage.apps.noble.lab.pcenicni.dev

View File

@@ -1,72 +0,0 @@
---
# Populates template variables from Secrets + Headlamp token (no_log on kubectl to avoid leaking into Ansible stdout).
- name: Fetch Argo CD initial admin password (base64)
ansible.builtin.command:
argv:
- kubectl
- -n
- argocd
- get
- secret
- argocd-initial-admin-secret
- -o
- jsonpath={.data.password}
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_fetch_argocd_pw_b64
failed_when: false
changed_when: false
no_log: true
- name: Fetch Grafana admin user (base64)
ansible.builtin.command:
argv:
- kubectl
- -n
- monitoring
- get
- secret
- kube-prometheus-grafana
- -o
- jsonpath={.data.admin-user}
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_fetch_grafana_user_b64
failed_when: false
changed_when: false
no_log: true
- name: Fetch Grafana admin password (base64)
ansible.builtin.command:
argv:
- kubectl
- -n
- monitoring
- get
- secret
- kube-prometheus-grafana
- -o
- jsonpath={.data.admin-password}
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_fetch_grafana_pw_b64
failed_when: false
changed_when: false
no_log: true
- name: Create Headlamp ServiceAccount token (for UI sign-in)
ansible.builtin.command:
argv:
- kubectl
- -n
- headlamp
- create
- token
- headlamp
- "--duration={{ noble_landing_urls_headlamp_token_duration | default('48h') }}"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_fetch_headlamp_token
failed_when: false
changed_when: false
no_log: true

View File

@@ -1,20 +0,0 @@
---
- name: Ensure output directory for generated landing page
ansible.builtin.file:
path: "{{ noble_repo_root }}/ansible/output"
state: directory
mode: "0755"
- name: Fetch initial credentials from cluster Secrets (optional)
ansible.builtin.include_tasks: fetch_credentials.yml
when: noble_landing_urls_fetch_credentials | default(true) | bool
- name: Write noble lab UI URLs (markdown landing page)
ansible.builtin.template:
src: noble-lab-ui-urls.md.j2
dest: "{{ noble_landing_urls_dest }}"
mode: "0644"
- name: Show landing page path
ansible.builtin.debug:
msg: "Noble lab UI list written to {{ noble_landing_urls_dest }}"

View File

@@ -1,52 +0,0 @@
# Noble lab — web UIs (LAN)
> **Sensitive:** This file may include **passwords read from Kubernetes Secrets** when credential fetch ran. It is **gitignored** — do not commit or share.
**DNS:** point **`*.apps.noble.lab.pcenicni.dev`** at the Traefik **LoadBalancer** (MetalLB **`192.168.50.211`** by default — see `clusters/noble/bootstrap/traefik/values.yaml`).
**TLS:** **cert-manager** + **`letsencrypt-prod`** on each Ingress (public **DNS-01** for **`pcenicni.dev`**).
This file is **generated** by Ansible (`noble_landing_urls` role). Use it as a temporary landing page to find services after deploy.
| UI | What | Kubernetes service | Namespace | URL |
|----|------|----------------------|-----------|-----|
{% for e in noble_lab_ui_entries %}
| {{ e.name }} | {{ e.description }} | `{{ e.service }}` | `{{ e.namespace }}` | {% if e.url | default('') | length > 0 %}[{{ e.url }}]({{ e.url }}){% else %}—{% endif %} |
{% endfor %}
## Initial access (logins)
| App | Username / identity | Password / secret |
|-----|---------------------|-------------------|
| **Argo CD** | `admin` | {% if (noble_fetch_argocd_pw_b64 is defined) and (noble_fetch_argocd_pw_b64.rc | default(1) == 0) and (noble_fetch_argocd_pw_b64.stdout | default('') | length > 0) %}`{{ noble_fetch_argocd_pw_b64.stdout | b64decode }}`{% else %}*(not fetched — use commands below)*{% endif %} |
| **Grafana** | {% if (noble_fetch_grafana_user_b64 is defined) and (noble_fetch_grafana_user_b64.rc | default(1) == 0) and (noble_fetch_grafana_user_b64.stdout | default('') | length > 0) %}`{{ noble_fetch_grafana_user_b64.stdout | b64decode }}`{% else %}*(from Secret — use commands below)*{% endif %} | {% if (noble_fetch_grafana_pw_b64 is defined) and (noble_fetch_grafana_pw_b64.rc | default(1) == 0) and (noble_fetch_grafana_pw_b64.stdout | default('') | length > 0) %}`{{ noble_fetch_grafana_pw_b64.stdout | b64decode }}`{% else %}*(not fetched — use commands below)*{% endif %} |
| **Headlamp** | ServiceAccount **`headlamp`** | {% if (noble_fetch_headlamp_token is defined) and (noble_fetch_headlamp_token.rc | default(1) == 0) and (noble_fetch_headlamp_token.stdout | default('') | trim | length > 0) %}Token ({{ noble_landing_urls_headlamp_token_duration | default('48h') }}): `{{ noble_fetch_headlamp_token.stdout | trim }}`{% else %}*(not generated — use command below)*{% endif %} |
| **Prometheus** | — | Browser login via **oauth2-proxy** → **Authentik** (OIDC). |
| **Alertmanager** | — | Browser login via **oauth2-proxy** → **Authentik** (OIDC). |
| **Longhorn** | — | Browser login via **oauth2-proxy** → **Authentik** (OIDC). |
### Commands to retrieve passwords (if not filled above)
```bash
# Argo CD initial admin (Secret removed after you change password)
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d
echo
# Grafana admin user / password
kubectl -n monitoring get secret kube-prometheus-grafana -o jsonpath='{.data.admin-user}' | base64 -d
echo
kubectl -n monitoring get secret kube-prometheus-grafana -o jsonpath='{.data.admin-password}' | base64 -d
echo
```
To generate this file **without** calling kubectl, run Ansible with **`-e noble_landing_urls_fetch_credentials=false`**.
## Notes
- **Argo CD** `argocd-initial-admin-secret` disappears after you change the admin password.
- **Grafana** password is random unless you set `grafana.adminPassword` in chart values.
- **Argo CD / Grafana / Headlamp** use **native OIDC** to **Authentik** when **`noble_authentik_install`** ran with **`ansible/roles/noble_authentik`** (see **`clusters/noble/bootstrap/**/values-authentik*.yaml`**).
- **Prometheus / Alertmanager / Longhorn** UIs use **oauth2-proxy** as an **OIDC RP** to Authentik (Traefik ForwardAuth), not HTTP BasicAuth.
- **SOPS:** cluster secrets in git under **`clusters/noble/secrets/`** are encrypted; decrypt with **`age-key.txt`** (not in git). See **`clusters/noble/secrets/README.md`**.
- **Headlamp** token above expires after the configured duration; re-run Ansible or `kubectl create token` to refresh.
- **Velero** has **no web UI** — use **`velero`** CLI or **`kubectl -n velero get backup,schedule,backupstoragelocation`**. Metrics: **`velero`** Service in **`velero`** (Prometheus scrape). See `clusters/noble/bootstrap/velero/README.md`.

View File

@@ -1,8 +0,0 @@
---
# Helm --wait default (5m) is often too short for first Longhorn install on several nodes
# (image pulls + manager/driver ordering). See ansible/roles/noble_metallb/defaults/main.yml.
noble_helm_longhorn_wait_timeout: 20m
# Transient Kyverno webhook timeouts during post-upgrade hooks / admission storms.
noble_helm_longhorn_retries: 8
noble_helm_longhorn_retry_delay: 25

View File

@@ -1,38 +0,0 @@
---
- name: Apply Longhorn namespace (PSA) from kustomization
ansible.builtin.command:
argv:
- kubectl
- apply
- -k
- "{{ noble_repo_root }}/clusters/noble/bootstrap/longhorn"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Install Longhorn chart
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- longhorn
- longhorn/longhorn
- -n
- longhorn-system
- --create-namespace
- --version
- "1.11.2"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/longhorn/values.yaml"
- --force-conflicts
- --wait
- --timeout
- "{{ noble_helm_longhorn_wait_timeout }}"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_longhorn_helm
retries: "{{ noble_helm_longhorn_retries | int }}"
delay: "{{ noble_helm_longhorn_retry_delay | int }}"
until: noble_longhorn_helm.rc == 0
changed_when: true

View File

@@ -1,3 +0,0 @@
---
# Helm **--wait** default is often too short when images pull slowly or nodes are busy.
noble_helm_metallb_wait_timeout: 20m

View File

@@ -1,40 +0,0 @@
---
- name: Apply MetalLB namespace (Pod Security labels)
ansible.builtin.command:
argv:
- kubectl
- apply
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/metallb/namespace.yaml"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Install MetalLB chart
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- metallb
- metallb/metallb
- --namespace
- metallb-system
- --force-conflicts
- --wait
- --timeout
- "{{ noble_helm_metallb_wait_timeout }}"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Apply IPAddressPool and L2Advertisement
ansible.builtin.command:
argv:
- kubectl
- apply
- -k
- "{{ noble_repo_root }}/clusters/noble/bootstrap/metallb"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true

View File

@@ -1,20 +0,0 @@
---
- name: Install metrics-server
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- metrics-server
- metrics-server/metrics-server
- -n
- kube-system
- --version
- "3.13.0"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/metrics-server/values.yaml"
- --force-conflicts
- --wait
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true

View File

@@ -1,11 +0,0 @@
---
# Set true after creating the newt-pangolin-auth Secret (see role / cluster docs).
noble_newt_install: true
# Pangolin Integration API — idempotent HTTP resources + Traefik targets (see clusters/noble/bootstrap/newt/README.md §4).
noble_pangolin_sync_http_resources: false
# Extra FQDNs to sync (in addition to **noble_authentik_ingress_extra_hosts** + **noble_open_webui_public_host** when set).
noble_pangolin_http_fqdns_extra: []
# Traefik HTTPS backend for Pangolin targets (MetalLB / LAN VIP). Empty → **kubectl** discovers the Traefik Service.
noble_pangolin_traefik_target_ip: ""
noble_pangolin_traefik_target_port: 443

View File

@@ -1,30 +0,0 @@
---
# See repository **.env.sample** — copy to **.env** (gitignored).
- name: Stat repository .env for deploy secrets
ansible.builtin.stat:
path: "{{ noble_repo_root }}/.env"
register: noble_deploy_env_file
changed_when: false
- name: Create newt-pangolin-auth Secret from .env
ansible.builtin.shell: |
set -euo pipefail
set -a
. "{{ noble_repo_root }}/.env"
set +a
if [ -z "${PANGOLIN_ENDPOINT:-}" ] || [ -z "${NEWT_ID:-}" ] || [ -z "${NEWT_SECRET:-}" ]; then
echo NO_VARS
exit 0
fi
kubectl -n newt create secret generic newt-pangolin-auth \
--from-literal=PANGOLIN_ENDPOINT="${PANGOLIN_ENDPOINT}" \
--from-literal=NEWT_ID="${NEWT_ID}" \
--from-literal=NEWT_SECRET="${NEWT_SECRET}" \
--dry-run=client -o yaml | kubectl apply -f -
echo APPLIED
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
when: noble_deploy_env_file.stat.exists | default(false)
no_log: true
register: noble_newt_secret_from_env
changed_when: "'APPLIED' in (noble_newt_secret_from_env.stdout | default(''))"

View File

@@ -1,48 +0,0 @@
---
- name: Skip Newt when not enabled
ansible.builtin.debug:
msg: "noble_newt_install is false — set PANGOLIN_ENDPOINT, NEWT_ID, NEWT_SECRET in repo .env (or create the Secret manually) and set noble_newt_install=true to deploy Newt."
when: not (noble_newt_install | bool)
tags: [newt]
- name: Deploy Newt (Pangolin tunnel) and optional Pangolin HTTP resource sync
when: noble_newt_install | bool
tags: [newt, pangolin]
block:
- name: Create Newt namespace
ansible.builtin.command:
argv:
- kubectl
- apply
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/newt/namespace.yaml"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Apply Newt Pangolin auth Secret from repository .env (optional)
ansible.builtin.include_tasks: from_env.yml
- name: Install Newt chart
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- newt
- fossorial/newt
- --namespace
- newt
- --version
- "1.5.0"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/newt/values.yaml"
- --force-conflicts
- --wait
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Optional Pangolin Integration API (HTTP resources + Traefik targets)
ansible.builtin.include_tasks: pangolin_sync.yml
when: noble_pangolin_sync_http_resources | default(false) | bool

View File

@@ -1,95 +0,0 @@
---
# Pangolin Integration API — public HTTP resources → Newt site → Traefik (see clusters/noble/bootstrap/newt/README.md §4).
# Included only when **noble_pangolin_sync_http_resources** is true.
- name: Build Pangolin HTTP FQDN list
ansible.builtin.set_fact:
noble_pangolin_http_fqdns_effective: >-
{{
(
noble_pangolin_http_fqdns_extra | default([])
+ (noble_authentik_ingress_extra_hosts | default([]))
+ ([noble_open_webui_public_host | trim] if (noble_open_webui_public_host | default('') | trim | length) > 0 else [])
) | unique | list
}}
- name: Discover Traefik LoadBalancer IP for Pangolin targets (when not set explicitly)
ansible.builtin.command:
argv:
- kubectl
- get
- svc
- -n
- traefik
- -l
- app.kubernetes.io/name=traefik
- -o
- jsonpath={.items[0].status.loadBalancer.ingress[0].ip}
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_pangolin_traefik_lb_ip
changed_when: false
failed_when: false
when:
- noble_pangolin_http_fqdns_effective | length > 0
- noble_pangolin_traefik_target_ip | default('') | trim | length == 0
- name: Resolve Traefik IP for Pangolin sync
ansible.builtin.set_fact:
noble_pangolin_traefik_ip_resolved: >-
{{
(noble_pangolin_traefik_target_ip | default('') | trim)
if (noble_pangolin_traefik_target_ip | default('') | trim | length > 0)
else (noble_pangolin_traefik_lb_ip.stdout | default('') | trim)
}}
when: noble_pangolin_http_fqdns_effective | length > 0
- name: Require Traefik IP for Pangolin sync
ansible.builtin.assert:
that:
- noble_pangolin_traefik_ip_resolved | length > 0
fail_msg: >-
Set **noble_pangolin_traefik_target_ip** in inventory (Traefik Service LoadBalancer / MetalLB IP), or ensure
**kubectl** can read **traefik** Services (see **clusters/noble/bootstrap/traefik/**).
when: noble_pangolin_http_fqdns_effective | length > 0
- name: Stat repository .env for Pangolin API credentials
ansible.builtin.stat:
path: "{{ noble_repo_root }}/.env"
register: noble_pangolin_env_file
changed_when: false
when: noble_pangolin_http_fqdns_effective | length > 0
- name: Require .env for Pangolin Integration API secrets
ansible.builtin.assert:
that:
- noble_pangolin_env_file.stat.exists | default(false)
fail_msg: >-
Pangolin sync needs **.env** at the repo root with **NOBLE_PANGOLIN_*** variables (see **.env.sample**).
when: noble_pangolin_http_fqdns_effective | length > 0
- name: Sync Pangolin public HTTP resources (Integration API)
ansible.builtin.command:
argv:
- python3
- "{{ noble_repo_root }}/clusters/noble/bootstrap/newt/scripts/sync_pangolin_http_resources.py"
- "--env-file"
- "{{ noble_repo_root }}/.env"
- "--fqdns"
- "{{ noble_pangolin_http_fqdns_effective | join(',') }}"
- "--traefik-ip"
- "{{ noble_pangolin_traefik_ip_resolved }}"
- "--traefik-port"
- "{{ noble_pangolin_traefik_target_port | int | string }}"
register: noble_pangolin_sync_cmd
changed_when: >-
'[create]' in (noble_pangolin_sync_cmd.stdout | default(''))
or '[target]' in (noble_pangolin_sync_cmd.stdout | default(''))
or 'target created' in (noble_pangolin_sync_cmd.stdout | default(''))
when: noble_pangolin_http_fqdns_effective | length > 0
- name: Skip Pangolin sync (no public FQDNs configured)
ansible.builtin.debug:
msg: >-
noble_pangolin_sync_http_resources is true but the FQDN list is empty
(set **noble_authentik_ingress_extra_hosts**, **noble_open_webui_public_host**, and/or **noble_pangolin_http_fqdns_extra**).
when: noble_pangolin_http_fqdns_effective | length == 0

View File

@@ -1,22 +0,0 @@
---
# kubectl apply -k can hit transient etcd timeouts under load; retries + longer API deadline help.
noble_platform_kubectl_request_timeout: 120s
noble_platform_kustomize_retries: 5
noble_platform_kustomize_delay: 20
# kube-prometheus-stack: operator Deployment uses Kubernetes default progressDeadlineSeconds (600s).
# First install (images + cert-manager webhook TLS) can exceed that; patch + optional rollout restart, then Helm --wait.
noble_platform_kube_prometheus_operator_progress_deadline_seconds: 1800
noble_platform_kube_prometheus_operator_wait_retries: 60
noble_platform_kube_prometheus_operator_wait_delay: 5
# Longhorn PVCs + full stack often need 45-60m; node-exporter DaemonSet can be last at 3/4 until one node catches up.
noble_platform_kube_prometheus_helm_wait_timeout: 60m
# Loki SingleBinary + Longhorn PVC: Helm **--wait** can exceed **5m** defaults; raise if Longhorn attach is slow.
noble_platform_loki_helm_wait_timeout: 30m
# Before Loki (first Longhorn PVC workload), ensure CSI plugin DaemonSet is fully rolled out (avoids **FailedMount** / backend timeouts).
noble_platform_wait_longhorn_csi_before_loki: true
noble_platform_longhorn_csi_rollout_timeout: 15m
# Decrypt **clusters/noble/secrets/*.yaml** with SOPS and kubectl apply (requires **sops**, **age**, and **age-key.txt**).
noble_apply_sops_secrets: true
noble_sops_age_key_file: "{{ noble_repo_root }}/age-key.txt"

View File

@@ -1,220 +0,0 @@
---
# Mirrors former **noble-platform** Argo Application: Helm releases + plain manifests under clusters/noble/bootstrap.
- name: Apply clusters/noble/bootstrap kustomize (namespaces, Grafana Loki datasource)
ansible.builtin.command:
argv:
- kubectl
- apply
- "--request-timeout={{ noble_platform_kubectl_request_timeout }}"
- -k
- "{{ noble_repo_root }}/clusters/noble/bootstrap"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_platform_kustomize
retries: "{{ noble_platform_kustomize_retries | int }}"
delay: "{{ noble_platform_kustomize_delay | int }}"
until: noble_platform_kustomize.rc == 0
changed_when: true
- name: Stat SOPS age private key (age-key.txt)
ansible.builtin.stat:
path: "{{ noble_sops_age_key_file }}"
register: noble_sops_age_key_stat
- name: Apply SOPS-encrypted cluster secrets (clusters/noble/secrets/*.yaml)
ansible.builtin.shell: |
set -euo pipefail
shopt -s nullglob
for f in "{{ noble_repo_root }}/clusters/noble/secrets"/*.yaml; do
sops -d "$f" | kubectl apply -f -
done
args:
executable: /bin/bash
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
SOPS_AGE_KEY_FILE: "{{ noble_sops_age_key_file }}"
when:
- noble_apply_sops_secrets | default(true) | bool
- noble_sops_age_key_stat.stat.exists
changed_when: true
# Helm --wait alone cannot extend the operator Deployment's progressDeadlineSeconds (default 10m).
- name: Install kube-prometheus-stack (apply without Helm wait)
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- kube-prometheus
- prometheus-community/kube-prometheus-stack
- -n
- monitoring
- --version
- "85.0.3"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/kube-prometheus-stack/values.yaml"
- --force-conflicts
- --wait=false
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Wait for prometheus-operator Deployment object
ansible.builtin.command:
argv:
- kubectl
- get
- deployment/kube-prometheus-kube-prome-operator
- -n
- monitoring
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_kube_prom_operator_deploy
until: noble_kube_prom_operator_deploy.rc == 0
retries: "{{ noble_platform_kube_prometheus_operator_wait_retries | int }}"
delay: "{{ noble_platform_kube_prometheus_operator_wait_delay | int }}"
changed_when: false
- name: Extend prometheus-operator Deployment progress deadline
ansible.builtin.command:
argv:
- kubectl
- patch
- deployment/kube-prometheus-kube-prome-operator
- -n
- monitoring
- --type=merge
- -p
- "{{ {'spec': {'progressDeadlineSeconds': (noble_platform_kube_prometheus_operator_progress_deadline_seconds | int)}} | to_json }}"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Restart prometheus-operator if Deployment already hit progress deadline
ansible.builtin.shell: |
set -euo pipefail
dep=kube-prometheus-kube-prome-operator
msg=$(kubectl get deployment "$dep" -n monitoring -o jsonpath='{.status.conditions[?(@.type=="Progressing")].message}' 2>/dev/null || true)
reason=$(kubectl get deployment "$dep" -n monitoring -o jsonpath='{.status.conditions[?(@.type=="Progressing")].reason}' 2>/dev/null || true)
combined="${reason}${msg}"
if printf '%s' "$combined" | grep -qiE 'ProgressDeadlineExceeded|progress[[:space:]]*deadline[[:space:]]*exceeded'; then
kubectl rollout restart deployment/"$dep" -n monitoring
echo restarted
fi
args:
executable: /bin/bash
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
register: noble_kube_prom_operator_restart
changed_when: "'restarted' in noble_kube_prom_operator_restart.stdout"
# Helm --wait prints nothing until done or timeout; override noble_platform_kube_prometheus_helm_wait_timeout if needed.
- name: Install kube-prometheus-stack (Helm wait for full release; often 30-60m silent - watch kubectl -n monitoring get pods,ds,pvc)
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- kube-prometheus
- prometheus-community/kube-prometheus-stack
- -n
- monitoring
- --version
- "85.0.3"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/kube-prometheus-stack/values.yaml"
- --force-conflicts
- --wait
- --timeout
- "{{ noble_platform_kube_prometheus_helm_wait_timeout }}"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Wait for Longhorn CSI plugin before Loki (PVC attach)
ansible.builtin.command:
argv:
- kubectl
- rollout
- status
- daemonset/longhorn-csi-plugin
- -n
- longhorn-system
- --timeout={{ noble_platform_longhorn_csi_rollout_timeout }}
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
when: noble_platform_wait_longhorn_csi_before_loki | default(true) | bool
changed_when: false
- name: Install Loki
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- loki
- grafana/loki
- -n
- loki
- --version
- "7.0.0"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/loki/values.yaml"
- --force-conflicts
- --wait
- --timeout
- "{{ noble_platform_loki_helm_wait_timeout }}"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Install Fluent Bit
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- fluent-bit
- fluent/fluent-bit
- -n
- logging
- --version
- "0.57.5"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/fluent-bit/values.yaml"
- --force-conflicts
- --wait
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Install Headlamp
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- headlamp
- headlamp/headlamp
- --version
- "0.42.0"
- -n
- headlamp
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/headlamp/values.yaml"
- --force-conflicts
- --wait
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true
- name: Apply Headlamp static manifests (metrics RBAC + OIDC group binding when used)
ansible.builtin.command:
argv:
- kubectl
- apply
- -k
- "{{ noble_repo_root }}/clusters/noble/bootstrap/headlamp"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: true

Some files were not shown because too many files have changed in this diff Show More