Files
cozystack/packages/core/platform/templates/apps.yaml
T
Kirill Ilin c96fe3d3ef fix(platform): refuse master as the OIDC realm name
The realm named here is where keycloak-configure creates every platform
client and the cluster-admin role, and where dashboard users sign in. Naming
master puts all of that inside the realm that holds the Keycloak super-admin,
and publishes its login page wherever the platform realm is published — and
the guard belongs where the value is set, since the Keycloak chart only reads
it once a separate admin hostname is configured.

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
2026-09-22 10:35:31 +05:00

272 lines
16 KiB
YAML

{{- $kubeRootCa := lookup "v1" "ConfigMap" "kube-system" "kube-root-ca.crt" }}
{{- $cozystackCm := lookup "v1" "ConfigMap" "cozy-system" "cozystack" }}
{{- $bundleName := .Values.bundles.system.variant }}
{{- $bundle := tpl (.Files.Get (printf "bundles/%s.yaml" $bundleName)) . | fromYaml }}
{{- /* Read root-host from ConfigMap if available, else use chart values */ -}}
{{- $rootHost := .Values.publishing.host -}}
{{- if and $cozystackCm $cozystackCm.data -}}
{{- $rootHost = default $rootHost (index $cozystackCm.data "root-host") -}}
{{- end -}}
---
apiVersion: v1
kind: Secret
metadata:
name: cozystack-values
namespace: cozy-system
labels:
reconcile.fluxcd.io/watch: Enabled
type: Opaque
stringData:
values.yaml: |
# keda.clusterDomain: authoritative cluster domain injected into the KEDA subchart
# (a subchart value a template cannot derive from _cluster). Rationale: packages/system/keda.
keda:
clusterDomain: {{ .Values.networking.clusterDomain | quote }}
{{- /*
The operator builds every in-cluster name it needs from DNS_BASE: the
securityconfig job target, its own OpenSearch API client URL, the
Dashboards host, and the SANs it writes into node certificates. This is
a vendored subchart value, so it travels under the subchart key; _cluster
stops at each package's own templates and never reaches a subchart.
A package rendered without this Secret falls back to cluster.local.
*/}}
opensearch-operator:
manager:
dnsBase: {{ .Values.networking.clusterDomain | quote }}
_cluster:
root-host: {{ $rootHost | quote }}
bundle-name: {{ .Values.bundles.system.variant | quote }}
solver: {{ .Values.publishing.certificates.solver | quote }}
issuer-name: {{ .Values.publishing.certificates.issuerName | quote }}
{{- /*
Wildcard certificate mode. Only the Secret NAME rides this
channel — never the cert/key material (unlike a private key, the
name is not sensitive). When set, the ingress flow points the
root ingress controller's --default-ssl-certificate at it and
drops per-host ACME, and the gateway chart switches the
TenantGateway to certMode=existingSecret — unless
the tenant's Gateway is on an edge-terminated class, which wins
over it and issues nothing at all.
The name comes from one of two sources, with the operator-
provided (BYO) name always winning:
1. publishing.certificates.wildcardSecretName — a Secret the
operator pre-creates in the publishing namespace
(tenant-root). wildcard-issue stays false: the platform
mints nothing.
2. Otherwise, on the ingress-nginx path (gateway.enabled=false)
with solver=dns01 and publishing.certificates.wildcard=true,
the platform issues its own shared wildcard. wildcard-issue
becomes true and the publishing ingress package renders the
Certificate into this Secret name. http01 cannot issue
wildcards, and the Gateway API path issues its own wildcard
via the TenantGateway controller, so both are excluded.
Empty name + wildcard-issue=false = per-host ACME as before.
*/}}
{{- $byoWildcard := .Values.publishing.certificates.wildcardSecretName | default "" }}
{{- $wildcardSecretName := $byoWildcard }}
{{- $wildcardIssue := false }}
{{- if and (not $byoWildcard) (eq .Values.publishing.certificates.solver "dns01") .Values.publishing.certificates.wildcard (not .Values.gateway.enabled) }}
{{- $wildcardSecretName = "cozystack-wildcard-tls" }}
{{- $wildcardIssue = true }}
{{- end }}
wildcard-secret-name: {{ $wildcardSecretName | quote }}
wildcard-issue: {{ $wildcardIssue | quote }}
{{- /*
DNS-01 solver wiring. Read only when solver=dns01, but the
keys are written unconditionally so a render-time switch from
http01 to dns01 doesn't require chart redeploy of every
consumer. Each consumer (extra/gateway, ingress flow) reads
the keys it needs and validates per-provider requirements.
*/}}
{{- with .Values.publishing.certificates.dns01 }}
dns01-provider: {{ .provider | default "cloudflare" | quote }}
{{- with .cloudflare }}
dns01-cloudflare-secret-name: {{ .secretName | default "cloudflare-api-token-secret" | quote }}
dns01-cloudflare-secret-key: {{ .secretKey | default "api-token" | quote }}
{{- end }}
{{- with .route53 }}
dns01-route53-region: {{ .region | default "" | quote }}
dns01-route53-access-key-id: {{ .accessKeyID | default "" | quote }}
dns01-route53-secret-name: {{ .secretName | default "" | quote }}
dns01-route53-secret-key: {{ .secretKey | default "secret-access-key" | quote }}
{{- end }}
{{- with .digitalocean }}
dns01-digitalocean-secret-name: {{ .secretName | default "digitalocean-api-token-secret" | quote }}
dns01-digitalocean-secret-key: {{ .secretKey | default "access-token" | quote }}
{{- end }}
{{- with .rfc2136 }}
dns01-rfc2136-nameserver: {{ .nameserver | default "" | quote }}
dns01-rfc2136-tsig-key-name: {{ .tsigKeyName | default "" | quote }}
dns01-rfc2136-tsig-algorithm: {{ .tsigAlgorithm | default "HMACSHA256" | quote }}
dns01-rfc2136-secret-name: {{ .secretName | default "" | quote }}
dns01-rfc2136-secret-key: {{ .secretKey | default "tsig-secret-key" | quote }}
{{- end }}
{{- end }}
oidc-enabled: {{ .Values.authentication.oidc.enabled | quote }}
oidc-insecure-skip-verify: {{ .Values.authentication.oidc.insecureSkipVerify | quote }}
extra-keycloak-redirect-uri-for-dashboard: {{ index .Values.authentication.oidc.keycloakExtraRedirectUri | quote }}
{{- /* The realm name becomes a Keycloak realm, a URL path segment in every
issuer/redirect/logout URL, and a shell token in the update-email
hook. Reject anything YAML or --set did not resolve to a string (a
bare number, or true/false) — coercing it would rename the realm to
something never typed and inject a "%!s(...)" into the derived URL —
and reject anything outside a safe slug charset, so a stray "/" or
space cannot split a URL path or the shell. Fail at the source
rather than emit a broken issuer later. The hint does not echo the
parsed value back: YAML has already lost the operator's original
token (on -> true), so quoting is the fix, not the reprinted bool. */}}
{{- /* Unset (null) falls back to cozy; an explicit "" is a meaningless
value, not a default request, so let it reach the charset guard
below rather than swallowing it — mirrors the keycloakInternalUrl
tri-state a few lines down (kindIs "invalid" is null, not ""). */}}
{{- $realmName := .Values.authentication.oidc.realmName }}
{{- if kindIs "invalid" $realmName }}
{{- $realmName = "cozy" }}
{{- end }}
{{- if not (kindIs "string" $realmName) }}
{{- fail (printf "authentication.oidc.realmName must be a string (got %s); wrap the value in quotes in the platform values, e.g. realmName: \"my-realm\"" (kindOf $realmName)) }}
{{- end }}
{{- if not (regexMatch "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" $realmName) }}
{{- fail (printf "authentication.oidc.realmName %q is invalid: use only letters, digits, '.', '_' and '-' (it is used verbatim as a URL path segment and a shell token)" $realmName) }}
{{- end }}
{{- /* master holds the Keycloak super-admin. Naming it here would put
every platform client, the cluster-admin role and the users who
log in to the dashboard inside that realm, and publish its login
page and token endpoint wherever the platform realm is published. */}}
{{- if eq $realmName "master" }}
{{- fail "authentication.oidc.realmName must not be master: that realm holds the Keycloak super-admin, so platform clients and end users would share it. Pick any other name, e.g. realmName: \"cozy\"" }}
{{- end }}
oidc-realm-name: {{ $realmName | quote }}
{{- /* null keycloakInternalUrl derives the in-cluster Service URL for the
configured realm; "" is the external-discovery escape hatch and an
explicit value is passed through, so distinguish null from "" with
kindIs rather than `default`, which would also fire on "". */}}
{{- $keycloakInternalUrl := .Values.authentication.oidc.keycloakInternalUrl }}
{{- if kindIs "invalid" $keycloakInternalUrl }}
{{- $keycloakInternalUrl = printf "http://keycloak-http.cozy-keycloak.svc:8080/realms/%s" $realmName }}
{{- end }}
keycloak-internal-url: {{ $keycloakInternalUrl | quote }}
expose-services: {{ .Values.publishing.exposedServices | join "," | quote }}
{{- /*
expose-ingress carries two semantically distinct values that
happen to coincide in the default cozystack deployment:
1. The ingressClassName the per-system app (dashboard,
cozystack-api, etc.) sets on its Ingress resource when
gateway-enabled=false.
2. The namespace of the Gateway that the same per-system
app's HTTPRoute / TLSRoute parentRefs target when
gateway-enabled=true (publishing tenant namespace).
Default `tenant-root` satisfies both because the ingress-nginx
controller and the publishing Gateway both live in tenant-root.
If a future deployment splits these (e.g. an ingress controller
in cozy-ingress vs a publishing Gateway in tenant-root), the
per-system templates need a separate _cluster.gateway-namespace
key. Documented here as a known limitation.
*/}}
expose-ingress: {{ .Values.publishing.ingressName | quote }}
{{- /* Optional namespace of a SEPARATE Gateway/ingressClass that
system components (currently Keycloak) attach their admin
routes to — e.g. a private-IP Gateway unreachable from the
public internet. Empty -> admin shares expose-ingress. The
Gateway itself is provisioned out of band; this only selects
where the admin route attaches. */}}
expose-ingress-admin: {{ .Values.publishing.ingressNameAdmin | default "" | quote }}
expose-external-ips: {{ .Values.publishing.externalIPs | join "," | quote }}
{{- /* Native loadBalancerClass for the host ingress LoadBalancer Service
(see extra/ingress). Empty lets the default LB controller claim it. */}}
load-balancer-class: {{ .Values.publishing.loadBalancerClass | default "" | quote }}
{{- /* Host PROXY-protocol switch. extra/ingress reads this and applies
use-proxy-protocol to the external-facing ingress (the namespace
named by expose-ingress), keeping it host-scoped like expose-external-ips. */}}
proxy-protocol: {{ .Values.publishing.proxyProtocol | default false | quote }}
gateway-enabled: {{ .Values.gateway.enabled | default false | quote }}
gateway-attached-namespaces: {{ .Values.gateway.attachedNamespaces | default (list) | join "," | quote }}
{{- /* Normalised once here rather than at each reader: two charts
compare this scalar against the comma-joined lists below,
which trim their own entries on split, so a padded name that
reached them would match in neither.
Unset falls back to the bundled class. Whitespace-only does
NOT: unset means "I did not choose", while whitespace means
"I chose, and what I typed is garbage" — and this key decides
where TLS terminates. Resolving it quietly would publish a
tenant directly, with a real certificate on a real address,
that the operator meant to put behind an edge terminator. */}}
{{- /* Read before normalising, and check the kind first. `default`
fires on any falsy value, so a `default ""` ahead of these
checks would launder 0 and false into the empty string and
resolve them to the bundled class — the guard would then
cover the loudest bad input and miss the two quietest. The
order of a guard's steps is part of the guard. */}}
{{- $rawClassName := .Values.gateway.className }}
{{- if not (kindIs "invalid" $rawClassName) }}
{{- if not (kindIs "string" $rawClassName) }}
{{- fail (printf "gateway.className must be a GatewayClass name, got %v" $rawClassName) }}
{{- end }}
{{- if and (ne $rawClassName "") (eq (trim $rawClassName) "") }}
{{- fail "gateway.className is only whitespace. Leave it unset to use the bundled cilium class, or name a real GatewayClass — resolving a blank value silently would decide TLS termination for every tenant that did not choose." }}
{{- end }}
{{- end }}
gateway-class-name: {{ $rawClassName | default "" | trim | default "cilium" | quote }}
{{- /*
A non-string entry is rejected here rather than travelling as
something the join below would render unreadably. Note what is NOT
checked: whether the names correspond to GatewayClasses that exist.
A misspelled name in this list simply matches no class, so edge
behaviour never engages for it and nothing says so — the same shape
as a misspelled name in className, which lands a tenant Gateway on
a class the cluster does not serve and shows up as a Gateway that
never programs.
*/}}
{{- $edgeClasses := .Values.gateway.edgeTerminatedClasses | default (list) }}
{{- $tenantClasses := .Values.gateway.tenantSelectableClasses | default (list) }}
{{- /* Both lists travel as one comma-joined scalar, so a name that
carries a comma would arrive as two names and match neither. */}}
{{- range $edgeClasses }}
{{- if not (kindIs "string" .) }}
{{- fail (printf "gateway.edgeTerminatedClasses must be a list of GatewayClass names, got %v" .) }}
{{- end }}
{{- if contains "," (. | toString) }}
{{- fail (printf "gateway.edgeTerminatedClasses entry %q contains a comma; the list travels comma-joined and the name would be split" .) }}
{{- end }}
{{- end }}
{{- range $tenantClasses }}
{{- if not (kindIs "string" .) }}
{{- fail (printf "gateway.tenantSelectableClasses must be a list of GatewayClass names, got %v" .) }}
{{- end }}
{{- if contains "," (. | toString) }}
{{- fail (printf "gateway.tenantSelectableClasses entry %q contains a comma; the list travels comma-joined and the name would be split" .) }}
{{- end }}
{{- end }}
gateway-edge-classes: {{ $edgeClasses | join "," | quote }}
gateway-tenant-classes: {{ $tenantClasses | join "," | quote }}
{{- /* Whether the root tenant hosts the platform monitoring stack
(Tenant/root spec.monitoring). cozystack-basics gates the
cozy-monitoring -> tenant-root vlinsert/vminsert ExternalName
redirects on this: when the root tenant has no monitoring, those
redirects dead-end and fluent-bit silently drops logs (#3181).
`dig` (not `default`) is used deliberately: it keys on presence,
so an explicit rootEnabled=false is preserved (Go's `default`
would treat the boolean false as empty and flip it back to true),
while `| default dict` guards against `.Values.monitoring` being
null/omitted in custom values. Missing key -> the true default,
matching the chart values.yaml. */}}
monitoring-enabled: {{ dig "rootEnabled" true (.Values.monitoring | default dict) | quote }}
cluster-domain: {{ .Values.networking.clusterDomain | quote }}
api-server-endpoint: {{ .Values.publishing.apiServerEndpoint | quote }}
{{- with .Values.branding }}
branding:
{{- . | toYaml | nindent 8 }}
{{- end }}
cpu-allocation-ratio: {{ .Values.resources.cpuAllocationRatio | quote }}
memory-allocation-ratio: {{ .Values.resources.memoryAllocationRatio | quote }}
ephemeral-storage-allocation-ratio: {{ .Values.resources.ephemeralStorageAllocationRatio | quote }}
{{- with .Values.scheduling }}
scheduling:
{{- . | toYaml | nindent 8 }}
{{- end }}
{{- with $kubeRootCa.data }}
kube-root-ca: {{ index . "ca.crt" | b64enc | quote }}
{{- end }}