Compare commits

..
Author SHA1 Message Date
hermes-bot 5e814c525a fix(authentik-gate-sync): stop sweeping every gate as an OIDC client
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Outage since 10:11Z: the first pass of image 2026-11-01-0959 (#1535)
removed all 14 gates ("removed OIDC client from Authentik", "14 objects
removed"), and every pass since recreates and removes them again.

ProxyProvider subclasses OAuth2Provider (authentik 2026.8.4
providers/proxy/models.py: class ProxyProvider(OutpostModel,
OAuth2Provider)), so GET providers/oauth2/ also lists every proxy
provider. owned_applications() yielded each gate a second time as an
OAuth2 application; it was not in the OIDC keep set, so the sweep
deleted its application and provider.

Both the sweep and ensure_oidc's provider lookup now take from
providers/oauth2/ only providers whose meta_model_name is
authentik_providers_oauth2.oauth2provider. The test fake now lists proxy
providers under providers/oauth2/ like the real API; the new tests
(gate-only and gate + OIDC client stable over two passes) fail against
the previous controller.
2026-11-01 10:12:58 +00:00
security-bot 1a99c02996 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-0959
Updates managed by image-automation-controller.
2026-11-01 10:07:04 +00:00
k.renner 09a03dfd76 Merge pull request 'feat(authentik-gate-sync): OIDC clients from OIDCClient resources' (#1535) from feat/gate-sync-oidc into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1535
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-01 09:59:01 +00:00
hermes-bot f76defb1e1 fix(authentik-gate-sync): review of the OIDC clients
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
- Desired redirect URIs carry redirect_uri_type: authorization, which the
  API returns on every entry (RedirectURISerializer default in 2026.8.4);
  without it every pass would re-PATCH the provider.
- OIDC slugs and gate names are kept apart: a slug that is a gate name is
  refused, an existing application without this controller's OAuth2
  provider is refused, and the sweep keeps proxy and OAuth2 applications
  by their own sets, so an OIDCClient can neither take over nor delete a
  gate.
- Status is written only when it changed.
- Adoption of an unassigned oidc-<slug> provider is documented; removal
  logs say OIDC client.
- reconcile() takes k8s/ak for tests; new loop tests: create then quiet,
  duplicate slug, transient error keeps the client, sweep after the
  resource is gone, slug equal to a gate name.
2026-11-01 09:49:33 +00:00
hermes-bot 3e0557cfe8 feat(authentik-gate-sync): OIDC clients as OIDCClient resources, not Ingress annotations
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
OIDC is not part of the Ingress. The client is now declared by an
OIDCClient (auth.renner.uno/v1alpha1, CRD in
infrastructure/authentik-gate-sync-crds, like kuma-sync-crds) next to the
app: launchURL, redirectURIs, optional slug, group, authorizationFlow.
The controller writes a Ready condition and the issuer to its status.

The auth.renner.uno/oidc-* Ingress annotations are gone; RBAC gains
get/list/watch on oidcclients and patch on their status.
2026-11-01 09:31:43 +00:00
hermes-bot 8739efe026 feat(authentik-gate-sync): OIDC clients from auth.renner.uno/oidc-client
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
An Ingress annotated auth.renner.uno/oidc-client: <slug> gets a
confidential OAuth2/OpenID provider (client ID = slug, secret generated by
Authentik), an application with that slug and, with auth.renner.uno/group,
the same group expression policy as gates. auth.renner.uno/oidc-redirect-uri
pins strict redirect URIs on the Ingress host; default is any path on it.

Same fail-closed and ownership rules as gates: owned clients are found via
the OAuth2 provider list and meta_publisher and swept when their Ingress
goes. The service-account role gains the OAuth2 provider permissions plus
view on scope mappings and certificate key pairs.

So apps that log in through Authentik via OIDC (first: YouTrack, #1534)
need no blueprint entries; the groups stay managed in Authentik.
2026-11-01 09:22:25 +00:00
k.renner e518b46045 Merge pull request 'chore(ci): bump ci-claude image to 2.1.316' (#1533) from ci-image/claude-2.1.316 into main
Reviewed-on: #1533
2026-11-01 09:14:15 +00:00
claude-bot d7a6c2bdc7 chore(ci): bump ci-claude image to 2.1.316
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-11-01 00:01:25 +00:00
k.renner 53d7e716f8 Merge pull request 'fix(loki): enforce retention via the compactor, keep 365 days' (#1532) from worktree-loki-retention-365d into main
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/ci-claude-refresh Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
Reviewed-on: #1532
2026-10-31 06:10:42 +00:00
security-bot 61e4ee2ba8 chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.4.1
Updates managed by image-automation-controller.
2026-10-31 05:50:56 +00:00
security-bot b447c57e78 chore(image-automation): bump ghcr.io/linuxserver/lidarr:3.1.6.5079-ls46
Updates managed by image-automation-controller.
2026-10-31 00:23:17 +00:00
security-bot 4f3cf541a4 chore(image-automation): bump docker.gitea.com/gitea-mcp-server:1.12.0
Updates managed by image-automation-controller.
2026-10-30 19:30:16 +00:00
security-bot 6463c38f68 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-30-1343
Updates managed by image-automation-controller.
2026-10-30 13:51:12 +00:00
security-bot b86aaf3f88 chore(image-automation): bump ghcr.io/music-assistant/server:2.10.9
Updates managed by image-automation-controller.
2026-10-30 13:50:57 +00:00
claude-botandClaude Opus 5.5 c9944cfce8 fix(loki): enforce retention via the compactor, keep 365 days
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
limits_config.retention_period was set (2160h) but never enforced: the
live config has no compactor block, so retention_enabled is false and
Loki keeps everything (logs from 2026-05-15 are still queryable).

Enable compactor retention (working_directory under the existing
/var/loki PVC, delete_request_store filesystem) and raise the period to
365 days.

Sizing: storage-loki-0 holds 21.3 GiB of 80 GiB after 5.5 months
(~0.13 GiB/day average, ~0.23 GiB/day over 22.-25.10). 365 days at the
recent rate is ~84 GiB, so the PVC may need expanding later
(ceph-block allows volume expansion).

Rendered with chart loki 7.5.0 and checked with
`loki:3.6.11 -verify-config`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:38:29 +01:00
k.renner 717d1ad619 Merge pull request 'fix(paperless): drop the off-site monitor of the internal Syncthing UI' (#1531) from paperless-drop-syncthing-monitor into main
Reviewed-on: #1531
2026-10-30 13:32:02 +00:00
claude-botandClaude Opus 5.5 40f30412aa fix(paperless): drop the off-site monitor of the internal Syncthing UI
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
syncthing.paperless.renner.uno is internal only (no public labels, no public
DNS record), so Uptime Kuma on the VPS cannot reach it and kept the Documents
group down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:29:42 +01:00
security-bot 3f73e7cd3a chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-30-1314
Updates managed by image-automation-controller.
2026-10-30 13:23:56 +00:00
k.renner a7543cb78b Merge pull request 'feat(alloy-syslog): label UniFi plain syslog on udp-5514 separately from CEF' (#1530) from worktree-alloy-unifi-syslog-label into main
Reviewed-on: #1530
2026-10-30 13:23:48 +00:00
claude-botandClaude Opus 5.5 229e0cf5d8 feat(alloy-syslog): label UniFi plain syslog on udp-5514 separately from CEF
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
UniFi activity logging has a single target, so the UDM SIEM CEF events,
the UDM system log (log_all_contents) and the AP/switch device logs
(debug) all arrive on udp-5514. Split them in loki.process.udm_cef:

- CEF lines keep source="udm-siem" and the existing CEF labels.
- Everything else gets source="unifi-syslog" plus host and app parsed
  from the line header (UDM: "<host> <host> <app>[pid]:", devices:
  "<host> <mac>,<model>-<fw>: [: ]<app>[pid]:").

The listener no longer sets source statically; each stage.match sets it,
so no label is overwritten. Regex checked against 10 min of live lines
(all non-CEF lines match); config validated with alloy v1.21.3
`validate --stability.level=experimental`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:22:28 +01:00
security-bot b7cf7632c6 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-30-1257
Updates managed by image-automation-controller.
2026-10-30 13:21:12 +00:00
k.renner b77b0a4d0d Merge pull request 'feat(kuma-sync): one mail per outage via a root group' (#1525) from kuma-root-group into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1525
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-30 13:14:40 +00:00
security-bot 2485b3ad38 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-30-1158
Updates managed by image-automation-controller.
2026-10-30 12:24:28 +00:00
k.renner a49c852666 Merge pull request 'feat(paperless): Syncthing UI behind the Authentik gate' (#1529) from worktree-syncthing-ui-gate into main
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1529
2026-10-30 10:45:08 +00:00
claude-botandClaude Opus 5.5 811178e321 feat(kuma-sync): one mail per outage via a root group
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Monitors without a parentRef are placed under the root group set by
KUMA_ROOT_GROUP (kuma-sync/renner-uno). Only the root references the
email notification, so a fleet-wide outage sends one DOWN and one UP mail
listing the affected groups. resendInterval (consecutive DOWN heartbeats)
is now a managed KumaMonitor field; the root resends hourly while down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:33:16 +01:00
40 changed files with 1209 additions and 130 deletions
+4 -4
View File
@@ -35,25 +35,25 @@ when:
steps:
- name: model-selection-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
commands:
- sh scripts/ci/test-model-selection.sh
- name: pr-review-mistral-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
commands:
- sh scripts/ci/test-pr-review-mistral.sh
- name: promtool-rules-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
commands:
- sh scripts/ci/test-promtool-rules.sh
- name: yaml-citations-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
commands:
- sh scripts/ci/test-yaml-citations.sh
+1 -1
View File
@@ -26,7 +26,7 @@ steps:
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
# test from rotting (it exercises the exact injection shapes).
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -70,7 +70,7 @@ steps:
# this step is skipped — otherwise we'd fork-bomb PRs.
when:
- branch: main
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -30,7 +30,7 @@ when:
steps:
- name: update-audit-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+237 -24
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""authentik-gate-sync: the Authentik side of the gate Middleware.
"""authentik-gate-sync: the Authentik side of the gate Middleware and of OIDC clients.
An Ingress is gated when its traefik.ingress.kubernetes.io/router.middlewares
annotation names the gate Middleware (authentik/gate, a ForwardAuth against the
@@ -18,6 +18,16 @@ Annotations (all optional):
auth.renner.uno/authorization-flow authorization flow slug (default below)
auth.renner.uno/skip-path provider skip_path_regex
OIDC clients: each OIDCClient (auth.renner.uno/v1alpha1, CRD in
infrastructure/authentik-gate-sync-crds) gets a confidential OAuth2/OpenID
provider oidc-<slug> (client ID = slug, secret generated by Authentik) and an
application with that slug, so the issuer is <authentik>/application/o/<slug>/;
with spec.group the same group policy as gates. Its status carries Ready and
the issuer. OIDC slugs and gate names are kept apart: a slug that is a gate's
name, or an existing application without this controller's OAuth2 provider,
is refused. An unassigned provider named oidc-<slug> is adopted (the remains
of an interrupted teardown, which deletes the application first).
Fail closed: until the provider exists, or after it is removed (e.g. the named
group is gone), the embedded outpost has no provider for the host and rejects
the request; Traefik answers 500 when the outpost is unreachable.
@@ -30,6 +40,7 @@ import hashlib
import json
import logging
import os
import re
import sys
import time
import urllib.error
@@ -46,6 +57,10 @@ DEFAULT_AUTHZ_FLOW = os.environ.get("DEFAULT_AUTHORIZATION_FLOW", "default-provi
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/")
SIGNING_KEY = os.environ.get("OIDC_SIGNING_KEY", "authentik Self-signed Certificate")
OIDC_SCOPES = tuple(f"goauthentik.io/providers/oauth2/scope-{s}" for s in ("openid", "profile", "email"))
OIDC_GRANTS = ["authorization_code", "refresh_token"]
SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$")
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
HTTP_TIMEOUT = 20
@@ -234,6 +249,74 @@ def provider_body(gate, flows):
}
OIDC_API = "/apis/auth.renner.uno/v1alpha1"
def oidc_slug(cr):
return ((cr.get("spec") or {}).get("slug") or cr["metadata"]["name"]).strip()
def oidc_spec(cr):
"""Translate an OIDCClient into the client it asks for. Raises GateError."""
meta, spec = cr["metadata"], cr.get("spec") or {}
slug = oidc_slug(cr)
if not SLUG_RE.match(slug):
raise GateError(f"slug {slug!r} is not a slug (a-z, 0-9, -; max 50)")
launch = spec.get("launchURL", "")
if not launch.startswith(("https://", "http://")):
raise GateError(f"launchURL {launch!r} is not an http(s) URL")
redirects = [{"matching_mode": r.get("matchingMode", "strict"), "url": r.get("url", ""),
"redirect_uri_type": "authorization"}
for r in spec.get("redirectURIs") or []]
if not redirects or any(not r["url"] or r["matching_mode"] not in ("strict", "regex") for r in redirects):
raise GateError("redirectURIs needs at least one entry with url and matchingMode strict|regex")
for r in redirects:
if r["matching_mode"] == "regex":
try:
re.compile(r["url"])
except re.error as e:
raise GateError(f"redirect regex {r['url']!r}: {e}") from None
return {
# name: provider and policy name; slug: application slug and client ID.
"name": f"oidc-{slug}",
"slug": slug,
"namespace": meta["namespace"],
"resource": meta["name"],
"launch_url": launch,
"group": spec.get("group") or None,
"authorization_flow": spec.get("authorizationFlow") or DEFAULT_AUTHZ_FLOW,
"redirect_uris": redirects,
}
def oidc_status(cr, ok, message):
cond = {"type": "Ready", "status": "True" if ok else "False",
"reason": "Reconciled" if ok else "Failed", "message": message[:1000],
"observedGeneration": cr["metadata"].get("generation", 0)}
old = next((c for c in (cr.get("status") or {}).get("conditions") or [] if c.get("type") == "Ready"), {})
same = all(old.get(k) == cond[k] for k in ("status", "reason", "message", "observedGeneration"))
cond["lastTransitionTime"] = old.get("lastTransitionTime") if same and old.get("lastTransitionTime") \
else time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
status = {"conditions": [cond], "observedGeneration": cond["observedGeneration"]}
if ok:
status["issuer"] = f"{AK_BROWSER_URL}application/o/{oidc_slug(cr)}/"
return status
def oauth2_body(spec, flows, refs):
return {
"name": spec["name"],
"authorization_flow": flows[spec["authorization_flow"]],
"invalidation_flow": flows[INVALIDATION_FLOW],
"client_type": "confidential",
"client_id": spec["slug"],
"grant_types": OIDC_GRANTS,
"signing_key": refs["signing_key"],
"property_mappings": sorted(refs["scopes"]),
"redirect_uris": spec["redirect_uris"],
}
def drift(current, desired):
return {k: v for k, v in desired.items() if current.get(k) != v}
@@ -264,6 +347,18 @@ class K8s:
return http("PATCH", K8S_API + path, obj, headers=self._h(), cafile=K8S_CA_FILE,
content_type="application/apply-patch+yaml")
def patch_status(self, path, status):
return http("PATCH", K8S_API + path + "/status", {"status": status}, headers=self._h(),
cafile=K8S_CA_FILE, content_type="application/merge-patch+json")
def oidc_clients(self):
try:
return self.get(f"{OIDC_API}/oidcclients").get("items", [])
except ApiError as e:
if e.code == 404: # CRD not installed: no clients, gates unaffected
return []
raise
def delete(self, obj):
try:
http("DELETE", K8S_API + object_path(obj), headers=self._h(), cafile=K8S_CA_FILE)
@@ -447,42 +542,120 @@ def ensure_access(ak, gate, target):
ak.req("DELETE", f"policies/expression/{stale['pk']}/")
def oidc_refs(ak):
"""Signing key and scope mappings every OIDC client uses; matched here, not
by server-side filters, so an ignored filter cannot pick another object."""
keys = [k["pk"] for k in ak.list("crypto/certificatekeypairs/") if k.get("name") == SIGNING_KEY]
if len(keys) != 1:
raise ApiError(f"expected one certificate {SIGNING_KEY!r}, found {len(keys)}")
scopes = {m.get("managed"): m["pk"] for m in ak.list("propertymappings/provider/scope/")
if m.get("managed") in OIDC_SCOPES}
missing = [s for s in OIDC_SCOPES if s not in scopes]
if missing:
raise ApiError(f"scope mappings not found: {missing}")
return {"signing_key": keys[0], "scopes": list(scopes.values())}
def ensure_oidc(ak, spec, flows, refs):
name, slug = spec["name"], spec["slug"]
if spec["group"] and ak.one("core/groups/", name=spec["group"]) is None:
raise GateError(f"group {spec['group']!r} not found in Authentik")
app = get_application(ak, slug)
if app is not None and app.get("meta_publisher") != MANAGER:
raise GateError(f"application {slug} exists and is not managed by {MANAGER}")
want = oauth2_body(spec, flows, refs)
provider = next((p for p in ak.list("providers/oauth2/")
if is_plain_oauth2(p) and p.get("name", "").lower() == name), None)
if app is not None and (provider is None or app.get("provider") != provider["pk"]):
# E.g. a gate's application: never re-point it at an OAuth2 provider.
raise GateError(f"application {slug} exists without OAuth2 provider {name}")
if provider is None:
# No client_secret: Authentik generates it (generate_client_secret).
provider = ak.req("POST", "providers/oauth2/", want)
LOG.info("%s: created OAuth2 provider %s", name, provider["pk"])
elif provider.get("assigned_application_slug") not in (None, slug) or \
(app is not None and app.get("provider") != provider["pk"]):
raise GateError(f"OAuth2 provider {name} belongs to another application")
else:
current = dict(provider, property_mappings=sorted(provider.get("property_mappings") or []))
if drift(current, want):
ak.req("PATCH", f"providers/oauth2/{provider['pk']}/", drift(current, want))
LOG.info("%s: updated OAuth2 provider %s", name, sorted(drift(current, want)))
want_app = {"name": f"{slug} (oidc)", "slug": slug, "provider": provider["pk"],
"meta_launch_url": spec["launch_url"], "meta_publisher": MANAGER,
"meta_description": f"OIDCClient {spec['namespace']}/{spec['resource']}"}
if app is None:
app = ak.req("POST", "core/applications/", want_app)
LOG.info("%s: created application %s", name, slug)
elif drift(app, want_app):
ak.req("PATCH", f"core/applications/{slug}/", drift(app, want_app))
LOG.info("%s: updated application %s", name, sorted(drift(app, want_app)))
ensure_access(ak, spec, app["pbm_uuid"])
PROVIDER_PATHS = ("providers/proxy/", "providers/oauth2/")
OAUTH2_MODEL = "authentik_providers_oauth2.oauth2provider"
def is_plain_oauth2(p):
# ProxyProvider subclasses OAuth2Provider, so providers/oauth2/ lists proxy providers too.
return p.get("meta_model_name") == OAUTH2_MODEL
def policy_name(app):
return f"oidc-{app['slug']}" if app.get("_provider_path") == "providers/oauth2/" else app["slug"]
def remove_gate(ak, app):
"""Delete an owned gate: the application (bindings cascade; its provider is
PROTECTed while referenced), then the provider (which leaves the embedded
outpost with it), the policy. Ownership is found through the application,
so until it is gone a failed step is retried on the next pass."""
"""Delete an owned gate or OIDC client: the application (bindings cascade;
its provider is PROTECTed while referenced), then the provider (a proxy
provider leaves the embedded outpost with it), the policy. Ownership is
found through the application, so until it is gone a failed step is
retried on the next pass."""
name, provider = app["slug"], app.get("provider")
remove_dedicated_outposts(ak, name, provider)
path = app.get("_provider_path", "providers/proxy/")
if path == "providers/proxy/":
remove_dedicated_outposts(ak, name, provider)
ak.req("DELETE", f"core/applications/{name}/")
if provider:
ak.req("DELETE", f"providers/proxy/{provider}/")
policy = ak.one("policies/expression/", name=name)
ak.req("DELETE", f"{path}{provider}/")
policy = ak.one("policies/expression/", name=policy_name(app))
if policy is not None:
ak.req("DELETE", f"policies/expression/{policy['pk']}/")
LOG.info("%s: removed gate from Authentik", name)
LOG.info("%s: removed %s from Authentik", name,
"OIDC client" if path == "providers/oauth2/" else "gate")
def owned_applications(ak):
"""Applications this controller owns, found through the (unfiltered) proxy
provider list and confirmed one by one."""
for p in ak.list("providers/proxy/"):
slug = p.get("assigned_application_slug")
if not slug:
continue
app = get_application(ak, slug)
if app and app.get("meta_publisher") == MANAGER and app.get("provider") == p["pk"]:
yield app
and OAuth2 provider lists and confirmed one by one."""
for path in PROVIDER_PATHS:
for p in ak.list(path):
if path == "providers/oauth2/" and not is_plain_oauth2(p):
continue
slug = p.get("assigned_application_slug")
if not slug:
continue
app = get_application(ak, slug)
if app and app.get("meta_publisher") == MANAGER and app.get("provider") == p["pk"]:
yield dict(app, _provider_path=path)
# --- reconcile --------------------------------------------------------------
def reconcile():
k8s = K8s()
with open(AK_TOKEN_FILE) as f:
ak = Authentik(f.read().strip())
def reconcile(k8s=None, ak=None):
k8s = k8s or K8s()
if ak is None:
with open(AK_TOKEN_FILE) as f:
ak = Authentik(f.read().strip())
sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", []) if is_gated(i)]
ingresses = k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", [])
sources = [i for i in ingresses if is_gated(i)]
clients = k8s.oidc_clients()
shared = shared_hosts(sources)
flows = {f["slug"]: f["pk"] for f in ak.list("flows/instances/")}
ensure_embedded_config(ak)
@@ -526,6 +699,44 @@ def reconcile():
held.add(gate_name(ing))
failed += 1
gate_names = {gate_name(i) for i in sources}
keep_clients = set() # OIDC slugs whose Authentik side should exist
slugs = [oidc_slug(i) for i in clients]
refs = None
for cr in sorted(clients, key=oidc_slug):
ref, slug = f'{cr["metadata"]["namespace"]}/{cr["metadata"]["name"]}', oidc_slug(cr)
ok, message = False, ""
try:
spec = oidc_spec(cr)
if slugs.count(slug) > 1:
raise GateError(f"slug {slug} is claimed by more than one OIDCClient")
if slug in gate_names:
raise GateError(f"slug {slug} is the name of a gate")
for flow in (spec["authorization_flow"], INVALIDATION_FLOW):
if flow not in flows:
raise GateError(f"flow {flow!r} not found in Authentik")
keep_clients.add(slug)
refs = refs or oidc_refs(ak)
ensure_oidc(ak, spec, flows, refs)
ok, message = True, f"provider {spec['name']}, application {slug}"
except GateError as e:
LOG.error("OIDCClient %s: %s", ref, e)
failed += 1
keep_clients.discard(slug)
message = str(e)
except Exception as e:
LOG.error("OIDCClient %s: %s", ref, e)
keep_clients.add(slug)
failed += 1
message = f"transient: {e}"
try:
path = f'{OIDC_API}/namespaces/{cr["metadata"]["namespace"]}/oidcclients/{cr["metadata"]["name"]}'
status = oidc_status(cr, ok, message)
if status != cr.get("status"):
k8s.patch_status(path, status)
except ApiError as e:
LOG.error("OIDCClient %s: status: %s", ref, e)
removed = 0
for obj in managed_objects(k8s):
key = (obj["kind"], obj["metadata"]["namespace"], obj["metadata"]["name"])
@@ -536,11 +747,13 @@ def reconcile():
removed += 1
for app in list(owned_applications(ak)):
if app["slug"] not in gates:
keep = keep_clients if app["_provider_path"] == "providers/oauth2/" else gates
if app["slug"] not in keep:
remove_gate(ak, app)
removed += 1
LOG.info("reconciled: %d gates, %d failed, %d objects removed", len(sources), failed, removed)
LOG.info("reconciled: %d gates, %d OIDC clients, %d failed, %d objects removed",
len(sources), len(clients), failed, removed)
def main():
@@ -176,8 +176,10 @@ class FakeAuthentik:
"""In-memory stand-in for the endpoints the controller uses; records calls."""
def __init__(self):
self.db = {"providers/proxy/": {}, "core/applications/": {}, "outposts/instances/": {},
"policies/bindings/": {}, "policies/expression/": {}, "core/groups/": {}}
self.db = {"providers/proxy/": {}, "providers/oauth2/": {}, "core/applications/": {},
"outposts/instances/": {}, "policies/bindings/": {}, "policies/expression/": {},
"core/groups/": {}, "crypto/certificatekeypairs/": {},
"propertymappings/provider/scope/": {}}
self.calls = []
self.next = 1
@@ -194,6 +196,9 @@ class FakeAuthentik:
return coll, rest or None
raise AssertionError(path)
def _provider(self, pk):
return self.db["providers/proxy/"].get(pk) or self.db["providers/oauth2/"][pk]
def _find(self, coll, key):
if coll == "core/applications/":
return next((a for a in self.db[coll].values() if a["slug"] == key), None)
@@ -201,7 +206,13 @@ class FakeAuthentik:
def list(self, path, **q):
out = []
for o in self.db[path].values():
objs = list(self.db[path].values())
if path == "providers/oauth2/":
# ProxyProvider subclasses OAuth2Provider: the real list endpoint returns both.
objs = [dict(o, meta_model_name=c.OAUTH2_MODEL) for o in objs] + \
[dict(o, meta_model_name="authentik_providers_proxy.proxyprovider")
for o in self.db["providers/proxy/"].values()]
for o in objs:
ok = True
for k, v in q.items():
field = k.replace("__iexact", "")
@@ -226,7 +237,7 @@ class FakeAuthentik:
obj = self.add(coll, dict(body))
if coll == "core/applications/":
obj["pbm_uuid"] = f"pbm-{obj['pk']}"
self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = obj["slug"]
self._provider(obj["provider"])["assigned_application_slug"] = obj["slug"]
return copy.deepcopy(obj)
obj = self._find(coll, key)
if method == "PATCH":
@@ -239,7 +250,7 @@ class FakeAuthentik:
if method == "DELETE":
del self.db[coll][obj["pk"]]
if coll == "core/applications/":
self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = None
self._provider(obj["provider"])["assigned_application_slug"] = None
for b in [b for b in self.db["policies/bindings/"].values() if b["target"] == obj["pbm_uuid"]]:
del self.db["policies/bindings/"][b["pk"]]
return None
@@ -382,5 +393,253 @@ class Reconcile(unittest.TestCase):
self.assertEqual(self.ak.get(f"core/applications/{self.g['name']}/")["provider"], app["provider"])
YOUTRACK = {
"metadata": {"name": "youtrack", "namespace": "youtrack", "generation": 3},
"spec": {
"launchURL": "https://youtrack.renner.uno",
"group": "YouTrack Users",
"redirectURIs": [{"matchingMode": "regex", "url": r"^https://youtrack\.renner\.uno/.*$"}],
},
}
class OidcSpec(unittest.TestCase):
def test_youtrack(self):
s = c.oidc_spec(YOUTRACK)
self.assertEqual((s["name"], s["slug"], s["launch_url"], s["group"]),
("oidc-youtrack", "youtrack", "https://youtrack.renner.uno", "YouTrack Users"))
self.assertEqual(s["redirect_uris"],
[{"matching_mode": "regex", "url": r"^https://youtrack\.renner\.uno/.*$",
"redirect_uri_type": "authorization"}])
def test_slug_overrides_name(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["slug"] = "yt"
self.assertEqual(c.oidc_spec(cr)["slug"], "yt")
def test_strict_is_default(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["redirectURIs"] = [{"url": "https://youtrack.renner.uno/cb"}]
self.assertEqual(c.oidc_spec(cr)["redirect_uris"][0]["matching_mode"], "strict")
def test_rejects_bad_slug(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["slug"] = "You Track"
with self.assertRaises(c.GateError):
c.oidc_spec(cr)
def test_rejects_missing_redirects(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["redirectURIs"] = []
with self.assertRaises(c.GateError):
c.oidc_spec(cr)
def test_rejects_bad_regex(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["redirectURIs"] = [{"matchingMode": "regex", "url": "("}]
with self.assertRaises(c.GateError):
c.oidc_spec(cr)
def test_status(self):
st = c.oidc_status(YOUTRACK, True, "ok")
self.assertEqual(st["issuer"], c.AK_BROWSER_URL + "application/o/youtrack/")
self.assertEqual((st["conditions"][0]["status"], st["observedGeneration"]), ("True", 3))
cr = dict(YOUTRACK, status=st)
self.assertEqual(c.oidc_status(cr, True, "ok")["conditions"][0]["lastTransitionTime"],
st["conditions"][0]["lastTransitionTime"])
self.assertNotIn("issuer", c.oidc_status(cr, False, "group missing"))
class ReconcileOidc(unittest.TestCase):
def setUp(self):
self.ak = FakeAuthentik()
self.ak.add("crypto/certificatekeypairs/", {"name": c.SIGNING_KEY, "pk": "key-1"})
self.ak.add("crypto/certificatekeypairs/", {"name": "other", "pk": "key-2"})
for i, scope in enumerate(c.OIDC_SCOPES):
self.ak.add("propertymappings/provider/scope/", {"pk": f"m{i}", "managed": scope})
self.ak.add("propertymappings/provider/scope/", {"pk": "m9", "managed": "goauthentik.io/x"})
self.ak.add("core/groups/", {"name": "YouTrack Users"})
self.s = c.oidc_spec(YOUTRACK)
def run_once(self):
c.ensure_oidc(self.ak, self.s, FLOWS, c.oidc_refs(self.ak))
def test_creates_owned_client(self):
self.run_once()
app = self.ak.get("core/applications/youtrack/")
self.assertEqual(app["meta_publisher"], c.MANAGER)
p = self.ak.db["providers/oauth2/"][app["provider"]]
self.assertEqual((p["name"], p["client_id"], p["client_type"], p["signing_key"]),
("oidc-youtrack", "youtrack", "confidential", "key-1"))
self.assertNotIn("client_secret", p)
self.assertEqual(p["grant_types"], ["authorization_code", "refresh_token"])
self.assertEqual(p["property_mappings"], ["m0", "m1", "m2"])
policy = self.ak.one("policies/expression/", name="oidc-youtrack")
self.assertIn("'YouTrack Users'", policy["expression"])
self.assertEqual([b["target"] for b in self.ak.list("policies/bindings/")], [app["pbm_uuid"]])
def test_second_pass_is_a_noop(self):
self.run_once()
before = len(self.ak.calls)
self.run_once()
self.assertEqual(self.ak.calls[before:], [])
def test_missing_group_is_a_gate_error(self):
self.ak.db["core/groups/"].clear()
with self.assertRaises(c.GateError):
self.run_once()
self.assertEqual([p for p in self.ak.list("providers/oauth2/") if c.is_plain_oauth2(p)], [])
def test_refuses_unowned_application(self):
self.ak.add("core/applications/", {"slug": "youtrack", "meta_publisher": "", "provider": None})
with self.assertRaises(c.GateError):
self.run_once()
def test_redirect_change_patches_provider(self):
self.run_once()
self.s = dict(self.s, redirect_uris=[{"matching_mode": "strict", "url": "https://youtrack.renner.uno/cb",
"redirect_uri_type": "authorization"}])
self.run_once()
p = next(iter(self.ak.db["providers/oauth2/"].values()))
self.assertEqual(p["redirect_uris"], self.s["redirect_uris"])
def test_missing_signing_key_is_transient(self):
self.ak.db["crypto/certificatekeypairs/"].clear()
with self.assertRaises(c.ApiError):
c.oidc_refs(self.ak)
def test_ownership_scan_and_removal(self):
foreign = self.ak.add("providers/oauth2/", {"name": "gitea", "assigned_application_slug": "gitea"})
self.ak.add("core/applications/", {"slug": "gitea", "meta_publisher": "", "provider": foreign["pk"]})
self.run_once()
owned = list(c.owned_applications(self.ak))
self.assertEqual([a["slug"] for a in owned], ["youtrack"])
c.remove_gate(self.ak, owned[0])
self.assertIsNone(self.ak.get("core/applications/youtrack/"))
self.assertEqual([p["name"] for p in self.ak.list("providers/oauth2/")], ["gitea"])
self.assertIsNone(self.ak.one("policies/expression/", name="oidc-youtrack"))
def test_refuses_application_without_its_provider(self):
gate_provider = self.ak.add("providers/proxy/", {"name": "g", "assigned_application_slug": "youtrack"})
self.ak.add("core/applications/", {"slug": "youtrack", "meta_publisher": c.MANAGER,
"provider": gate_provider["pk"], "pbm_uuid": "pbm-x"})
with self.assertRaises(c.GateError):
self.run_once()
self.assertEqual([p for p in self.ak.list("providers/oauth2/") if c.is_plain_oauth2(p)], [])
self.assertEqual(self.ak.get("core/applications/youtrack/")["provider"], gate_provider["pk"])
class FakeK8s:
def __init__(self, ingresses=(), clients=(), services=None):
self.ingresses, self.clients = list(ingresses), list(clients)
self.services = services or {}
self.status, self.applied = {}, []
def get(self, path):
if path == "/apis/networking.k8s.io/v1/ingresses":
return {"items": copy.deepcopy(self.ingresses)}
if "labelSelector=" in path:
return {"items": []}
if path.startswith("/api/v1/namespaces/") and "/services/" in path:
return copy.deepcopy(self.services[path.rsplit("/", 1)[1]])
raise AssertionError(path)
def oidc_clients(self):
return copy.deepcopy(self.clients)
def apply(self, obj):
self.applied.append(obj)
def patch_status(self, path, status):
self.status[path.rsplit("/", 1)[1]] = status
for cr in self.clients:
if cr["metadata"]["name"] == path.rsplit("/", 1)[1]:
cr["status"] = status
class ReconcileLoop(unittest.TestCase):
def setUp(self):
self.ak = FakeAuthentik()
self.ak.db["flows/instances/"] = {}
for slug, pk in FLOWS.items():
self.ak.add("flows/instances/", {"slug": slug, "pk": pk})
self.ak.add("outposts/instances/", {"name": "authentik Embedded Outpost", "managed": c.EMBEDDED_OUTPOST,
"providers": [], "config": {}})
self.ak.add("crypto/certificatekeypairs/", {"name": c.SIGNING_KEY, "pk": "key-1"})
for i, scope in enumerate(c.OIDC_SCOPES):
self.ak.add("propertymappings/provider/scope/", {"pk": f"m{i}", "managed": scope})
self.ak.add("core/groups/", {"name": "YouTrack Users"})
def client(self, name="youtrack", **spec):
cr = copy.deepcopy(YOUTRACK)
cr["metadata"]["name"] = name
cr["spec"].update(spec)
return cr
def test_creates_then_quiet(self):
k8s = FakeK8s(clients=[self.client()])
c.reconcile(k8s, self.ak)
self.assertEqual(k8s.status["youtrack"]["conditions"][0]["status"], "True")
self.assertIsNotNone(self.ak.get("core/applications/youtrack/"))
before, k8s.status = len(self.ak.calls), {}
c.reconcile(k8s, self.ak)
self.assertEqual(self.ak.calls[before:], [])
self.assertEqual(k8s.status, {}) # unchanged status is not re-written
def test_duplicate_slug_withholds_both(self):
k8s = FakeK8s(clients=[self.client(), self.client("other", slug="youtrack")])
c.reconcile(k8s, self.ak)
self.assertIsNone(self.ak.get("core/applications/youtrack/"))
self.assertEqual({k: v["conditions"][0]["status"] for k, v in k8s.status.items()},
{"youtrack": "False", "other": "False"})
def test_transient_error_keeps_client(self):
k8s = FakeK8s(clients=[self.client()])
c.reconcile(k8s, self.ak)
self.ak.db["crypto/certificatekeypairs/"].clear()
c.reconcile(k8s, self.ak)
self.assertIsNotNone(self.ak.get("core/applications/youtrack/"))
self.assertIn("transient", k8s.status["youtrack"]["conditions"][0]["message"])
def test_sweeps_client_whose_resource_is_gone(self):
k8s = FakeK8s(clients=[self.client()])
c.reconcile(k8s, self.ak)
k8s.clients = []
c.reconcile(k8s, self.ak)
self.assertIsNone(self.ak.get("core/applications/youtrack/"))
self.assertEqual([p for p in self.ak.list("providers/oauth2/") if c.is_plain_oauth2(p)], [])
def test_gates_survive_alongside_oidc(self):
k8s = FakeK8s(ingresses=[LLAMA], services={"llama-server": SERVICE}, clients=[self.client()])
c.reconcile(k8s, self.ak)
gate = c.gate_name(LLAMA)
app = self.ak.get(f"core/applications/{gate}/")
before = len(self.ak.calls)
c.reconcile(k8s, self.ak)
self.assertEqual(self.ak.calls[before:], [])
self.assertEqual(self.ak.get(f"core/applications/{gate}/")["provider"], app["provider"])
self.assertIsNotNone(self.ak.get("core/applications/youtrack/"))
def test_gate_only_is_stable(self):
k8s = FakeK8s(ingresses=[LLAMA], services={"llama-server": SERVICE})
c.reconcile(k8s, self.ak)
before = len(self.ak.calls)
c.reconcile(k8s, self.ak)
self.assertEqual([m for m, _ in self.ak.calls[before:] if m != "GET"], [])
self.assertIsNotNone(self.ak.get(f"core/applications/{c.gate_name(LLAMA)}/"))
def test_slug_equal_to_gate_name_leaves_gate_alone(self):
k8s = FakeK8s(ingresses=[LLAMA], services={"llama-server": SERVICE})
c.reconcile(k8s, self.ak)
gate = c.gate_name(LLAMA)
app = self.ak.get(f"core/applications/{gate}/")
k8s.clients = [self.client("evil", slug=gate)]
c.reconcile(k8s, self.ak)
self.assertEqual(self.ak.get(f"core/applications/{gate}/")["provider"], app["provider"])
self.assertEqual([p for p in self.ak.list("providers/oauth2/") if c.is_plain_oauth2(p)], [])
self.assertEqual(k8s.status["evil"]["conditions"][0]["status"], "False")
if __name__ == "__main__":
unittest.main()
@@ -97,6 +97,10 @@ type KumaMonitorSpec struct {
// +kubebuilder:validation:Minimum=0
// +kubebuilder:default=0
MaxRetries int32 `json:"maxRetries,omitempty"`
// Consecutive DOWN heartbeats after which Kuma sends the DOWN notification
// again, not minutes; 0 sends it once per outage.
// +kubebuilder:validation:Minimum=0
ResendInterval int32 `json:"resendInterval,omitempty"`
// Seconds.
// +kubebuilder:validation:Minimum=1
Timeout *int32 `json:"timeout,omitempty"`
@@ -131,6 +131,15 @@ func TestNegativeMaxRetriesRejected(t *testing.T) {
expectInvalid(t, m, "spec.maxRetries")
}
func TestNegativeResendIntervalRejected(t *testing.T) {
m := monitor("neg-resend", KumaMonitorSpec{
Type: "group",
DisplayName: "g",
ResendInterval: -1,
})
expectInvalid(t, m, "spec.resendInterval")
}
func TestJSONQueryBadOperator(t *testing.T) {
m := monitor("jq-bad-op", KumaMonitorSpec{
Type: "json-query",
+32 -4
View File
@@ -3,19 +3,25 @@ package main
import (
"errors"
"fmt"
"strings"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/validation"
)
type config struct {
URL string
Username string
Password string
// RootGroup is the zero value when KUMA_ROOT_GROUP is unset.
RootGroup types.NamespacedName
}
// loadConfig reads the Kuma connection settings from the environment. It
// names every missing variable.
// names every missing or invalid variable.
func loadConfig(getenv func(string) string) (config, error) {
var cfg config
var missing []error
var errs []error
for _, v := range []struct {
name string
dst *string
@@ -25,8 +31,30 @@ func loadConfig(getenv func(string) string) (config, error) {
{"KUMA_PASSWORD", &cfg.Password},
} {
if *v.dst = getenv(v.name); *v.dst == "" {
missing = append(missing, fmt.Errorf("missing required environment variable %s", v.name))
errs = append(errs, fmt.Errorf("missing required environment variable %s", v.name))
}
}
return cfg, errors.Join(missing...)
if raw := getenv("KUMA_ROOT_GROUP"); raw != "" {
root, err := parseRootGroup(raw)
if err != nil {
errs = append(errs, fmt.Errorf("KUMA_ROOT_GROUP: %w", err))
}
cfg.RootGroup = root
}
return cfg, errors.Join(errs...)
}
// parseRootGroup parses namespace/name of a KumaMonitor.
func parseRootGroup(raw string) (types.NamespacedName, error) {
ns, name, ok := strings.Cut(raw, "/")
if !ok || strings.Contains(name, "/") {
return types.NamespacedName{}, fmt.Errorf("%q is not namespace/name", raw)
}
if msgs := validation.IsDNS1123Label(ns); len(msgs) > 0 {
return types.NamespacedName{}, fmt.Errorf("namespace %q: %s", ns, strings.Join(msgs, "; "))
}
if msgs := validation.IsDNS1123Subdomain(name); len(msgs) > 0 {
return types.NamespacedName{}, fmt.Errorf("name %q: %s", name, strings.Join(msgs, "; "))
}
return types.NamespacedName{Namespace: ns, Name: name}, nil
}
@@ -3,6 +3,8 @@ package main
import (
"strings"
"testing"
"k8s.io/apimachinery/pkg/types"
)
func TestLoadConfig(t *testing.T) {
@@ -36,3 +38,21 @@ func TestLoadConfig(t *testing.T) {
}
}
}
func TestLoadConfigRootGroup(t *testing.T) {
env := func(root string) func(string) string {
m := map[string]string{"KUMA_URL": "http://kuma", "KUMA_USERNAME": "u", "KUMA_PASSWORD": "p", "KUMA_ROOT_GROUP": root}
return func(k string) string { return m[k] }
}
cfg, err := loadConfig(env("kuma-sync/renner-uno"))
if err != nil || cfg.RootGroup != (types.NamespacedName{Namespace: "kuma-sync", Name: "renner-uno"}) {
t.Fatalf("cfg=%+v err=%v", cfg, err)
}
for _, bad := range []string{"renner-uno", "/renner-uno", "kuma-sync/", "a/b/c", "Kuma/x", "ns/UPPER", " ns/x"} {
if _, err := loadConfig(env(bad)); err == nil || !strings.Contains(err.Error(), "KUMA_ROOT_GROUP") {
t.Errorf("%q: err = %v, want KUMA_ROOT_GROUP rejected", bad, err)
}
}
}
@@ -48,6 +48,7 @@ func run() error {
GCInterval: 5 * time.Minute,
OrphanGrace: time.Hour,
Resync: 5 * time.Minute,
RootGroup: cfg.RootGroup,
})
if err != nil {
return err
@@ -222,6 +222,13 @@ spec:
- hostname
- port
type: object
resendInterval:
description: |-
Consecutive DOWN heartbeats after which Kuma sends the DOWN notification
again, not minutes; 0 sends it once per outage.
format: int32
minimum: 0
type: integer
retryInterval:
default: 60
description: Seconds.
+7 -1
View File
@@ -10,6 +10,7 @@ import (
"time"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/utils/ptr"
@@ -41,6 +42,9 @@ type Options struct {
// Resync is how often a healthy resource is reconciled again, which is
// what repairs drift made in Kuma; 0 means 5 minutes.
Resync time.Duration
// RootGroup is the group KumaMonitor that monitors without a parentRef are
// placed under; the zero value leaves them at the top level.
RootGroup types.NamespacedName
// SkipNameValidation allows several managers in one process, as in tests
// that restart the manager.
@@ -72,7 +76,9 @@ func NewManager(o Options) (manager.Manager, error) {
return nil, fmt.Errorf("create manager: %w", err)
}
if err := (&controller.Reconciler{Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync}).SetupWithManager(mgr); err != nil {
if err := (&controller.Reconciler{
Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync, RootGroup: o.RootGroup,
}).SetupWithManager(mgr); err != nil {
return nil, fmt.Errorf("set up reconciler: %w", err)
}
if err := (&controller.StatusPageReconciler{Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync}).SetupWithManager(mgr); err != nil {
@@ -58,6 +58,15 @@ type Reconciler struct {
// Resync is how often a healthy resource is reconciled again, which repairs
// drift made in Kuma. 0 means 5 minutes.
Resync time.Duration
// RootGroup is the group KumaMonitor every monitor without a parentRef is
// placed under in Kuma; the zero value places them at the top level.
RootGroup types.NamespacedName
}
func (r *Reconciler) hasRoot() bool { return r.RootGroup.Name != "" }
func (r *Reconciler) isRoot(o client.Object) bool {
return r.hasRoot() && o.GetNamespace() == r.RootGroup.Namespace && o.GetName() == r.RootGroup.Name
}
func (r *Reconciler) resync() time.Duration {
@@ -70,14 +79,18 @@ func (r *Reconciler) resync() time.Duration {
// SetupWithManager ignores status-only updates, which keeps failure backoff
// intact. Deletion still reconciles: setting deletionTimestamp bumps
// metadata.generation. A KumaNotification whose ID changes enqueues the
// monitors referencing it.
// monitors referencing it; a change to the root group enqueues the monitors
// without a parentRef.
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
b := ctrl.NewControllerManagedBy(mgr).
For(&v1.KumaMonitor{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Watches(&v1.KumaNotification{}, handler.EnqueueRequestsFromMapFunc(r.referencingMonitors),
builder.WithPredicates(predicate.Funcs{UpdateFunc: notificationIDChanged})).
Named("kumamonitor").
Complete(r)
builder.WithPredicates(predicate.Funcs{UpdateFunc: notificationIDChanged}))
if r.hasRoot() {
b = b.Watches(&v1.KumaMonitor{}, handler.EnqueueRequestsFromMapFunc(r.rootChildren),
builder.WithPredicates(r.rootPredicate()))
}
return b.Named("kumamonitor").Complete(r)
}
// failure is a Ready=False outcome. The message is fixed per reason; the cause
@@ -99,6 +112,9 @@ const (
msgParentNotGroup = "parent must be of type group"
msgParentCycle = "parentRef forms a cycle"
msgParentTooDeep = "parentRef chain is too deep"
msgRootPending = "root group has no monitor ID yet"
msgRootNotGroup = "root group must be of type group"
msgRootHasParent = "root group must not have a parentRef"
msgNotificationPending = "a referenced notification has no notification ID yet"
msgSynced = "monitor in sync"
msgNotOwned = "previous monitor exists without our ownership tag; restore the tag or delete it in Kuma"
@@ -119,17 +135,24 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
}
desired := Desired(km)
if km.Spec.ParentRef != nil {
p, err := r.resolveParent(ctx, km)
if err != nil {
var p parentOutcome
var err error
switch {
case km.Spec.ParentRef != nil:
p, err = r.resolveParent(ctx, km)
case r.hasRoot() && !r.isRoot(km):
p, err = r.resolveRoot(ctx)
}
if err != nil {
return ctrl.Result{}, err
}
if p.reason != "" {
if err := r.setReady(ctx, km, metav1.ConditionFalse, p.reason, p.message, nil); err != nil {
return ctrl.Result{}, err
}
if p.reason != "" {
if err := r.setReady(ctx, km, metav1.ConditionFalse, p.reason, p.message, nil); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: p.requeue}, nil
}
return ctrl.Result{RequeueAfter: p.requeue}, nil
}
if p.id != 0 {
desired.Parent = &p.id
}
@@ -323,8 +346,75 @@ type parentOutcome struct {
requeue time.Duration
}
// rootChanged reports changes to the root group that alter what the monitors
// without a parentRef resolve to.
func rootChanged(e event.UpdateEvent) bool {
o, ok1 := e.ObjectOld.(*v1.KumaMonitor)
n, ok2 := e.ObjectNew.(*v1.KumaMonitor)
if !ok1 || !ok2 {
return true
}
return o.Status.MonitorID != n.Status.MonitorID ||
o.Spec.Type != n.Spec.Type ||
(o.Spec.ParentRef == nil) != (n.Spec.ParentRef == nil) ||
o.DeletionTimestamp.IsZero() != n.DeletionTimestamp.IsZero()
}
// rootPredicate passes events of the root group only.
func (r *Reconciler) rootPredicate() predicate.Funcs {
return predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool { return r.isRoot(e.Object) },
DeleteFunc: func(e event.DeleteEvent) bool { return r.isRoot(e.Object) },
GenericFunc: func(e event.GenericEvent) bool { return r.isRoot(e.Object) },
UpdateFunc: func(e event.UpdateEvent) bool { return r.isRoot(e.ObjectNew) && rootChanged(e) },
}
}
// rootChildren enqueues the KumaMonitors of all namespaces that have no
// parentRef, except the root group itself.
func (r *Reconciler) rootChildren(ctx context.Context, _ client.Object) []reconcile.Request {
var list v1.KumaMonitorList
if err := r.Client.List(ctx, &list); err != nil {
log.FromContext(ctx).Error(err, "list KumaMonitors to enqueue")
return nil
}
var reqs []reconcile.Request
for i := range list.Items {
km := &list.Items[i]
if km.Spec.ParentRef == nil && !r.isRoot(km) {
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: km.Namespace, Name: km.Name}})
}
}
return reqs
}
// resolveRoot returns the Kuma ID of the root group for a monitor without a
// parentRef. The root must be a group without a parentRef: under one of its
// own ancestors it would close a cycle in Kuma.
func (r *Reconciler) resolveRoot(ctx context.Context) (parentOutcome, error) {
pending := parentOutcome{reason: ReasonParentPending, message: msgRootPending, requeue: parentRetry}
root := &v1.KumaMonitor{}
err := r.Client.Get(ctx, r.RootGroup, root)
if apierrors.IsNotFound(err) {
return pending, nil
}
if err != nil {
return parentOutcome{}, err
}
switch {
case root.Spec.Type != "group":
return parentOutcome{reason: ReasonInvalidParent, message: msgRootNotGroup, requeue: r.resync()}, nil
case root.Spec.ParentRef != nil:
return parentOutcome{reason: ReasonInvalidParent, message: msgRootHasParent, requeue: r.resync()}, nil
case root.Status.MonitorID == 0 || !root.DeletionTimestamp.IsZero():
return pending, nil
}
return parentOutcome{id: root.Status.MonitorID}, nil
}
// resolveParent checks the whole parentRef chain: every ancestor must exist
// and be a group, without cycles or more than maxParentDepth levels.
// and be a group, without cycles or more than maxParentDepth levels. The root
// group, which the topmost ancestor is placed under, counts as a level.
func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (parentOutcome, error) {
invalid := func(msg string) (parentOutcome, error) {
return parentOutcome{reason: ReasonInvalidParent, message: msg, requeue: r.resync()}, nil
@@ -334,7 +424,8 @@ func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (par
visited := map[string]bool{km.Name: true}
cur := km
var directID int64
for depth := 0; cur.Spec.ParentRef != nil; depth++ {
depth := 0
for ; cur.Spec.ParentRef != nil; depth++ {
if depth >= maxParentDepth {
return invalid(msgParentTooDeep)
}
@@ -359,6 +450,9 @@ func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (par
}
cur = parent
}
if r.hasRoot() && !r.isRoot(cur) && depth >= maxParentDepth {
return invalid(msgParentTooDeep)
}
if directID == 0 {
return pending, nil
}
@@ -24,14 +24,15 @@ func OwnerKey(km *v1.KumaMonitor) string {
func Desired(km *v1.KumaMonitor) kumaclient.Monitor {
s := km.Spec
m := kumaclient.Monitor{
Type: s.Type,
Name: s.DisplayName,
Description: s.Description,
Active: !s.Paused,
Interval: s.Interval,
RetryInterval: s.RetryInterval,
MaxRetries: s.MaxRetries,
OwnerKey: OwnerKey(km),
Type: s.Type,
Name: s.DisplayName,
Description: s.Description,
Active: !s.Paused,
Interval: s.Interval,
RetryInterval: s.RetryInterval,
MaxRetries: s.MaxRetries,
ResendInterval: s.ResendInterval,
OwnerKey: OwnerKey(km),
}
if s.Timeout != nil {
m.Timeout = *s.Timeout
@@ -57,6 +57,8 @@ func TestMappingAllTypes(t *testing.T) {
port.Port = &v1.PortSpec{Hostname: "p.com", Port: 443}
paused := base("group")
paused.Paused = true
resend := base("group")
resend.ResendInterval = 60
wantKW := withHTTP(common("keyword"))
wantKW.Keyword, wantKW.InvertKeyword = "ok", true
@@ -74,6 +76,8 @@ func TestMappingAllTypes(t *testing.T) {
wantPort.Hostname, wantPort.Port = "p.com", 443
wantPaused := common("group")
wantPaused.Active = false
wantResend := common("group")
wantResend.ResendInterval = 60
cases := []struct {
name string
@@ -88,6 +92,7 @@ func TestMappingAllTypes(t *testing.T) {
{"dns with conditions", dnsCond, wantDNSCond},
{"port", port, wantPort},
{"paused maps to inactive", paused, wantPaused},
{"resend interval", resend, wantResend},
}
for _, c := range cases {
got := Desired(mk(c.spec))
@@ -0,0 +1,209 @@
package controller
import (
"fmt"
"slices"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
)
const rootID = 7
var rootName = types.NamespacedName{Namespace: "default", Name: "root"}
func rootGroup() *v1.KumaMonitor {
g := group("root", "")
g.Status.MonitorID = rootID
return g
}
func newRootEnv(t *testing.T, objs ...client.Object) *env {
t.Helper()
e := newEnv(t, objs...)
e.r.RootGroup = rootName
return e
}
func (e *env) parentOf(t *testing.T, name string) *int64 {
t.Helper()
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
if got == nil {
t.Fatalf("%s: no Kuma monitor", name)
}
return got.Parent
}
func TestRootGroupParentsParentless(t *testing.T) {
e := newRootEnv(t, rootGroup(), httpMonitor("a"))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "a"); p == nil || *p != rootID {
t.Errorf("parent = %v, want %d", p, rootID)
}
}
func TestRootGroupKeepsParentRef(t *testing.T) {
grp := group("grp", "")
grp.Status.MonitorID = 42
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
e := newRootEnv(t, rootGroup(), grp, child)
if _, err := e.reconcile(t, "child"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "child"); p == nil || *p != 42 {
t.Errorf("parent = %v, want 42", p)
}
}
func TestRootGroupItselfHasNoParent(t *testing.T) {
root := rootGroup()
root.Status.MonitorID = 0
e := newRootEnv(t, root)
if _, err := e.reconcile(t, "root"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "root"); p != nil {
t.Errorf("parent = %d, want none", *p)
}
}
func TestRootGroupUnsetLeavesTopLevel(t *testing.T) {
e := newEnv(t, rootGroup(), httpMonitor("a"))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "a"); p != nil {
t.Errorf("parent = %d, want none", *p)
}
}
func TestRootGroupPending(t *testing.T) {
noID := rootGroup()
noID.Status.MonitorID = 0
deletingRoot := deleting(rootGroup())
cases := map[string][]client.Object{
"missing": nil,
"no ID": {noID},
"deleting": {deletingRoot},
"not group": {func() *v1.KumaMonitor { m := httpMonitor("root"); m.Status.MonitorID = rootID; return m }()},
"has parent": {func() *v1.KumaMonitor {
m := rootGroup()
m.Spec.ParentRef = &v1.LocalRef{Name: "x"}
return m
}()},
}
want := map[string]struct {
reason string
requeue time.Duration
}{
"missing": {ReasonParentPending, 10 * time.Second},
"no ID": {ReasonParentPending, 10 * time.Second},
"deleting": {ReasonParentPending, 10 * time.Second},
"not group": {ReasonInvalidParent, 5 * time.Minute},
"has parent": {ReasonInvalidParent, 5 * time.Minute},
}
for name, objs := range cases {
e := newRootEnv(t, append(objs, httpMonitor("a"))...)
res, err := e.reconcile(t, "a")
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if res.RequeueAfter != want[name].requeue {
t.Errorf("%s: RequeueAfter = %v, want %v", name, res.RequeueAfter, want[name].requeue)
}
if c := ready(t, e.get(t, "a")); c.Status != metav1.ConditionFalse || c.Reason != want[name].reason {
t.Errorf("%s: Ready = %+v", name, c)
}
if e.cnt.creates != 0 {
t.Errorf("%s: creates = %d", name, e.cnt.creates)
}
}
}
// A chain of maxParentDepth groups is allowed on its own, but the root group
// adds one more level above it.
func TestRootGroupCountsTowardsDepth(t *testing.T) {
var objs []client.Object
for i := range maxParentDepth {
parent := ""
if i < maxParentDepth-1 {
parent = fmt.Sprintf("c%d", i+1)
}
objs = append(objs, group(fmt.Sprintf("c%d", i), parent))
}
deep := httpMonitor("deep")
deep.Spec.ParentRef = &v1.LocalRef{Name: "c0"}
objs = append(objs, deep)
if _, err := newEnv(t, objs...).reconcile(t, "deep"); err != nil {
t.Fatal(err)
}
e := newRootEnv(t, append(objs, rootGroup())...)
if _, err := e.reconcile(t, "deep"); err != nil {
t.Fatal(err)
}
if c := ready(t, e.get(t, "deep")); c.Reason != ReasonInvalidParent || c.Message != msgParentTooDeep {
t.Errorf("Ready = %+v", c)
}
// A chain ending in the root itself gets no extra level.
objs[maxParentDepth-1] = func() *v1.KumaMonitor { g := rootGroup(); g.Name = "c9"; return g }()
e = newEnv(t, objs...)
e.r.RootGroup = types.NamespacedName{Namespace: "default", Name: "c9"}
if _, err := e.reconcile(t, "deep"); err != nil {
t.Fatal(err)
}
if c := ready(t, e.get(t, "deep")); c.Status != metav1.ConditionTrue {
t.Errorf("chain ending in root: Ready = %+v", c)
}
}
func TestRootChildrenEnqueuesParentless(t *testing.T) {
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
other := httpMonitor("other")
other.Namespace = "elsewhere"
e := newRootEnv(t, rootGroup(), group("grp", ""), child, httpMonitor("a"), other)
var got []string
for _, req := range e.r.rootChildren(t.Context(), rootGroup()) {
got = append(got, req.String())
}
slices.Sort(got)
want := []string{"default/a", "default/grp", "elsewhere/other"}
if !slices.Equal(got, want) {
t.Errorf("enqueued %v, want %v", got, want)
}
}
func TestRootPredicate(t *testing.T) {
r := &Reconciler{RootGroup: rootName}
p := r.rootPredicate()
old := rootGroup()
old.Status.MonitorID = 0
withID := rootGroup()
other := group("other", "")
if !p.Update(event.UpdateEvent{ObjectOld: old, ObjectNew: withID}) {
t.Error("root ID change must pass")
}
if p.Update(event.UpdateEvent{ObjectOld: withID, ObjectNew: withID.DeepCopy()}) {
t.Error("unchanged root must not pass")
}
if p.Update(event.UpdateEvent{ObjectOld: other, ObjectNew: other.DeepCopy()}) || p.Create(event.CreateEvent{Object: other}) {
t.Error("other monitors must not pass")
}
if !p.Create(event.CreateEvent{Object: withID}) || !p.Delete(event.DeleteEvent{Object: withID}) {
t.Error("root create and delete must pass")
}
}
@@ -378,7 +378,7 @@ func (b *Breml) update(ctx context.Context, m Monitor) error {
return fmt.Errorf("update %d: monitor carries owner key %q, not %q: %w", m.ID, key, m.OwnerKey, ErrNotOwned)
}
// Start from the full current object, also across a type change, so
// fields we do not manage (proxy, resend interval, upside down, auth)
// fields we do not manage (proxy, upside down, auth)
// survive the overwrite.
wire, err := toWire(m, &cur)
if err != nil {
@@ -420,6 +420,7 @@ func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
Interval: int32(base.Interval),
RetryInterval: int32(base.RetryInterval),
MaxRetries: int32(base.MaxRetries),
ResendInterval: int32(base.ResendInterval),
NotificationIDs: slices.Clone(base.NotificationIDs),
OwnerKey: ownerKey,
}
@@ -581,6 +582,7 @@ func writeBase(b *monitor.Base, m Monitor) {
b.Interval = int64(m.Interval)
b.RetryInterval = int64(m.RetryInterval)
b.MaxRetries = int64(m.MaxRetries)
b.ResendInterval = int64(m.ResendInterval)
b.IsActive = m.Active
b.NotificationIDs = slices.Clone(m.NotificationIDs)
}
@@ -127,7 +127,7 @@ func TestCreateFieldsIntact(t *testing.T) {
group := Monitor{
Type: "group", Name: "grp", Description: "a group", Active: true,
Interval: 60, RetryInterval: 60, MaxRetries: 0, OwnerKey: "default/grp",
Interval: 60, RetryInterval: 60, MaxRetries: 0, ResendInterval: 60, OwnerKey: "default/grp",
}
gid, err := b.Create(ctx, group)
if err != nil {
@@ -313,7 +313,6 @@ func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
t.Fatal(err)
}
kw.UpsideDown = true
kw.ResendInterval = 7
kw.NotificationIDs = []int64{nid}
if err := b.c.UpdateMonitor(ctx, &kw); err != nil {
t.Fatal(err)
@@ -328,8 +327,8 @@ func assertUnmanaged(t *testing.T, c *Breml, id int64, when string) {
if err != nil {
t.Fatal(err)
}
if !base.UpsideDown || base.ResendInterval != 7 {
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d", when, base.UpsideDown, base.ResendInterval)
if !base.UpsideDown {
t.Errorf("%s: unmanaged state lost: upsideDown=%v", when, base.UpsideDown)
}
}
@@ -344,7 +343,7 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
}
orig := Monitor{
Type: "keyword", Name: "kw", Active: true, Parent: &gid,
Interval: 60, RetryInterval: 60, URL: "https://example.com",
Interval: 60, RetryInterval: 60, ResendInterval: 7, URL: "https://example.com",
Headers: map[string]string{"A": "1"}, Keyword: "up", OwnerKey: "default/kw",
}
id, err := b.Create(ctx, orig)
@@ -360,6 +359,7 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
cleared.Headers = nil
cleared.Keyword = ""
cleared.Interval = 90
cleared.ResendInterval = 0
if err := b.Update(ctx, cleared); err != nil {
t.Fatal(err)
}
@@ -369,7 +369,7 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
t.Fatal(err)
}
m := byID(got)[id]
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 || m.NotificationIDs != nil {
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 || m.ResendInterval != 0 || m.NotificationIDs != nil {
t.Errorf("managed fields not cleared: %+v", m)
}
assertUnmanaged(t, fresh, id, "after plain update")
@@ -141,6 +141,9 @@ type Monitor struct {
Interval int32
RetryInterval int32
MaxRetries int32
// ResendInterval counts consecutive DOWN heartbeats between repeated DOWN
// notifications; 0 disables resending.
ResendInterval int32
// Timeout is in seconds and only applies to http, keyword and json-query.
// 0 is written as round(0.8*Interval), because Kuma treats a stored 0 as a
// huge request timeout. For other types it is ignored and reads back as 0.
@@ -0,0 +1,61 @@
package kumaclient
import (
"encoding/json"
"testing"
"github.com/breml/go-uptime-kuma-client/monitor"
)
// resendOnWire writes m over existing (nil for a create) and returns the wire
// resendInterval and the value read back from it.
func resendOnWire(t *testing.T, m Monitor, existing *monitor.Base) (wire, back int64) {
t.Helper()
w, err := toWire(m, existing)
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(w)
if err != nil {
t.Fatal(err)
}
var f struct {
ResendInterval int64 `json:"resendInterval"`
}
if err := json.Unmarshal(raw, &f); err != nil {
t.Fatal(err)
}
var base monitor.Base
if err := json.Unmarshal(raw, &base); err != nil {
t.Fatal(err)
}
got, err := toDomain(base, m.OwnerKey)
if err != nil {
t.Fatal(err)
}
return f.ResendInterval, int64(got.ResendInterval)
}
func TestResendIntervalRoundTrip(t *testing.T) {
for _, m := range []Monitor{
{ID: 1, Type: "group", Name: "g", Active: true, Interval: 60, RetryInterval: 60, ResendInterval: 60, OwnerKey: "ns/g"},
{ID: 2, Type: "http", Name: "h", Active: true, Interval: 60, RetryInterval: 60, ResendInterval: 3, URL: "https://e.com", OwnerKey: "ns/h"},
} {
wire, back := resendOnWire(t, m, nil)
if wire != int64(m.ResendInterval) || back != int64(m.ResendInterval) {
t.Errorf("%s: wire=%d read back=%d, want %d", m.Type, wire, back, m.ResendInterval)
}
}
}
// 0 must overwrite a resend interval stored in Kuma, not keep it.
func TestResendIntervalClearedOnUpdate(t *testing.T) {
var existing monitor.Base
if err := json.Unmarshal([]byte(`{"id":1,"type":"group","name":"g","resendInterval":7}`), &existing); err != nil {
t.Fatal(err)
}
m := Monitor{ID: 1, Type: "group", Name: "g", Active: true, Interval: 60, RetryInterval: 60, OwnerKey: "ns/g"}
if wire, back := resendOnWire(t, m, &existing); wire != 0 || back != 0 {
t.Errorf("wire=%d read back=%d, want 0", wire, back)
}
}
@@ -1262,6 +1262,12 @@ data:
- authentik_policies_expression.add_expressionpolicy
- authentik_policies_expression.change_expressionpolicy
- authentik_policies_expression.delete_expressionpolicy
- authentik_providers_oauth2.view_oauth2provider
- authentik_providers_oauth2.add_oauth2provider
- authentik_providers_oauth2.change_oauth2provider
- authentik_providers_oauth2.delete_oauth2provider
- authentik_providers_oauth2.view_scopemapping
- authentik_crypto.view_certificatekeypair
- authentik_core.view_group
- authentik_flows.view_flow
- authentik_outposts.view_outpostserviceconnection
+7 -1
View File
@@ -62,6 +62,12 @@ rules:
- apiGroups: ["cilium.io"]
resources: ["ciliumnetworkpolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["auth.renner.uno"]
resources: ["oidcclients"]
verbs: ["get", "list", "watch"]
- apiGroups: ["auth.renner.uno"]
resources: ["oidcclients/status"]
verbs: ["get", "patch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
@@ -105,7 +111,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-29-1349 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-0959 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
type: RuntimeDefault
containers:
- name: gitea-mcp
image: docker.gitea.com/gitea-mcp-server:1.11.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
image: docker.gitea.com/gitea-mcp-server:1.12.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
# Image has no ENTRYPOINT (binary is in CMD), so args alone would replace it.
command:
- /app/gitea-mcp
@@ -14,6 +14,9 @@ monitor, and its history starts over.
proxies, the Cloudflare-served apex `renner.uno` and third-party sites.
- A monitor alerts only through its `notificationRefs` to cluster-scoped `KumaNotification`
objects (`kubernetes/apps/kuma-sync/notification-*.yaml`). Without refs it sends nothing.
- Parentless monitors hang under the root group `kuma-sync/renner-uno` (controller env
`KUMA_ROOT_GROUP`). Only the root carries `notificationRefs`; don't add `notificationRefs`
to service monitors.
- The CRD lives in `infrastructure/kuma-sync-crds`. Every consuming Flux Kustomization needs
the KumaMonitor `healthCheckExprs` entry with `current: "true"` (see SKILL.md pitfalls).
@@ -64,7 +64,7 @@ spec:
type: RuntimeDefault
containers:
- name: shelly-manager
image: gitea.renner.uno/konairius/shelly-manager:2026-10-30-0913 # {"$imagepolicy": "flux-system:shelly-manager"}
image: gitea.renner.uno/konairius/shelly-manager:2026-10-30-1343 # {"$imagepolicy": "flux-system:shelly-manager"}
imagePullPolicy: IfNotPresent
ports:
- name: http
+3 -1
View File
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-29-1349 # {"$imagepolicy": "flux-system:kuma-sync"}
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-30-1314 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
@@ -44,6 +44,8 @@ spec:
secretKeyRef:
name: kuma-sync-kuma
key: password
- name: KUMA_ROOT_GROUP
value: kuma-sync/renner-uno
ports:
- name: metrics
containerPort: 8080
@@ -0,0 +1,14 @@
# Root group (KUMA_ROOT_GROUP): parent of every monitor without a parentRef; the only one with notificationRefs.
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: renner-uno
namespace: kuma-sync
spec:
type: group
displayName: renner.uno
interval: 60
# heartbeats, not minutes: 60 x 60s = hourly reminder while down
resendInterval: 60
notificationRefs:
- name: email
@@ -6,3 +6,4 @@ resources:
- deployment.yaml
- statuspage-renner.yaml
- notification-email.yaml
- kumamonitor-root.yaml
@@ -158,43 +158,69 @@ spec:
relabel_rules = loki.relabel.syslog.rules
}
// UniFi UDM SIEM Server (Network app) — UDP/5514. UDM emits CEF
// (Common Event Format), not RFC syslog: payload starts with
// "<bsd-ts> <iso-ts> <device-name> CEF:0|Vendor|Product|...".
// raw listener captures verbatim; loki.process.udm_cef extracts
// CEF header + UNIFI* extension fields and promotes a curated
// low-cardinality set to labels.
// UniFi activity logging — UDP/5514, one target for two payloads:
// the UDM SIEM events in CEF ("<bsd-ts> <device-name> CEF:0|...")
// and plain syslog from the UDM (log_all_contents) and from every
// adopted AP/switch (debug). raw listener captures verbatim;
// loki.process.udm_cef sets `source` per payload type.
loki.source.syslog "udp_5514" {
listener {
address = "0.0.0.0:5514"
protocol = "udp"
syslog_format = "raw"
labels = { job = "syslog", listener = "udp-5514", source = "udm-siem" }
labels = { job = "syslog", listener = "udp-5514" }
}
forward_to = [loki.process.udm_cef.receiver]
}
// CEF parser for UDM SIEM payload. The listener also receives the
// UDM's plain syslog (log_all_contents), which does not match and
// passes through unlabelled. CEF extension keys differ per event
// type, so the regex anchors only on the header
// (vendor|product|product_version|sig_id|name|sev), UNIFIcategory,
// UNIFIhost and the trailing msg. The full raw line stays the log
// content.
loki.process "udm_cef" {
forward_to = [loki.write.default.receiver]
stage.regex {
expression = "CEF:\\d+\\|(?P<vendor>[^|]*)\\|(?P<product>[^|]*)\\|(?P<product_version>[^|]*)\\|(?P<signature_id>[^|]*)\\|(?P<event_name>[^|]*)\\|(?P<cef_severity>[^|]*)\\|UNIFIcategory=(?P<category>.+?) UNIFIhost=(?P<unifi_host>.+?) \\S+=.* msg=(?P<msg>.+)$"
// CEF: extension keys differ per event type, so the regex
// anchors only on the header
// (vendor|product|product_version|sig_id|name|sev),
// UNIFIcategory, UNIFIhost and the trailing msg. The full raw
// line stays the log content.
stage.match {
selector = "{listener=\"udp-5514\"} |= \"CEF:\""
stage.static_labels {
values = { source = "udm-siem" }
}
stage.regex {
expression = "CEF:\\d+\\|(?P<vendor>[^|]*)\\|(?P<product>[^|]*)\\|(?P<product_version>[^|]*)\\|(?P<signature_id>[^|]*)\\|(?P<event_name>[^|]*)\\|(?P<cef_severity>[^|]*)\\|UNIFIcategory=(?P<category>.+?) UNIFIhost=(?P<unifi_host>.+?) \\S+=.* msg=(?P<msg>.+)$"
}
stage.labels {
values = {
vendor = "vendor",
product = "product",
signature_id = "signature_id",
event_name = "event_name",
category = "category",
}
}
}
stage.labels {
values = {
vendor = "vendor",
product = "product",
signature_id = "signature_id",
event_name = "event_name",
category = "category",
// Plain syslog. UDM: "<ts> <host> <host> <app>[pid]: ...";
// AP/switch: "<ts> <host> <mac>,<model>-<fw>: [: ]<app>[pid]: ...".
stage.match {
selector = "{listener=\"udp-5514\"} != \"CEF:\""
stage.static_labels {
values = { source = "unifi-syslog" }
}
stage.regex {
expression = "^\\S+ +\\d+ \\S+ (?P<host>\\S+) (?:[0-9a-f]{12},\\S+:(?: :)? |\\S+ )(?P<app>[A-Za-z0-9_./+-]+)"
}
stage.labels {
values = {
host = "host",
app = "app",
}
}
}
}
@@ -39,8 +39,14 @@ spec:
replication_factor: 1
storage:
type: filesystem
# retention_period is only enforced by the compactor with
# retention_enabled; Loki keeps logs forever otherwise.
compactor:
working_directory: /var/loki/compactor
retention_enabled: true
delete_request_store: filesystem
limits_config:
retention_period: 2160h
retention_period: 8760h
ingestion_rate_mb: 10
ingestion_burst_size_mb: 20
max_streams_per_user: 10000
@@ -54,7 +54,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.9 # {"$imagepolicy": "flux-system:music-assistant"}
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
volumeMounts:
- name: data
@@ -67,7 +67,7 @@ spec:
# the stock file is the one it was written against (2.10.7 = 2.10.8);
# any other version keeps the stock provider.
- name: pocketcasts-overlay
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.9 # {"$imagepolicy": "flux-system:music-assistant"}
command:
- /bin/sh
- -euc
@@ -88,7 +88,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.9 # {"$imagepolicy": "flux-system:music-assistant"}
env:
- name: LOG_LEVEL
value: info
+2 -2
View File
@@ -93,7 +93,7 @@ spec:
type: RuntimeDefault
containers:
- name: lidarr
image: lscr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
image: ghcr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
env:
- name: PUID
value: "1000"
@@ -140,7 +140,7 @@ spec:
drop: ["ALL"]
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
- name: reconcile
image: lscr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
image: ghcr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
command: ["sh", "/scripts/reconcile.sh"]
volumeMounts:
- name: config
@@ -138,25 +138,3 @@ spec:
expiryNotification: true
keyword:
keyword: "<title>authentik</title>"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: syncthing-ui-gate
namespace: paperless
spec:
type: keyword
displayName: Syncthing Login
parentRef:
name: docs
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 20
http:
# behind the Authentik gate; asserts the login page the redirect chain ends on
url: https://syncthing.paperless.renner.uno/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: "<title>authentik</title>"
+1 -1
View File
@@ -182,7 +182,7 @@ spec:
drop: ["ALL"]
containers:
- name: paperless
image: ghcr.io/paperless-ngx/paperless-ngx:3.4.0 # {"$imagepolicy": "flux-system:paperless-ngx"}
image: ghcr.io/paperless-ngx/paperless-ngx:3.4.1 # {"$imagepolicy": "flux-system:paperless-ngx"}
ports:
- name: http
containerPort: 8000
+6 -2
View File
@@ -1,5 +1,5 @@
# kuma-sync controller: KumaMonitor -> Uptime Kuma monitors. Ships no KumaMonitor objects;
# they live with their app, the CRD in infrastructure/kuma-sync-crds.
# kuma-sync controller: KumaMonitor -> Uptime Kuma monitors. Ships only the root group KumaMonitor;
# service monitors live with their app, the CRD in infrastructure/kuma-sync-crds.
# healthCheckExprs: Flux has no status reader for ExternalSecret; fail only on the non-retrying reasons.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
@@ -21,6 +21,10 @@ spec:
kind: ExternalSecret
failed: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'False' && (c.reason == 'SecretOwnedByOther' || c.reason == 'SecretImmutable'))"
current: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')"
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
dependsOn:
- name: infrastructure
- name: infrastructure-crds
@@ -0,0 +1,99 @@
# OIDCClient: an Authentik OAuth2/OpenID client, reconciled by authentik-gate-sync (ci/images/authentik-gate-sync).
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: oidcclients.auth.renner.uno
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
group: auth.renner.uno
names:
kind: OIDCClient
listKind: OIDCClientList
plural: oidcclients
singular: oidcclient
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
additionalPrinterColumns:
- jsonPath: .status.issuer
name: Issuer
type: string
- jsonPath: .status.conditions[?(@.type=="Ready")].status
name: Ready
type: string
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
apiVersion:
type: string
kind:
type: string
metadata:
type: object
spec:
type: object
required: [launchURL, redirectURIs]
properties:
slug:
description: Application slug and client ID; issuer is /application/o/<slug>/. Default metadata.name (then checked by the controller only).
type: string
maxLength: 50
pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
launchURL:
description: The app's URL, shown in the Authentik library.
type: string
pattern: ^https?://
redirectURIs:
type: array
minItems: 1
items:
type: object
required: [url]
properties:
url:
type: string
matchingMode:
type: string
enum: [strict, regex]
default: strict
group:
description: Authentik group whose members may sign in (managed in Authentik); none = any user.
type: string
authorizationFlow:
description: Authorization flow slug; default default-provider-authorization-implicit-consent.
type: string
status:
type: object
properties:
observedGeneration:
type: integer
format: int64
issuer:
type: string
conditions:
type: array
items:
type: object
required: [type, status]
properties:
type:
type: string
status:
type: string
reason:
type: string
message:
type: string
observedGeneration:
type: integer
format: int64
lastTransitionTime:
type: string
format: date-time
@@ -0,0 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- crd-oidcclients.yaml
@@ -223,6 +223,13 @@ spec:
- hostname
- port
type: object
resendInterval:
description: |-
Consecutive DOWN heartbeats after which Kuma sends the DOWN notification
again, not minutes; 0 sends it once per outage.
format: int32
minimum: 0
type: integer
retryInterval:
default: 60
description: Seconds.
@@ -27,6 +27,7 @@ resources:
- admission-policies/
- kuma-sync-crds/
- cloudflare-lb-sync-crds/
- authentik-gate-sync-crds/
# The OpenBao ClusterSecretStore lives in post-crds/ (needs ESO's CRDs and
# an initialized OpenBao; see kubernetes/MIGRATION-sops-openbao.md).
# cnpg-barman-plugin/ lives in its own Flux Kustomization