Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e814c525a | ||
|
|
1a99c02996 | ||
|
|
09a03dfd76 | ||
|
|
f76defb1e1 | ||
|
|
3e0557cfe8 | ||
|
|
8739efe026 | ||
|
|
e518b46045 | ||
|
|
d7a6c2bdc7 | ||
|
|
53d7e716f8 | ||
|
|
61e4ee2ba8 | ||
|
|
b447c57e78 | ||
|
|
4f3cf541a4 | ||
|
|
6463c38f68 | ||
|
|
b86aaf3f88 | ||
|
|
c9944cfce8 | ||
|
|
717d1ad619 | ||
|
|
40f30412aa | ||
|
|
3f73e7cd3a | ||
|
|
a7543cb78b | ||
|
|
229e0cf5d8 | ||
|
|
b7cf7632c6 | ||
|
|
b77b0a4d0d | ||
|
|
2485b3ad38 | ||
|
|
a49c852666 | ||
|
|
3aa2cf7616 | ||
|
|
0a36c324da | ||
|
|
8d3578f020 | ||
|
|
10f1a88802 | ||
|
|
4dd46a8e7b | ||
|
|
859d1a7c65 | ||
|
|
0575a50d9e | ||
|
|
33b7b58849 | ||
|
|
811178e321 |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,16 @@ data:
|
||||
slug: paperless-proxy
|
||||
state: absent
|
||||
|
||||
# syncthing.paperless.renner.uno is gated by authentik-gate-sync
|
||||
# (kubernetes/apps/paperless/syncthing.yaml), which admits this group.
|
||||
# Members are managed in Authentik.
|
||||
- model: authentik_core.group
|
||||
identifiers:
|
||||
name: Paperless Admins
|
||||
state: present
|
||||
attrs:
|
||||
is_superuser: false
|
||||
|
||||
# vscode.home.renner.uno is gated by authentik-gate-sync
|
||||
# (kubernetes/apps/home-assistant/vscode-ingress.yaml), which admits this
|
||||
# group. Members are managed in Authentik.
|
||||
@@ -1252,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-29-1803 # {"$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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -142,4 +142,4 @@ spec:
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: syncthing.paperless.renner.uno
|
||||
dns_name: sync.paperless.renner.uno
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ spec:
|
||||
prober: dns
|
||||
timeout: 5s
|
||||
dns:
|
||||
query_name: syncthing.paperless.renner.uno
|
||||
query_name: sync.paperless.renner.uno
|
||||
query_type: A
|
||||
valid_rcodes: [NOERROR]
|
||||
validate_answer_rrs:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -182,7 +182,7 @@ spec:
|
||||
drop: ["ALL"]
|
||||
containers:
|
||||
- name: paperless
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:3.3.4 # {"$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
|
||||
|
||||
@@ -52,11 +52,8 @@ spec:
|
||||
- name: syncthing
|
||||
image: syncthing/syncthing:2.1.7@sha256:2b71da7c0d0a91a6ae5285ad079226fecc0a365d08b27720e1f7037b018f7546
|
||||
env:
|
||||
# UI bind: any-interface so the ClusterIP Service can reach
|
||||
# it. Network exposure is gated by the absence of an Ingress
|
||||
# and the namespace default-deny CNP — set a UI password in
|
||||
# the Syncthing UI on first port-forward access before
|
||||
# publishing it.
|
||||
# UI bind: any-interface so the ClusterIP Service can reach it.
|
||||
# External access goes through the syncthing-ui Ingress only.
|
||||
- name: STGUIADDRESS
|
||||
value: "0.0.0.0:8384"
|
||||
- name: PUID
|
||||
@@ -115,10 +112,6 @@ spec:
|
||||
persistentVolumeClaim:
|
||||
claimName: paperless-consume
|
||||
---
|
||||
# UI Service — ClusterIP only. Access via `kubectl -n paperless
|
||||
# port-forward svc/syncthing-ui 8384:8384` for first-time setup
|
||||
# (set admin password). Future Ingress + Authentik gate is a
|
||||
# separate change once auth is configured.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
@@ -132,6 +125,36 @@ spec:
|
||||
port: 8384
|
||||
targetPort: ui
|
||||
---
|
||||
# LAN-only UI: Paperless Admins through the Authentik gate.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: syncthing-ui
|
||||
namespace: paperless
|
||||
labels:
|
||||
dns.renner.uno/internal: "true"
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
auth.renner.uno/group: Paperless Admins
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: syncthing.paperless.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: syncthing-ui
|
||||
port:
|
||||
number: 8384
|
||||
tls:
|
||||
- secretName: syncthing-ui-tls
|
||||
hosts:
|
||||
- syncthing.paperless.renner.uno
|
||||
---
|
||||
# Sync ports — LAN-reachable LoadBalancer. Peers (phones, laptops)
|
||||
# connect here for the dropbox sync. external-dns publishes the
|
||||
# hostname into UniFi DNS only (no cloudflare label); off-LAN peers
|
||||
@@ -146,7 +169,7 @@ metadata:
|
||||
dns.renner.uno/internal: "true"
|
||||
annotations:
|
||||
io.cilium/lb-ipam-ips: "10.2.99.85,fd99::85"
|
||||
external-dns.kubernetes.io/hostname: syncthing.paperless.renner.uno
|
||||
external-dns.kubernetes.io/hostname: sync.paperless.renner.uno
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
externalTrafficPolicy: Local
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -145,8 +145,8 @@ spec:
|
||||
# Allow Syncthing peer-to-peer sync from the LAN/world.
|
||||
# - 22000 TCP+QUIC: actual sync traffic between peers.
|
||||
# - 21027 UDP: local-discovery broadcast packets from peers on the LAN.
|
||||
# UI (8384) is intentionally NOT listed — accessed via port-forward
|
||||
# until an Ingress + Authentik gate is added.
|
||||
# UI (8384) is intentionally NOT listed — authentik-gate-sync generates the
|
||||
# CNP syncthing-ui-gate that admits traefik.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
|
||||
Reference in New Issue
Block a user