Compare commits

...

23 Commits

Author SHA1 Message Date
Nikholas Pcenicni
7df12e3396 Update compose.yaml to change Jellyseerr configuration path to Seerr, ensuring consistency in service setup. 2026-05-28 19:28:27 -04:00
Nikholas Pcenicni
02f6ab03bc Update cert-manager configurations for Let's Encrypt to include DNS-01 challenge support for both pcenicni.dev and nikflix.ca. Clarify Cloudflare API token requirements in comments and remove HTTP-01 fallback references for cleaner configuration. 2026-05-15 01:20:07 -04:00
Nikholas Pcenicni
6e76a400b6 Update .env.sample and Ansible configurations to enhance Pangolin Integration API setup. Add detailed comments for environment variables and clarify usage in README. Implement HTTP-01 challenge support in cert-manager configurations for Let's Encrypt, ensuring proper resource management for domain validation. 2026-05-15 01:10:51 -04:00
Nikholas Pcenicni
2fb86f5930 Enhance Authentik and Newt configurations to support Open WebUI integration. Add necessary environment variables and secrets management for Open WebUI in .env.sample and Ansible tasks. Update README to clarify setup steps for automating HTTP resources with Pangolin, ensuring consistency with new branding and deployment practices. 2026-05-15 00:04:34 -04:00
Nikholas Pcenicni
97da42b15c Add open-webui resource to Kustomization configuration for Noble cluster, enhancing application deployment structure. 2026-05-14 23:36:35 -04:00
Nikholas Pcenicni
73ff9fd022 Revise Authentik configuration to reflect the transition from public to Nikflix branding, updating directory group settings and invitation flows accordingly. Adjust README to clarify group merging and user invitation processes, ensuring consistency with the new branding. Enhance blueprint templates to support the updated structure for directory groups. 2026-05-14 23:30:13 -04:00
Nikholas Pcenicni
f37768b789 Update Authentik configuration to default public invitation user type to internal, enhancing clarity on user isolation. Revise README to reflect changes in user type settings and provide guidance for managing existing external users. Improve validation in Ansible tasks to ensure correct user type configuration. 2026-05-14 23:15:34 -04:00
Nikholas Pcenicni
7fed8820ce Enhance Authentik configuration by introducing dedicated authentication flows for public and lab brands, including stricter password policies and MFA requirements. Update README to clarify flow distinctions and invitation enrollment processes. Improve validation in Ansible tasks to ensure all necessary blueprint variables are set, enhancing deployment robustness. 2026-05-14 22:59:40 -04:00
Nikholas Pcenicni
7b337f7128 Refactor Authentik blueprint configuration to merge public, extra, and Nikflix directory groups into a single YAML template. Update README to clarify group entry requirements and enhance validation in Ansible tasks for blueprint entries. This improves the structure and usability of directory groups in Authentik deployments. 2026-05-14 22:39:53 -04:00
Nikholas Pcenicni
93d602de9d Enhance Authentik deployment by adding verification for the presence of noble blueprints volume mounts in the authentik-worker deployment. Update README to clarify blueprint confirmation steps and troubleshooting for mounted files. This ensures proper configuration when blueprints are enabled. 2026-05-14 22:26:41 -04:00
Nikholas Pcenicni
fbcd2416e6 Add optional SMTP configuration for Authentik, including email host, port, and credentials. Update README and .env.sample to clarify usage for outbound email settings. Introduce blueprint support for enhanced deployment flexibility, with assertions in Ansible tasks to ensure required variables are set when enabled. 2026-05-14 22:21:11 -04:00
Nikholas Pcenicni
e48b19b64c Implement S3 media storage for Authentik by adding configuration options for dedicated S3 bucket and credentials. Update README and default values to clarify usage and requirements for S3 integration, ensuring compatibility with Velero settings. Enhance Ansible tasks to load S3 configurations from the environment. 2026-05-14 20:07:52 -04:00
Nikholas Pcenicni
57a149b3d2 Update Authentik values.yaml to clarify PVC usage for media uploads. Specify that authentik-data is mounted on the server only to avoid Multi-Attach errors, and recommend using S3 or an RWX StorageClass for shared media access from workers. 2026-05-14 20:05:30 -04:00
Nikholas Pcenicni
032ffee866 Enable Authentik installation and add support for extra public hostnames in the configuration. Updated README and values files to reflect changes for improved deployment flexibility and documentation clarity. 2026-05-14 19:58:56 -04:00
Nikholas Pcenicni
b90ee2d531 Disable infra assessment in Trivy configuration for Talos to prevent node-collector job failures due to read-only file system issues. Updated comments for clarity and removed unnecessary volume mounts to align with Talos constraints. 2026-05-14 19:33:36 -04:00
Nikholas Pcenicni
b994e2d6d0 Refine Trivy configuration for Talos by removing systemd mounts from nodeCollector to prevent read-only file system errors. Updated comments for clarity and aligned with upstream changes regarding volume management. 2026-05-14 19:29:34 -04:00
Nikholas Pcenicni
c3bdda1dd7 Enhance Trivy configuration for Talos by adding nodeCollector settings with emptyDir for systemd paths to address read-only file system issues. Updated volume mounts and volumes for improved compliance and functionality. 2026-05-14 19:26:58 -04:00
Nikholas Pcenicni
7c9fd1fde6 Enhance Headlamp's metrics access by updating the ClusterRoleBinding to include permissions for metrics.k8s.io, nodes, and CustomResourceDefinitions. Update README and RBAC documentation to clarify OIDC user permissions and troubleshooting steps for metrics visibility issues. 2026-05-14 19:24:44 -04:00
Nikholas Pcenicni
bb0bd4ca90 Update OIDC configuration in Headlamp documentation and Talos config to use preferred_username claim instead of email. Added troubleshooting steps for "Unauthorized" errors related to OIDC token validation. 2026-05-14 19:15:47 -04:00
Nikholas Pcenicni
817849ee3c Add CA certificates bundle and update Headlamp configuration for OIDC 2026-05-14 18:59:47 -04:00
Nikholas Pcenicni
2321209626 Update Helm chart versions across multiple components to latest stable releases, including Argo CD (9.5.14), cert-manager (v1.20.2), Cilium (1.19.4), kube-prometheus-stack (85.0.3), Loki (7.0.0), Fluent Bit (0.57.5), Headlamp (0.42.0), Traefik (40.2.0), and Kyverno (3.8.0). Adjusted related documentation and values files to reflect these changes for improved deployment consistency and compatibility. 2026-05-14 18:55:18 -04:00
Nikholas Pcenicni
95b1866144 Update .gitignore to exclude all .tmp files and enhance clarity in middleware-https-proto.yaml by adding X-Forwarded-Host and X-Forwarded-Port headers for improved redirect handling in Headlamp. 2026-05-14 18:35:49 -04:00
Nikholas Pcenicni
86df02f9bd Update Traefik Ingress middleware reference in values.yaml for Headlamp to align with CRD naming conventions and improve documentation clarity. 2026-05-14 18:31:27 -04:00
82 changed files with 5412 additions and 154 deletions

View File

@@ -12,13 +12,25 @@ 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 (native OIDC) and Prometheus/Alertmanager/Longhorn via oauth2-proxy (OIDC to Authentik + Traefik ForwardAuth). See **ansible/roles/noble_authentik/README.md**.
# 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=
@@ -28,5 +40,28 @@ 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

2
.gitignore vendored
View File

@@ -6,7 +6,7 @@ talos/kubeconfig
# Local secrets
age-key.txt
.env
.tmp
.tmp*
# Generated by ansible noble_landing_urls
ansible/output/noble-lab-ui-urls.md

View File

@@ -184,7 +184,7 @@ Shared services used across multiple applications.
- **[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`. 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).
**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).
---

View File

@@ -14,16 +14,23 @@ noble_k8s_api_server_fallback: "https://192.168.50.20:6443"
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: false
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: false
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

@@ -225,7 +225,7 @@
- role: noble_cert_manager
tags: [cert_manager, certs]
- role: noble_newt
tags: [newt]
tags: [newt, pangolin]
- role: noble_argocd
tags: [argocd, gitops]
- role: noble_platform

View File

@@ -11,7 +11,7 @@
- argocd
- --create-namespace
- --version
- "9.4.17"
- "9.5.14"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/argocd/values.yaml"
- --force-conflicts

View File

@@ -4,7 +4,7 @@ Installs **Authentik** (Helm `goauthentik/authentik`) as the cluster IdP, **oaut
## Enable
1. Copy repository **`.env.sample`** to **`.env`** and set every **`NOBLE_AUTHENTIK_*`** variable (see comments there).
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`**.
@@ -14,9 +14,105 @@ Installs **Authentik** (Helm `goauthentik/authentik`) as the cluster IdP, **oaut
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**, 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).
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
@@ -25,6 +121,7 @@ When **`noble_authentik_configure_idp`** is true, Ansible creates/updates OAuth2
## 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`**.
@@ -41,7 +138,7 @@ When **`noble_authentik_configure_idp`** is true, Ansible creates/updates OAuth2
- **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.
- **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)

View File

@@ -21,13 +21,108 @@ 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
@@ -49,8 +144,15 @@ 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.
@@ -75,8 +177,8 @@ 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.4.17"
noble_authentik_kube_prometheus_chart_version: "82.15.1"
noble_authentik_headlamp_chart_version: "0.40.1"
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

@@ -195,6 +195,69 @@
- (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
@@ -215,3 +278,302 @@
- 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

@@ -22,9 +22,38 @@
- 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:
@@ -52,6 +81,78 @@
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:
@@ -76,6 +177,24 @@
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:
@@ -454,6 +573,32 @@
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

View File

@@ -22,5 +22,11 @@
"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

@@ -11,6 +11,61 @@ global:
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

@@ -27,6 +27,12 @@
"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

@@ -0,0 +1,38 @@
# 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

@@ -0,0 +1,148 @@
# 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

@@ -0,0 +1,80 @@
# 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

@@ -0,0 +1,227 @@
# 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

@@ -0,0 +1,27 @@
# 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

@@ -21,7 +21,7 @@
- --namespace
- cert-manager
- --version
- v1.20.0
- v1.20.2
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/cert-manager/values.yaml"
- --force-conflicts

View File

@@ -51,7 +51,7 @@
- --namespace
- kube-system
- --version
- "1.16.6"
- "1.19.4"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/cilium/values.yaml"
- --force-conflicts

View File

@@ -56,7 +56,7 @@
- -n
- kyverno
- --version
- "3.7.1"
- "3.8.0"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/kyverno/values.yaml"
- --force-conflicts

View File

@@ -44,7 +44,7 @@
- -n
- kyverno
- --version
- "3.7.1"
- "3.8.0"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/kyverno/policies-values.yaml"
- --force-conflicts

View File

@@ -1,3 +1,11 @@
---
# 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

@@ -3,40 +3,46 @@
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: Create Newt namespace
ansible.builtin.command:
argv:
- kubectl
- apply
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/newt/namespace.yaml"
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
- name: Deploy Newt (Pangolin tunnel) and optional Pangolin HTTP resource sync
when: noble_newt_install | bool
changed_when: true
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
when: noble_newt_install | bool
- 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.2.0"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/newt/values.yaml"
- --force-conflicts
- --wait
environment:
KUBECONFIG: "{{ noble_kubeconfig }}"
when: noble_newt_install | bool
changed_when: true
- 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

@@ -0,0 +1,95 @@
---
# 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

@@ -50,7 +50,7 @@
- -n
- monitoring
- --version
- "82.15.1"
- "85.0.3"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/kube-prometheus-stack/values.yaml"
- --force-conflicts
@@ -120,7 +120,7 @@
- -n
- monitoring
- --version
- "82.15.1"
- "85.0.3"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/kube-prometheus-stack/values.yaml"
- --force-conflicts
@@ -157,7 +157,7 @@
- -n
- loki
- --version
- "6.55.0"
- "7.0.0"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/loki/values.yaml"
- --force-conflicts
@@ -179,7 +179,7 @@
- -n
- logging
- --version
- "0.56.0"
- "0.57.5"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/fluent-bit/values.yaml"
- --force-conflicts
@@ -197,7 +197,7 @@
- headlamp
- headlamp/headlamp
- --version
- "0.40.1"
- "0.42.0"
- -n
- headlamp
- -f

View File

@@ -21,7 +21,7 @@
- --namespace
- traefik
- --version
- "39.0.6"
- "40.2.0"
- -f
- "{{ noble_repo_root }}/clusters/noble/bootstrap/traefik/values.yaml"
- --force-conflicts

View File

@@ -1,6 +1,6 @@
---
# **noble_velero_install** is in **ansible/inventory/group_vars/all.yml**. Override S3 fields via extra-vars or group_vars.
noble_velero_chart_version: "12.0.0"
noble_velero_chart_version: "12.0.1"
noble_velero_s3_bucket: ""
noble_velero_s3_url: ""

View File

@@ -5,4 +5,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- homepage
- open-webui
- trivy

View File

@@ -0,0 +1,32 @@
# Argo CD — [Open WebUI](https://openwebui.com/) ([helm chart](https://github.com/open-webui/helm-charts)).
# Values: **`./values.yaml`** (multi-source **`$values`** ref).
#
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: open-webui
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io/background
spec:
project: default
sources:
- repoURL: https://helm.openwebui.com
chart: open-webui
targetRevision: 14.5.0
helm:
releaseName: open-webui
valueFiles:
- $values/clusters/noble/apps/open-webui/values.yaml
- repoURL: https://gitea.pcenicni.ca/gsdavidp/home-server.git
targetRevision: HEAD
ref: values
destination:
server: https://kubernetes.default.svc
namespace: open-webui
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true

View File

@@ -0,0 +1,6 @@
# Helm values live alongside but are not kustomize resources — Argo loads them via **$values** in **application.yaml**.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- application.yaml

View File

@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: open-webui
labels:
app.kubernetes.io/name: open-webui

View File

@@ -0,0 +1,65 @@
# Open WebUI — https://github.com/open-webui/helm-charts (chart **open-webui**).
# Ingress: Traefik + cert-manager (same pattern as **`clusters/noble/apps/homepage/values.yaml`**).
#
# **Secrets** (**`OPENAI_API_KEY`**, **`WEBUI_SECRET_KEY`**, **`OAUTH_CLIENT_SECRET`**) are created in-cluster by
# **`ansible-playbook … --tags authentik`** as **`open-webui/open-webui-secrets`** (see **noble_authentik** role and **`.env.sample`**).
# **OIDC** uses Authentik provider slug **`open-webui`** (issuer **`…/application/o/open-webui/`**). Do **not** put ForwardAuth on this Ingress while using native OIDC (same pattern as Headlamp).
# **Public host only** (Pangolin → Newt → Traefik): keep **ingress.host**, **OPENID_PROVIDER_URL**, **OPENID_REDIRECT_URI**, and **WEBUI_URL** in sync with **`noble_open_webui_public_host`** in Ansible **group_vars** (see **noble_authentik** README).
#
ollama:
enabled: false
pipelines:
enabled: false
ollamaUrls: []
ingress:
enabled: true
class: traefik
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
host: webui.nikflix.ca
additionalHosts: []
tls: true
existingSecret: ""
enableOpenaiApi: true
openaiBaseApiUrl: "https://api.openai.com/v1"
openaiApiKey: ""
openaiApiKeyExistingSecret: open-webui-secrets
openaiApiKeyExistingSecretKey: OPENAI_API_KEY
extraEnvVars:
- name: ENABLE_OAUTH_SIGNUP
value: "true"
- name: OAUTH_MERGE_ACCOUNTS_BY_EMAIL
value: "true"
- name: OAUTH_PROVIDER_NAME
value: "Authentik"
- name: OAUTH_CLIENT_ID
value: "open-webui"
- name: OPENID_PROVIDER_URL
value: "https://auth.nikflix.ca/application/o/open-webui/.well-known/openid-configuration"
- name: OAUTH_SCOPES
value: "openid email profile offline_access"
- name: OPENID_REDIRECT_URI
value: "https://webui.nikflix.ca/oauth/oidc/callback"
- name: WEBUI_URL
value: "https://webui.nikflix.ca"
- name: ENABLE_OAUTH_PERSISTENT_CONFIG
value: "false"
- name: WEBUI_SECRET_KEY
valueFrom:
secretKeyRef:
name: open-webui-secrets
key: WEBUI_SECRET_KEY
- name: OAUTH_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: open-webui-secrets
key: OAUTH_CLIENT_SECRET
persistence:
enabled: true
size: 5Gi

View File

@@ -1,4 +1,7 @@
# Trivy Operator — in-cluster image vulnerability + config reports (Aqua trivy-operator Helm chart).
# **Talos:** **infra assessment** runs **node-collector** Jobs that expect traditional paths under **/etc** (systemd, kubernetes, CNI).
# The image/runtime still hits **`mkdir /etc/systemd: read-only file system`** even without those volume mounts — a PVC for logs does not change that.
# This repo disables **infra assessment** (no node-collector). Re-enable only if you accept reduced mounts below or upstream fixes Talos.
# Deploy via Argo CD: **noble-trivy-operator** (`clusters/noble/bootstrap/argocd/app-of-apps/trivy-operator-application.yaml`).
#
# Web UI (separate chart; OAuth via Traefik ForwardAuth → oauth2-proxy / Authentik): sync **noble-trivy-dashboard**
@@ -22,12 +25,27 @@ operator:
sbomGenerationEnabled: false
clusterSbomCacheEnabled: false
clusterComplianceEnabled: false
# Node-collector Jobs (hostPath under /etc, systemd) break on Talos RO layout; disable infra assessment entirely.
infraAssessmentScannerEnabled: false
trivyOperator:
# Run scan Jobs on every node (Talos / mixed taints).
scanJobTolerations:
- operator: Exists
# If you set **infraAssessmentScannerEnabled: true** on Talos, keep mounts off **/etc** (RO in the image). Kubelet is the main useful host path.
nodeCollector:
tolerations:
- operator: Exists
volumeMounts:
- name: var-lib-kubelet
mountPath: /var/lib/kubelet
readOnly: true
volumes:
- name: var-lib-kubelet
hostPath:
path: /var/lib/kubelet
serviceMonitor:
enabled: true
namespace: monitoring

View File

@@ -10,7 +10,7 @@ helm repo update
helm upgrade --install argocd argo/argo-cd \
--namespace argocd \
--create-namespace \
--version 9.4.17 \
--version 9.5.14 \
-f clusters/noble/bootstrap/argocd/values.yaml \
--wait
```
@@ -43,7 +43,7 @@ If **`helm upgrade --wait`** fails with *Secret was previously issued by `letsen
kubectl -n argocd delete certificate argocd-server --ignore-not-found
kubectl -n argocd delete secret argocd-server-tls --ignore-not-found
helm upgrade --install argocd argo/argo-cd -n argocd --create-namespace \
--version 9.4.17 -f clusters/noble/bootstrap/argocd/values.yaml --wait
--version 9.5.14 -f clusters/noble/bootstrap/argocd/values.yaml --wait
```
## 3. Register this repo (if private)
@@ -112,4 +112,4 @@ After **`noble-bootstrap-root`** is automated and leaf apps are synced, **git**
## Versions
Pinned in **`values.yaml`** comments (chart **9.4.17** / Argo CD **v3.3.6** at time of writing). Bump **`--version`** when upgrading.
Pinned in **`values.yaml`** comments (chart **9.5.14** / Argo CD **v3.4.2** at time of writing). Bump **`--version`** when upgrading.

View File

@@ -14,7 +14,7 @@ spec:
path: clusters/noble/bootstrap/cert-manager
- repoURL: https://charts.jetstack.io
chart: cert-manager
targetRevision: v1.20.0
targetRevision: v1.20.2
helm:
releaseName: cert-manager
valueFiles:

View File

@@ -11,7 +11,7 @@ spec:
sources:
- repoURL: https://helm.cilium.io/
chart: cilium
targetRevision: 1.16.6
targetRevision: 1.19.4
helm:
releaseName: cilium
valueFiles:

View File

@@ -11,7 +11,7 @@ spec:
sources:
- repoURL: https://fluent.github.io/helm-charts
chart: fluent-bit
targetRevision: 0.56.0
targetRevision: 0.57.5
helm:
releaseName: fluent-bit
valueFiles:

View File

@@ -11,7 +11,7 @@ spec:
sources:
- repoURL: https://kubernetes-sigs.github.io/headlamp/
chart: headlamp
targetRevision: 0.40.1
targetRevision: 0.42.0
helm:
releaseName: headlamp
valueFiles:

View File

@@ -11,7 +11,7 @@ spec:
sources:
- repoURL: https://prometheus-community.github.io/helm-charts
chart: kube-prometheus-stack
targetRevision: 82.15.1
targetRevision: 85.0.3
helm:
skipCrds: true
releaseName: kube-prometheus

View File

@@ -11,7 +11,7 @@ spec:
sources:
- repoURL: https://kyverno.github.io/kyverno/
chart: kyverno
targetRevision: 3.7.1
targetRevision: 3.8.0
helm:
releaseName: kyverno
valueFiles:

View File

@@ -11,7 +11,7 @@ spec:
sources:
- repoURL: https://kyverno.github.io/kyverno/
chart: kyverno-policies
targetRevision: 3.7.1
targetRevision: 3.8.0
helm:
releaseName: kyverno-policies
valueFiles:

View File

@@ -11,7 +11,7 @@ spec:
sources:
- repoURL: https://grafana.github.io/helm-charts
chart: loki
targetRevision: 6.55.0
targetRevision: 7.0.0
helm:
releaseName: loki
valueFiles:

View File

@@ -11,7 +11,7 @@ spec:
sources:
- repoURL: https://traefik.github.io/charts
chart: traefik
targetRevision: 39.0.6
targetRevision: 40.2.0
helm:
releaseName: traefik
valueFiles:

View File

@@ -1,13 +1,13 @@
# Argo CD — noble lab (GitOps)
#
# Chart: argo/argo-cd — pin version on the helm command (e.g. 9.4.17).
# Chart: argo/argo-cd — pin version on the helm command (e.g. 9.5.14).
# UI/API: **Ingress** via **Traefik** at **argo.apps.noble.lab.pcenicni.dev** (TLS: cert-manager
# ClusterIssuer + **`server.insecure`** so TLS terminates at Traefik).
# DNS: **`argo.apps.noble.lab.pcenicni.dev`** → Traefik LB **192.168.50.211** (same wildcard as apps).
#
# helm repo add argo https://argoproj.github.io/argo-helm
# helm upgrade --install argocd argo/argo-cd -n argocd --create-namespace \
# --version 9.4.17 -f clusters/noble/bootstrap/argocd/values.yaml --wait
# --version 9.5.14 -f clusters/noble/bootstrap/argocd/values.yaml --wait
#
# Initial admin password: kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d
#

View File

@@ -3,12 +3,18 @@
# Secrets (secret_key, postgres password, bootstrap) are supplied at install time by Ansible
# (-f authentik-extra-values.yaml from noble_authentik role). Do not commit real secrets here.
#
# DNS: auth.apps.noble.lab.pcenicni.dev → Traefik LB (see traefik/values.yaml).
# DNS: auth.apps.noble.lab.pcenicni.dev → Traefik LB (see traefik/values.yaml). Optional **extra** Ingress hostnames
# (e.g. a public Pangolin FQDN) are merged by Ansible — **`noble_authentik_ingress_extra_hosts`** in **group_vars** (see **noble_authentik** README).
#
# helm repo add goauthentik https://charts.goauthentik.io && helm repo update
# kubectl apply -f clusters/noble/bootstrap/authentik/namespace.yaml
# helm upgrade --install authentik goauthentik/authentik -n authentik --create-namespace \
# --version 2026.2.3 -f clusters/noble/bootstrap/authentik/values.yaml -f /path/to/extra.yaml --wait
#
# **Media / uploads:** **S3** (same endpoint/credentials pattern as **Velero** — see **ansible/roles/noble_authentik** and **.env.sample**).
# Ansible sets **`AUTHENTIK_STORAGE__BACKEND=s3`** in **`authentik-extra-values.yaml.j2`**; use a **dedicated** media bucket, not the Velero backup bucket.
# **SMTP:** optional — set **`NOBLE_AUTHENTIK_SMTP_HOST`** + **`NOBLE_AUTHENTIK_SMTP_FROM`** in repo **`.env`** so Ansible injects **`AUTHENTIK_EMAIL__*`** into Helm **`global.env`** (see role README).
# **Blueprints:** optional **`blueprints.configMaps`** is merged by Ansible when **`noble_authentik_blueprints_enabled`** is true — see **`ansible/roles/noble_authentik/README.md`**.
postgresql:
enabled: true

View File

@@ -29,7 +29,7 @@ Without this Secret, **`ClusterIssuer`** will not complete certificate orders.
helm repo update
helm upgrade --install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--version v1.20.0 \
--version v1.20.2 \
-f clusters/noble/bootstrap/cert-manager/values.yaml \
--wait
```

View File

@@ -11,8 +11,8 @@ spec:
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
# DNS-01 — works when public HTTP to Traefik is wrong (e.g. hostname proxied through Cloudflare
# returns 404 for /.well-known/acme-challenge). Requires Secret cloudflare-dns-api-token in cert-manager.
# DNS-01 — Cloudflare token must have Zone.Read + DNS.Edit for BOTH pcenicni.dev AND nikflix.ca.
# Edit the token in Cloudflare → My Profile → API Tokens to add nikflix.ca zone permissions.
- dns01:
cloudflare:
apiTokenSecretRef:
@@ -21,3 +21,4 @@ spec:
selector:
dnsZones:
- pcenicni.dev
- nikflix.ca

View File

@@ -19,3 +19,4 @@ spec:
selector:
dnsZones:
- pcenicni.dev
- nikflix.ca

View File

@@ -1,12 +1,12 @@
# cert-manager — noble lab
#
# Chart: jetstack/cert-manager — pin version on the helm command (e.g. v1.20.0).
# Chart: jetstack/cert-manager — pin version on the helm command (e.g. v1.20.2).
#
# kubectl apply -f clusters/noble/bootstrap/cert-manager/namespace.yaml
# helm repo add jetstack https://charts.jetstack.io
# helm repo update
# helm upgrade --install cert-manager jetstack/cert-manager -n cert-manager \
# --version v1.20.0 -f clusters/noble/bootstrap/cert-manager/values.yaml --wait
# --version v1.20.2 -f clusters/noble/bootstrap/cert-manager/values.yaml --wait
#
# kubectl apply -k clusters/noble/bootstrap/cert-manager

View File

@@ -13,7 +13,7 @@ helm repo add cilium https://helm.cilium.io/
helm repo update
helm upgrade --install cilium cilium/cilium \
--namespace kube-system \
--version 1.16.6 \
--version 1.19.4 \
-f clusters/noble/bootstrap/cilium/values.yaml \
--wait
```

View File

@@ -1,7 +1,7 @@
# Cilium on Talos — phase 1: bring up CNI while kube-proxy still runs.
# See README.md for install order (before MetalLB scheduling) and optional kube-proxy replacement.
#
# Chart: cilium/cilium — pin version in helm command (e.g. 1.16.6).
# Chart: cilium/cilium — pin version in helm command (e.g. 1.19.4).
# Ref: https://www.talos.dev/latest/kubernetes-guides/network/deploying-cilium/
ipam:

View File

@@ -1,6 +1,6 @@
# Fluent Bit — noble lab (DaemonSet; ship Kubernetes container logs to Loki gateway).
#
# Chart: fluent/fluent-bit — pin version on install (e.g. 0.56.0).
# Chart: fluent/fluent-bit — pin version on install (e.g. 0.57.5).
# Install **after** Loki so `loki-gateway.loki.svc` exists.
#
# Talos: only **tail** `/var/log/containers` (no host **systemd** input — journal layout differs from typical Linux).
@@ -9,7 +9,7 @@
# helm repo add fluent https://fluent.github.io/helm-charts
# helm repo update
# helm upgrade --install fluent-bit fluent/fluent-bit -n logging \
# --version 0.56.0 -f clusters/noble/bootstrap/fluent-bit/values.yaml --wait --timeout 15m
# --version 0.57.5 -f clusters/noble/bootstrap/fluent-bit/values.yaml --wait --timeout 15m
config:
inputs: |

View File

@@ -2,8 +2,9 @@
[Headlamp](https://headlamp.dev/) web UI for the cluster. Exposed on **`https://headlamp.apps.noble.lab.pcenicni.dev`** via **Traefik** + **cert-manager** (`letsencrypt-prod`), same pattern as Grafana.
- **Chart:** `headlamp/headlamp` **0.40.1** (`config.sessionTTL: null` avoids chart/binary mismatch — [issue #4883](https://github.com/kubernetes-sigs/headlamp/issues/4883))
- **Chart:** `headlamp/headlamp` **0.42.0** (`config.sessionTTL: null` still omits **`-session-ttl`** if needed — [issue #4883](https://github.com/kubernetes-sigs/headlamp/issues/4883))
- **Namespace:** `headlamp`
- **OIDC TLS:** **`cacert.pem`** (Mozilla bundle from [curl CA extract](https://curl.se/ca/cacert.pem)) is baked into ConfigMap **`headlamp-oidc-ca-bundle`** via **`kustomization.yaml`** and mounted at **`/etc/ssl/headlamp/oidc-ca-bundle.pem`** for **`-oidc-ca-file`** (stops empty-PEM log noise; refresh the file occasionally). If Authentik used a **private** CA, append that PEM to **`cacert.pem`** (or replace) before sync.
## Install
@@ -12,7 +13,7 @@ helm repo add headlamp https://kubernetes-sigs.github.io/headlamp/
helm repo update
kubectl apply -f clusters/noble/bootstrap/headlamp/namespace.yaml
helm upgrade --install headlamp headlamp/headlamp -n headlamp \
--version 0.40.1 -f clusters/noble/bootstrap/headlamp/values.yaml --wait --timeout 10m
--version 0.42.0 -f clusters/noble/bootstrap/headlamp/values.yaml --wait --timeout 10m
```
Sign-in uses a **ServiceAccount token** (Headlamp docs: create a limited SA for day-to-day use). This repo binds the Headlamp workload SA to the built-in **`edit`** ClusterRole (**`clusterRoleBinding.clusterRoleName: edit`** in **`values.yaml`**) — not **`cluster-admin`**. For cluster-scoped admin work, use **`kubectl`** with your admin kubeconfig. Optional **OIDC** in **`config.oidc`** replaces token login for SSO. **In-cluster OIDC requires kube-apiserver OIDC** (same Authentik app issuer + **`oidc-client-id: headlamp`**) or proxied K8s calls return **401** while **`/me`** still returns 200 — see **`talos/talconfig.yaml`**, **`oidc-noble-admins-clusterrolebinding.yaml`**, and **`ansible/roles/noble_authentik/README.md`** troubleshooting.
@@ -28,6 +29,19 @@ kubectl -n headlamp create token headlamp --duration=48h
Paste the printed JWT into Headlamps token field at **`https://headlamp.apps.noble.lab.pcenicni.dev`**.
## OIDC: still “Unauthorized” while pod logs look fine
Headlamp logs like **“Request completed successfully”** for **`/plugins`** or static assets do **not** prove cluster API auth. After SSO, calls such as **`/clusters/main/version`** or **`…/selfsubjectrulesreviews`** use your **OIDC id_token**; **kube-apiserver** must validate it ([Kubernetes OIDC](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens)).
1. **Confirm API server flags** match **`talos/talconfig.yaml`** (same **`oidc-issuer-url`** and **`oidc-client-id: headlamp`** as Secret **`headlamp-oidc`** / Authentik app **headlamp**). On Talos, apply regenerated control-plane machine configs and roll nodes so **`kube-apiserver`** actually picks up **`oidc-*`** extraArgs.
2. **Inspect the id_token** (browser devtools → Headlamp storage / network, or Authentik “Preview”): **`aud`** must include **`headlamp`**; for this repos **`oidc-noble-admins-clusterrolebinding.yaml`**, **`groups`** must list **`noble-admins`** exactly (if missing, see **`noble_authentik_headlamp_oidc_scopes`** and **`ansible/roles/noble_authentik/README.md`**).
3. **API server logs** often spell out the failure (**invalid bearer token**, wrong **audience**, unknown **issuer**). Check **`kube-apiserver`** logs on a control-plane node if steps 12 look correct.
4. **`oidc: email not verified`**: with **`oidc-username-claim: email`**, the API server rejects **`email_verified: false`**. Either set **`oidc-username-claim`** to a non-email claim (this repo uses **`preferred_username`** in **`talos/talconfig.yaml`**) or make Authentik issue **`email_verified: true`** for that user.
## OIDC: no nodes, no CPU/memory, plugins misbehave
In-cluster Headlamp calls the API **as your OIDC user**, not as the **headlamp** ServiceAccount. The built-in **`edit`** role does not cover **`metrics.k8s.io`** or cluster **nodes**. Re-apply **`kubectl apply -k clusters/noble/bootstrap/headlamp`** so **`metrics-clusterrolebinding.yaml`** stays current: it binds **`noble-admins`** to **`headlamp-metrics-reader`**, which adds metrics, **nodes**, and read-only **CustomResourceDefinitions** (helps many plugins). Ensure **metrics-server** (or equivalent) is installed. If the plugin marketplace never loads, check the browser network tab for blocked HTTPS requests to external hosts.
To use another duration (cluster `spec.serviceAccount` / admission limits may cap it):
```bash

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,14 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# namespace.yaml is owned by noble-bootstrap-root (clusters/noble/bootstrap/kustomization.yaml).
# Do not include it here — two Applications owning the same Namespace causes SharedResourceWarning.
generatorOptions:
disableNameSuffixHash: true
configMapGenerator:
# Mozilla CA bundle (https://curl.se/ca/cacert.pem) — mounted for **-oidc-ca-file** so Headlamps OIDC
# client uses a non-empty PEM pool (avoids spurious “failed to append ca cert to pool” when IdP TLS is public PKI).
- name: headlamp-oidc-ca-bundle
files:
- oidc-ca-bundle.pem=cacert.pem
resources:
- metrics-clusterrolebinding.yaml
- oidc-noble-admins-clusterrolebinding.yaml

View File

@@ -1,7 +1,10 @@
# Grant Headlamp's ServiceAccount read access to the Kubernetes Metrics API.
# The chart binds headlamp SA to 'edit' (safe default) but 'edit' does not include
# metrics.k8s.io — without this, Headlamp shows no CPU/memory/node data on the dashboard.
# This binding is additive: it does not escalate headlamp beyond 'edit' elsewhere.
# Additive dashboard permissions on top of the built-in **edit** ClusterRole (Helm **clusterRoleBinding.clusterRoleName**).
# The chart binds the Headlamp **ServiceAccount** to **edit**, but **edit** does not cover:
# - **metrics.k8s.io** (no CPU/memory from metrics-server without this)
# - **nodes** / **nodes/status** at cluster scope (cluster overview / node pages stay empty)
# **OIDC** users authenticate as themselves, not the pod SA — the same ClusterRole must be bound to IdP groups
# (e.g. **noble-admins**) or they see 403 on metrics and node list while namespaced resources still work.
# **customresourcedefinitions** (read-only): many Headlamp plugins list CRDs to register views; **edit** alone often omits this.
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
@@ -14,6 +17,12 @@ rules:
- apiGroups: ["metrics.k8s.io"]
resources: ["nodes", "pods"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["nodes", "nodes/status"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apiextensions.k8s.io"]
resources: ["customresourcedefinitions"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
@@ -30,3 +39,6 @@ subjects:
- kind: ServiceAccount
name: headlamp
namespace: headlamp
- apiGroup: rbac.authorization.k8s.io
kind: Group
name: noble-admins

View File

@@ -1,7 +1,10 @@
# Traefik terminates TLS; the hop Traefik → Headlamp is often HTTP, so Headlamp may see
# X-Forwarded-Proto=http and build OAuth redirect/callback as http — Authentik then rejects
# the flow (redirect URI / PKCE / cookie issues). Force the external scheme for Headlamp.
# Reference from Ingress: headlamp-https-proto@kubernetescrd (same namespace as the Ingress).
# Also set host/port so post-callback redirects and cookie/session logic match the browser URL
# (see Headlamp in-cluster OIDC docs: X-Forwarded-Proto; missing Forwarded-* can strand users
# after IdP login).
# Ingress ref: <namespace>-headlamp-https-proto@kubernetescrd (e.g. headlamp-headlamp-https-proto@kubernetescrd).
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
@@ -14,3 +17,5 @@ spec:
headers:
customRequestHeaders:
X-Forwarded-Proto: "https"
X-Forwarded-Host: "headlamp.apps.noble.lab.pcenicni.dev"
X-Forwarded-Port: "443"

View File

@@ -4,7 +4,7 @@
# helm repo update
# kubectl apply -f clusters/noble/bootstrap/headlamp/namespace.yaml
# helm upgrade --install headlamp headlamp/headlamp -n headlamp \
# --version 0.40.1 -f clusters/noble/bootstrap/headlamp/values.yaml --wait --timeout 10m
# --version 0.42.0 -f clusters/noble/bootstrap/headlamp/values.yaml --wait --timeout 10m
#
# DNS: headlamp.apps.noble.lab.pcenicni.dev → Traefik LB (see talos/CLUSTER-BUILD.md).
# Default chart RBAC is broad — restrict for production (Phase G).
@@ -16,10 +16,22 @@
clusterRoleBinding:
clusterRoleName: edit
#
# Chart 0.40.1 passes -session-ttl but the v0.40.1 binary does not define it — omit the flag:
# https://github.com/kubernetes-sigs/headlamp/issues/4883
# Optional: set **config.sessionTTL** (seconds) or **null** to omit **-session-ttl** (see headlamp#4883 for older chart/binary mismatches).
config:
sessionTTL: null
extraArgs:
# PEM pool from ConfigMap **headlamp-oidc-ca-bundle** (see **kustomization.yaml** + **cacert.pem**).
- "-oidc-ca-file=/etc/ssl/headlamp/oidc-ca-bundle.pem"
volumeMounts:
- name: oidc-ca-bundle
mountPath: /etc/ssl/headlamp
readOnly: true
volumes:
- name: oidc-ca-bundle
configMap:
name: headlamp-oidc-ca-bundle
ingress:
enabled: true
@@ -27,7 +39,8 @@ ingress:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
# Headlamp OIDC behind Traefik: ensure external TLS scheme reaches the app (see middleware-https-proto.yaml).
traefik.ingress.kubernetes.io/router.middlewares: headlamp-https-proto@kubernetescrd
# Traefik Ingress refs CRD middlewares as <k8s-namespace>-<middleware-metadata.name>@kubernetescrd (see Traefik docs).
traefik.ingress.kubernetes.io/router.middlewares: headlamp-headlamp-https-proto@kubernetescrd
hosts:
- host: headlamp.apps.noble.lab.pcenicni.dev
paths:

View File

@@ -1,13 +1,13 @@
# kube-prometheus-stack — noble lab (Prometheus Operator + Grafana + Alertmanager + exporters)
#
# Chart: prometheus-community/kube-prometheus-stack — pin version on install (e.g. 82.15.1).
# Chart: prometheus-community/kube-prometheus-stack — pin version on install (e.g. 85.0.3).
#
# Install (use one terminal; chain with && so `helm upgrade` always runs after `helm repo update`):
#
# kubectl apply -f clusters/noble/bootstrap/kube-prometheus-stack/namespace.yaml
# helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
# helm repo update && helm upgrade --install kube-prometheus prometheus-community/kube-prometheus-stack -n monitoring \
# --version 82.15.1 -f clusters/noble/bootstrap/kube-prometheus-stack/values.yaml --wait --timeout 60m
# --version 85.0.3 -f clusters/noble/bootstrap/kube-prometheus-stack/values.yaml --wait --timeout 60m
#
# Why it looks "stalled": with --wait, Helm prints almost nothing until the release finishes (can be many minutes).
# Do not use --timeout 5m for first install — Longhorn PVCs + StatefulSets often need 3060m. To watch progress,

View File

@@ -35,7 +35,7 @@ spec:
effect: NoExecute
containers:
- name: kube-vip
image: ghcr.io/kube-vip/kube-vip:v0.8.3
image: ghcr.io/kube-vip/kube-vip:v0.8.10
imagePullPolicy: IfNotPresent
args:
- manager

View File

@@ -2,7 +2,7 @@
Admission policies using [Kyverno](https://kyverno.io/). The main chart installs controllers and CRDs; **`kyverno-policies`** installs **Pod Security Standard** rules matching the **`baseline`** profile in **`Audit`** mode (violations are visible in policy reports; workloads are not denied).
- **Charts:** `kyverno/kyverno` **3.7.1** (app **v1.17.1**), `kyverno/kyverno-policies` **3.7.1**
- **Charts:** `kyverno/kyverno` **3.8.0** (app **v1.18.0**), `kyverno/kyverno-policies` **3.8.0**
- **Namespace:** `kyverno`
## Install
@@ -12,9 +12,9 @@ helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update
kubectl apply -f clusters/noble/bootstrap/kyverno/namespace.yaml
helm upgrade --install kyverno kyverno/kyverno -n kyverno \
--version 3.7.1 -f clusters/noble/bootstrap/kyverno/values.yaml --wait --timeout 15m
--version 3.8.0 -f clusters/noble/bootstrap/kyverno/values.yaml --wait --timeout 15m
helm upgrade --install kyverno-policies kyverno/kyverno-policies -n kyverno \
--version 3.7.1 -f clusters/noble/bootstrap/kyverno/policies-values.yaml --wait --timeout 10m
--version 3.8.0 -f clusters/noble/bootstrap/kyverno/policies-values.yaml --wait --timeout 10m
```
Verify:

View File

@@ -1,7 +1,7 @@
# kyverno/kyverno-policies — Pod Security Standards as Kyverno ClusterPolicies
#
# helm upgrade --install kyverno-policies kyverno/kyverno-policies -n kyverno \
# --version 3.7.1 -f clusters/noble/bootstrap/kyverno/policies-values.yaml --wait --timeout 10m
# --version 3.8.0 -f clusters/noble/bootstrap/kyverno/policies-values.yaml --wait --timeout 10m
#
# Default profile is baseline; validationFailureAction is Audit so existing privileged
# workloads are not blocked. Kyverno still emits PolicyReports for matches — Headlamp

View File

@@ -4,7 +4,7 @@
# helm repo update
# kubectl apply -f clusters/noble/bootstrap/kyverno/namespace.yaml
# helm upgrade --install kyverno kyverno/kyverno -n kyverno \
# --version 3.7.1 -f clusters/noble/bootstrap/kyverno/values.yaml --wait --timeout 15m
# --version 3.8.0 -f clusters/noble/bootstrap/kyverno/values.yaml --wait --timeout 15m
#
# Baseline Pod Security policies (separate chart): see policies-values.yaml + README.md
#

View File

@@ -1,12 +1,12 @@
# Grafana Loki — noble lab (SingleBinary, filesystem on Longhorn; no MinIO/S3).
#
# Chart: grafana/loki — pin version on install (e.g. 6.55.0).
# Chart: grafana/loki — pin version on install (e.g. 7.0.0).
#
# kubectl apply -f clusters/noble/bootstrap/loki/namespace.yaml
# helm repo add grafana https://grafana.github.io/helm-charts
# helm repo update
# helm upgrade --install loki grafana/loki -n loki \
# --version 6.55.0 -f clusters/noble/bootstrap/loki/values.yaml --wait --timeout 30m
# --version 7.0.0 -f clusters/noble/bootstrap/loki/values.yaml --wait --timeout 30m
#
# Query/push URL for Grafana + Fluent Bit: http://loki-gateway.loki.svc.cluster.local:80
#

View File

@@ -41,7 +41,7 @@ helm repo add fossorial https://charts.fossorial.io
helm repo update
helm upgrade --install newt fossorial/newt \
--namespace newt \
--version 1.2.0 \
--version 1.5.0 \
-f clusters/noble/bootstrap/newt/values.yaml \
--wait
```
@@ -88,6 +88,79 @@ curl -sS -X PUT -H "Authorization: Bearer ${TOKEN}" -H 'Content-Type: applicatio
Exact JSON fields and IDs differ by domain type (**ns** vs **cname** vs **wildcard**); see [Common API routes](https://docs.pangolin.net/manage/common-api-routes) and Swagger.
## 4. Automate HTTP resources (Integration API + Ansible)
You still **link domains** in Pangolin and create **CNAME** records at your DNS host manually (Pangolin does not replace your registrar). After that, this repository can **ensure** public **HTTP** resources and **Traefik** targets exist for the same FQDNs you use in GitOps / Ansible:
- **`noble_authentik_ingress_extra_hosts`** (e.g. **`auth.example.com`**)
- **`noble_open_webui_public_host`** when set (e.g. **`webui.example.com`**)
- Optional extra list **`noble_pangolin_http_fqdns_extra`** in **`ansible/inventory/group_vars/all.yml`**
Steps:
0. **Enable the Integration API** in Pangolins `config.yml` on the Pangolin host — it is **off by default**. Add to `config.yml`:
```yaml
flags:
enable_integration_api: true
server:
integration_port: 3003 # default; omit to keep 3003
```
Then expose it with a **Traefik route** in `config/traefik/dynamic_config.yml`. The Integration API is a *separate* process from the main Pangolin server and needs its own hostname (e.g. `api.pcenicni.dev`):
```yaml
routers:
int-api-router:
rule: "Host(`api.pcenicni.dev`)"
service: int-api-service
entryPoints: [websecure]
tls: { certResolver: letsencrypt }
int-api-router-redirect:
rule: "Host(`api.pcenicni.dev`)"
service: int-api-service
entryPoints: [web]
middlewares: [redirect-to-https]
services:
int-api-service:
loadBalancer:
servers: [{ url: "http://pangolin:3003" }]
```
After restarting Pangolin, verify: `curl https://api.pcenicni.dev/v1/` should return `{"message":"Pangolin Integration API"}`. Also add a **CNAME** for `api.pcenicni.dev` pointing at the same upstream as `pangolin.pcenicni.dev`.
> **Common mistake:** `https://pangolin.pcenicni.dev/api/v1` is the session-based **external API** — it will always return **401** to Bearer tokens. The Integration API must have its own Traefik-exposed hostname.
1. In Pangolin, create an **organization API key** ([Integration API docs](https://docs.pangolin.net/self-host/advanced/integration-api)) with permission to manage domains, resources, and targets. The API expects **`Authorization: Bearer {apiKeyId}.{apiKeySecret}`** — paste **`id.secret`** as a single string into **`NOBLE_PANGOLIN_API_TOKEN`**, or set **`NOBLE_PANGOLIN_API_KEY_ID`** + only the **secret** in **`NOBLE_PANGOLIN_API_TOKEN`**. For **`NOBLE_PANGOLIN_SITE_ID`** as a **niceId** slug, enable **Site → List Sites** (the script falls back to listing sites if **Get Site** returns **404**).
2. Add to repository **`.env`** (never commit secrets): **`NOBLE_PANGOLIN_API_BASE`** is the Integration API hostname with `/v1` suffix — e.g. **`https://api.pcenicni.dev/v1`** (not `https://pangolin.pcenicni.dev/api/v1`). Also set **`NOBLE_PANGOLIN_ORG_ID`**, **`NOBLE_PANGOLIN_API_TOKEN`** (optionally **`NOBLE_PANGOLIN_API_KEY_ID`**), **`NOBLE_PANGOLIN_SITE_ID`** (numeric **siteId** *or* **niceId**). Optionally **`NOBLE_PANGOLIN_TRAEFIK_IP`** / **`NOBLE_PANGOLIN_TRAEFIK_PORT`** — if unset, Ansible uses **`kubectl`** to read the Traefik Service **LoadBalancer** IP. TLS: **`NOBLE_PANGOLIN_CA_BUNDLE`** or **`NOBLE_PANGOLIN_INSECURE_SKIP_TLS_VERIFY=true`** for self-signed APIs
3. Set **`noble_pangolin_sync_http_resources: true`** in **`ansible/inventory/group_vars/all.yml`** (or pass **`-e noble_pangolin_sync_http_resources=true`**).
4. Run **`ansible-playbook playbooks/noble.yml --tags newt`** (or a full **`noble.yml`**) with **`KUBECONFIG`** pointed at the cluster.
Implementation: **`clusters/noble/bootstrap/newt/scripts/sync_pangolin_http_resources.py`** (stdlib **Python 3**). Dry run:
`python3 clusters/noble/bootstrap/newt/scripts/sync_pangolin_http_resources.py --env-file .env --fqdns auth.example.com,webui.example.com --traefik-ip 192.168.50.211 --dry-run`
The script matches each FQDN to the **longest** linked **`baseDomain`** in Pangolin, creates the HTTP resource if missing, then adds a **target** (**`siteId`** + Traefik **`ip`:`port`**, **`method`:** **`http`**) if none matches. Pangolins API is still evolving — if a call fails, compare with [Swagger](https://api.pangolin.net/v1/docs) for your deployment version.
**`.env` vs shell:** If **`NOBLE_PANGOLIN_API_TOKEN`** (or other **`NOBLE_PANGOLIN_*`**) is set in your shell to an empty or old value, older script versions could ignore **`.env`**. Current script overwrites **`os.environ`** from **`.env`** when **`--env-file`** is passed — unset stray exports if you still see **401**.
### Authentik on a public name
Use **`noble_authentik_ingress_extra_hosts`** (see **`ansible/roles/noble_authentik/README.md`**) so the Authentik Ingress (and **cert-manager** SANs) include your public FQDN, then create the Pangolin **HTTP** resource + **target** to the same Traefik **:443** endpoint as other apps. One Newt site can carry many hostnames.
### What to put in Pangolin (resource + target)
1. **Public hostname** — the FQDN users type in the browser (must match **`noble_authentik_ingress_extra_hosts`** and your **CNAME** at the DNS host Pangolin documents for that domain).
2. **Site** — the Pangolin **site** that owns your **Newt** pair (same **`NEWT_ID`** / **`NEWT_SECRET`** as the cluster Secret). In the UI: **Sites** → pick the site connected to this cluster.
3. **Target `ip`** — an address **reachable from inside the tunnel** to **Traefik HTTPS**. On noble this is usually the Traefik **LoadBalancer** IP (repo pins **`192.168.50.211`** in **`clusters/noble/bootstrap/traefik/values.yaml`**). Confirm live:
`kubectl -n traefik get svc -l app.kubernetes.io/name=traefik -o wide`
Use **`EXTERNAL-IP`** (or **`LOAD_BALANCER_IP`** from the Service status) as **`ip`**. If Newt runs **in** the cluster, that MetalLB/LAN VIP is correct; if you run Newt elsewhere, use whatever L3 path reaches Traefik from that host.
4. **Target `port`** — **`443`** (TLS to Traefik; SNI carries the public hostname).
5. **Target `method`** — **`http`** in the Integration API examples above (TLS is still terminated at Traefik; Pangolins field names follow their docs).
Discovery in Pangolins UI: **Domains** (see required CNAME) → **Resources** → **Add** HTTP resource for the subdomain/FQDN → **Targets** / **Backends** → attach **site** + **ip:port**. Official flow: [Domains](https://docs.pangolin.net/manage/common-api-routes#list-domains), [Integration API](https://docs.pangolin.net/manage/integration-api), and your deployments **Swagger** at **`https://<integration-api-host>/v1/docs`** when enabled.
## LAN vs internet
- **LAN / VPN:** point **`*.apps.noble.lab.pcenicni.dev`** at the Traefik **LoadBalancer** (**`192.168.50.211`**) with local or split-horizon DNS if you want direct in-lab access.

View File

@@ -0,0 +1,569 @@
#!/usr/bin/env python3
"""
Idempotently create Pangolin public HTTP resources + Traefik targets via the Integration API.
Docs: https://docs.pangolin.net/manage/integration-api
Walkthrough: https://docs.pangolin.net/manage/common-api-routes
Environment (or --env-file) can set:
NOBLE_PANGOLIN_API_BASE, NOBLE_PANGOLIN_ORG_ID,
NOBLE_PANGOLIN_API_TOKEN — must be **apiKeyId.apiKeySecret** (one string, dot in the middle), OR
set **NOBLE_PANGOLIN_API_KEY_ID** and put only the **secret** in **NOBLE_PANGOLIN_API_TOKEN**,
NOBLE_PANGOLIN_SITE_ID (numeric siteId **or** Pangolin site **niceId**, e.g. unruly-asian-badger),
NOBLE_PANGOLIN_TRAEFIK_IP, NOBLE_PANGOLIN_TRAEFIK_PORT (default 443)
Optional TLS: NOBLE_PANGOLIN_CA_BUNDLE (path to PEM) or NOBLE_PANGOLIN_INSECURE_SKIP_TLS_VERIFY=true
(homelab self-signed Integration API only — insecure).
CLI overrides env. FQDNs: --fqdns a.example.com,b.example.com (required).
"""
from __future__ import annotations
import argparse
import json
import os
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
def load_dotenv(path: str) -> dict[str, str]:
out: dict[str, str] = {}
with open(path, encoding="utf-8") as f:
for raw in f:
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
v = v.strip().strip('"').strip("'")
if k:
out[k] = v
return out
def env_truthy(raw: str | None) -> bool:
if raw is None:
return False
return raw.strip().lower() in ("1", "true", "yes", "on")
def normalize_api_token(raw: str) -> str:
"""Strip whitespace and a single accidental ``Bearer `` prefix (`.env` / copy-paste)."""
t = str(raw).strip()
if t.lower().startswith("bearer "):
return t[7:].strip()
return t
def pangolin_bearer_credential(token: str, key_id: str) -> str:
"""
Pangolin's Integration API expects ``Authorization: Bearer {apiKeyId}.{apiKeySecret}``
(see Pangolin ``verifyApiKey`` — the part after ``Bearer`` is split on the first ``.``).
"""
t = normalize_api_token(token)
kid = (key_id or "").strip()
if "." in t:
return t
if kid:
return f"{kid}.{t}"
return t
def tls_ssl_context(ca_bundle: str, insecure: bool) -> ssl.SSLContext | None:
"""Return custom SSL context, or None for default certificate verification."""
path = (ca_bundle or "").strip()
if path:
if not os.path.isfile(path):
raise SystemExit(f"NOBLE_PANGOLIN_CA_BUNDLE is not a readable file: {path!r}")
return ssl.create_default_context(cafile=path)
if insecure:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
return None
def http_exchange(
method: str,
url: str,
token: str,
body: dict[str, Any] | None = None,
ssl_context: ssl.SSLContext | None = None,
) -> tuple[int, Any]:
"""HTTP request; returns (status_code, parsed JSON body or dict with __raw on parse error)."""
data_bytes = None if body is None else json.dumps(body).encode("utf-8")
req = urllib.request.Request(
url,
data=data_bytes,
method=method,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
**({"Content-Type": "application/json"} if body is not None else {}),
},
)
try:
with urllib.request.urlopen(req, timeout=120, context=ssl_context) as resp:
payload = resp.read().decode("utf-8")
code = int(getattr(resp, "status", None) or resp.getcode() or 200)
except urllib.error.HTTPError as e:
code = int(e.code)
raw = e.read().decode("utf-8", errors="replace")
try:
return code, (json.loads(raw) if raw.strip() else {})
except json.JSONDecodeError:
return code, {"__raw": raw[:2000]}
except urllib.error.URLError as e:
reason = str(e.reason) if getattr(e, "reason", None) is not None else str(e)
if "CERTIFICATE_VERIFY_FAILED" in reason or "certificate verify failed" in reason.lower():
raise SystemExit(
f"{e}\n"
"TLS verification failed. For a self-signed Integration API, set in .env:\n"
" NOBLE_PANGOLIN_INSECURE_SKIP_TLS_VERIFY=true\n"
"or set NOBLE_PANGOLIN_CA_BUNDLE to a PEM file that trusts that API (preferred)."
) from e
raise SystemExit(f"Request failed: {e}") from e
if not payload.strip():
return code, {}
try:
return code, json.loads(payload)
except json.JSONDecodeError:
return code, {"__raw": payload[:2000]}
def api_request(
method: str,
url: str,
token: str,
body: dict[str, Any] | None = None,
ssl_context: ssl.SSLContext | None = None,
) -> Any:
code, parsed = http_exchange(method, url, token, body, ssl_context)
if code >= 400:
detail = json.dumps(parsed) if isinstance(parsed, (dict, list)) else str(parsed)
hint = ""
if code in (401, 403):
hint = (
"\n\nPangolin expects **Authorization: Bearer {apiKeyId}.{apiKeySecret}** (id, dot, secret "
"from **Organization → API keys** when the key is created). Put **`id.secret`** in "
"**`NOBLE_PANGOLIN_API_TOKEN`**, or set **`NOBLE_PANGOLIN_API_KEY_ID`** and only the secret in "
"**`NOBLE_PANGOLIN_API_TOKEN`**. A browser tab uses **session cookies**, not this header.\n\n"
"Also check **NOBLE_PANGOLIN_API_BASE**: this must be the **Integration API** hostname "
"(e.g. `https://api.example.com/v1`), NOT the main Pangolin UI host "
"(`https://pangolin.example.com/api/v1` is the session-based external API and always "
"returns 401 to Bearer tokens). The Integration API runs on a **separate port** (default 3003) "
"and needs its own Traefik-exposed hostname. See `flags.enable_integration_api` in Pangolin `config.yml`."
)
raise SystemExit(f"HTTP {code} {method} {url}\n{detail}{hint}")
return parsed
def unwrap(resp: dict[str, Any]) -> Any:
if resp.get("error") or resp.get("success") is False:
raise SystemExit(f"Pangolin API error: {resp!r}")
return resp.get("data")
def normalize_fqdn(s: str) -> str:
return s.lower().strip().rstrip(".")
def resolve_domain(
fqdn: str, domains: list[dict[str, Any]]
) -> tuple[str, str | None]:
"""Return (domainId, subdomain) for an HTTP resource. subdomain is None for apex / cname-only domains."""
fqdn = normalize_fqdn(fqdn)
best: tuple[str, str | None, int] | None = None
for d in domains:
bd = (d.get("baseDomain") or "").strip()
if not bd:
continue
bd_n = normalize_fqdn(bd)
if fqdn == bd_n:
cand = (str(d["domainId"]), None, len(bd_n))
elif fqdn.endswith("." + bd_n):
sub = fqdn[: -(len(bd_n) + 1)]
if not sub:
continue
cand = (str(d["domainId"]), sub, len(bd_n))
else:
continue
if best is None or cand[2] > best[2]:
best = cand
if best is None:
avail = ", ".join(sorted({normalize_fqdn(d.get("baseDomain") or "") for d in domains if d.get("baseDomain")}))
raise SystemExit(
f"No linked Pangolin domain matches FQDN {fqdn!r}. "
f"Link the parent domain in Pangolin first. Known baseDomain values: {avail or '(none)'}"
)
return best[0], best[1]
def list_all_resources(
api_base: str,
org_id: str,
token: str,
ssl_context: ssl.SSLContext | None,
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
page = 1
page_size = 100
while True:
url = f"{api_base.rstrip('/')}/org/{org_id}/resources?page={page}&pageSize={page_size}"
data = unwrap(api_request("GET", url, token, ssl_context=ssl_context))
if isinstance(data, list):
out.extend(data)
break
if not isinstance(data, dict):
break
batch = data.get("resources") or data.get("items") or []
if not isinstance(batch, list):
break
out.extend(batch)
pag = data.get("pagination") or {}
total = pag.get("total")
if isinstance(total, int) and len(out) >= total:
break
if len(batch) < page_size:
break
page += 1
if page > 500:
raise SystemExit("Pagination safety stop (>500 pages)")
return out
def list_all_sites(
api_base: str,
org_id: str,
token: str,
ssl_context: ssl.SSLContext | None,
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
page = 1
page_size = 100
while True:
url = f"{api_base.rstrip('/')}/org/{org_id}/sites?page={page}&pageSize={page_size}"
data = unwrap(api_request("GET", url, token, ssl_context=ssl_context))
if isinstance(data, list):
out.extend(data)
break
if not isinstance(data, dict):
break
batch = data.get("sites") or data.get("items") or []
if not isinstance(batch, list):
break
out.extend(batch)
pag = data.get("pagination") or {}
total = pag.get("total")
if isinstance(total, int) and len(out) >= total:
break
if len(batch) < page_size:
break
page += 1
if page > 500:
raise SystemExit("Pagination safety stop (sites >500 pages)")
return out
def resource_public_fqdn(res: dict[str, Any]) -> str | None:
fd = res.get("fullDomain")
if isinstance(fd, str) and fd.strip():
return normalize_fqdn(fd)
sub = res.get("subdomain")
dom = res.get("domain") or {}
bd = dom.get("baseDomain") if isinstance(dom, dict) else None
if isinstance(bd, str) and bd.strip():
bd_n = normalize_fqdn(bd)
if sub is None or sub == "":
return bd_n
if isinstance(sub, str):
return normalize_fqdn(f"{sub}.{bd_n}")
return None
def find_resource_for_fqdn(resources: list[dict[str, Any]], fqdn: str) -> dict[str, Any] | None:
target = normalize_fqdn(fqdn)
for r in resources:
if not r.get("http"):
continue
got = resource_public_fqdn(r)
if got == target:
return r
return None
def list_targets(
api_base: str,
resource_id: int,
token: str,
ssl_context: ssl.SSLContext | None,
) -> list[dict[str, Any]]:
url = f"{api_base.rstrip('/')}/resource/{resource_id}/targets"
data = unwrap(api_request("GET", url, token, ssl_context=ssl_context))
if isinstance(data, list):
return data
if isinstance(data, dict):
return data.get("targets") or data.get("items") or []
return []
def target_matches(t: dict[str, Any], site_id: int, ip: str, port: int) -> bool:
return (
int(t.get("siteId") or 0) == site_id
and str(t.get("ip") or "") == ip
and int(t.get("port") or 0) == port
)
def resolve_site_id(
api_base: str,
org_id: str,
token: str,
site_ref: str,
ssl_context: ssl.SSLContext | None,
) -> int:
"""Pangolin targets need numeric siteId. Accept digits or site **niceId** (UI slug)."""
ref = site_ref.strip()
if not ref:
raise SystemExit("NOBLE_PANGOLIN_SITE_ID is empty")
if ref.isdigit():
return int(ref)
slug = urllib.parse.quote(ref, safe="")
url = f"{api_base.rstrip('/')}/org/{org_id}/site/{slug}"
code, envelope = http_exchange("GET", url, token, None, ssl_context)
sid: int | None = None
if code == 200 and isinstance(envelope, dict):
if not envelope.get("error") and envelope.get("success") is not False:
data = envelope.get("data")
if isinstance(data, dict) and data.get("siteId") is not None:
sid = int(data["siteId"])
if sid is not None:
return sid
sites = list_all_sites(api_base, org_id, token, ssl_context)
ref_l = ref.lower()
for s in sites:
nid = s.get("niceId") or s.get("nice_id")
if isinstance(nid, str) and nid.lower() == ref_l:
out = s.get("siteId") if s.get("siteId") is not None else s.get("id")
if out is not None:
print(f"[site] resolved niceId {ref!r} via List Sites -> siteId={int(out)}")
return int(out)
if code in (401, 403):
msg = (
f"HTTP {code} on GET {url} and no site with niceId {ref!r} in List Sites. "
"Grant **Site → Get Site** and **Site → List Sites** on the organization API key, "
"or set **NOBLE_PANGOLIN_SITE_ID** to the numeric **siteId** from Pangolin **Sites**. "
"Remember: **Bearer** must be **apiKeyId.apiKeySecret**; a browser tab uses **session cookies** instead."
)
if isinstance(envelope, dict) and envelope.get("message"):
msg += f" Server message: {envelope.get('message')!r}."
raise SystemExit(msg)
detail = json.dumps(envelope) if isinstance(envelope, (dict, list)) else str(envelope)
raise SystemExit(
f"No site matched {ref!r}. GET {url} returned HTTP {code}.\n{detail}\n"
"Check **NOBLE_PANGOLIN_API_BASE** (self-hosted: often https://<pangolin-host>/api/v1 — match "
"Swagger **servers**), **NOBLE_PANGOLIN_ORG_ID**, and API key permissions. "
"Or set **NOBLE_PANGOLIN_SITE_ID** to the numeric **siteId**."
)
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--env-file", help="Parse KEY=value lines (optional overrides for env)")
ap.add_argument("--api-base", default=os.environ.get("NOBLE_PANGOLIN_API_BASE", ""))
ap.add_argument("--org-id", default=os.environ.get("NOBLE_PANGOLIN_ORG_ID", ""))
ap.add_argument("--token", default=os.environ.get("NOBLE_PANGOLIN_API_TOKEN", ""))
ap.add_argument("--site-id", default=os.environ.get("NOBLE_PANGOLIN_SITE_ID", ""))
ap.add_argument("--traefik-ip", default=os.environ.get("NOBLE_PANGOLIN_TRAEFIK_IP", ""))
ap.add_argument(
"--traefik-port",
type=int,
default=int(os.environ.get("NOBLE_PANGOLIN_TRAEFIK_PORT", "443") or 443),
)
ap.add_argument(
"--fqdns",
required=True,
help="Comma-separated public FQDNs (must match linked Pangolin domains / SANs)",
)
ap.add_argument(
"--dry-run",
action="store_true",
help="Print actions only; do not call mutating endpoints",
)
ap.add_argument(
"--ca-bundle",
default=os.environ.get("NOBLE_PANGOLIN_CA_BUNDLE", ""),
help="Path to PEM CA bundle for the Integration API (preferred over --insecure-skip-tls-verify)",
)
ap.add_argument(
"--insecure-skip-tls-verify",
action="store_true",
help="Disable TLS certificate verification (homelab self-signed API only)",
)
args = ap.parse_args()
if args.env_file:
for k, v in load_dotenv(args.env_file).items():
if k.startswith("NOBLE_PANGOLIN_"):
# Assign (not setdefault): a stale or empty **NOBLE_*** in the parent environment
# must not hide values from **.env** when Ansible runs this script.
os.environ[k] = v
args.api_base = os.environ.get("NOBLE_PANGOLIN_API_BASE", args.api_base or "")
args.org_id = os.environ.get("NOBLE_PANGOLIN_ORG_ID", args.org_id or "")
args.token = os.environ.get("NOBLE_PANGOLIN_API_TOKEN", args.token or "")
args.site_id = os.environ.get("NOBLE_PANGOLIN_SITE_ID", args.site_id or "")
args.traefik_ip = os.environ.get("NOBLE_PANGOLIN_TRAEFIK_IP", args.traefik_ip or "")
args.ca_bundle = os.environ.get("NOBLE_PANGOLIN_CA_BUNDLE", args.ca_bundle or "")
if not str(args.ca_bundle or "").strip():
args.ca_bundle = os.environ.get("NOBLE_PANGOLIN_CA_BUNDLE", "")
if not args.insecure_skip_tls_verify:
args.insecure_skip_tls_verify = env_truthy(
os.environ.get("NOBLE_PANGOLIN_INSECURE_SKIP_TLS_VERIFY", "")
)
api_key_id = os.environ.get("NOBLE_PANGOLIN_API_KEY_ID", "").strip()
args.token = pangolin_bearer_credential(str(args.token or ""), api_key_id)
# Print the key ID (never the secret) so mismatches are easy to spot.
used_key_id = args.token.split(".")[0] if "." in args.token else "(none)"
print(f"[auth] using apiKeyId={used_key_id!r} — verify this exists in Pangolin → Organization → API keys")
if "." not in args.token:
print(
"[auth] WARNING: NOBLE_PANGOLIN_API_TOKEN has no '.' — Pangolin requires **apiKeyId.apiKeySecret**. "
"Set **NOBLE_PANGOLIN_API_KEY_ID** + secret, or paste **id.secret** as a single value in **NOBLE_PANGOLIN_API_TOKEN**.",
file=sys.stderr,
)
missing = [
n
for n, v in [
("NOBLE_PANGOLIN_API_BASE", args.api_base),
("NOBLE_PANGOLIN_ORG_ID", args.org_id),
("NOBLE_PANGOLIN_API_TOKEN", args.token),
("NOBLE_PANGOLIN_SITE_ID", args.site_id),
("NOBLE_PANGOLIN_TRAEFIK_IP", args.traefik_ip),
]
if not str(v).strip()
]
if missing:
raise SystemExit(f"Missing required settings: {', '.join(missing)}")
fqdns = [normalize_fqdn(x) for x in args.fqdns.split(",") if x.strip()]
if not fqdns:
raise SystemExit("No FQDNs in --fqdns")
api_base = str(args.api_base).rstrip("/")
org_id = str(args.org_id).strip()
token = str(args.token).strip()
ssl_ctx = tls_ssl_context(str(args.ca_bundle).strip(), bool(args.insecure_skip_tls_verify))
if str(args.ca_bundle).strip():
print(f"[tls] using CA bundle {args.ca_bundle!r}")
elif args.insecure_skip_tls_verify:
print("[tls] WARNING: certificate verification disabled (NOBLE_PANGOLIN_INSECURE_SKIP_TLS_VERIFY)")
site_id = resolve_site_id(api_base, org_id, token, str(args.site_id).strip(), ssl_ctx)
print(f"[site] siteId={site_id} (NOBLE_PANGOLIN_SITE_ID={args.site_id!r})")
traefik_ip = str(args.traefik_ip).strip()
traefik_port = int(args.traefik_port)
dom_url = f"{api_base}/org/{org_id}/domains"
domains_raw = unwrap(api_request("GET", dom_url, token, ssl_context=ssl_ctx))
domains: list[dict[str, Any]] = []
if isinstance(domains_raw, list):
domains = domains_raw
elif isinstance(domains_raw, dict):
domains = domains_raw.get("domains") or []
if not isinstance(domains, list):
raise SystemExit(f"Unexpected domains response: {domains_raw!r}")
resources = list_all_resources(api_base, org_id, token, ssl_ctx)
for fqdn in fqdns:
domain_id, subdomain = resolve_domain(fqdn, domains)
res = find_resource_for_fqdn(resources, fqdn)
if res is None:
body = {
"name": f"noble {fqdn}",
"http": True,
"protocol": "tcp",
"domainId": domain_id,
**({"subdomain": subdomain} if subdomain is not None else {"subdomain": None}),
}
print(f"[create] resource {fqdn!r} domainId={domain_id} subdomain={subdomain!r}")
if args.dry_run:
continue
created = unwrap(
api_request(
"PUT",
f"{api_base}/org/{org_id}/resource",
token,
body,
ssl_context=ssl_ctx,
)
)
rid = int(str(created.get("resourceId", "")).strip() or 0)
if not rid:
raise SystemExit(f"Create resource response missing resourceId: {created!r}")
print(f" -> resourceId={rid}")
resources.append(
{
"resourceId": rid,
"http": True,
"fullDomain": fqdn,
"subdomain": subdomain,
}
)
res = find_resource_for_fqdn(resources, fqdn)
else:
rid = int(str(res.get("resourceId", "")).strip() or 0)
if not rid:
raise SystemExit(f"Resource missing resourceId: {res!r}")
print(f"[exists] resource {fqdn!r} resourceId={rid}")
if res is None:
raise SystemExit("internal: resource missing after create")
rid = int(str(res.get("resourceId", "")).strip() or 0)
if not rid:
raise SystemExit(f"Resource missing resourceId: {res!r}")
targets = list_targets(api_base, rid, token, ssl_ctx)
if any(target_matches(t, site_id, traefik_ip, traefik_port) for t in targets):
print(f" -> target OK site={site_id} {traefik_ip}:{traefik_port}")
continue
tbody = {
"siteId": site_id,
"ip": traefik_ip,
"port": traefik_port,
"method": "http",
}
print(f"[target] PUT /resource/{rid}/target {tbody}")
if args.dry_run:
continue
api_request(
"PUT",
f"{api_base}/resource/{rid}/target",
token,
tbody,
ssl_context=ssl_ctx,
)
print(" -> target created")
print("Done.")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(130)

View File

@@ -10,7 +10,7 @@
#
# helm repo add fossorial https://charts.fossorial.io
# helm upgrade --install newt fossorial/newt -n newt \
# --version 1.2.0 -f clusters/noble/bootstrap/newt/values.yaml --wait
# --version 1.5.0 -f clusters/noble/bootstrap/newt/values.yaml --wait
#
# See README.md for Pangolin Integration API (domains + HTTP resources + CNAME).

View File

@@ -15,7 +15,7 @@
helm repo update
helm upgrade --install traefik traefik/traefik \
--namespace traefik \
--version 39.0.6 \
--version 40.2.0 \
-f clusters/noble/bootstrap/traefik/values.yaml \
--wait
```

View File

@@ -1,12 +1,12 @@
# Traefik ingress controller — noble lab
#
# Chart: traefik/traefik — pin version on the helm command (e.g. 39.0.6).
# Chart: traefik/traefik — pin version on the helm command (e.g. 40.2.0).
# DNS: point *.apps.noble.lab.pcenicni.dev to the LoadBalancer IP below.
#
# kubectl apply -f clusters/noble/bootstrap/traefik/namespace.yaml
# helm repo add traefik https://traefik.github.io/charts
# helm upgrade --install traefik traefik/traefik -n traefik \
# --version 39.0.6 -f clusters/noble/bootstrap/traefik/values.yaml --wait
# --version 40.2.0 -f clusters/noble/bootstrap/traefik/values.yaml --wait
service:
type: LoadBalancer

View File

@@ -5,7 +5,7 @@ Ansible-managed core stack — **not** reconciled by Argo CD (`clusters/noble/ap
## What you get
- **No web UI** — Velero is operated with the **`velero`** CLI and **`kubectl`** (Backup, Schedule, Restore CRDs). Metrics are exposed for Prometheus; there is no first-party dashboard in this chart.
- **vmware-tanzu/velero** Helm chart (**12.0.0** → Velero **1.18.0**) in namespace **`velero`**
- **vmware-tanzu/velero** Helm chart (**12.0.1** → Velero **1.18.0**) in namespace **`velero`**
- **AWS plugin** init container for **S3-compatible** object storage (`velero/velero-plugin-for-aws:v1.14.0`)
- **CSI snapshots** via Veleros built-in CSI support (`EnableCSI`) and **VolumeSnapshotLocation** `velero.io/csi` (no separate CSI plugin image for Velero ≥ 1.14)
- **Prometheus** scraping: **ServiceMonitor** labeled for **kube-prometheus** (`release: kube-prometheus`)
@@ -19,6 +19,8 @@ Ansible-managed core stack — **not** reconciled by Argo CD (`clusters/noble/ap
4. **S3-compatible** endpoint (MinIO, VersityGW, AWS, etc.) and a **bucket**.
5. **Authentik** (when **`noble_authentik_install=true`**) can reuse the **same** S3 endpoint and access-key credentials for a **separate** media bucket (**`NOBLE_AUTHENTIK_MEDIA_S3_BUCKET`**); see **`ansible/roles/noble_authentik/README.md`**.
## Credentials Secret
Velero expects **`velero/velero-cloud-credentials`**, key **`cloud`**, in **INI** form for the AWS plugin:
@@ -99,7 +101,7 @@ From repo root:
kubectl apply -f clusters/noble/bootstrap/velero/namespace.yaml
# Create velero-cloud-credentials (see above), then:
helm repo add vmware-tanzu https://vmware-tanzu.github.io/helm-charts && helm repo update
helm upgrade --install velero vmware-tanzu/velero -n velero --version 12.0.0 \
helm upgrade --install velero vmware-tanzu/velero -n velero --version 12.0.1 \
-f clusters/noble/bootstrap/velero/values.yaml \
--set-string configuration.backupStorageLocation[0].bucket=YOUR_BUCKET \
--set-string configuration.backupStorageLocation[0].config.s3Url=https://YOUR-S3-ENDPOINT \

View File

@@ -2,10 +2,10 @@
# Install: **ansible/playbooks/noble.yml** role **noble_velero** (override S3 settings via **noble_velero_*** vars).
# Requires Secret **velero/velero-cloud-credentials** key **cloud** (INI for AWS plugin — see README).
#
# Chart: vmware-tanzu/velero — pin version on install (e.g. 12.0.0 / Velero 1.18.0).
# Chart: vmware-tanzu/velero — pin version on install (e.g. 12.0.1 / Velero 1.18.0).
# helm repo add vmware-tanzu https://vmware-tanzu.github.io/helm-charts && helm repo update
# kubectl apply -f clusters/noble/bootstrap/velero/namespace.yaml
# helm upgrade --install velero vmware-tanzu/velero -n velero --version 12.0.0 -f clusters/noble/bootstrap/velero/values.yaml
# helm upgrade --install velero vmware-tanzu/velero -n velero --version 12.0.1 -f clusters/noble/bootstrap/velero/values.yaml
initContainers:
- name: velero-plugin-for-aws

View File

@@ -13,7 +13,7 @@ spec:
source:
repoURL: https://eclipse-che.github.io/che-operator/charts
chart: eclipse-che
targetRevision: 7.116.0
targetRevision: 7.117.0
helm:
releaseName: eclipse-che
destination:

View File

@@ -198,17 +198,17 @@ See [`talos/CLUSTER-BUILD.md`](../talos/CLUSTER-BUILD.md) for the authoritative
| Component | Chart / app (from CLUSTER-BUILD.md) |
|-----------|-------------------------------------|
| Talos / Kubernetes | v1.12.6 / 1.35.2 bundled |
| Cilium | Helm 1.16.6 |
| Cilium | Helm 1.19.4 |
| MetalLB | 0.15.3 |
| Longhorn | 1.11.1 |
| Traefik | 39.0.6 / app v3.6.11 |
| cert-manager | v1.20.0 |
| Argo CD | 9.4.17 / app v3.3.6 |
| kube-prometheus-stack | 82.15.1 |
| Loki / Fluent Bit | 6.55.0 / 0.56.0 |
| Longhorn | 1.11.2 |
| Traefik | 40.2.0 / app v3.7.1 |
| cert-manager | v1.20.2 |
| Argo CD | 9.5.14 / app v3.4.2 |
| kube-prometheus-stack | 85.0.3 |
| Loki / Fluent Bit | 7.0.0 / 0.57.5 |
| SOPS (client tooling) | see `clusters/noble/secrets/README.md` |
| Kyverno | 3.7.1 / policies 3.7.1 |
| Newt | 1.2.0 / app 1.10.1 |
| Kyverno | 3.8.0 / policies 3.8.0 |
| Newt | 1.5.0 / app 1.12.5 |
---

View File

@@ -120,7 +120,7 @@ services:
ports:
- 5055:5055
volumes:
- ${CONFIG_PATH}/jellyseerr:/app/config
- ${CONFIG_PATH}/seerr:/app/config
restart: unless-stopped
prowlarr:

View File

@@ -26,11 +26,30 @@ VERSITYGW_WEBUI_PORT=8080
# VGW_WEBUI_GATEWAYS=https://s3.example.com
VGW_WEBUI_GATEWAYS=
# Public origin of the **WebUI** page (Pangolin → :8080), e.g. https://s3-ui.example.com
# Required when UI and API are on different hosts so the browser can call the API (CORS).
# Public origin of the **browser app** that calls the S3 API (no path, no trailing slash).
# Use this when the UI is on a different hostname than the API — e.g. third-party S3 consoles,
# or the built-in WebUI behind Pangolin on another host than :10000.
# Example: VGW_CORS_ALLOW_ORIGIN=https://s3-ui.pcenicni.dev
#
# VersityGW maps the browsers preflight Access-Control-Request-Headers into
# Access-Control-Allow-Headers (Authorization, X-Amz-Date, X-Amz-Content-Sha256, Content-Type, …)
# when there is **no** per-bucket CORS configuration. You do not set those headers separately here.
#
# If you used PutBucketCors on a bucket, that config replaces this fallback for that bucket: add
# the same Origin and AllowedHeader entries (or *) there, or delete bucket CORS to rely on this.
# VGW_CORS_ALLOW_ORIGIN=https://s3-ui.example.com
VGW_CORS_ALLOW_ORIGIN=
# --- Pangolin (edge) vs Versity CORS ---
# If VGW_CORS_ALLOW_ORIGIN is correct but the browser still says CORS failed, the edge often
# never returns Versitys Access-Control-* headers: Pangolin can answer OPTIONS / block preflight
# before Newt reaches :10000. S3 clients send OPTIONS without SigV4 auth; Pangolin SSO or
# “authorization” on the HTTP resource can reject that (see https://github.com/fosrl/pangolin/issues/2369 ).
# Mitigations: make the **S3 API** hostname resource public (no Pangolin auth on that resource),
# or add a rule that allows OPTIONS to pass through when Pangolin supports method-based rules;
# confirm with: curl -sv -X OPTIONS -H "Origin: https://your-s3-ui" -H "Access-Control-Request-Method: PUT" \
# -H "Access-Control-Request-Headers: authorization,content-type" "https://your-s3-api-host/" 2>&1 | head -40
# NFS: object metadata defaults to xattrs; most NFS mounts need sidecar mode
# (compose.yaml uses --sidecar /data/sidecar). Create the host path, e.g.
# mkdir -p /mnt/nfs/versity/sidecar

View File

@@ -26,7 +26,8 @@ services:
# Public base URL of the *S3 API* only (Pangolin → :10000). Not the WebUI hostname.
# No trailing slash. If this points at the UI URL, bucket ops return 404/wrong host.
VGW_WEBUI_GATEWAYS: ${VGW_WEBUI_GATEWAYS}
# Browser Origin when WebUI and API use different HTTPS hostnames (see wiki / WebGUI CORS).
# Browser Origin for cross-host S3 from the UI (maps to --cors-allow-origin). See .env.sample
# for third-party consoles vs bucket PutBucketCors overrides.
VGW_CORS_ALLOW_ORIGIN: ${VGW_CORS_ALLOW_ORIGIN}
ports:
- "${VERSITYGW_PORT:-10000}:10000"

View File

@@ -7,20 +7,20 @@ This document is the **exported TODO** for the **noble** Talos cluster (4 nodes)
Lab stack is **up** on-cluster through **Phase D****F** and **Phase G** (**`talos/runbooks/`**, **SOPS**-encrypted secrets in **`clusters/noble/secrets/`**). **Next focus:** optional **Alertmanager** receivers (Slack/PagerDuty); tighten **RBAC** (Headlamp / cluster-admin); **Cilium** policies for other namespaces as needed; enable **Mend Renovate** for PRs; Pangolin/sample Ingress; **Velero** backup/restore drill after S3 credentials are set (**`noble_velero_install`**).
- **Talos** v1.12.6 (target) / **Kubernetes** as bundled — four nodes **Ready** unless upgrading; **`talosctl health`**; **`talos/kubeconfig`** is **local only** (gitignored — never commit; regenerate with `talosctl kubeconfig` per `talos/README.md`). **Image Factory (nocloud installer):** `factory.talos.dev/nocloud-installer/249d9135de54962744e917cfe654117000cba369f9152fbab9d055a00aa3664f:v1.12.6`
- **Cilium** Helm **1.16.6** / app **1.16.6** (`clusters/noble/bootstrap/cilium/`, phase 1 values).
- **Cilium** Helm **1.19.4** / app **1.19.4** (`clusters/noble/bootstrap/cilium/`, phase 1 values).
- **CSI Volume Snapshot** — **external-snapshotter** **v8.5.0** CRDs + **`registry.k8s.io/sig-storage/snapshot-controller`** (`clusters/noble/bootstrap/csi-snapshot-controller/`, Ansible **`noble_csi_snapshot_controller`**).
- **MetalLB** Helm **0.15.3** / app **v0.15.3**; **IPAddressPool** `noble-l2` + **L2Advertisement** — pool **`192.168.50.210``192.168.50.229`**.
- **kube-vip** DaemonSet **3/3** on control planes; VIP **`192.168.50.230`** on **`ens18`** (`vip_subnet` **`/32`** required — bare **`32`** breaks parsing). **Verified from workstation:** `kubectl config set-cluster noble --server=https://192.168.50.230:6443` then **`kubectl get --raw /healthz`** → **`ok`** (`talos/kubeconfig`; see `talos/README.md`).
- **metrics-server** Helm **3.13.0** / app **v0.8.0**`clusters/noble/bootstrap/metrics-server/values.yaml` (`--kubelet-insecure-tls` for Talos); **`kubectl top nodes`** works.
- **Longhorn** Helm **1.11.1** / app **v1.11.1**`clusters/noble/bootstrap/longhorn/` (PSA **privileged** namespace, `defaultDataPath` `/var/mnt/longhorn`, `preUpgradeChecker` enabled); **StorageClass** `longhorn` (default); **`nodes.longhorn.io`** all **Ready**; test **PVC** `Bound` on `longhorn`.
- **Traefik** Helm **39.0.6** / app **v3.6.11**`clusters/noble/bootstrap/traefik/`; **`Service`** **`LoadBalancer`** **`EXTERNAL-IP` `192.168.50.211`**; **`IngressClass`** **`traefik`** (default). Point **`*.apps.noble.lab.pcenicni.dev`** at **`192.168.50.211`**. MetalLB pool verification was done before replacing the temporary nginx test with Traefik.
- **cert-manager** Helm **v1.20.0** / app **v1.20.0**`clusters/noble/bootstrap/cert-manager/`; **`ClusterIssuer`** **`letsencrypt-staging`** and **`letsencrypt-prod`** (**DNS-01** via **Cloudflare** for **`pcenicni.dev`**, Secret **`cloudflare-dns-api-token`** in **`cert-manager`**); ACME email **`certificates@noble.lab.pcenicni.dev`** (edit in manifests if you want a different mailbox).
- **Newt** Helm **1.2.0** / app **1.10.1**`clusters/noble/bootstrap/newt/` (**fossorial/newt**); Pangolin site tunnel — **`newt-pangolin-auth`** Secret (**`PANGOLIN_ENDPOINT`**, **`NEWT_ID`**, **`NEWT_SECRET`**). Store credentials in git with **SOPS** (`clusters/noble/secrets/newt-pangolin-auth.secret.yaml`, **`age-key.txt`**, **`.sops.yaml`**) — see **`clusters/noble/secrets/README.md`**. **Public DNS** is **not** automated with ExternalDNS: **CNAME** records at your DNS host per Pangolins domain instructions, plus **Integration API** for HTTP resources/targets — see **`clusters/noble/bootstrap/newt/README.md`**. LAN access to Traefik can still use **`*.apps.noble.lab.pcenicni.dev`** → **`192.168.50.211`** (split horizon / local resolver).
- **Argo CD** Helm **9.4.17** / app **v3.3.6**`clusters/noble/bootstrap/argocd/`; **`argocd-server`** **`LoadBalancer`** **`192.168.50.210`**; **`noble-bootstrap-root`** → **`clusters/noble/bootstrap`** (kustomize includes **`clusters/noble/apps/`** for optional leaf **`Application`** manifests; manual sync until **`argocd/README.md`** §5 after **`noble.yml`**). Edit **`repoURL`** in **`bootstrap-root-application.yaml`** before applying.
- **kube-prometheus-stack** — Helm chart **82.15.1**`clusters/noble/bootstrap/kube-prometheus-stack/` (**namespace** `monitoring`, PSA **privileged****node-exporter** needs host mounts); **Longhorn** PVCs for Prometheus, Grafana, Alertmanager; **node-exporter** DaemonSet **4/4**. **Grafana Ingress:** **`https://grafana.apps.noble.lab.pcenicni.dev`** (Traefik **`ingressClassName: traefik`**, **`cert-manager.io/cluster-issuer: letsencrypt-prod`**). **Loki** datasource in Grafana: ConfigMap **`clusters/noble/bootstrap/grafana-loki-datasource/loki-datasource.yaml`** (sidecar label **`grafana_datasource: "1"`**) — not via **`grafana.additionalDataSources`** in the chart. **`helm upgrade --install` with `--wait` is silent until done** — use **`--timeout 30m`**; Grafana admin: Secret **`kube-prometheus-grafana`**, keys **`admin-user`** / **`admin-password`**.
- **Loki** + **Fluent Bit****`grafana/loki` 6.55.0** SingleBinary + **filesystem** on **Longhorn** (`clusters/noble/bootstrap/loki/`); **`loki.auth_enabled: false`**; **`chunksCache.enabled: false`** (no memcached chunk cache). **`fluent/fluent-bit` 0.56.0** → **`loki-gateway.loki.svc:80`** (`clusters/noble/bootstrap/fluent-bit/`); **`logging`** PSA **privileged**. **Grafana Explore:** **`kubectl apply -f clusters/noble/bootstrap/grafana-loki-datasource/loki-datasource.yaml`** then **Explore → Loki** (e.g. `{job="fluent-bit"}`).
- **Longhorn** Helm **1.11.2** / app **v1.11.2**`clusters/noble/bootstrap/longhorn/` (PSA **privileged** namespace, `defaultDataPath` `/var/mnt/longhorn`, `preUpgradeChecker` enabled); **StorageClass** `longhorn` (default); **`nodes.longhorn.io`** all **Ready**; test **PVC** `Bound` on `longhorn`.
- **Traefik** Helm **40.2.0** / app **v3.7.1**`clusters/noble/bootstrap/traefik/`; **`Service`** **`LoadBalancer`** **`EXTERNAL-IP` `192.168.50.211`**; **`IngressClass`** **`traefik`** (default). Point **`*.apps.noble.lab.pcenicni.dev`** at **`192.168.50.211`**. MetalLB pool verification was done before replacing the temporary nginx test with Traefik.
- **cert-manager** Helm **v1.20.2** / app **v1.20.2**`clusters/noble/bootstrap/cert-manager/`; **`ClusterIssuer`** **`letsencrypt-staging`** and **`letsencrypt-prod`** (**DNS-01** via **Cloudflare** for **`pcenicni.dev`**, Secret **`cloudflare-dns-api-token`** in **`cert-manager`**); ACME email **`certificates@noble.lab.pcenicni.dev`** (edit in manifests if you want a different mailbox).
- **Newt** Helm **1.5.0** / app **1.12.5**`clusters/noble/bootstrap/newt/` (**fossorial/newt**); Pangolin site tunnel — **`newt-pangolin-auth`** Secret (**`PANGOLIN_ENDPOINT`**, **`NEWT_ID`**, **`NEWT_SECRET`**). Store credentials in git with **SOPS** (`clusters/noble/secrets/newt-pangolin-auth.secret.yaml`, **`age-key.txt`**, **`.sops.yaml`**) — see **`clusters/noble/secrets/README.md`**. **Public DNS** is **not** automated with ExternalDNS: **CNAME** records at your DNS host per Pangolins domain instructions, plus **Integration API** for HTTP resources/targets — see **`clusters/noble/bootstrap/newt/README.md`**. LAN access to Traefik can still use **`*.apps.noble.lab.pcenicni.dev`** → **`192.168.50.211`** (split horizon / local resolver).
- **Argo CD** Helm **9.5.14** / app **v3.4.2**`clusters/noble/bootstrap/argocd/`; **`argocd-server`** **`LoadBalancer`** **`192.168.50.210`**; **`noble-bootstrap-root`** → **`clusters/noble/bootstrap`** (kustomize includes **`clusters/noble/apps/`** for optional leaf **`Application`** manifests; manual sync until **`argocd/README.md`** §5 after **`noble.yml`**). Edit **`repoURL`** in **`bootstrap-root-application.yaml`** before applying.
- **kube-prometheus-stack** — Helm chart **85.0.3**`clusters/noble/bootstrap/kube-prometheus-stack/` (**namespace** `monitoring`, PSA **privileged****node-exporter** needs host mounts); **Longhorn** PVCs for Prometheus, Grafana, Alertmanager; **node-exporter** DaemonSet **4/4**. **Grafana Ingress:** **`https://grafana.apps.noble.lab.pcenicni.dev`** (Traefik **`ingressClassName: traefik`**, **`cert-manager.io/cluster-issuer: letsencrypt-prod`**). **Loki** datasource in Grafana: ConfigMap **`clusters/noble/bootstrap/grafana-loki-datasource/loki-datasource.yaml`** (sidecar label **`grafana_datasource: "1"`**) — not via **`grafana.additionalDataSources`** in the chart. **`helm upgrade --install` with `--wait` is silent until done** — use **`--timeout 30m`**; Grafana admin: Secret **`kube-prometheus-grafana`**, keys **`admin-user`** / **`admin-password`**.
- **Loki** + **Fluent Bit****`grafana/loki` 7.0.0** SingleBinary + **filesystem** on **Longhorn** (`clusters/noble/bootstrap/loki/`); **`loki.auth_enabled: false`**; **`chunksCache.enabled: false`** (no memcached chunk cache). **`fluent/fluent-bit` 0.57.5** → **`loki-gateway.loki.svc:80`** (`clusters/noble/bootstrap/fluent-bit/`); **`logging`** PSA **privileged**. **Grafana Explore:** **`kubectl apply -f clusters/noble/bootstrap/grafana-loki-datasource/loki-datasource.yaml`** then **Explore → Loki** (e.g. `{job="fluent-bit"}`).
- **SOPS** — cluster **`Secret`** manifests under **`clusters/noble/secrets/`** encrypted with **age** (see **`.sops.yaml`**, **`age-key.txt`** gitignored); **`noble.yml`** decrypt-applies when the private key is present.
- **Velero** Helm **12.0.0** / app **v1.18.0**`clusters/noble/bootstrap/velero/` (**Ansible** **`noble_velero`**, not Argo); **S3-compatible** backup location + **CSI** snapshots (**`EnableCSI`**); enable with **`noble_velero_install`** per **`velero/README.md`**.
- **Velero** Helm **12.0.1** / app **v1.18.0**`clusters/noble/bootstrap/velero/` (**Ansible** **`noble_velero`**, not Argo); **S3-compatible** backup location + **CSI** snapshots (**`EnableCSI`**); enable with **`noble_velero_install`** per **`velero/README.md`**.
- **Still open:** **Renovate** — install **[Mend Renovate](https://github.com/apps/renovate)** (or self-host) so PRs run; optional **Alertmanager** notification channels; optional **sample Ingress + cert + Pangolin** end-to-end; **Argo CD SSO**.
## Inventory
@@ -51,20 +51,20 @@ Lab stack is **up** on-cluster through **Phase D****F** and **Phase G** (**`t
- Talos: **v1.12.6** — align `talosctl` client with node image
- Talos **Image Factory** (iscsi-tools + util-linux-tools): **`factory.talos.dev/nocloud-installer/249d9135de54962744e917cfe654117000cba369f9152fbab9d055a00aa3664f:v1.12.6`** — same schematic must appear in **`machine.install.image`** after `talhelper genconfig` (bare metal may use `metal-installer/` instead of `nocloud-installer/`)
- Kubernetes: **1.35.2** on current nodes (bundled with Talos; not pinned in repo)
- Cilium: **1.16.6** (Helm chart; see `clusters/noble/bootstrap/cilium/README.md`)
- Cilium: **1.19.4** (Helm chart; see `clusters/noble/bootstrap/cilium/README.md`)
- MetalLB: **0.15.3** (Helm chart; app **v0.15.3**)
- metrics-server: **3.13.0** (Helm chart; app **v0.8.0**)
- Longhorn: **1.11.1** (Helm chart; app **v1.11.1**)
- Traefik: **39.0.6** (Helm chart; app **v3.6.11**)
- cert-manager: **v1.20.0** (Helm chart; app **v1.20.0**)
- Newt (Fossorial): **1.2.0** (Helm chart; app **1.10.1**)
- Argo CD: **9.4.17** (Helm chart `argo/argo-cd`; app **v3.3.6**)
- kube-prometheus-stack: **82.15.1** (Helm chart `prometheus-community/kube-prometheus-stack`; app **v0.89.x** bundle)
- Loki: **6.55.0** (Helm chart `grafana/loki`; app **3.6.7**)
- Fluent Bit: **0.56.0** (Helm chart `fluent/fluent-bit`; app **4.2.3**)
- Kyverno: **3.7.1** (Helm chart `kyverno/kyverno`; app **v1.17.1**); **kyverno-policies** **3.7.1****baseline** PSS, **Audit** (`clusters/noble/bootstrap/kyverno/`)
- Headlamp: **0.40.1** (Helm chart `headlamp/headlamp`; app matches chart — see [Artifact Hub](https://artifacthub.io/packages/helm/headlamp/headlamp))
- Velero: **12.0.0** (Helm chart `vmware-tanzu/velero`; app **v1.18.0**) — **`clusters/noble/bootstrap/velero/`**; AWS plugin **v1.14.0**; Ansible **`noble_velero`**
- Longhorn: **1.11.2** (Helm chart; app **v1.11.2**)
- Traefik: **40.2.0** (Helm chart; app **v3.7.1**)
- cert-manager: **v1.20.2** (Helm chart; app **v1.20.2**)
- Newt (Fossorial): **1.5.0** (Helm chart; app **1.12.5**)
- Argo CD: **9.5.14** (Helm chart `argo/argo-cd`; app **v3.4.2**)
- kube-prometheus-stack: **85.0.3** (Helm chart `prometheus-community/kube-prometheus-stack`; app **v0.90.1** bundle)
- Loki: **7.0.0** (Helm chart `grafana/loki`; app **3.6.7**)
- Fluent Bit: **0.57.5** (Helm chart `fluent/fluent-bit`; app **5.0.5**)
- Kyverno: **3.8.0** (Helm chart `kyverno/kyverno`; app **v1.18.0**); **kyverno-policies** **3.8.0****baseline** PSS, **Audit** (`clusters/noble/bootstrap/kyverno/`)
- Headlamp: **0.42.0** (Helm chart `headlamp/headlamp`; app matches chart — see [Artifact Hub](https://artifacthub.io/packages/helm/headlamp/headlamp))
- Velero: **12.0.1** (Helm chart `vmware-tanzu/velero`; app **v1.18.0**) — **`clusters/noble/bootstrap/velero/`**; AWS plugin **v1.14.0**; Ansible **`noble_velero`**
- Renovate: **hosted** (Mend **Renovate** GitHub/GitLab app — no cluster chart) **or** **self-hosted** — pin chart when added ([Helm charts](https://docs.renovatebot.com/helm-charts/), OCI `ghcr.io/renovatebot/charts/renovate`); pair **`renovate.json`** with this repos Helm paths under **`clusters/noble/`**
## Repo paths (this workspace)
@@ -137,10 +137,10 @@ Lab stack is **up** on-cluster through **Phase D****F** and **Phase G** (**`t
**Install order:** **Cilium****Volume Snapshot CRDs + snapshot-controller** (`clusters/noble/bootstrap/csi-snapshot-controller/`, Ansible **`noble_csi_snapshot_controller`**) → **metrics-server****Longhorn** (Talos disk + Helm) → **MetalLB** (Helm → pool manifests) → ingress / certs / DNS as planned.
- [x] **Cilium** (Helm **1.16.6**) — **required** before MetalLB if `cni: none` (`clusters/noble/bootstrap/cilium/`)
- [x] **Cilium** (Helm **1.19.4**) — **required** before MetalLB if `cni: none` (`clusters/noble/bootstrap/cilium/`)
- [x] **CSI Volume Snapshot** — CRDs + **`snapshot-controller`** in **`kube-system`** (`clusters/noble/bootstrap/csi-snapshot-controller/`); Ansible **`noble_csi_snapshot_controller`**; verify `kubectl api-resources | grep VolumeSnapshot`
- [x] **metrics-server** — Helm **3.13.0**; values in `clusters/noble/bootstrap/metrics-server/values.yaml`; verify `kubectl top nodes`
- [x] **Longhorn** — Talos: user volume + kubelet mounts + extensions (`talos/README.md` §5); Helm **1.11.1**; `kubectl apply -k clusters/noble/bootstrap/longhorn`; verify **`nodes.longhorn.io`** and test PVC **`Bound`**
- [x] **Longhorn** — Talos: user volume + kubelet mounts + extensions (`talos/README.md` §5); Helm **1.11.2**; `kubectl apply -k clusters/noble/bootstrap/longhorn`; verify **`nodes.longhorn.io`** and test PVC **`Bound`**
- [x] **MetalLB** — chart installed; **pool + L2** from `clusters/noble/bootstrap/metallb/` applied (`192.168.50.210``229`)
- [x] **`Service` `LoadBalancer`** / pool check — MetalLB assigns from `210``229` (validated before Traefik; temporary nginx test removed in favor of Traefik)
- [x] **Traefik** `LoadBalancer` for `*.apps.noble.lab.pcenicni.dev``clusters/noble/bootstrap/traefik/`; **`192.168.50.211`**
@@ -157,9 +157,9 @@ Lab stack is **up** on-cluster through **Phase D****F** and **Phase G** (**`t
## Phase D — Observability
- [x] **kube-prometheus-stack**`kubectl apply -f clusters/noble/bootstrap/kube-prometheus-stack/namespace.yaml` then **`helm upgrade --install`** as in `clusters/noble/bootstrap/kube-prometheus-stack/values.yaml` (chart **82.15.1**); PVCs **`longhorn`**; **`--wait --timeout 30m`** recommended; verify **`kubectl -n monitoring get pods,pvc`**
- [x] **Loki** + **Fluent Bit** + **Grafana Loki datasource****order:** **`kubectl apply -f clusters/noble/bootstrap/loki/namespace.yaml`** → **`helm upgrade --install loki`** `grafana/loki` **6.55.0** `-f clusters/noble/bootstrap/loki/values.yaml`**`kubectl apply -f clusters/noble/bootstrap/fluent-bit/namespace.yaml`** → **`helm upgrade --install fluent-bit`** `fluent/fluent-bit` **0.56.0** `-f clusters/noble/bootstrap/fluent-bit/values.yaml`**`kubectl apply -f clusters/noble/bootstrap/grafana-loki-datasource/loki-datasource.yaml`**. Verify **Explore → Loki** in Grafana; **`kubectl -n loki get pods,pvc`**, **`kubectl -n logging get pods`**
- [x] **Headlamp** — Kubernetes web UI ([Headlamp](https://headlamp.dev/)); **`helm repo add headlamp https://kubernetes-sigs.github.io/headlamp/`**; **`kubectl apply -f clusters/noble/bootstrap/headlamp/namespace.yaml`** → **`helm upgrade --install headlamp headlamp/headlamp --version 0.40.1 -n headlamp -f clusters/noble/bootstrap/headlamp/values.yaml`**; **Ingress** **`https://headlamp.apps.noble.lab.pcenicni.dev`** (**`ingressClassName: traefik`**, **`cert-manager.io/cluster-issuer: letsencrypt-prod`**). **`values.yaml`:** **`config.sessionTTL: null`** works around chart **0.40.1** / binary mismatch ([headlamp#4883](https://github.com/kubernetes-sigs/headlamp/issues/4883)). **RBAC:** chart defaults are permissive — tighten before LAN-wide exposure; align with **Phase G** hardening.
- [x] **kube-prometheus-stack**`kubectl apply -f clusters/noble/bootstrap/kube-prometheus-stack/namespace.yaml` then **`helm upgrade --install`** as in `clusters/noble/bootstrap/kube-prometheus-stack/values.yaml` (chart **85.0.3**); PVCs **`longhorn`**; **`--wait --timeout 30m`** recommended; verify **`kubectl -n monitoring get pods,pvc`**
- [x] **Loki** + **Fluent Bit** + **Grafana Loki datasource****order:** **`kubectl apply -f clusters/noble/bootstrap/loki/namespace.yaml`** → **`helm upgrade --install loki`** `grafana/loki` **7.0.0** `-f clusters/noble/bootstrap/loki/values.yaml`**`kubectl apply -f clusters/noble/bootstrap/fluent-bit/namespace.yaml`** → **`helm upgrade --install fluent-bit`** `fluent/fluent-bit` **0.57.5** `-f clusters/noble/bootstrap/fluent-bit/values.yaml`**`kubectl apply -f clusters/noble/bootstrap/grafana-loki-datasource/loki-datasource.yaml`**. Verify **Explore → Loki** in Grafana; **`kubectl -n loki get pods,pvc`**, **`kubectl -n logging get pods`**
- [x] **Headlamp** — Kubernetes web UI ([Headlamp](https://headlamp.dev/)); **`helm repo add headlamp https://kubernetes-sigs.github.io/headlamp/`**; **`kubectl apply -f clusters/noble/bootstrap/headlamp/namespace.yaml`** → **`helm upgrade --install headlamp headlamp/headlamp --version 0.42.0 -n headlamp -f clusters/noble/bootstrap/headlamp/values.yaml`** (add **`-f values-authentik-oidc.yaml`** when using OIDC); **Ingress** **`https://headlamp.apps.noble.lab.pcenicni.dev`** (**`ingressClassName: traefik`**, **`cert-manager.io/cluster-issuer: letsencrypt-prod`**). **`values.yaml`:** **`config.sessionTTL: null`** omits **`-session-ttl`** if you hit older chart/binary mismatches ([headlamp#4883](https://github.com/kubernetes-sigs/headlamp/issues/4883)); chart **0.42.x** defaults are otherwise fine. **RBAC:** chart defaults are permissive — tighten before LAN-wide exposure; align with **Phase G** hardening.
## Phase E — Secrets
@@ -167,7 +167,7 @@ Lab stack is **up** on-cluster through **Phase D****F** and **Phase G** (**`t
## Phase F — Policy + backups
- [x] **Kyverno** baseline policies — `clusters/noble/bootstrap/kyverno/` (Helm **kyverno** **3.7.1** + **kyverno-policies** **3.7.1**, **baseline** / **Audit** — see **`README.md`**)
- [x] **Kyverno** baseline policies — `clusters/noble/bootstrap/kyverno/` (Helm **kyverno** **3.8.0** + **kyverno-policies** **3.8.0**, **baseline** / **Audit** — see **`README.md`**)
- [ ] **Velero** — manifests + Ansible **`noble_velero`** (`clusters/noble/bootstrap/velero/`); enable with **`noble_velero_install: true`** + S3 bucket/URL + **`velero/velero-cloud-credentials`** (see **`velero/README.md`**); optional backup/restore drill
## Phase G — Hardening

View File

@@ -9,6 +9,9 @@ Headlamp sends your **IdP JWT** to the Kubernetes API. **`/me`** is answered by
2. **Ensure control planes can reach** `https://auth.apps.noble.lab.pcenicni.dev/...` (JWKS / discovery). If that URL is unreachable from nodes, OIDC validation fails.
3. **Apply cluster RBAC for OIDC groups**: **`kubectl apply -k clusters/noble/bootstrap/headlamp`** (includes **`oidc-noble-admins-clusterrolebinding.yaml`**). Your user must be in Authentik group **`noble-admins`** and the id_token should carry a **`groups`** claim if you rely on that binding.
**Headlamp OIDC: nodes / CPU-memory metrics / plugins look broken (403 or empty)**
The chart binds only the **pod ServiceAccount** to **`headlamp-metrics-reader`** unless you also bind your **IdP group**. **`metrics-clusterrolebinding.yaml`** binds **`noble-admins`** to the same additive ClusterRole as the SA (metrics API, **nodes**, read-only **CRDs**). Without **`metrics-server`** (or another **metrics.k8s.io** provider), CPU and memory stay empty even with RBAC. Plugin catalogs that load from the public internet can still fail from the browser (network, ad blockers) unrelated to RBAC.
Quick discovery check (any machine with DNS to Authentik):
```bash

View File

@@ -103,5 +103,6 @@ patches:
extraArgs:
oidc-issuer-url: https://auth.apps.noble.lab.pcenicni.dev/application/o/headlamp/
oidc-client-id: headlamp
oidc-username-claim: email
# Not "email": kube-apiserver rejects tokens when email_verified is false; Authentik often emits that.
oidc-username-claim: preferred_username
oidc-groups-claim: groups

View File

@@ -103,5 +103,6 @@ patches:
extraArgs:
oidc-issuer-url: https://auth.apps.noble.lab.pcenicni.dev/application/o/headlamp/
oidc-client-id: headlamp
oidc-username-claim: email
# Not "email": kube-apiserver rejects tokens when email_verified is false; Authentik often emits that.
oidc-username-claim: preferred_username
oidc-groups-claim: groups