diff --git a/charts/dgraph/files/validation/validate.sh b/charts/dgraph/files/validation/validate.sh new file mode 100644 index 000000000..4e914805b --- /dev/null +++ b/charts/dgraph/files/validation/validate.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +# dgraph post-install validator. +# +# Asserts the running dgraph cluster matches what the chart rendered, read +# from /config/expected.json. Runs in-cluster three ways: a `helm test` Pod, a +# post-install hook Job (gates the release), or the suspended manual CronJob +# (kubectl create job --from=cronjob/...). +# +# Transport is taken from env the pod template sets (mirrors the ACL bootstrap +# reconciler): plaintext by default, or HTTPS with the chart's CA and optional +# client cert under native TLS. The logical expected state comes from +# expected.json, which the chart templates from its own values, so the validator +# cannot drift from what was deployed. +# +# Checks: health, cluster membership, ACL enforcement, admin login, an +# authenticated query, per-user logins, group predicate rules, and (optionally) +# backup CronJob scheduling. When ACL is disabled, the login and auth-dependent +# checks are skipped. No `set -e`: each check aggregates into FAILURES so one +# failure still lets the rest report. Exit 0 = all pass; non-zero = a failure. +# +# check_* helpers are dispatched indirectly through retry()/run() (via "$@"), +# which shellcheck cannot trace; silence the false "never invoked" (SC2329) and +# "unreachable command" (SC2317) it infers for them. +# shellcheck disable=SC2329,SC2317 +set -u + +EXPECTED_JSON="${EXPECTED_JSON:-/config/expected.json}" +CREDS_DIR="${CREDS_DIR:-/creds}" +RETRIES="${RETRIES:-10}" +RETRY_SLEEP="${RETRY_SLEEP:-12}" +K8S_API="${K8S_API:-https://kubernetes.default.svc}" +SA_DIR="/var/run/secrets/kubernetes.io/serviceaccount" +: "${ALPHA_HOST:?ALPHA_HOST not set}" + +# TLS: when the pod supplies a CA path (alpha.tls.enabled), talk HTTPS and present +# the client cert if one was provided. Empty CERTOPTS keeps plaintext. Mirrors the +# ACL bootstrap and backup CronJob curl handling. +SCHEME="http" +CERTOPTS=() +if [ -n "${CACERT_PATH:-}" ]; then + SCHEME="https" + CERTOPTS+=('--cacert' "${CACERT_PATH}") + if [ -n "${CLIENT_CERT_PATH:-}" ] && [ -n "${CLIENT_KEY_PATH:-}" ]; then + CERTOPTS+=('--cert' "${CLIENT_CERT_PATH}" '--key' "${CLIENT_KEY_PATH}") + fi +fi +ALPHA="${SCHEME}://${ALPHA_HOST}:8080" + +NS="$(jq -r '.namespace' "$EXPECTED_JSON")" +ACL_ENABLED="$(jq -r '.aclEnabled' "$EXPECTED_JSON")" + +FAILURES=0 +pass() { printf 'PASS %s\n' "$1"; } +fail() { + printf 'FAIL %s\n' "$1" + FAILURES=$((FAILURES + 1)) +} +log() { printf '%s\n' "$1"; } + +# Alpha-facing curl with the resolved TLS options applied. +curl_alpha() { /usr/bin/curl -fsS "${CERTOPTS[@]}" "$@"; } + +# retry LABEL CMD... : run until exit 0 or RETRIES exhausted, logging each wait so +# a slow first deploy shows progress instead of going silent. +retry() { + _label="$1" + shift + _i=1 + while true; do + if "$@"; then return 0; fi + [ "$_i" -ge "$RETRIES" ] && return 1 + log "$_label: not ready; retry $_i/$RETRIES in ${RETRY_SLEEP}s" + _i=$((_i + 1)) + sleep "$RETRY_SLEEP" + done +} + +# login USER PASSWORD -> accessJWT on stdout, non-zero on failure. Credentials go +# in as GraphQL variables (jq --arg) so any generated password is injection-safe. +login() { + _payload="$(jq -n --arg u "$1" --arg p "$2" \ + '{query:"mutation($u:String!,$p:String!){login(userId:$u,password:$p){response{accessJWT}}}",variables:{u:$u,p:$p}}')" + _resp="$(curl_alpha -X POST "$ALPHA/admin" -H 'Content-Type: application/json' -d "$_payload")" || return 1 + _jwt="$(printf '%s' "$_resp" | jq -r '.data.login.response.accessJWT // empty')" + [ -n "$_jwt" ] || return 1 + printf '%s' "$_jwt" +} + +read_pw() { + _f="$CREDS_DIR/$1" + [ -f "$_f" ] || return 1 + cat "$_f" +} + +# A. Health: /health is public; every instance must report "healthy". +check_health() { + _h="$(curl_alpha "$ALPHA/health")" || return 1 + _total="$(printf '%s' "$_h" | jq 'length')" + _ok="$(printf '%s' "$_h" | jq '[.[] | select(.status == "healthy")] | length')" + [ "$_total" -gt 0 ] && [ "$_total" = "$_ok" ] +} + +# Admin JWT underpins membership/query/groups; fetch with retry before they run. +ADMIN_JWT="" +# Admin auth header args, populated once we hold a JWT. Stays empty when ACL is +# disabled so we never send an empty X-Dgraph-AccessToken, which an auth proxy +# could treat as a failed auth attempt and reject. +AUTH_HDR=() +get_admin_jwt() { + _u="$(jq -r '.adminUser' "$EXPECTED_JSON")" + _k="$(jq -r '.adminPasswordKey' "$EXPECTED_JSON")" + _pw="$(read_pw "$_k")" || return 1 + ADMIN_JWT="$(login "$_u" "$_pw")" || return 1 + [ -n "$ADMIN_JWT" ] || return 1 + AUTH_HDR=(-H "X-Dgraph-AccessToken: $ADMIN_JWT") +} + +# B. Membership: counted Alphas/Zeros in /state match expected. As a +# post-install/upgrade gate this compares against the just-rendered replicaCount, +# so counts match. Note: the zeros count assumes /state lists only live members; +# if a decommissioned zero lingered in /state after a scale-down it would +# over-count and FAIL. Not a concern for the install/upgrade gate, but a manual +# CronJob run after a scale-down could surface it. +check_membership() { + _state="$(curl_alpha "$ALPHA/state" "${AUTH_HDR[@]}")" || return 1 + _a="$(printf '%s' "$_state" | jq '[.groups[].members | length] | add // 0')" + _z="$(printf '%s' "$_state" | jq '(.zeros // {}) | length')" + [ "$_a" = "$(jq -r '.expectedAlphas' "$EXPECTED_JSON")" ] && + [ "$_z" = "$(jq -r '.expectedZeros' "$EXPECTED_JSON")" ] +} + +# C. ACL enforcing: an unauthenticated admin query is rejected. Rejection can be +# a GraphQL 200 with a non-empty errors[] (current Dgraph) OR an HTTP 401/403 +# (an auth proxy or a future Dgraph version). Deliberately not +# curl_alpha here: its -f turns a 4xx rejection into a non-zero exit that would +# read as "not enforcing" — exactly backwards. Assert on the status/body instead. +check_acl_enforcing() { + _out="$(/usr/bin/curl -sS -w '\n%{http_code}' "${CERTOPTS[@]}" \ + -X POST "$ALPHA/admin" -H 'Content-Type: application/json' \ + -d '{"query":"{ queryGroup { name } }"}')" || return 1 + _code="${_out##*$'\n'}" + _body="${_out%$'\n'*}" + # A 401/403 is an explicit rejection => enforcing. + case "$_code" in 401 | 403) return 0 ;; esac + # A 200 counts only if the GraphQL response carries a non-empty errors[]. + [ "$_code" = "200" ] && + [ "$(printf '%s' "$_body" | jq -r '(.errors // []) | length' 2>/dev/null || echo 0)" -gt 0 ] +} + +# D. Authenticated query: a DQL schema read through the auth path returns data. +check_query() { + _resp="$(curl_alpha -X POST "$ALPHA/query" \ + -H 'Content-Type: application/dql' \ + "${AUTH_HDR[@]}" \ + --data-binary 'schema {}')" || return 1 + printf '%s' "$_resp" | jq -e '.data' >/dev/null 2>&1 +} + +# E. Per-user login: every declared account logs in with its stored password. +check_user_logins() { + _rc=0 + _n="$(jq -r '.users | length' "$EXPECTED_JSON")" + _i=0 + while [ "$_i" -lt "$_n" ]; do + _u="$(jq -r ".users[$_i].name" "$EXPECTED_JSON")" + _k="$(jq -r ".users[$_i].passwordKey" "$EXPECTED_JSON")" + _i=$((_i + 1)) + _pw="$(read_pw "$_k")" || { + fail "user-login: $_u (no password key $_k)" + _rc=1 + continue + } + if login "$_u" "$_pw" >/dev/null; then + pass "user-login: $_u" + else + fail "user-login: $_u" + _rc=1 + fi + done + return "$_rc" +} + +# F. Groups + rules: each expected group exists AND carries every expected +# predicate rule (matching predicate and permission). +check_groups() { + _ng="$(jq -r '.groups | length' "$EXPECTED_JSON")" + [ "$_ng" -eq 0 ] && return 0 + _resp="$(curl_alpha -X POST "$ALPHA/admin" -H 'Content-Type: application/json' \ + "${AUTH_HDR[@]}" \ + -d '{"query":"{ queryGroup { name rules { predicate permission } } }"}')" || return 1 + _rc=0 + _i=0 + while [ "$_i" -lt "$_ng" ]; do + _g="$(jq -r ".groups[$_i].name" "$EXPECTED_JSON")" + _exp_rules="$(jq -c ".groups[$_i].rules // []" "$EXPECTED_JSON")" + _i=$((_i + 1)) + _present="$(printf '%s' "$_resp" | jq --arg g "$_g" '[.data.queryGroup[] | select(.name == $g)] | length')" + if [ "${_present:-0}" -lt 1 ]; then + fail "group: $_g (absent)" + _rc=1 + continue + fi + _act_rules="$(printf '%s' "$_resp" | jq -c --arg g "$_g" '([.data.queryGroup[] | select(.name == $g)][0].rules) // []')" + _missing="$(jq -n --argjson e "$_exp_rules" --argjson a "$_act_rules" \ + '[$e[] | . as $r | select(($a | any(.predicate == $r.predicate and .permission == $r.permission)) | not)] | length')" + if [ "${_missing:-0}" -eq 0 ]; then + pass "group: $_g (rules ok)" + else + fail "group: $_g ($_missing expected rule(s) missing)" + _rc=1 + fi + done + return "$_rc" +} + +# G. Backups: each expected backup CronJob exists with the expected schedule. +# Reads the Kubernetes API with the pod's ServiceAccount token (requires the +# validation RBAC). Only runs when expected.json marks backups.check true. +check_backups() { + _token="$(cat "$SA_DIR/token")" + _n="$(jq -r '.backups.cronjobs | length' "$EXPECTED_JSON")" + _rc=0 + _i=0 + while [ "$_i" -lt "$_n" ]; do + _name="$(jq -r ".backups.cronjobs[$_i].name" "$EXPECTED_JSON")" + _sched="$(jq -r ".backups.cronjobs[$_i].schedule" "$EXPECTED_JSON")" + _i=$((_i + 1)) + _cj="$(/usr/bin/curl -fsS --cacert "$SA_DIR/ca.crt" -H "Authorization: Bearer $_token" \ + "$K8S_API/apis/batch/v1/namespaces/$NS/cronjobs/$_name")" || { + fail "backup-cronjob: $_name (absent)" + _rc=1 + continue + } + _got="$(printf '%s' "$_cj" | jq -r '.spec.schedule')" + if [ "$_got" = "$_sched" ]; then + pass "backup-cronjob: $_name ($_got)" + else + fail "backup-cronjob: $_name ($_got != $_sched)" + _rc=1 + fi + done + return "$_rc" +} + +run() { + _name="$1" + shift + if retry "$_name" "$@"; then pass "$_name"; else fail "$_name"; fi +} + +# run_reported LABEL FN...: retry wrapper for checks that emit their own per-item +# PASS/FAIL and increment FAILURES (check_user_logins, check_groups, +# check_backups). Bare, these get one shot and can FAIL on a still-propagating +# ACL rule or a not-yet-visible CronJob while the run()-wrapped checks above them +# would retry past the same window. Probe quietly in a subshell (its FAILURES +# increments and output are discarded) until it passes or attempts run out, then +# run once authoritatively so per-item detail and the failure count land exactly +# once. +run_reported() { + _name="$1" + shift + _i=1 + while [ "$_i" -lt "$RETRIES" ]; do + if ("$@") >/dev/null 2>&1; then break; fi + log "$_name: not ready; retry $_i/$RETRIES in ${RETRY_SLEEP}s" + _i=$((_i + 1)) + sleep "$RETRY_SLEEP" + done + "$@" +} + +log "dgraph validator -> $ALPHA (ns=$NS, aclEnabled=$ACL_ENABLED); up to $RETRIES x ${RETRY_SLEEP}s per check" +run "health" check_health + +if [ "$ACL_ENABLED" = "true" ]; then + if retry "admin-login" get_admin_jwt; then pass "admin-login"; else fail "admin-login"; fi + run "acl-enforcing" check_acl_enforcing + run "membership" check_membership + run "query" check_query + run_reported "user-logins" check_user_logins + run_reported "groups" check_groups +else + log "ACL disabled (expected.json aclEnabled != true); skipping login, ACL-enforcement, user-login, and group checks" + run "membership" check_membership + run "query" check_query +fi + +if [ "$(jq -r '.backups.check' "$EXPECTED_JSON")" = "true" ]; then + run_reported "backups" check_backups +fi + +echo "----" +if [ "$FAILURES" -eq 0 ]; then + echo "dgraph validation: PASS" + exit 0 +fi +echo "dgraph validation: FAIL ($FAILURES check(s))" +exit 1 diff --git a/charts/dgraph/templates/acl/bootstrap-configmap.yaml b/charts/dgraph/templates/acl/bootstrap-configmap.yaml new file mode 100644 index 000000000..424a373b0 --- /dev/null +++ b/charts/dgraph/templates/acl/bootstrap-configmap.yaml @@ -0,0 +1,126 @@ +{{- if and .Values.alpha.acl.enabled .Values.alpha.acl.bootstrap.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "dgraph.alpha.fullname" . }}-acl-bootstrap + namespace: {{ include "dgraph.namespace" . }} + labels: + {{- include "dgraph.labels" (dict "ctx" . "component" "acl-bootstrap") | nindent 4 }} +data: + reconcile.sh: | + #!/usr/bin/env bash + ## Idempotent ACL reconciler. + ## 1. Rotates `groot` off Dgraph's default password to the supplied per-env one. + ## 2. Converges the declared groups (with predicate rules) and users (with group + ## membership), creating or updating as needed. + ## Safe to re-run on every helm upgrade. Authenticates via the GraphQL /admin + ## endpoint with the same login pattern the backup CronJob uses. The dgraph + ## image provides jq and /usr/bin/curl; all parsing goes through jq. + set -euo pipefail + + : "${ALPHA_HOST:?ALPHA_HOST not set}" + : "${GROOT_PASSWORD_KEY:?GROOT_PASSWORD_KEY not set}" + CREDS_DIR="${CREDS_DIR:-/creds}" + USERS_JSON="${USERS_JSON:-[]}" + GROUPS_JSON="${GROUPS_JSON:-[]}" + + ## TLS: when the Job supplies a CA path (alpha.tls.enabled), talk HTTPS and + ## present the client cert if one was provided. Empty CERTOPTS keeps plaintext. + ## Mirrors the backup CronJob curl handling. + SCHEME="http" + CERTOPTS=() + if [ -n "${CACERT_PATH:-}" ]; then + SCHEME="https" + CERTOPTS+=('--cacert' "${CACERT_PATH}") + if [ -n "${CLIENT_CERT_PATH:-}" ] && [ -n "${CLIENT_KEY_PATH:-}" ]; then + CERTOPTS+=('--cert' "${CLIENT_CERT_PATH}" '--key' "${CLIENT_KEY_PATH}") + fi + fi + ADMIN="${SCHEME}://${ALPHA_HOST}:8080/admin" + HEALTH="${SCHEME}://${ALPHA_HOST}:8080/health" + + groot_password="$(cat "${CREDS_DIR}/${GROOT_PASSWORD_KEY}")" + + ## post -> response body. Empty token = anonymous (login). + post() { + local token="$1" body="$2" + if [ -n "${token}" ]; then + /usr/bin/curl -fsS "${CERTOPTS[@]}" "${ADMIN}" -H 'Content-Type: application/json' \ + -H "X-Dgraph-AccessToken: ${token}" --data "${body}" + else + /usr/bin/curl -fsS "${CERTOPTS[@]}" "${ADMIN}" -H 'Content-Type: application/json' --data "${body}" + fi + } + + ## login -> accessJWT on stdout, non-zero on failure. + ## GraphQL variables (not string interpolation) keep credentials injection-safe. + login() { + local body resp jwt + body="$(jq -n --arg u "$1" --arg p "$2" \ + '{query:"mutation($u:String!,$p:String!){login(userId:$u,password:$p){response{accessJWT}}}",variables:{u:$u,p:$p}}')" + resp="$(post "" "${body}")" || return 1 + jwt="$(printf '%s' "${resp}" | jq -r '.data.login.response.accessJWT // empty')" + [ -n "${jwt}" ] || return 1 + printf '%s' "${jwt}" + } + + echo "Waiting for Alpha at ${HEALTH} ..." + until /usr/bin/curl -fsS "${CERTOPTS[@]}" "${HEALTH}" >/dev/null 2>&1; do sleep 5; done + + ## --- groot rotation (idempotent) --- + if TOKEN="$(login groot "${groot_password}" 2>/dev/null)"; then + echo "groot already rotated." + else + echo "Rotating groot from its default password ..." + TOKEN="$(login groot password)" || { + echo "FATAL: groot is neither the target nor the default password; refusing to guess." >&2 + exit 1 + } + body="$(jq -n --arg p "${groot_password}" \ + '{query:"mutation($p:String!){updateUser(input:{filter:{name:{eq:\"groot\"}},set:{password:$p}}){user{name}}}",variables:{p:$p}}')" + post "${TOKEN}" "${body}" | jq -e '.data.updateUser.user[0].name=="groot"' >/dev/null || { + echo "FATAL: groot password rotation failed." >&2; exit 1; } + TOKEN="$(login groot "${groot_password}")" || { + echo "FATAL: cannot log in as groot after rotation." >&2; exit 1; } + echo "groot rotated." + fi + + ## --- groups: create if absent, then converge rules --- + printf '%s' "${GROUPS_JSON}" | jq -c '.[]' | while read -r g; do + name="$(printf '%s' "${g}" | jq -r '.name')" + rules="$(printf '%s' "${g}" | jq -c '.rules // []')" + echo "Ensuring group ${name} ..." + add="$(jq -n --arg n "${name}" \ + '{query:"mutation($n:String!){addGroup(input:[{name:$n}]){group{name}}}",variables:{n:$n}}')" + post "${TOKEN}" "${add}" >/dev/null 2>&1 || true # ignore "already exists" + if [ "${rules}" != "[]" ]; then + upd="$(jq -n --arg n "${name}" --argjson r "${rules}" \ + '{query:"mutation($n:String!,$r:[RuleRef!]!){updateGroup(input:{filter:{name:{eq:$n}},set:{rules:$r}}){group{name}}}",variables:{n:$n,r:$r}}')" + post "${TOKEN}" "${upd}" | jq -e '.data.updateGroup.group[0].name' >/dev/null || { + echo "FATAL: failed to set rules on group ${name}." >&2; exit 1; } + fi + done + + ## --- users: create if absent, else reset password + group membership --- + printf '%s' "${USERS_JSON}" | jq -c '.[]' | while read -r u; do + name="$(printf '%s' "${u}" | jq -r '.name')" + pwkey="$(printf '%s' "${u}" | jq -r '.passwordSecretKey')" + groupsref="$(printf '%s' "${u}" | jq -c '[.groups[]? | {name: .}]')" + pw="$(cat "${CREDS_DIR}/${pwkey}")" + echo "Ensuring user ${name} ..." + add="$(jq -n --arg n "${name}" --arg p "${pw}" --argjson g "${groupsref}" \ + '{query:"mutation($n:String!,$p:String!,$g:[GroupRef]){addUser(input:[{name:$n,password:$p,groups:$g}]){user{name}}}",variables:{n:$n,p:$p,g:$g}}')" + resp="$(post "${TOKEN}" "${add}")" + if printf '%s' "${resp}" | jq -e '.data.addUser.user[0].name' >/dev/null 2>&1; then + echo " created ${name}" + else + upd="$(jq -n --arg n "${name}" --arg p "${pw}" --argjson g "${groupsref}" \ + '{query:"mutation($n:String!,$p:String!,$g:[GroupRef]){updateUser(input:{filter:{name:{eq:$n}},set:{password:$p,groups:$g}}){user{name}}}",variables:{n:$n,p:$p,g:$g}}')" + post "${TOKEN}" "${upd}" | jq -e '.data.updateUser.user[0].name' >/dev/null || { + echo "FATAL: could not upsert user ${name}." >&2; exit 1; } + echo " updated ${name}" + fi + done + + echo "ACL bootstrap complete." +{{- end }} diff --git a/charts/dgraph/templates/acl/bootstrap-job.yaml b/charts/dgraph/templates/acl/bootstrap-job.yaml new file mode 100644 index 000000000..b2ebf0a80 --- /dev/null +++ b/charts/dgraph/templates/acl/bootstrap-job.yaml @@ -0,0 +1,120 @@ +{{- if and .Values.alpha.acl.enabled .Values.alpha.acl.bootstrap.enabled }} +{{- /* + Resolve the Secret that holds the credentials the reconciler reads (groot and + per-user passwords). Defaults to the bootstrap-specific existingSecret, then the + ACL existingSecret, then the chart-created ACL Secret. +*/}} +{{- $bootstrap := .Values.alpha.acl.bootstrap }} +{{- $secretName := $bootstrap.existingSecret | default .Values.alpha.acl.existingSecret | default (printf "%s-acl-secret" (include "dgraph.alpha.fullname" .)) }} +{{- /* native TLS active (alpha.tls.enabled): the reconciler talks HTTPS to + Alpha's /admin and mounts the client cert. Reused at each TLS branch below. */}} +{{- $nativeTLS := .Values.alpha.tls.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "dgraph.alpha.fullname" . }}-acl-bootstrap + namespace: {{ include "dgraph.namespace" . }} + labels: + {{- include "dgraph.labels" (dict "ctx" . "component" "acl-bootstrap") | nindent 4 }} + annotations: + ## Run after Alpha/Zero are up, on both first install and every upgrade. + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-weight": "5" + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + backoffLimit: 6 + template: + metadata: + {{- with .Values.alpha.acl.bootstrap.rotation }} + annotations: + ## Changing acl.bootstrap.rotation changes this annotation, so a rotation + ## (e.g. a Terraform counter bump) produces a release diff that re-runs this + ## Job and its reconciler. The annotation lands only on the Job, so Alpha is + ## never restarted by a rotation. + dgraph.io/acl-rotation: {{ . | quote }} + {{- end }} + labels: + {{- include "dgraph.labels" (dict "ctx" . "component" "acl-bootstrap") | nindent 8 }} + spec: + restartPolicy: Never +{{- include "dgraph.imagePullSecrets" . | indent 6 }} + automountServiceAccountToken: false + ## Run alongside Alpha (same taint/label) so the Job can reach the service. + {{- if .Values.alpha.nodeSelector }} + nodeSelector: +{{ toYaml .Values.alpha.nodeSelector | indent 8 }} + {{- end }} + {{- if .Values.alpha.tolerations }} + tolerations: +{{ toYaml .Values.alpha.tolerations | indent 8 }} + {{- end }} + {{- if .Values.alpha.securityContext.enabled }} + securityContext: + {{- omit .Values.alpha.securityContext "enabled" | toYaml | nindent 8 }} + {{- end }} + containers: + - name: acl-bootstrap + {{- /* Use the override only when registry, repository, and tag are all set; a + partial override would render an invalid image reference, so fall back to + the shared dgraph image instead. */}} + {{- if and .Values.alpha.acl.bootstrap.image .Values.alpha.acl.bootstrap.image.registry .Values.alpha.acl.bootstrap.image.repository .Values.alpha.acl.bootstrap.image.tag }} + image: {{ printf "%s/%s:%s" .Values.alpha.acl.bootstrap.image.registry .Values.alpha.acl.bootstrap.image.repository (.Values.alpha.acl.bootstrap.image.tag | toString) }} + {{- else }} + image: {{ include "dgraph.image" . }} + {{- end }} + imagePullPolicy: {{ .Values.image.pullPolicy | quote }} + command: ["/usr/bin/bash", "/scripts/reconcile.sh"] + env: + - name: ALPHA_HOST + {{- if $nativeTLS }} + ## Native TLS: target the alpha-0 headless FQDN (…svc.) that + ## scripts/make_tls_secrets.sh lists in the cert SANs, so curl's hostname + ## verification of Alpha's server cert passes. The bare ClusterIP Service + ## name is not a SAN. + value: {{ printf "%s-0.%s-headless.%s.svc%s" (include "dgraph.alpha.fullname" .) (include "dgraph.alpha.fullname" .) (include "dgraph.namespace" .) (include "dgraph.domainSuffix" .) | quote }} + {{- else }} + value: {{ include "dgraph.alpha.fullname" . | quote }} + {{- end }} + - name: GROOT_PASSWORD_KEY + value: {{ .Values.alpha.acl.bootstrap.grootPasswordSecretKey | quote }} + - name: USERS_JSON + value: {{ .Values.alpha.acl.bootstrap.users | toJson | quote }} + - name: GROUPS_JSON + value: {{ .Values.alpha.acl.bootstrap.groups | toJson | quote }} + {{- if $nativeTLS }} + ## Native TLS (alpha.tls.enabled): reconcile.sh reads these to talk HTTPS + ## to Alpha's /admin and present a client cert under mutual TLS. + - name: CACERT_PATH + value: /dgraph/tls/ca.crt + {{- if .Values.alpha.tls.clientName }} + - name: CLIENT_CERT_PATH + value: /dgraph/tls/client.{{ .Values.alpha.tls.clientName }}.crt + - name: CLIENT_KEY_PATH + value: /dgraph/tls/client.{{ .Values.alpha.tls.clientName }}.key + {{- end }} + {{- end }} + volumeMounts: + - name: scripts + mountPath: /scripts + - name: creds + mountPath: /creds + readOnly: true + {{- if $nativeTLS }} + - name: tls-volume + mountPath: /dgraph/tls + readOnly: true + {{- end }} + volumes: + - name: scripts + configMap: + name: {{ include "dgraph.alpha.fullname" . }}-acl-bootstrap + defaultMode: 0555 + - name: creds + secret: + secretName: {{ $secretName }} + {{- if $nativeTLS }} + - name: tls-volume + secret: + secretName: {{ include "dgraph.alpha.fullname" . }}-tls-secret + {{- end }} +{{- end }} diff --git a/charts/dgraph/templates/validation/_pod.tpl b/charts/dgraph/templates/validation/_pod.tpl new file mode 100644 index 000000000..763452a37 --- /dev/null +++ b/charts/dgraph/templates/validation/_pod.tpl @@ -0,0 +1,111 @@ +{{/* +Shared pod spec for the dgraph validator, used by the `helm test` Pod, the +post-install hook Job, and the suspended manual CronJob. Emits the body of a pod +`spec:` at column 0; each caller includes it with `nindent` for its nesting. + +Transport mirrors the ACL bootstrap reconciler: under native TLS +(alpha.tls.enabled) the validator targets the alpha-0 headless FQDN (a cert SAN) +over HTTPS with the chart CA and optional client cert; otherwise it talks +plaintext to the ClusterIP Service. +*/}} +{{- define "dgraph.validation.podSpec" -}} +{{- $nativeTLS := .Values.alpha.tls.enabled -}} +{{- $credsSecret := .Values.alpha.acl.bootstrap.existingSecret | default .Values.alpha.acl.existingSecret | default (printf "%s-acl-secret" (include "dgraph.alpha.fullname" .)) -}} +restartPolicy: Never +{{- include "dgraph.imagePullSecrets" . | nindent 0 }} +{{- if .Values.validation.rbac.enabled }} +serviceAccountName: {{ include "dgraph.alpha.fullname" . }}-validate +{{- else }} +automountServiceAccountToken: false +{{- end }} +{{- $nodeSelector := .Values.validation.nodeSelector | default .Values.alpha.nodeSelector }} +{{- with $nodeSelector }} +nodeSelector: +{{- toYaml . | nindent 2 }} +{{- end }} +{{- $tolerations := .Values.validation.tolerations | default .Values.alpha.tolerations }} +{{- with $tolerations }} +tolerations: +{{- toYaml . | nindent 2 }} +{{- end }} +{{- if .Values.alpha.securityContext.enabled }} +securityContext: +{{- omit .Values.alpha.securityContext "enabled" | toYaml | nindent 2 }} +{{- end }} +containers: +- name: validate +{{- /* Use the override only when registry, repository, and tag are all set; a + partial override would render an invalid image reference, so fall back to + the shared dgraph image instead. */}} +{{- if and .Values.validation.image .Values.validation.image.registry .Values.validation.image.repository .Values.validation.image.tag }} + image: {{ printf "%s/%s:%s" .Values.validation.image.registry .Values.validation.image.repository (.Values.validation.image.tag | toString) }} +{{- else }} + image: {{ include "dgraph.image" . }} +{{- end }} + imagePullPolicy: {{ .Values.image.pullPolicy | quote }} + command: ["/usr/bin/bash", "/scripts/validate.sh"] + env: + - name: ALPHA_HOST +{{- if $nativeTLS }} + value: {{ printf "%s-0.%s-headless.%s.svc%s" (include "dgraph.alpha.fullname" .) (include "dgraph.alpha.fullname" .) (include "dgraph.namespace" .) (include "dgraph.domainSuffix" .) | quote }} +{{- else }} + value: {{ include "dgraph.alpha.fullname" . | quote }} +{{- end }} + - name: EXPECTED_JSON + value: /config/expected.json + - name: CREDS_DIR + value: /creds + - name: RETRIES + value: {{ .Values.validation.retries | quote }} + - name: RETRY_SLEEP + value: {{ .Values.validation.retrySleep | quote }} +{{- if $nativeTLS }} + - name: CACERT_PATH + value: /dgraph/tls/ca.crt +{{- if .Values.alpha.tls.clientName }} + - name: CLIENT_CERT_PATH + value: /dgraph/tls/client.{{ .Values.alpha.tls.clientName }}.crt + - name: CLIENT_KEY_PATH + value: /dgraph/tls/client.{{ .Values.alpha.tls.clientName }}.key +{{- end }} +{{- end }} + volumeMounts: + - name: scripts + mountPath: /scripts + - name: config + mountPath: /config +{{- if .Values.alpha.acl.enabled }} + - name: creds + mountPath: /creds + readOnly: true +{{- end }} +{{- if $nativeTLS }} + - name: tls-volume + mountPath: /dgraph/tls + readOnly: true +{{- end }} +volumes: +- name: scripts + configMap: + name: {{ include "dgraph.alpha.fullname" . }}-validate + defaultMode: 0555 + items: + - key: validate.sh + path: validate.sh +- name: config + secret: + secretName: {{ include "dgraph.alpha.fullname" . }}-validate + items: + - key: expected.json + path: expected.json +{{- if .Values.alpha.acl.enabled }} +- name: creds + secret: + secretName: {{ $credsSecret }} +{{- end }} +{{- if $nativeTLS }} +- name: tls-volume + secret: + secretName: {{ include "dgraph.alpha.fullname" . }}-tls-secret +{{- end }} +{{- end -}} diff --git a/charts/dgraph/templates/validation/configmap.yaml b/charts/dgraph/templates/validation/configmap.yaml new file mode 100644 index 000000000..34a8e5d21 --- /dev/null +++ b/charts/dgraph/templates/validation/configmap.yaml @@ -0,0 +1,26 @@ +{{- if .Values.validation.enabled }} +{{- /* + checkBackups reads the backup CronJobs via the Kubernetes API, which needs the + validator ServiceAccount/Role. Fail at render time rather than deploy a + validator that cannot satisfy the check it was asked to run. +*/}} +{{- if and .Values.validation.checkBackups (not .Values.validation.rbac.enabled) }} +{{- fail "validation.checkBackups requires validation.rbac.enabled: the backup-CronJob check reads the Kubernetes API with the validator ServiceAccount." }} +{{- end }} +{{- /* + The validator script only. The expected-state document it reads lives in a + Secret (templates/validation/secret.yaml), not here, because it names the + Secret keys that hold the admin/user passwords and so is kept out of a + world-readable ConfigMap. +*/}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "dgraph.alpha.fullname" . }}-validate + namespace: {{ include "dgraph.namespace" . }} + labels: + {{- include "dgraph.labels" (dict "ctx" . "component" "validate") | nindent 4 }} +data: + validate.sh: | +{{ .Files.Get "files/validation/validate.sh" | indent 4 }} +{{- end }} diff --git a/charts/dgraph/templates/validation/cronjob.yaml b/charts/dgraph/templates/validation/cronjob.yaml new file mode 100644 index 000000000..a8edf4520 --- /dev/null +++ b/charts/dgraph/templates/validation/cronjob.yaml @@ -0,0 +1,39 @@ +{{- if and .Values.validation.enabled .Values.validation.cronjob.enabled }} +{{- /* + Suspended manual-trigger template. It never fires on its own (suspended + + a never-occurring schedule); an operator runs it on demand via + `kubectl create job --from=cronjob/-alpha-validate ... -manual`. + NOTES.txt renders the exact command (with the release's name and namespace + substituted) on install; this is a template comment, so it is not the place + for a rendered command. +*/}} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "dgraph.alpha.fullname" . }}-validate + namespace: {{ include "dgraph.namespace" . }} + labels: + {{- include "dgraph.labels" (dict "ctx" . "component" "validate") | nindent 4 }} +spec: + schedule: "0 0 31 2 *" # 31 February: never fires. + suspend: true + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + metadata: + labels: + {{- include "dgraph.labels" (dict "ctx" . "component" "validate") | nindent 8 }} + spec: + backoffLimit: {{ .Values.validation.job.backoffLimit }} + template: + metadata: + labels: + {{- include "dgraph.labels" (dict "ctx" . "component" "validate") | nindent 12 }} + {{- with .Values.validation.podAnnotations }} + annotations: + {{- toYaml . | nindent 12 }} + {{- end }} + spec: + {{- include "dgraph.validation.podSpec" . | nindent 10 }} +{{- end }} diff --git a/charts/dgraph/templates/validation/job.yaml b/charts/dgraph/templates/validation/job.yaml new file mode 100644 index 000000000..61cc7dfe0 --- /dev/null +++ b/charts/dgraph/templates/validation/job.yaml @@ -0,0 +1,32 @@ +{{- if and .Values.validation.enabled .Values.validation.job.enabled }} +{{- /* + Post-install/upgrade hook Job that gates the release: a failed validation fails + the hook, which fails `helm install/upgrade` (and, under Terraform, the + helm_release with wait). Weight 10 runs it after the ACL bootstrap (weight 5) + so accounts exist first. A failed Job is kept (only hook-succeeded deletes) for + inspection; before-hook-creation clears the prior one on the next run. +*/}} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "dgraph.alpha.fullname" . }}-validate + namespace: {{ include "dgraph.namespace" . }} + labels: + {{- include "dgraph.labels" (dict "ctx" . "component" "validate") | nindent 4 }} + annotations: + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-weight": "10" + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + backoffLimit: {{ .Values.validation.job.backoffLimit }} + template: + metadata: + labels: + {{- include "dgraph.labels" (dict "ctx" . "component" "validate") | nindent 8 }} + {{- with .Values.validation.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- include "dgraph.validation.podSpec" . | nindent 6 }} +{{- end }} diff --git a/charts/dgraph/templates/validation/rbac.yaml b/charts/dgraph/templates/validation/rbac.yaml new file mode 100644 index 000000000..912e0f142 --- /dev/null +++ b/charts/dgraph/templates/validation/rbac.yaml @@ -0,0 +1,44 @@ +{{- if and .Values.validation.enabled .Values.validation.rbac.enabled }} +{{- /* + ServiceAccount + Role for the validator's backup-CronJob check (Check G reads + CronJobs via the Kubernetes API). Regular release resources, not hooks, so the + ServiceAccount exists before the post-install hook Job and persists for `helm + test`. Off by default (validation.rbac.enabled=false) for consumers who do not + want the validator granted API access. +*/}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "dgraph.alpha.fullname" . }}-validate + namespace: {{ include "dgraph.namespace" . }} + labels: + {{- include "dgraph.labels" (dict "ctx" . "component" "validate") | nindent 4 }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "dgraph.alpha.fullname" . }}-validate + namespace: {{ include "dgraph.namespace" . }} + labels: + {{- include "dgraph.labels" (dict "ctx" . "component" "validate") | nindent 4 }} +rules: +- apiGroups: ["batch"] + resources: ["cronjobs"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "dgraph.alpha.fullname" . }}-validate + namespace: {{ include "dgraph.namespace" . }} + labels: + {{- include "dgraph.labels" (dict "ctx" . "component" "validate") | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "dgraph.alpha.fullname" . }}-validate +subjects: +- kind: ServiceAccount + name: {{ include "dgraph.alpha.fullname" . }}-validate + namespace: {{ include "dgraph.namespace" . }} +{{- end }} diff --git a/charts/dgraph/templates/validation/secret.yaml b/charts/dgraph/templates/validation/secret.yaml new file mode 100644 index 000000000..cd1ee0831 --- /dev/null +++ b/charts/dgraph/templates/validation/secret.yaml @@ -0,0 +1,60 @@ +{{- if .Values.validation.enabled }} +{{- /* + The validator's expected state, derived from the chart's own values so it + cannot drift from what was deployed. Rendered into a Secret rather than a + ConfigMap: expected.json names the Secret keys that hold the admin/user + passwords (adminPasswordKey, users[].passwordKey), so it is credential-adjacent + and kept out of a world-readable ConfigMap. adminPasswordKey defaults to + groot's key (the always-present superadmin); set validation.adminUser / + adminPasswordSecretKey to validate as a different account. users carry their + bootstrap passwordSecretKey. +*/}} +{{- $adminUser := .Values.validation.adminUser | default "groot" }} +{{- $adminKey := .Values.validation.adminPasswordSecretKey }} +{{- if not $adminKey }} + {{- if eq $adminUser "groot" }} + {{- $adminKey = .Values.alpha.acl.bootstrap.grootPasswordSecretKey }} + {{- else }} + {{- $adminKey = printf "%s_password" $adminUser }} + {{- end }} +{{- end }} +{{- $users := list }} +{{- range .Values.alpha.acl.bootstrap.users }} + {{- $users = append $users (dict "name" .name "passwordKey" .passwordSecretKey) }} +{{- end }} +{{- $groups := list }} +{{- range .Values.alpha.acl.bootstrap.groups }} + {{- $groups = append $groups (dict "name" .name "rules" (.rules | default (list))) }} +{{- end }} +{{- $cronjobs := list }} +{{- if .Values.validation.checkBackups }} + {{- if .Values.backups.full.enabled }} + {{- $cronjobs = append $cronjobs (dict "name" (printf "%s-full" (include "dgraph.backups.fullname" .)) "schedule" .Values.backups.full.schedule) }} + {{- end }} + {{- if .Values.backups.incremental.enabled }} + {{- $cronjobs = append $cronjobs (dict "name" (printf "%s-inc" (include "dgraph.backups.fullname" .)) "schedule" .Values.backups.incremental.schedule) }} + {{- end }} +{{- end }} +{{- $expected := dict + "namespace" (include "dgraph.namespace" .) + "expectedAlphas" (.Values.alpha.replicaCount | int) + "expectedZeros" (.Values.zero.replicaCount | int) + "aclEnabled" .Values.alpha.acl.enabled + "adminUser" $adminUser + "adminPasswordKey" $adminKey + "users" $users + "groups" $groups + "backups" (dict "check" .Values.validation.checkBackups "roundtrip" .Values.validation.backupRoundtrip "cronjobs" $cronjobs) +}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "dgraph.alpha.fullname" . }}-validate + namespace: {{ include "dgraph.namespace" . }} + labels: + {{- include "dgraph.labels" (dict "ctx" . "component" "validate") | nindent 4 }} +type: Opaque +stringData: + expected.json: | + {{ $expected | toJson }} +{{- end }} diff --git a/charts/dgraph/templates/validation/test.yaml b/charts/dgraph/templates/validation/test.yaml new file mode 100644 index 000000000..e70b74ca0 --- /dev/null +++ b/charts/dgraph/templates/validation/test.yaml @@ -0,0 +1,21 @@ +{{- if .Values.validation.enabled }} +{{- /* + `helm test` entrypoint. Runs the validator on demand (helm test ) + without gating install/upgrade. +*/}} +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "dgraph.alpha.fullname" . }}-validate-test + namespace: {{ include "dgraph.namespace" . }} + labels: + {{- include "dgraph.labels" (dict "ctx" . "component" "validate") | nindent 4 }} + annotations: + "helm.sh/hook": test + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded + {{- with .Values.validation.podAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- include "dgraph.validation.podSpec" . | nindent 2 }} +{{- end }} diff --git a/charts/dgraph/values.yaml b/charts/dgraph/values.yaml index 80a8e1992..1d05abcd5 100644 --- a/charts/dgraph/values.yaml +++ b/charts/dgraph/values.yaml @@ -509,6 +509,42 @@ alpha: # ## Note that Kubernetes secrets must be base64-encoded # hmac_secret_file: MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMQo= + ## Bootstrap: a run-once-per-deploy Job (post-install/post-upgrade hook) that + ## rotates `groot` off its default password and converges the declared users and + ## groups. Idempotent, so it is safe on every helm upgrade. Requires acl.enabled + ## and a credentials Secret (acl.existingSecret or the chart-created ACL Secret, + ## holding groot and per-user passwords). Default off. + ## + ## Add an engineer: append a `users` entry with `groups: [guardians]` and add + ## their password under a new key in the credentials Secret (`passwordSecretKey`). + ## Add a service: add a `groups` entry with predicate `rules`, then a `users` + ## entry placed in that group. + bootstrap: + enabled: false + ## Secret holding the credentials the Job reads; defaults to acl.existingSecret + ## (else the chart-created ACL Secret). + existingSecret: "" + ## Key in the credentials Secret holding groot's target (rotated) password. + grootPasswordSecretKey: groot_password + ## Opaque rotation token rendered as a Job pod annotation. Change it (e.g. from + ## a Terraform counter) to force a helm upgrade that re-runs the reconciler, + ## without touching Alpha. Empty = no annotation. + rotation: "" + ## Image for the bootstrap Job. Empty (the default) reuses the deployed dgraph + ## image, which provides bash, curl, and jq. Set registry/repository/tag to pin + ## an explicit override: + # image: + # registry: docker.io + # repository: dgraph/dgraph + # tag: v25.3.8 + image: {} + ## Each group: { name, rules: [{ predicate, permission }] }. + ## permission is OR-summed: 1=read, 2=write, 4=modify. + groups: [] + ## Each user: { name, passwordSecretKey, groups: [ ... ] }. + ## `guardians` is the built-in superadmin group. + users: [] + ## Encryption at Rest Configuration ## ref: https://docs.dgraph.io/installation/configuration/encryption-at-rest encryption: @@ -859,6 +895,61 @@ backups: ## AWS_SECRET_ACCESS_KEY env var secret: "" +## Post-install validation subsystem. A validator asserts the running cluster +## matches what the chart rendered (health, membership, ACL enforcement, admin +## and per-user logins, group rules, and optionally backup CronJob schedules). +## Default off and inert on a stock install: nothing renders unless enabled. +validation: + ## Master switch for all validator resources (ConfigMap, test Pod, Job, CronJob, RBAC). + enabled: false + ## Validator image. Empty (the default) reuses the deployed dgraph image (which + ## bundles bash, curl, jq) so the validator always matches the running version. + ## Set all three fields below only to pin an explicit override: + # image: + # registry: docker.io + # repository: dgraph/dgraph + # tag: v25.3.8 + image: {} + ## Account the validator logs in as for auth-dependent checks. Defaults to the + ## groot superadmin. + adminUser: groot + ## Secret key holding adminUser's password. Empty derives it (groot's key, else + ## _password). + adminPasswordSecretKey: "" + ## The validator reads passwords from the ACL creds Secret (alpha.acl.bootstrap.existingSecret / + ## alpha.acl.existingSecret, else the chart-managed -acl-secret). An external creds Secret + ## must carry the password keys the validator reads — adminPasswordSecretKey (or groot's key) and each + ## bootstrap user's passwordSecretKey — otherwise the admin/user-login checks FAIL the gating hook. + ## Post-install/upgrade hook Job that gates the release on a passing validation. + ## Default off: a failed check would fail `helm install/upgrade`. + job: + enabled: false + ## Job backoffLimit (also used by the manual CronJob's jobTemplate). + backoffLimit: 1 + ## Suspended manual-trigger CronJob (kubectl create job --from=cronjob/...). + ## Default off. + cronjob: + enabled: false + ## RBAC for the backup-CronJob check (the validator reads CronJobs via the + ## Kubernetes API). Create the validator ServiceAccount/Role/RoleBinding. + ## Required by checkBackups. + rbac: + enabled: false + ## Also assert the backup CronJobs exist with their expected schedules (requires rbac.enabled). + checkBackups: false + ## Trigger a live backup round-trip to S3 (side-effecting, slow; reserved for future use). Default off. + backupRoundtrip: false + ## Per-check retry attempts before failing. + retries: 10 + ## Seconds between retries. + retrySleep: 12 + ## Extra annotations for validator pods. + podAnnotations: {} + ## nodeSelector for validator pods. Empty falls back to alpha.nodeSelector. + nodeSelector: {} + ## tolerations for validator pods. Empty falls back to alpha.tolerations. + tolerations: [] + global: domain: cluster.local ## Combined ingress resource for alpha and ratel services