Compare commits
23 Commits
af3ab32de8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7df12e3396 | ||
|
|
02f6ab03bc | ||
|
|
6e76a400b6 | ||
|
|
2fb86f5930 | ||
|
|
97da42b15c | ||
|
|
73ff9fd022 | ||
|
|
f37768b789 | ||
|
|
7fed8820ce | ||
|
|
7b337f7128 | ||
|
|
93d602de9d | ||
|
|
fbcd2416e6 | ||
|
|
e48b19b64c | ||
|
|
57a149b3d2 | ||
|
|
032ffee866 | ||
|
|
b90ee2d531 | ||
|
|
b994e2d6d0 | ||
|
|
c3bdda1dd7 | ||
|
|
7c9fd1fde6 | ||
|
|
bb0bd4ca90 | ||
|
|
817849ee3c | ||
|
|
2321209626 | ||
|
|
95b1866144 | ||
|
|
86df02f9bd |
37
.env.sample
37
.env.sample
@@ -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
2
.gitignore
vendored
@@ -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
|
||||
@@ -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 Komodo’s `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 Komodo’s `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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 Authentik’s 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 Authentik’s 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 version’s 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`): Authentik’s 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 user’s 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 repo’s **`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 repo’s **`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)
|
||||
|
||||
|
||||
@@ -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 flow’s 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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). Helm’s 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 %}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
95
ansible/roles/noble_newt/tasks/pangolin_sync.yml
Normal file
95
ansible/roles/noble_newt/tasks/pangolin_sync.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -5,4 +5,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- homepage
|
||||
- open-webui
|
||||
- trivy
|
||||
|
||||
32
clusters/noble/apps/open-webui/application.yaml
Normal file
32
clusters/noble/apps/open-webui/application.yaml
Normal 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
|
||||
6
clusters/noble/apps/open-webui/kustomization.yaml
Normal file
6
clusters/noble/apps/open-webui/kustomization.yaml
Normal 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
|
||||
6
clusters/noble/apps/open-webui/namespace.yaml
Normal file
6
clusters/noble/apps/open-webui/namespace.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: open-webui
|
||||
labels:
|
||||
app.kubernetes.io/name: open-webui
|
||||
65
clusters/noble/apps/open-webui/values.yaml
Normal file
65
clusters/noble/apps/open-webui/values.yaml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,3 +19,4 @@ spec:
|
||||
selector:
|
||||
dnsZones:
|
||||
- pcenicni.dev
|
||||
- nikflix.ca
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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 Headlamp’s 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 repo’s **`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 1–2 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
|
||||
|
||||
2957
clusters/noble/bootstrap/headlamp/cacert.pem
Normal file
2957
clusters/noble/bootstrap/headlamp/cacert.pem
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 Headlamp’s 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 30–60m. To watch progress,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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 Pangolin’s `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. Pangolin’s 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; Pangolin’s field names follow their docs).
|
||||
|
||||
Discovery in Pangolin’s 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 deployment’s **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.
|
||||
|
||||
Binary file not shown.
569
clusters/noble/bootstrap/newt/scripts/sync_pangolin_http_resources.py
Executable file
569
clusters/noble/bootstrap/newt/scripts/sync_pangolin_http_resources.py
Executable 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)
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 Velero’s 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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ services:
|
||||
ports:
|
||||
- 5055:5055
|
||||
volumes:
|
||||
- ${CONFIG_PATH}/jellyseerr:/app/config
|
||||
- ${CONFIG_PATH}/seerr:/app/config
|
||||
restart: unless-stopped
|
||||
|
||||
prowlarr:
|
||||
|
||||
@@ -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 browser’s 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 Versity’s 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 Pangolin’s 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 Pangolin’s 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 repo’s 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user