Compare commits

..
2 Commits
Author SHA1 Message Date
claude-botandClaude Opus 5.5 58196acebc fix(authentik): embedded outpost config lives in the blueprint
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The embedded outpost's config is Authentik's own and does not depend on
authentik-gate-sync. A blueprint update of an existing outpost is partial
(importer: partial=True unless MUST_CREATED), so the entry sets config
without providers and the gates the controller attached stay.

config is replaced whole; the entry mirrors the live row
(authentik_outposts_outpost, 2026-10-23) with refresh_interval
minutes=1. kubernetes_namespace stays default: the outpost has the
Local Kubernetes Cluster connection, and the authentik namespace would
let the worker create an ak-outpost Service for it.

The controller drops embedded_config/ensure_embedded_config and only
attaches providers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:00:21 +02:00
claude-botandClaude Opus 5.5 95f3ff86b9 fix(authentik-gate-sync): refresh the embedded outpost every minute
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Each authentik-server replica's embedded outpost loses its websocket whenever gunicorn recycles the worker holding it (AUTHENTIK_WEB__MAX_REQUESTS). The reconnect backoff never resets (goauthentik/authentik#26463), so a replica then misses gate changes for up to 5 minutes and catches up only on the refresh interval. A 1-minute interval bounds that gap; worker recycling stays as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:19:49 +02:00
7 changed files with 37 additions and 42 deletions
@@ -45,7 +45,6 @@ INGRESS_NS = os.environ.get("INGRESS_NAMESPACE", "ingress")
DEFAULT_AUTHZ_FLOW = os.environ.get("DEFAULT_AUTHORIZATION_FLOW", "default-provider-authorization-implicit-consent")
INVALIDATION_FLOW = os.environ.get("INVALIDATION_FLOW", "default-provider-invalidation-flow")
COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", "renner.uno")
AK_BROWSER_URL = os.environ.get("AUTHENTIK_HOST_BROWSER", "https://auth.renner.uno/")
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
HTTP_TIMEOUT = 20
@@ -209,14 +208,6 @@ def resolve_backend(gate, service):
return sp["port"], selector, target
def embedded_config(config):
"""The embedded outpost's config with the host the gates depend on. The
embedded outpost builds the sign-in redirect from authentik_host, so it is
the public URL, which is also Authentik's default for it."""
return {**(config or {}), "authentik_host": AK_BROWSER_URL, "authentik_host_insecure": False,
"authentik_host_browser": AK_BROWSER_URL}
def provider_body(gate, flows):
return {
"name": gate["name"],
@@ -349,14 +340,6 @@ def embedded_outpost(ak):
return found[0]
def ensure_embedded_config(ak):
outpost = embedded_outpost(ak)
want = embedded_config(outpost.get("config"))
if outpost.get("config") != want:
ak.req("PATCH", f"outposts/instances/{outpost['pk']}/", {"config": want})
LOG.info("embedded outpost: updated config")
def ensure_gate(ak, gate, flows):
name = gate["name"]
if gate["group"] and ak.one("core/groups/", name=gate["group"]) is None:
@@ -485,7 +468,6 @@ def reconcile():
sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", []) if is_gated(i)]
shared = shared_hosts(sources)
flows = {f["slug"]: f["pk"] for f in ak.list("flows/instances/")}
ensure_embedded_config(ak)
desired = set() # (kind, ns, name) of every Kubernetes object some gate wants
gates = set() # gate names whose Authentik side should exist
@@ -166,11 +166,6 @@ class Objects(unittest.TestCase):
def test_drift(self):
self.assertEqual(c.drift({"a": 1, "b": 2}, {"a": 1, "b": 3}), {"b": 3})
def test_embedded_config_keeps_other_keys(self):
cfg = c.embedded_config({"log_level": "debug", "authentik_host": c.AK_URL})
self.assertEqual(cfg, {"log_level": "debug", "authentik_host": c.AK_BROWSER_URL,
"authentik_host_insecure": False, "authentik_host_browser": c.AK_BROWSER_URL})
class FakeAuthentik:
"""In-memory stand-in for the endpoints the controller uses; records calls."""
@@ -299,15 +294,6 @@ class Reconcile(unittest.TestCase):
c.embedded_outpost(self.ak)
self.assertEqual(self.ak.get(f"outposts/instances/{ldap['pk']}/")["providers"], [7])
def test_embedded_config(self):
c.ensure_embedded_config(self.ak)
cfg = self.ak.get(f"outposts/instances/{self.embedded['pk']}/")["config"]
self.assertEqual((cfg["log_level"], cfg["authentik_host"], cfg["authentik_host_browser"]),
("info", c.AK_BROWSER_URL, c.AK_BROWSER_URL))
before = len(self.ak.calls)
c.ensure_embedded_config(self.ak)
self.assertEqual(self.ak.calls[before:], [])
def test_provider_moves_from_proxy_mode(self):
self.ak.add("providers/proxy/", {"name": self.g["name"], "mode": "proxy",
"internal_host": "http://llama-server.ai.svc.cluster.local:8080",
@@ -70,7 +70,7 @@ spec:
mountPath: /ca
containers:
- name: audiobookshelf
image: ghcr.io/advplyr/audiobookshelf:2.37.2 # {"$imagepolicy": "flux-system:audiobookshelf"}
image: ghcr.io/advplyr/audiobookshelf:2.37.1 # {"$imagepolicy": "flux-system:audiobookshelf"}
ports:
- name: http
containerPort: 80
@@ -374,10 +374,39 @@ data:
name: lidarr-gate
state: absent
# The embedded outpost is not declared here: authentik-gate-sync owns its
# providers list (every gate) and the hosts in its config, and a
# blueprint entry must carry `providers`, which would detach the gates
# on every apply.
# No `providers`: authentik-gate-sync attaches the gates, and an update
# of an existing outpost is partial. `config` is replaced whole, so it
# carries every key, kubernetes_namespace included.
- model: authentik_outposts.outpost
identifiers:
name: authentik Embedded Outpost
state: present
attrs:
config:
log_level: info
# The embedded outpost builds the sign-in redirect from
# authentik_host, so it is the public URL.
authentik_host: "https://auth.renner.uno/"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
# A replica whose outpost websocket is down picks up gate changes
# only on this interval; the reconnect backoff never resets
# (goauthentik/authentik#26463).
refresh_interval: "minutes=1"
object_naming_template: "ak-outpost-%(name)s"
container_image: null
docker_labels: null
docker_network: ladmin_default
docker_map_ports: true
kubernetes_namespace: default
kubernetes_replicas: 1
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: []
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
# Shared LDAPS cert for the LDAP Outpost. Authentik's data model
# owns the cert per-LDAPProvider (no outpost-level cert field), so
-2
View File
@@ -114,8 +114,6 @@ spec:
env:
- name: AK_URL
value: http://authentik-server.authentik.svc.cluster.local
- name: AUTHENTIK_HOST_BROWSER
value: https://auth.renner.uno/
- name: DEFAULT_AUTHORIZATION_FLOW
value: default-provider-authorization-implicit-consent
- name: INTERVAL_SECONDS
@@ -128,7 +128,7 @@ spec:
memory: 32Mi
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:2026.10.3 # {"$imagepolicy": "flux-system:home-assistant"}
image: ghcr.io/home-assistant/home-assistant:2026.10.2 # {"$imagepolicy": "flux-system:home-assistant"}
env:
- name: TZ
value: Europe/Berlin
@@ -52,7 +52,7 @@ spec:
# stdlib only; Flux keeps the tag in lockstep via the shared image
# policy) — no extra dependency.
- name: reconcile-publish-ip
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.7 # {"$imagepolicy": "flux-system:music-assistant"}
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
volumeMounts:
- name: data
@@ -62,7 +62,7 @@ spec:
readOnly: true
containers:
- name: music-assistant
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.7 # {"$imagepolicy": "flux-system:music-assistant"}
env:
- name: LOG_LEVEL
value: info