From e2a6705aa2abfc9c91ac6bab413e9f4edd5cb284 Mon Sep 17 00:00:00 2001 From: as51340 Date: Thu, 15 Oct 2026 15:03:27 +0200 Subject: [PATCH] feat!: Reference Secrets through secretRef and record an Event per action Two changes to how the operator talks about what it touches. Secret references. The four bare secretName fields become a secretRef struct, the shape the Kubernetes API conventions recommend for a single-kind object reference, so a qualifier can be added later without breaking a stored object: - awsCredentials.secretName -> awsCredentials.secretRef.name - tls.bolt.secretName -> tls.bolt.secretRef.name - tls.intraCluster.secretName -> tls.intraCluster.secretRef.name - monitoring.vmagentRemote.remoteWrite.basicAuth.secretName and monitoring.vectorRemote.auth.secretName -> ....secretRef.name The struct carries name only. The Secret must still live in the cluster's namespace, because every one of them reaches the pods as a mount or an env reference, and a pod can only read Secrets of its own namespace; a namespace field would need the operator to copy the Secret. The secrets license block keeps the HA chart's name/licenseKey/organizationKey shape, already a struct. Events. The operator recorded what it did only in its own log, and the Bolt commands it sends to Memgraph are invisible to the Kubernetes audit log. It now records an events.k8s.io/v1 Event on the MemgraphCluster for every action, one reason per action, with the touched pod, claim or StatefulSet as the related object: CoordinatorAdded, InstanceRegistered, MainPromoted, MainPromotionRefused, MainDemoted, InstanceUnregistered, CoordinatorRemoved, LeadershipYielded, BoltServerUpdated, RegistrationFailed, SettingApplied, SettingRejected, CoordinatorSettingApplied, PodRestarted, StatefulSetShrunk, VolumeExpansionRequested and StatefulSetOrphaned. Registration Events come from a decorator over the leader's client, so they record what Memgraph accepted or refused rather than what the plan intended: a promotion tries its candidates inside one command, and only the client sees which one won. Values read from a Secret never reach an Event. Waiting records nothing; that is what the conditions report. The manager gains create and patch on events.k8s.io events. docs/events.md lists every reason. BREAKING CHANGE: the secretName fields are gone from the schema, so the API server prunes them. A cluster using awsCredentials, tls, or the monitoring basic-auth blocks must be rewritten to secretRef.name before the CRD is upgraded; tls.intraCluster in particular cannot be dropped and re-added on a live cluster, so stop the operator, upgrade the CRD, rewrite those clusters, then start the new operator. --- CLAUDE.md | 9 +- README.md | 2 +- api/v1alpha1/memgraphcluster_types.go | 46 ++-- api/v1alpha1/zz_generated.deepcopy.go | 19 ++ charts/memgraph-operator/Chart.yaml | 2 +- .../crds/memgraph.com_memgraphclusters.yaml | 109 ++++++---- .../memgraph-operator/rbac/manager-rules.yaml | 7 + cmd/main.go | 1 + .../bases/memgraph.com_memgraphclusters.yaml | 109 ++++++---- config/rbac/role.yaml | 7 + config/samples/v1alpha1_memgraphcluster.yaml | 16 +- docs/events.md | 55 +++++ docs/flags.md | 3 +- docs/monitoring.md | 10 +- docs/tls.md | 10 +- internal/controller/events.go | 198 ++++++++++++++++++ internal/controller/events_test.go | 69 ++++++ .../controller/memgraphcluster_controller.go | 45 +++- .../memgraphcluster_controller_test.go | 83 +++++++- .../memgraphcluster_validation_test.go | 18 +- internal/resources/monitoring_test.go | 2 +- internal/resources/resources.go | 10 +- internal/resources/statefulset_test.go | 12 +- internal/resources/topology_test.go | 2 +- internal/resources/vector_test.go | 4 +- internal/resources/vmagent_test.go | 4 +- test/e2e/memgraphcluster_test.go | 4 +- test/e2e/tls_test.go | 5 +- 28 files changed, 701 insertions(+), 160 deletions(-) create mode 100644 docs/events.md create mode 100644 internal/controller/events.go create mode 100644 internal/controller/events_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 384622f..2f9100c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,9 +67,9 @@ Test philosophy: assert external behavior, never internal call ordering or priva - **Memgraph flags** (`spec.flags.`, `map[string]string`, flag name without dashes to value; replaced the `extraArgs` list 2026-10-02) never travel as container args: each role's flags are rendered into a gflags flag file in a ConfigMap `--flags` (canonical underscore spelling, sorted, the operator's logging defaults `log_level=INFO`, `also_log_to_stderr=true`, `log_retention_days=35` as the base the map merges over) that the container loads with `--flag-file` as its **first** argument; the command line carries exactly the pinned flags admission denies in the map (ports, `bolt-address`, `monitoring-address`, `monitoring-port`, `metrics-port`, `metrics-format`, coordinator identity, `data-directory`, `log-file`, the TLS file flags) plus, on data instances with `spec.awsCredentials`, `--aws-access-key`/`--aws-secret-key`/`--aws-region`/`--aws-endpoint-url` as `$(MEMGRAPH_AWS_*)` references (see AWS credentials below). gflags processes `--flag-file` in argument order and takes the last occurrence, so a pinned flag always wins; an unknown name inside a flag file is silently ignored, a bad value is fatal. The pod template carries `memgraph.com/flags-restart-hash` over the **startup-only** lines only, so a startup-only change rolls through `internal/rollout` unchanged and a run-time change rolls nothing. Run-time flags (the sixteen in `internal/settings`, flag name ≠ setting name, copied from `src/flags/run_time_configurable.cpp` and pinned by a test) are applied with `SET DATABASE SETTING` on **every ready pod of both roles** in the converged branch before the roll picks a pod, each pod dialed over pod DNS (`resources.DataEndpoints` joined `CoordinatorEndpoints`), read-before-write, only keys present in the file. Memgraph refuses Bolt parameters in that grammar, so values are rendered as Cypher string literals (`memgraph.cypherString`). Failures ride `Converged`: `SettingsPending` (ready pod did not answer), `SettingsRejected` (pod, command, Memgraph's error verbatim); neither fails the pass or escalates to a restart. Removing a key issues no `SET` (Andi's call until core gains a reset): a non-persisted setting reverts on the next restart, a `kRestore` one keeps its last value; a removed override of an operator default does revert, because the file then says the default. - **What a key is, the operator asks Memgraph** (Andi's corrections 2026-10-02): there is no `coordinatorSettings` block. Coordinator settings (`enabled_reads_on_main`, `instance_down_timeout_sec`, ...) go under `flags.coordinators`, and a key is classified per pass by `settings.Classify` against `SHOW CONFIG` (flags, read from a pod on the current template; Memgraph hides `also_log_to_stderr`, `scheduler`, `license_key`, `organization_name` from it, the first two allowed, the last two denied) and `SHOW COORDINATOR SETTINGS` (coordinator settings), the coordinator settings read over the leader connection. Coordinator settings are `SET COORDINATOR SETTING` once (`settings.Diff`); unknown names are `Converged=False/UnknownFlags` and applied by nothing. The pod template's `memgraph.com/flags` annotation is per-key digests of every non-run-time key and `memgraph.com/template-hash` (`resources.PodTemplateHash`) hashes everything else, both pure functions of the spec; the **restart** decision is per pod: a pod is considered for exemption only when its template hash matches its role's (the flags are the one thing that changed), then `resources.ChangedFlags` between the pod's flags annotation and the template's, and a pod whose changed keys are all non-flags is marked `rollout.Pod.Current` and not restarted. `SHOW CONFIG` is local, not forwarded, so it is read from a ready pod already on its role's template (data first), the leader only as fallback: during an image upgrade the leader is the last pod on the old binary. Views unavailable with changed keys means `Updated=False/FlagsUnclassified` and no restart: never restart on a guess. Admission keeps shape checks for the eight known coordinator setting names and rejects them under `flags.data`. - **User containers** (`spec.userContainers.`, the HA chart's block) are raw `core/v1` Containers appended after the operator's own (Memgraph, then the uploader), stored schemaless like `extraVolumes` because a full Container schema per role would push the CRD past the client-side apply limit. The builder fills the restricted security context on a container that names none, so the chart's busybox example runs under PodSecurity `restricted`; a container naming its own keeps it. Name collisions and malformed entries surface as `ApplyFailed`, not at admission. **Init containers** (`spec.initContainers.`, the chart's block) are the same passthrough, with the same schemaless storage and the same security context fill, appended after the operator's own init containers (`init-sysctl`, `init-core-pattern`, `init-fix-perms`) in the chart's order, so a user's runs on volumes the ownership container already fixed. -- No secret material in spec or status; secrets are consumed by reference only. +- No secret material in spec or status; secrets are consumed by reference only. A reference to a Secret the pods consume is a `secretRef: {name}` struct (`SecretReference`, decided 2026-10-14), never a bare `secretName`, so a qualifier such as `namespace` can be added later without a breaking change; for now the Secret must sit in the cluster's namespace, because a pod mounts and reads into env only Secrets of its own. The `secrets` license block keeps the HA chart's `name`/`licenseKey`/`organizationKey` shape, already a struct. - **License renewal is live** (decided 2026-10-03, organization rule changed 2026-10-05): pods read the license Secret into env at start, so the operator reads it too and `SET DATABASE SETTING`s `organization.name` then `enterprise.license`, each only where it differs, on **every ready pod of both roles**, read-before-write against `SHOW DATABASE SETTINGS` (`settings.License`). It runs in `Reconcile` **before** registration and writes its own `LicenseApplied` condition (`LicenseMatchesSecret` / `LicensePending` / `LicenseRejected`), because an expired license is what stops convergence and every registration return overwrites `Converged`. Secret RBAC is `get;list;watch` only: a metadata-only watch (`WatchesMetadata`) maps a Secret to the clusters naming it, and the value is read uncached through `APIReader`; the value is never logged, evented or put in status. A different organization is SET like the license (Andi's call, no special case). A shorter-expiry replacement is assumed away for now. Core masks `enterprise.license` in its query log (Andi's fix), since Memgraph rejects Bolt parameters in the SET grammar. -- **AWS credentials** (`spec.awsCredentials.secretName`, decided 2026-10-05) carry all four AWS values from one Secret (Andi's call: region and endpoint too, so they left `spec.flags` and the run-time table) and follow the license on both paths, data instances only (coordinators run no S3 queries). Fixed, all-required keys `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_REGION`/`AWS_ENDPOINT_URL` (AWS SDK names; empty endpoint for AWS itself): required because Kubernetes leaves an unresolvable `$(VAR)` as literal text. Startup: env from `secretKeyRef` into `MEMGRAPH_AWS_*`, handed to the flags by Kubernetes `$(VAR)` expansion, appended after the pinned args; a flag is required because all four settings are `kRestore` (persisted across restarts and restored over Memgraph's own `AWS_ACCESS_KEY` env fallback, but not over an explicit flag), and Memgraph's gflags has no `--fromenv`/`--tryfromenv` (verified on 3.13.0). Live: `reconcileAWSCredentials` SETs `aws.access_key`/`aws.secret_key`/`aws.region`/`aws.endpoint_url` read-before-write on every ready data pod in the converged branch beside the run-time flags, reporting on `Converged` (`SettingsPending`/`SettingsRejected`, setting named, value never); the shared metadata Secret watch maps it too. Removing the block issues no SET, like removing a flag. All four `aws-*` flags are denied in `spec.flags`, `MEMGRAPH_AWS_*` in `extraEnv`. +- **AWS credentials** (`spec.awsCredentials.secretRef`, decided 2026-10-05) carry all four AWS values from one Secret (Andi's call: region and endpoint too, so they left `spec.flags` and the run-time table) and follow the license on both paths, data instances only (coordinators run no S3 queries). Fixed, all-required keys `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_REGION`/`AWS_ENDPOINT_URL` (AWS SDK names; empty endpoint for AWS itself): required because Kubernetes leaves an unresolvable `$(VAR)` as literal text. Startup: env from `secretKeyRef` into `MEMGRAPH_AWS_*`, handed to the flags by Kubernetes `$(VAR)` expansion, appended after the pinned args; a flag is required because all four settings are `kRestore` (persisted across restarts and restored over Memgraph's own `AWS_ACCESS_KEY` env fallback, but not over an explicit flag), and Memgraph's gflags has no `--fromenv`/`--tryfromenv` (verified on 3.13.0). Live: `reconcileAWSCredentials` SETs `aws.access_key`/`aws.secret_key`/`aws.region`/`aws.endpoint_url` read-before-write on every ready data pod in the converged branch beside the run-time flags, reporting on `Converged` (`SettingsPending`/`SettingsRejected`, setting named, value never); the shared metadata Secret watch maps it too. Removing the block issues no SET, like removing a flag. All four `aws-*` flags are denied in `spec.flags`, `MEMGRAPH_AWS_*` in `extraEnv`. - Storage is never deleted by the operator: no finalizer-based cleanup; PVC retention (deletion *and* scale-down) maps to the StatefulSet PVC retention policy (default `Retain`). The only cluster members the operator removes are the ones a lowered replica count retires; a coordinator removed from Raft keeps running and keeps its state on purpose, which is what makes re-growing onto a retained volume safe. - Workload pods: non-root uid 101 / gid 103 / fsGroup 103 unless `spec.securityContext` is written (an absent block is schema-defaulted to those three, so the stored object shows the identity; no field inside it has a default), in which case exactly its `runAsUser`/`runAsGroup`/`fsGroup` land and no other (an empty block leaves all three to the platform: OpenShift `restricted-v2` assigns them at admission and rejects named ones; `runAsNonRoot` and seccomp stay unconditional; not pinned, because moving the uid is what the ownership container is for), seccomp RuntimeDefault, all capabilities dropped, `terminationGracePeriodSeconds: 300` (a ceiling, not a delay — an instance killed mid-shutdown recovers from its WAL and lengthens the catch-up a roll waits on). The only privileged containers the operator builds are two init containers that write under `/proc/sys`, both on the cluster's own Memgraph image: `init-sysctl` (`spec.sysctlInitContainer`, the HA chart's block minus its image knobs and its `enabled`: presence-based, absent means off, so the sample and the quickstart example carry it) raises the node's `vm.max_map_count` to `maxMapCount` and only ever raises — the default 524288 is the floor Memgraph itself checks at startup, not the chart's 262144 — and `init-core-pattern` (`coreDumps.corePattern: Operator`) sets the core pattern for a role collecting dumps. Both are node properties no namespaced sysctl can set, so a PodSecurity `restricted` namespace must leave the block out and set `corePattern: Node`. A third init container, `init-fix-perms` (`spec.fixOwnershipInitContainer`, the HA chart's block minus its image knobs, its `enabled` and its uid/gid knobs; presence-based with no fields, off when absent like the chart), runs last on the same image as root with `CAP_CHOWN` alone, not privileged, and chowns the lib, log and core dumps mounts to the pod's identity (`runAsUser`, and `runAsGroup` or else `fsGroup`) for storage drivers that ignore `fsGroup` (rancher.io/local-path); `restricted` rejects it too since it runs as root, and a spec-level CEL rule rejects it beside a `securityContext` that names no uid or no group, because it cannot chown to an identity the platform assigns later. Every e2e namespace enforces `restricted` (`createClusterNamespace`), so the quickstart carries none of the three and one dedicated scenario in an unlabeled namespace (`test/e2e/init_containers_test.go`) proves all of them. - **Readiness probe only, no liveness or startup probe**, on both roles. Memgraph recovers every database inside the `DbmsHandler` constructor before any server starts, so during recovery nothing listens and no probe can tell slow from hung; a TCP liveness check could only kill a recovery that outlived a guessed budget (the HA chart's 2h), and a Bolt listener that disappears after startup means the process exited, which restarts the container anyway. A recovering pod is simply unready. The field converged on the same answer (ECK, CockroachDB, MongoDB Community run no liveness; Strimzi and cass-operator make it a process check; Percona deprecated its 7200s startup delay). Do not reintroduce them; a core health endpoint reporting recovery *progress* is the only thing that would justify a liveness check. @@ -79,8 +79,9 @@ Test philosophy: assert external behavior, never internal call ordering or priva - The operator never promotes a MAIN outside bootstrap and the scale-down handover: a roll deletes the MAIN's pod and lets the Raft coordinators promote. That is only safe on a Memgraph reporting an unreachable MAIN as `role=main, health=down` — a release that vacates the `main` row makes `planner.Plan` believe there is no MAIN and race the failover. - **The shrink is the one write with an expectation** (`internal/expectations`, decided 2026-10-09). The operator reads its StatefulSets from the informer cache, and `replicaCounts` applies `max(declared, cached spec.replicas)`, so a pass reading the StatefulSet from before `shedRetiredPods` lowered it would grow it back (and shrink it again once it found the member gone, the pod recreated in between). `shedRetiredPods` records the UID and generation the apply returned; `replicaCounts` marks a role whose cached StatefulSet is the same UID at a lower generation, and the pass ends before applying anything, waiting on the StatefulSet event (`Owns`). It is in-memory on purpose: a restarted operator lists into a fresh cache that already holds every write it made, so only the writing process can be ahead of its cache. Every other cached read is safe without one (the apply restates desired state, pod deletes carry a UID precondition, `rollout`'s `Stale` covers status, membership is read live over Bolt); a new write whose stale read would undo it, or a decision pushed into Memgraph from a cached count, needs one too. - **Conditions** (decided 2026-10-14): four, all normal-True. `Converged` (membership, storage, settings), `Updated` (the roll) and `LicenseApplied` each answer one question; `Ready` is their summary, because kstatus (and with it Flux health checks) decides on `Ready` alone for a custom resource. `Ready` is True only when the cluster serves (a MAIN behind a reachable leader) and the other three are True **for the current generation**; otherwise it carries the reason of the first that is not, in the order not serving, `LicenseApplied`, `Converged`, `Updated`, the message prefixed with the condition's name, and a True decided for an older generation makes it `Unknown/Reconciling`. Whether the cluster serves is a pass-local verdict (`conditionServing`, set where `Ready` used to be) that `summarizeReady` folds into `Ready` at the end of `Reconcile` and removes, so it never reaches the API; a pass that did not observe the cluster keeps a not-serving `Ready`. `status.main` is what says a MAIN is serving through a roll, scale or resize. Detail goes into reasons, never a new condition per activity (cass-operator's fourteen is the shape avoided). +- **Events** (decided 2026-10-14, `internal/controller/events.go`, `docs/events.md`): one `events.k8s.io/v1` Event on the `MemgraphCluster` per action, reason per action (cass-operator's shape, not Zalando's broad reasons), the touched pod/claim/StatefulSet as `related`; the survey of thirteen operators found every one that reports in-database actions does it this way and none keeps an audit log of its own. Registration Events come from `recordingClient`, a decorator over the leader's `memgraph.Client` for the plan's commands, so they record what Memgraph accepted or refused, not what the plan intended (only the client sees which promotion candidate won). Settings are recorded in `applySettings` through its `describe`, so secret-derived values never reach an Event. Never an Event for waiting (that is the conditions'), never a status history (`status.actions` is ClickHouse's shape, avoided). A new action the operator takes gets a reason here and a row in the doc. - Log messages follow Kubernetes style: capital first letter, no trailing period, past tense, object type named (see AGENTS.md for examples). - **External access** (`spec.externalAccess`, `type: LoadBalancer | Gateway`) is operator-owned on both sides: the external objects *and* the announced `bolt_server` of every exposed member. Both modes build the same Services, one shared by the coordinators plus one per data instance (selected by the StatefulSet pod-name label); with `LoadBalancer` they are the way in, with `Gateway` they are ClusterIPs behind one operator-owned Gateway (`gatewayClassName` required, listeners `coordinators-bolt` on 7687 and `data--bolt` on `dataPortBase + n`, default 9000) fed by one TCPRoute each. The address is discovered, never declared: external-dns hostname annotation (on the Service, or in Gateway mode on the TCPRoute, then the Gateway), then the reported hostname, then the IP, pod DNS until any exists (`Converged=False/ExternalAddressPending`). `{ordinal}` in per-instance annotation values is substituted with the pod ordinal and CEL requires it on a data hostname annotation. Only the bolt address ever leaves the cluster network; the operator itself always dials coordinators on pod DNS (`resources.CoordinatorEndpoints`), never through the LoadBalancer or Gateway. External objects follow the data pods the operator *runs* (applied count), are pruned by label once the spec stops describing them, and removing the block reverts every `bolt_server` to pod DNS. The Gateway API CRDs are never bundled: `main.go` discovers once at start (`controller.GatewayAPIServed`) whether `Gateway` and `TCPRoute` are served at `v1`, watches the kinds only if so, and a cluster asking for `Gateway` without that gets `Converged=False/ApplyFailed` naming what is missing. TCPRoute is `v1` only from Gateway API v1.6 (Envoy Gateway ≥ v1.9); older releases serve it as `v1alpha2` alone and v1.6 stops serving `v1alpha2`, so no single version works on both sides — the operator requires ≥ v1.6. The e2e suite gets LoadBalancer addresses from MetalLB and a Gateway from Envoy Gateway (`hack/kind-metallb.sh`, `hack/kind-envoy-gateway.sh`, run by `make setup-test-e2e`); envtest loads the Gateway API CRDs from the `sigs.k8s.io/gateway-api` module. -- **TLS** (`spec.tls`) is presence-based per mode and takes one shared `kubernetes.io/tls` Secret per mode for both roles, fixed keys, no key-name knobs: a StatefulSet has one pod template, and Memgraph verifies no member hostname, so per-pod identities buy nothing. `bolt` (`secretName`; `tls.crt`/`tls.key` mounted read-only at `/etc/memgraph/ssl`, `--bolt-cert-file`/`--bolt-key-file`) also moves 9091 to https because Memgraph serves metrics from the Bolt server context. Everything downstream is derived, never a knob: the operator dials `bolt+ssc` (TLS, unverified — verification would force pod-DNS SANs and a CA onto a user-facing cert and buys nothing while Bolt is unauthenticated; Bolt auth is the trigger to revisit) and falls back to the other scheme once, because a roll turning TLS on or off leaves coordinators in the old mode while the spec says the new one (`memgraph.dialSchemes`); the ServiceMonitor switches to https with `insecureSkipVerify`. Probes are TCP and untouched. Rotation is not the operator's: the Secret is mounted without `subPath` so the kubelet refreshes it, and Memgraph picks it up on `RELOAD BOLT SERVER TLS` until the core reloads by mtime (planned upstream); the operator watches no Secret and never restarts for an in-place update, and a `secretName` change is an ordinary roll. `intraCluster` (`secretName`; `tls.crt`/`tls.key`/`ca.crt` mounted read-only at `/etc/memgraph/intra_cluster_tls`, the three `--cluster-*-file` flags always together) is mTLS proving membership, not identity: Memgraph verifies the chain and demands a client cert but checks no hostname, so one shared cert is the shape (nine of thirteen surveyed operators do the same). Its presence is pinned by a CEL transition rule on `MemgraphClusterSpec`, not on `TLSSpec`, because a rule on the block does not fire when the block is added or removed whole: a member with it cannot talk to one without, so a one-pod-at-a-time roll deadlocks on lag. `secretName` changes roll normally; a CA cut-over needs a bundled `ca.crt` in both Secrets, documented, not detected. Renewal is `RELOAD INTRA_CLUSTER TLS` per instance until the core reloads by mtime (Raft already does). +- **TLS** (`spec.tls`) is presence-based per mode and takes one shared `kubernetes.io/tls` Secret per mode for both roles, fixed keys, no key-name knobs: a StatefulSet has one pod template, and Memgraph verifies no member hostname, so per-pod identities buy nothing. `bolt` (`secretRef`; `tls.crt`/`tls.key` mounted read-only at `/etc/memgraph/ssl`, `--bolt-cert-file`/`--bolt-key-file`) also moves 9091 to https because Memgraph serves metrics from the Bolt server context. Everything downstream is derived, never a knob: the operator dials `bolt+ssc` (TLS, unverified — verification would force pod-DNS SANs and a CA onto a user-facing cert and buys nothing while Bolt is unauthenticated; Bolt auth is the trigger to revisit) and falls back to the other scheme once, because a roll turning TLS on or off leaves coordinators in the old mode while the spec says the new one (`memgraph.dialSchemes`); the ServiceMonitor switches to https with `insecureSkipVerify`. Probes are TCP and untouched. Rotation is not the operator's: the Secret is mounted without `subPath` so the kubelet refreshes it, and Memgraph picks it up on `RELOAD BOLT SERVER TLS` until the core reloads by mtime (planned upstream); the operator watches no Secret and never restarts for an in-place update, and a `secretRef` change is an ordinary roll. `intraCluster` (`secretRef`; `tls.crt`/`tls.key`/`ca.crt` mounted read-only at `/etc/memgraph/intra_cluster_tls`, the three `--cluster-*-file` flags always together) is mTLS proving membership, not identity: Memgraph verifies the chain and demands a client cert but checks no hostname, so one shared cert is the shape (nine of thirteen surveyed operators do the same). Its presence is pinned by a CEL transition rule on `MemgraphClusterSpec`, not on `TLSSpec`, because a rule on the block does not fire when the block is added or removed whole: a member with it cannot talk to one without, so a one-pod-at-a-time roll deadlocks on lag. `secretRef` changes roll normally; a CA cut-over needs a bundled `ca.crt` in both Secrets, documented, not detected. Renewal is `RELOAD INTRA_CLUSTER TLS` per instance until the core reloads by mtime (Raft already does). - **Scheduling** (`spec.scheduling`) restates the HA chart's `affinity` block in the shape the survey of sixteen operators converged on (CloudNativePG's): one rule the operator writes, `podAntiAffinity` (`type: preferred | required`, `scope: role | cluster`, `topologyKey`), presence-based like every other optional block, so an absent block writes no rule at all and `{}` is the chart's soft per-role default; plus per role the raw `core/v1` surface (`nodeSelector`, `tolerations`, `topologySpreadConstraints`, `podAntiAffinity`, `priorityClassName`) passed through as written. The chart's `parity` is `required`/`role`, its `unique` is `required`/`cluster`, its `nodeSelection` is a per-role `nodeSelector`. Two builder conveniences: a spread constraint naming no `labelSelector` gets the role's selector, and role anti-affinity terms are *appended* to the operator's rule list by list, never merged or replacing it. Nothing is pinned and the controller does nothing new: a change is a pod-template change the roll carries. The CRD's compact JSON must stay under the 256KB client-side apply limit; the `core/v1` schemas here cost about 47KB of it. -- **Monitoring** (`spec.monitoring`) is OpenMetrics only, and the endpoint is unconditional: both roles declare a `metrics` port on 9091 (container and headless Service) and pin `--metrics-port=9091 --metrics-format=OpenMetrics`, because Memgraph's enterprise build serves it regardless and a user-owned ServiceMonitor/PodMonitor must work with an empty spec. The block only creates objects for a stack the user runs, each presence-based like `externalAccess` (no `enabled`, no `namespace`: owner references cannot cross namespaces, and Prometheus Operator's `serviceMonitorNamespaceSelector` covers it). `serviceMonitor` (`labels`, `annotations`, `interval`) builds one typed ServiceMonitor from the `prometheus-operator/.../pkg/apis/monitoring` module, selecting both headless Services by identity labels on port `metrics`; the external Services carry no such port and yield no targets. The CRD is never bundled: `main.go` discovers it once at start (`controller.ServiceMonitorServed`), the kind is owned and listed only if served, and a cluster asking for it without gets `Converged=False/ApplyFailed`. Monitoring objects carry the `memgraph.com/monitoring` marker and are pruned by it through the shared `pruneKind`, separately from the external-access marker. The module ships no CRD, so `test/crds/` carries the ServiceMonitor CRD YAML at the module's version: envtest loads it, `hack/kind-prometheus-crds.sh` applies it in the e2e Kind cluster, and no Prometheus runs in the suite. The endpoint's `scheme` follows `spec.tls.bolt` (https with `insecureSkipVerify` when set), never a knob. `grafanaDashboard` (`labels`, default `grafana_dashboard: "1"` and a named set *replaces* it; `annotations` for `grafana_folder`) builds one ConfigMap `-grafana-dashboard` holding the HA chart's `memgraph_openmetrics.json`, `go:embed`ded from `internal/resources/dashboards/` and updated by copying the chart's file over it; it is applied every pass like everything else (no skip-if-unchanged path), and the ConfigMap informer is label-scoped in `main.go` like the Pod one because every namespace has ConfigMaps. `vmagentRemote` (the HA chart's block, and how Memgraph monitors customer clusters: the customer's cluster pushes metrics outbound to the VictoriaMetrics Memgraph runs, since Memgraph cannot scrape into their network; `remoteWrite.url` required, `remoteWrite.basicAuth.secretName`, `scrapeInterval` default `15s`, `externalLabels` CEL-checked as Prometheus label names, `image.{repository,tag,pullPolicy}`, `resources`; no `enabled`, `namespace`, `httpPort` or key-name knobs) runs one vmagent Deployment `-vmagent` plus a ConfigMap `-vmagent-config` holding a `scrape.yml` rendered by marshalling a typed struct (`internal/resources/vmagent.go`): one job `memgraph` with static pod-DNS targets on 9091 at the **applied** counts (so `desiredMonitoring` takes `replicaCounts`), scheme following `spec.tls.bolt`, re-read by vmagent through `-promscrape.configCheckInterval=1m` so a scale changes no pod. The basic-auth Secret has fixed `kubernetes.io/basic-auth` keys, feeds `-remoteWrite.basicAuth.username=$(VMAGENT_BASIC_AUTH_USERNAME)` from an env var and `-remoteWrite.basicAuth.passwordFile` from a read-only mount (re-read every second, never on the command line; the pinned image has no `usernameFile`); the operator reads no Secret but the license one. The pod runs as uid 65534 under `restrictedSecurityContext()` with no ServiceAccount token because the image names no user; the chart's `kubernetes.*` infra-scraping branch is deliberately out (cluster-scoped RBAC no owner reference collects). Deployments joined the RBAC markers, `Owns`, the label-scoped cache in `main.go` and `pruneMonitoring`. The e2e deploys a single-node VictoriaMetrics in the cluster namespace and asserts every instance's `up` series arrives with the external label. `vectorRemote` (the chart's block, the logs half of the same remote monitoring: `logsEndpoint` required, a base URL Vector appends `/loki/api/v1/push` to; `auth.secretName` with the same fixed keys, both reaching Vector as env vars it interpolates with `${LOGS_USERNAME}`/`${LOGS_PASSWORD}`; `extraLabels`; `image`; `resources`; no per-role switches, `websocketPort`, `namespace` or logs-dashboard ConfigMap) adds a `vector` sidecar after the uploader and before user containers on **both** roles (`internal/resources/vector.go`, hooked in `podContainers`/`podVolumes`) on one shared ConfigMap `-vector-config` (`vector.yaml` marshalled from a typed struct; per-pod values come through env: `ROLE`, downward-API `POD_NAME`/`POD_NAMESPACE`). Source is Vector's `websocket` on `ws://127.0.0.1:7444`, Memgraph's always-on monitoring websocket (both roles, no off switch, streams `{"event":"log","level":…,"message":…}` with only quotes and newlines escaped, so the VRL remap keeps unparseable frames whole with `level=unknown`; `warning`→`warn`, `critical`→`fatal` like the chart); with `tls.bolt` it is `wss` with `tls.enabled: true` and verification off, because the websocket shares the Bolt TLS context and Vector does not infer TLS from the scheme (smoke-tested). Loki sink labels are the chart's (`app`/`job` memgraph, `role`, `namespace`, `pod`, `level`) plus `extraLabels`, `healthcheck` off, `remove_label_fields`. Vector retries a refused websocket itself, so it may start before Memgraph listens; the sidecar has no probe. Adding/removing the block is a roll; the e2e deploys a single-node VictoriaLogs, watches the roll in and out, and asserts every pod's lines arrive via LogsQL `stats by (pod)`. +- **Monitoring** (`spec.monitoring`) is OpenMetrics only, and the endpoint is unconditional: both roles declare a `metrics` port on 9091 (container and headless Service) and pin `--metrics-port=9091 --metrics-format=OpenMetrics`, because Memgraph's enterprise build serves it regardless and a user-owned ServiceMonitor/PodMonitor must work with an empty spec. The block only creates objects for a stack the user runs, each presence-based like `externalAccess` (no `enabled`, no `namespace`: owner references cannot cross namespaces, and Prometheus Operator's `serviceMonitorNamespaceSelector` covers it). `serviceMonitor` (`labels`, `annotations`, `interval`) builds one typed ServiceMonitor from the `prometheus-operator/.../pkg/apis/monitoring` module, selecting both headless Services by identity labels on port `metrics`; the external Services carry no such port and yield no targets. The CRD is never bundled: `main.go` discovers it once at start (`controller.ServiceMonitorServed`), the kind is owned and listed only if served, and a cluster asking for it without gets `Converged=False/ApplyFailed`. Monitoring objects carry the `memgraph.com/monitoring` marker and are pruned by it through the shared `pruneKind`, separately from the external-access marker. The module ships no CRD, so `test/crds/` carries the ServiceMonitor CRD YAML at the module's version: envtest loads it, `hack/kind-prometheus-crds.sh` applies it in the e2e Kind cluster, and no Prometheus runs in the suite. The endpoint's `scheme` follows `spec.tls.bolt` (https with `insecureSkipVerify` when set), never a knob. `grafanaDashboard` (`labels`, default `grafana_dashboard: "1"` and a named set *replaces* it; `annotations` for `grafana_folder`) builds one ConfigMap `-grafana-dashboard` holding the HA chart's `memgraph_openmetrics.json`, `go:embed`ded from `internal/resources/dashboards/` and updated by copying the chart's file over it; it is applied every pass like everything else (no skip-if-unchanged path), and the ConfigMap informer is label-scoped in `main.go` like the Pod one because every namespace has ConfigMaps. `vmagentRemote` (the HA chart's block, and how Memgraph monitors customer clusters: the customer's cluster pushes metrics outbound to the VictoriaMetrics Memgraph runs, since Memgraph cannot scrape into their network; `remoteWrite.url` required, `remoteWrite.basicAuth.secretRef`, `scrapeInterval` default `15s`, `externalLabels` CEL-checked as Prometheus label names, `image.{repository,tag,pullPolicy}`, `resources`; no `enabled`, `namespace`, `httpPort` or key-name knobs) runs one vmagent Deployment `-vmagent` plus a ConfigMap `-vmagent-config` holding a `scrape.yml` rendered by marshalling a typed struct (`internal/resources/vmagent.go`): one job `memgraph` with static pod-DNS targets on 9091 at the **applied** counts (so `desiredMonitoring` takes `replicaCounts`), scheme following `spec.tls.bolt`, re-read by vmagent through `-promscrape.configCheckInterval=1m` so a scale changes no pod. The basic-auth Secret has fixed `kubernetes.io/basic-auth` keys, feeds `-remoteWrite.basicAuth.username=$(VMAGENT_BASIC_AUTH_USERNAME)` from an env var and `-remoteWrite.basicAuth.passwordFile` from a read-only mount (re-read every second, never on the command line; the pinned image has no `usernameFile`); the operator reads no Secret but the license one. The pod runs as uid 65534 under `restrictedSecurityContext()` with no ServiceAccount token because the image names no user; the chart's `kubernetes.*` infra-scraping branch is deliberately out (cluster-scoped RBAC no owner reference collects). Deployments joined the RBAC markers, `Owns`, the label-scoped cache in `main.go` and `pruneMonitoring`. The e2e deploys a single-node VictoriaMetrics in the cluster namespace and asserts every instance's `up` series arrives with the external label. `vectorRemote` (the chart's block, the logs half of the same remote monitoring: `logsEndpoint` required, a base URL Vector appends `/loki/api/v1/push` to; `auth.secretRef` with the same fixed keys, both reaching Vector as env vars it interpolates with `${LOGS_USERNAME}`/`${LOGS_PASSWORD}`; `extraLabels`; `image`; `resources`; no per-role switches, `websocketPort`, `namespace` or logs-dashboard ConfigMap) adds a `vector` sidecar after the uploader and before user containers on **both** roles (`internal/resources/vector.go`, hooked in `podContainers`/`podVolumes`) on one shared ConfigMap `-vector-config` (`vector.yaml` marshalled from a typed struct; per-pod values come through env: `ROLE`, downward-API `POD_NAME`/`POD_NAMESPACE`). Source is Vector's `websocket` on `ws://127.0.0.1:7444`, Memgraph's always-on monitoring websocket (both roles, no off switch, streams `{"event":"log","level":…,"message":…}` with only quotes and newlines escaped, so the VRL remap keeps unparseable frames whole with `level=unknown`; `warning`→`warn`, `critical`→`fatal` like the chart); with `tls.bolt` it is `wss` with `tls.enabled: true` and verification off, because the websocket shares the Bolt TLS context and Vector does not infer TLS from the scheme (smoke-tested). Loki sink labels are the chart's (`app`/`job` memgraph, `role`, `namespace`, `pod`, `level`) plus `extraLabels`, `healthcheck` off, `remove_label_fields`. Vector retries a refused websocket itself, so it may start before Memgraph listens; the sidecar has no probe. Adding/removing the block is a roll; the e2e deploys a single-node VictoriaLogs, watches the roll in and out, and asserts every pod's lines arrive via LogsQL `stats by (pod)`. diff --git a/README.md b/README.md index 27e9400..e7e601a 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ The MVP is deliberately "provision, bootstrap, observe". It does: - **serve OpenMetrics** from every instance on port 9091, declared on the pods and the headless Services and pinned to that format, so a Prometheus you already run scrapes the cluster with a ServiceMonitor, PodMonitor or scrape config of your own — and, on request, create the ServiceMonitor for a Prometheus Operator and the "Memgraph OpenMetrics" dashboard ConfigMap for a Grafana sidecar, or run the vmagent that pushes every instance's metrics to the VictoriaMetrics Memgraph runs and the Vector sidecars that push every instance's logs to its VictoriaLogs, so Memgraph can monitor the cluster without reaching into your network (any Prometheus remote-write and any Loki endpoint work), with `spec.monitoring` (see [`docs/monitoring.md`](docs/monitoring.md)); - **serve Bolt and the metrics endpoint over TLS** from a `kubernetes.io/tls` Secret you name in `spec.tls.bolt`, on both roles, with the operator's own dials and the ServiceMonitor following suit, and **authenticate the members to each other over mutual TLS** from a Secret with `ca.crt` you name in `spec.tls.intraCluster`, decided at creation (see [`docs/tls.md`](docs/tls.md)); - **keep the pods of a role on distinct nodes**, softly by default, as hard as `required` with scope `role` (the HA chart's `parity`) or `cluster` (its `unique`) on request, and pass a node selector, tolerations, topology spread constraints and a priority class through per role with `spec.scheduling` (see [`docs/scheduling.md`](docs/scheduling.md)); -- report the observed MAIN, the registered member counts, the external addresses, and the readiness and convergence conditions on the resource's status. +- report the observed MAIN, the registered member counts, the external addresses, and the readiness and convergence conditions on the resource's status, and record an Event on it for every action it takes: each Bolt command Memgraph accepts or refuses, each setting it writes, each pod it restarts (see [`docs/events.md`](docs/events.md)). Scaling is one edit, and `Converged` tells you when it is finished: diff --git a/api/v1alpha1/memgraphcluster_types.go b/api/v1alpha1/memgraphcluster_types.go index cf66757..fe76443 100644 --- a/api/v1alpha1/memgraphcluster_types.go +++ b/api/v1alpha1/memgraphcluster_types.go @@ -530,6 +530,20 @@ type SecretsSpec struct { OrganizationKey string `json:"organizationKey,omitempty"` } +// SecretReference names a Secret the cluster's pods consume. It is a struct +// rather than a bare name so that a field qualifying the reference, such as a +// namespace, can be added later without breaking a stored object. Today the +// Secret must live in the cluster's namespace: a pod mounts and reads into its +// environment only Secrets of its own namespace. +type SecretReference struct { + // name is the name of the Secret in the cluster's namespace. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` + // +required + Name string `json:"name"` +} + // AWSCredentialsSpec references an existing Kubernetes Secret holding the AWS // configuration of the data instances, all four values under fixed keys: // AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION and AWS_ENDPOINT_URL. @@ -551,15 +565,12 @@ type SecretsSpec struct { // values until SET DATABASE SETTING clears them, for example // SET DATABASE SETTING "aws.access_key" TO "". type AWSCredentialsSpec struct { - // secretName names a Secret in the cluster's namespace holding the access + // secretRef names a Secret in the cluster's namespace holding the access // key under AWS_ACCESS_KEY_ID, the secret key under AWS_SECRET_ACCESS_KEY, // the region under AWS_REGION and the endpoint URL under // AWS_ENDPOINT_URL. - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=253 - // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` // +required - SecretName string `json:"secretName"` + SecretRef SecretReference `json:"secretRef"` } // RoleStorageSpec configures the PersistentVolumeClaim every pod of a role @@ -1725,13 +1736,10 @@ type VMAgentImageSpec struct { // reach it through environment variables, which Vector interpolates into its // configuration. type BasicAuthSecretSpec struct { - // secretName is the name of the Secret in the cluster's namespace holding - // the keys "username" and "password". - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=253 - // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` + // secretRef names a Secret in the cluster's namespace holding the keys + // "username" and "password". // +required - SecretName string `json:"secretName"` + SecretRef SecretReference `json:"secretRef"` } // RemoteWriteSpec is where vmagent ships what it scrapes. @@ -1956,16 +1964,13 @@ type MonitoringSpec struct { // every instance. Pointing at a differently named Secret is a pod-template // change and rolls the cluster instead. type BoltTLSSpec struct { - // secretName names a Secret in the cluster's namespace holding the + // secretRef names a Secret in the cluster's namespace holding the // certificate under tls.crt and the private key under tls.key — the shape // of a kubernetes.io/tls Secret, which kubectl create secret tls and a // cert-manager Certificate both produce. Both roles mount it read-only and // serve Bolt and metrics with it. - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=253 - // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` // +required - SecretName string `json:"secretName"` + SecretRef SecretReference `json:"secretRef"` } // IntraClusterTLSSpec makes the members of the cluster talk to each other @@ -1987,7 +1992,7 @@ type BoltTLSSpec struct { // the still-plaintext MAIN can no longer replicate to it, and lag never // converges. Adding or removing the block on a live cluster is therefore // refused at admission (the rule lives on the spec, where both the old and -// the new tls block are in view). Changing secretName inside the block is +// the new tls block are in view). Pointing secretRef at another Secret is // allowed and rolls the cluster; both Secrets must then chain to a CA the // other side trusts, so a CA cut-over needs ca.crt in both to be a bundle of // the old and the new CA. @@ -1996,16 +2001,13 @@ type BoltTLSSpec struct { // in-place update reaches every pod's files, Raft picks them up on its own // and the rest on RELOAD INTRA_CLUSTER TLS, issued on every instance. type IntraClusterTLSSpec struct { - // secretName names a Secret in the cluster's namespace holding the + // secretRef names a Secret in the cluster's namespace holding the // certificate under tls.crt, the private key under tls.key and the CA to // verify peers against under ca.crt — what a cert-manager Certificate // issued by a private CA writes. Every pod of both roles mounts it // read-only. - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=253 - // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` // +required - SecretName string `json:"secretName"` + SecretRef SecretReference `json:"secretRef"` } // TLSSpec holds the cluster's TLS modes, each an optional presence-based block diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index af34767..da53895 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -29,6 +29,7 @@ import ( // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AWSCredentialsSpec) DeepCopyInto(out *AWSCredentialsSpec) { *out = *in + out.SecretRef = in.SecretRef } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSCredentialsSpec. @@ -44,6 +45,7 @@ func (in *AWSCredentialsSpec) DeepCopy() *AWSCredentialsSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BasicAuthSecretSpec) DeepCopyInto(out *BasicAuthSecretSpec) { *out = *in + out.SecretRef = in.SecretRef } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BasicAuthSecretSpec. @@ -59,6 +61,7 @@ func (in *BasicAuthSecretSpec) DeepCopy() *BasicAuthSecretSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BoltTLSSpec) DeepCopyInto(out *BoltTLSSpec) { *out = *in + out.SecretRef = in.SecretRef } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BoltTLSSpec. @@ -476,6 +479,7 @@ func (in *InitContainersSpec) DeepCopy() *InitContainersSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IntraClusterTLSSpec) DeepCopyInto(out *IntraClusterTLSSpec) { *out = *in + out.SecretRef = in.SecretRef } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IntraClusterTLSSpec. @@ -956,6 +960,21 @@ func (in *SchedulingSpec) DeepCopy() *SchedulingSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretReference) DeepCopyInto(out *SecretReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretReference. +func (in *SecretReference) DeepCopy() *SecretReference { + if in == nil { + return nil + } + out := new(SecretReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SecretsSpec) DeepCopyInto(out *SecretsSpec) { *out = *in diff --git a/charts/memgraph-operator/Chart.yaml b/charts/memgraph-operator/Chart.yaml index 75ae856..9425432 100644 --- a/charts/memgraph-operator/Chart.yaml +++ b/charts/memgraph-operator/Chart.yaml @@ -4,7 +4,7 @@ description: Kubernetes operator for Memgraph high-availability clusters type: application # version is the chart version, appVersion the operator version it installs. # Both move together with the git tag at release time. -version: 1.0.1-rc.3 +version: 1.0.1-rc.4 appVersion: "1.0.1-rc.1" # The CustomResourceDefinition uses CEL validation rules # (x-kubernetes-validations), available from Kubernetes 1.25. diff --git a/charts/memgraph-operator/crds/memgraph.com_memgraphclusters.yaml b/charts/memgraph-operator/crds/memgraph.com_memgraphclusters.yaml index 462ccce..8c82f01 100644 --- a/charts/memgraph-operator/crds/memgraph.com_memgraphclusters.yaml +++ b/charts/memgraph-operator/crds/memgraph.com_memgraphclusters.yaml @@ -77,18 +77,25 @@ spec: AWS integration (LOAD PARQUET and the other S3 readers). Absent, the operator sets none of them. properties: - secretName: + secretRef: description: |- - secretName names a Secret in the cluster's namespace holding the access + secretRef names a Secret in the cluster's namespace holding the access key under AWS_ACCESS_KEY_ID, the secret key under AWS_SECRET_ACCESS_KEY, the region under AWS_REGION and the endpoint URL under AWS_ENDPOINT_URL. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string + properties: + name: + description: name is the name of the Secret in the cluster's + namespace. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object required: - - secretName + - secretRef type: object clusterDomain: default: cluster.local @@ -1051,16 +1058,23 @@ spec: auth names the Secret Vector authenticates to the endpoint with. Absent, the endpoint is pushed to unauthenticated. properties: - secretName: + secretRef: description: |- - secretName is the name of the Secret in the cluster's namespace holding - the keys "username" and "password". - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string + secretRef names a Secret in the cluster's namespace holding the keys + "username" and "password". + properties: + name: + description: name is the name of the Secret in the + cluster's namespace. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object required: - - secretName + - secretRef type: object extraLabels: additionalProperties: @@ -1254,16 +1268,23 @@ spec: basicAuth names the Secret vmagent authenticates to the endpoint with. Absent, the endpoint is written to unauthenticated. properties: - secretName: + secretRef: description: |- - secretName is the name of the Secret in the cluster's namespace holding - the keys "username" and "password". - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string + secretRef names a Secret in the cluster's namespace holding the keys + "username" and "password". + properties: + name: + description: name is the name of the Secret in + the cluster's namespace. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object required: - - secretName + - secretRef type: object url: description: |- @@ -3059,19 +3080,26 @@ spec: bolt serves Bolt and the metrics endpoint over TLS on both roles, from the certificate in the named Secret. properties: - secretName: + secretRef: description: |- - secretName names a Secret in the cluster's namespace holding the + secretRef names a Secret in the cluster's namespace holding the certificate under tls.crt and the private key under tls.key — the shape of a kubernetes.io/tls Secret, which kubectl create secret tls and a cert-manager Certificate both produce. Both roles mount it read-only and serve Bolt and metrics with it. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string + properties: + name: + description: name is the name of the Secret in the cluster's + namespace. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object required: - - secretName + - secretRef type: object intraCluster: description: |- @@ -3079,19 +3107,26 @@ spec: the certificate and CA in the named Secret. It cannot be added to or removed from a live cluster; see IntraClusterTLSSpec. properties: - secretName: + secretRef: description: |- - secretName names a Secret in the cluster's namespace holding the + secretRef names a Secret in the cluster's namespace holding the certificate under tls.crt, the private key under tls.key and the CA to verify peers against under ca.crt — what a cert-manager Certificate issued by a private CA writes. Every pod of both roles mounts it read-only. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string + properties: + name: + description: name is the name of the Secret in the cluster's + namespace. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object required: - - secretName + - secretRef type: object type: object userContainers: diff --git a/charts/memgraph-operator/rbac/manager-rules.yaml b/charts/memgraph-operator/rbac/manager-rules.yaml index 45930ca..d5027e7 100644 --- a/charts/memgraph-operator/rbac/manager-rules.yaml +++ b/charts/memgraph-operator/rbac/manager-rules.yaml @@ -66,6 +66,13 @@ - list - patch - watch +- apiGroups: + - events.k8s.io + resources: + - events + verbs: + - create + - patch - apiGroups: - gateway.networking.k8s.io resources: diff --git a/cmd/main.go b/cmd/main.go index 5d545cf..59070d7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -253,6 +253,7 @@ func main() { Scheme: mgr.GetScheme(), APIReader: mgr.GetAPIReader(), Memgraph: memgraph.NewBoltConnector(), + Recorder: mgr.GetEventRecorder("memgraph-operator"), GatewayAPI: gatewayAPI, GatewayAPIMissing: gatewayAPIMissing, ServiceMonitorAPI: serviceMonitorAPI, diff --git a/config/crd/bases/memgraph.com_memgraphclusters.yaml b/config/crd/bases/memgraph.com_memgraphclusters.yaml index 2da0312..3eeb8bd 100644 --- a/config/crd/bases/memgraph.com_memgraphclusters.yaml +++ b/config/crd/bases/memgraph.com_memgraphclusters.yaml @@ -75,18 +75,25 @@ spec: AWS integration (LOAD PARQUET and the other S3 readers). Absent, the operator sets none of them. properties: - secretName: + secretRef: description: |- - secretName names a Secret in the cluster's namespace holding the access + secretRef names a Secret in the cluster's namespace holding the access key under AWS_ACCESS_KEY_ID, the secret key under AWS_SECRET_ACCESS_KEY, the region under AWS_REGION and the endpoint URL under AWS_ENDPOINT_URL. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string + properties: + name: + description: name is the name of the Secret in the cluster's + namespace. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object required: - - secretName + - secretRef type: object clusterDomain: default: cluster.local @@ -1049,16 +1056,23 @@ spec: auth names the Secret Vector authenticates to the endpoint with. Absent, the endpoint is pushed to unauthenticated. properties: - secretName: + secretRef: description: |- - secretName is the name of the Secret in the cluster's namespace holding - the keys "username" and "password". - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string + secretRef names a Secret in the cluster's namespace holding the keys + "username" and "password". + properties: + name: + description: name is the name of the Secret in the + cluster's namespace. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object required: - - secretName + - secretRef type: object extraLabels: additionalProperties: @@ -1252,16 +1266,23 @@ spec: basicAuth names the Secret vmagent authenticates to the endpoint with. Absent, the endpoint is written to unauthenticated. properties: - secretName: + secretRef: description: |- - secretName is the name of the Secret in the cluster's namespace holding - the keys "username" and "password". - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string + secretRef names a Secret in the cluster's namespace holding the keys + "username" and "password". + properties: + name: + description: name is the name of the Secret in + the cluster's namespace. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object required: - - secretName + - secretRef type: object url: description: |- @@ -3057,19 +3078,26 @@ spec: bolt serves Bolt and the metrics endpoint over TLS on both roles, from the certificate in the named Secret. properties: - secretName: + secretRef: description: |- - secretName names a Secret in the cluster's namespace holding the + secretRef names a Secret in the cluster's namespace holding the certificate under tls.crt and the private key under tls.key — the shape of a kubernetes.io/tls Secret, which kubectl create secret tls and a cert-manager Certificate both produce. Both roles mount it read-only and serve Bolt and metrics with it. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string + properties: + name: + description: name is the name of the Secret in the cluster's + namespace. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object required: - - secretName + - secretRef type: object intraCluster: description: |- @@ -3077,19 +3105,26 @@ spec: the certificate and CA in the named Secret. It cannot be added to or removed from a live cluster; see IntraClusterTLSSpec. properties: - secretName: + secretRef: description: |- - secretName names a Secret in the cluster's namespace holding the + secretRef names a Secret in the cluster's namespace holding the certificate under tls.crt, the private key under tls.key and the CA to verify peers against under ca.crt — what a cert-manager Certificate issued by a private CA writes. Every pod of both roles mounts it read-only. - maxLength: 253 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string + properties: + name: + description: name is the name of the Secret in the cluster's + namespace. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object required: - - secretName + - secretRef type: object type: object userContainers: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 8d51c40..b75ea02 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -54,6 +54,13 @@ rules: - list - patch - watch +- apiGroups: + - events.k8s.io + resources: + - events + verbs: + - create + - patch - apiGroups: - gateway.networking.k8s.io resources: diff --git a/config/samples/v1alpha1_memgraphcluster.yaml b/config/samples/v1alpha1_memgraphcluster.yaml index 41660d4..c8966a5 100644 --- a/config/samples/v1alpha1_memgraphcluster.yaml +++ b/config/samples/v1alpha1_memgraphcluster.yaml @@ -503,7 +503,8 @@ spec: # remoteWrite: # url: http://vmsingle.monitoring.svc.cluster.local:8428/api/v1/write # basicAuth: - # secretName: monitoring-basic-auth + # secretRef: + # name: monitoring-basic-auth # scrapeInterval: 15s # externalLabels: # cluster: production @@ -515,7 +516,8 @@ spec: # vectorRemote: # logsEndpoint: http://victoria-logs.monitoring.svc.cluster.local:9428/insert # auth: - # secretName: logs-basic-auth + # secretRef: + # name: logs-basic-auth # extraLabels: # cluster_id: production # image: @@ -538,7 +540,7 @@ spec: # https with verification skipped, both derived from this block. Renewing # the certificate is an in-place Secret update — the kubelet refreshes the # files — followed by RELOAD BOLT SERVER TLS on every instance; a different - # secretName rolls the cluster instead. Removing the block rolls back to + # secretRef rolls the cluster instead. Removing the block rolls back to # plaintext. # # intraCluster makes the members authenticate each other over mutual TLS — @@ -550,12 +552,14 @@ spec: # same two pod-DNS wildcards in its SANs anyway. Decide at creation: the # block cannot be added to or removed from a live cluster, because a member # with it cannot talk to one without and the one-pod-at-a-time restart would - # wait on replication forever; admission refuses it. A different secretName + # wait on replication forever; admission refuses it. A different secretRef # rolls the cluster, and a CA change needs ca.crt in both Secrets to hold # both CAs. Renewal is an in-place Secret update followed by RELOAD # INTRA_CLUSTER TLS on every instance. # tls: # bolt: - # secretName: bolt-tls + # secretRef: + # name: bolt-tls # intraCluster: - # secretName: intra-cluster-tls + # secretRef: + # name: intra-cluster-tls diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000..61ffac5 --- /dev/null +++ b/docs/events.md @@ -0,0 +1,55 @@ +# Events + +The operator records a Kubernetes Event on the `MemgraphCluster` for every action it takes on the cluster. The Bolt commands are why: the Kubernetes audit log records every write the operator makes to the API server, but nothing it sends to Memgraph. Without Events, promoting a MAIN or unregistering an instance would show up only in the operator's log. + +```sh +kubectl describe mgc my-cluster # the Events section at the bottom +kubectl events --for mgc/my-cluster # the same, as a list +``` + +Events are recorded through the `events.k8s.io/v1` API. Each carries a reason, an action and a note. Where the action touched a pod, a claim or a StatefulSet, that object is the Event's `related` object, so `kubectl describe` on it shows the Event too. + +## Reasons + +There is one reason per action, so alerts and event exporters can filter on it. + +**Registration**, run on the coordinator leader. There is one Event per command the leader answers, in the order the commands ran: + +| Reason | Type | When | +|---|---|---| +| `CoordinatorAdded` | Normal | `ADD COORDINATOR` was accepted | +| `InstanceRegistered` | Normal | `REGISTER INSTANCE` was accepted | +| `MainPromoted` | Normal | `SET INSTANCE ... TO MAIN` was accepted: at bootstrap, after a retiring MAIN was demoted, or when a refused handover restores MAIN to the instance it came from | +| `MainPromotionRefused` | Warning | The coordinators refused one promotion candidate. The operator then tries the next candidate, and a later `MainPromoted` names the instance that did become MAIN | +| `MainDemoted` | Normal | `DEMOTE INSTANCE` on a retiring MAIN was accepted | +| `InstanceUnregistered` | Normal | `UNREGISTER INSTANCE` on a retiring data instance was accepted | +| `CoordinatorRemoved` | Normal | `REMOVE COORDINATOR` on a retiring coordinator was accepted | +| `LeadershipYielded` | Normal | `YIELD LEADERSHIP` moved Raft leadership off a retiring coordinator | +| `BoltServerUpdated` | Normal | `UPDATE CONFIG FOR INSTANCE` or `UPDATE CONFIG FOR COORDINATOR` changed a member's announced `bolt_server` | +| `RegistrationFailed` | Warning | The leader refused any of the commands above except a promotion, with Memgraph's error | + +**Settings**: + +| Reason | Type | When | +|---|---|---| +| `SettingApplied` | Normal | `SET DATABASE SETTING` landed on a pod: a run-time flag, the license and organization, or an AWS credential | +| `SettingRejected` | Warning | A pod or the coordinators refused a `SET`, with Memgraph's error | +| `CoordinatorSettingApplied` | Normal | `SET COORDINATOR SETTING` landed | + +**Kubernetes actions**: + +| Reason | Type | When | +|---|---|---| +| `PodRestarted` | Normal | The roll deleted a pod to restart it onto the current template. The note says why that pod was next | +| `StatefulSetShrunk` | Normal | A lowered count shed the pods of members that had already left the cluster | +| `VolumeExpansionRequested` | Normal | A claim was patched to a grown size | +| `StatefulSetOrphaned` | Normal | A StatefulSet was deleted with its pods and claims orphaned, to be recreated with grown claim templates | + +## What Events never carry + +- **Secret values.** A setting read from a Secret (`enterprise.license`, `organization.name`, the four `aws.*` settings) is named in the note with "from the Secret", never with its value. A run-time flag's value comes from the spec and is shown. +- **Waiting.** A pass that is waiting on something records nothing: replication lag, a filesystem still growing, an address not yet assigned. Waiting is what the `Converged` and `Updated` conditions report, and an Event repeated every pass would bury the actions. A refused command does repeat while it keeps being retried. Kubernetes folds repeats of the same Event into one with a count. + +## Keeping them + +The API server keeps an Event for about an hour by default (`--event-ttl`). For a lasting record, run an event exporter or ship Events with your log pipeline, the same way as for every other controller's Events. The operator keeps no history of its own. Together with the Kubernetes audit log, which covers writes to the `MemgraphCluster` and the operator's own API calls, the Events give a complete record of who changed what. diff --git a/docs/flags.md b/docs/flags.md index 0cac63e..c699b07 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -156,7 +156,8 @@ kubectl create secret generic aws-s3-credentials \ ```yaml spec: awsCredentials: - secretName: aws-s3-credentials + secretRef: + name: aws-s3-credentials ``` | Secret key | Flag | Setting | diff --git a/docs/monitoring.md b/docs/monitoring.md index 118a0c3..443dbc2 100644 --- a/docs/monitoring.md +++ b/docs/monitoring.md @@ -96,7 +96,8 @@ spec: remoteWrite: url: http://vmsingle.monitoring.svc.cluster.local:8428/api/v1/write basicAuth: # optional - secretName: monitoring-basic-auth + secretRef: + name: monitoring-basic-auth scrapeInterval: 15s # the default externalLabels: # optional; how the remote tells clusters apart cluster: production @@ -117,7 +118,7 @@ Changing `remoteWrite.url`, the image or the credentials Secret is a pod-templat ### Credentials -`basicAuth.secretName` names a Secret in the cluster's namespace with the keys **`username`** and **`password`** — the keys of a `kubernetes.io/basic-auth` Secret, which is what the chart's `usernameKey` and `passwordKey` default to; here they are fixed, for the reason the TLS Secrets' keys are. Create it with +`basicAuth.secretRef` names a Secret in the cluster's namespace with the keys **`username`** and **`password`** — the keys of a `kubernetes.io/basic-auth` Secret, which is what the chart's `usernameKey` and `passwordKey` default to; here they are fixed, for the reason the TLS Secrets' keys are. Create it with ```sh kubectl create secret generic monitoring-basic-auth --namespace \ @@ -141,7 +142,8 @@ spec: vectorRemote: logsEndpoint: http://victoria-logs.monitoring.svc.cluster.local:9428/insert auth: # optional - secretName: logs-basic-auth + secretRef: + name: logs-basic-auth extraLabels: # optional; how the remote tells clusters apart cluster_id: production image: # optional; defaults to timberio/vector at the pinned tag @@ -162,7 +164,7 @@ How it works: ### Credentials -`auth.secretName` names a Secret in the cluster's namespace with the keys **`username`** and **`password`**, as for the [vmagent](#credentials). Both reach Vector through environment variables it interpolates into its configuration (`${LOGS_USERNAME}`, `${LOGS_PASSWORD}`), so neither is on a command line or in the ConfigMap; a rotated Secret takes effect when the pod next restarts, which a roll of your choosing provides. The operator never reads the Secret. +`auth.secretRef` names a Secret in the cluster's namespace with the keys **`username`** and **`password`**, as for the [vmagent](#credentials). Both reach Vector through environment variables it interpolates into its configuration (`${LOGS_USERNAME}`, `${LOGS_PASSWORD}`), so neither is on a command line or in the ConfigMap; a rotated Secret takes effect when the pod next restarts, which a roll of your choosing provides. The operator never reads the Secret. ### What is not a knob diff --git a/docs/tls.md b/docs/tls.md index 7e0e6b0..58ed41f 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -8,7 +8,8 @@ spec: tls: bolt: - secretName: bolt-tls # a kubernetes.io/tls Secret in the cluster's namespace + secretRef: + name: bolt-tls # a kubernetes.io/tls Secret in the cluster's namespace ``` The block is presence-based, like `externalAccess` and `monitoring`: present, both roles serve Bolt on 7687 over TLS; absent, in plaintext. There is no `enabled` field and no key-name knobs. @@ -87,7 +88,8 @@ The one party the roll affects is the operator. Coordinators roll last, so after spec: tls: intraCluster: - secretName: intra-cluster-tls # tls.crt, tls.key and ca.crt, in the cluster's namespace + secretRef: + name: intra-cluster-tls # tls.crt, tls.key and ca.crt, in the cluster's namespace ``` Present, every member talks to every other member over mutual TLS: replication between data instances, the management RPC the coordinators drive the instances with, and Raft between the coordinators. Every pod of both roles mounts the Secret read-only at `/etc/memgraph/intra_cluster_tls` and runs with `--cluster-cert-file`, `--cluster-key-file` and `--cluster-ca-file`; Memgraph refuses to start with only some of the three, so the operator always passes all of them. @@ -133,7 +135,7 @@ policy) and recreate it with the new setting The reason is the roll. Intra-cluster TLS is all-or-nothing per process, and a member with it cannot talk to a member without it. The operator replaces pods one at a time, data instances first, and waits for replication to catch up before every step. Turning the mode on would restart one replica into TLS, the still-plaintext MAIN could no longer replicate to it, lag would never converge, and the next pod would never be restarted. Turning it off deadlocks the same way, and the coordinators partition the same way under Raft. The procedure that works is the one the message gives: delete the resource, keep the claims, recreate it with the block. Decide at creation. -Changing `secretName` inside the block is allowed and rolls the cluster normally, because every member speaks TLS throughout. The two Secrets must satisfy each other during the roll: a restarted member presents the new leaf to members still on the old one and verifies their old leaf against the new `ca.crt`. Same CA, new leaf, is the ordinary case and always fine. A CA cut-over needs `ca.crt` in **both** Secrets to be a bundle holding the old and the new CA, or the roll stalls at the first restarted pod. The operator cannot detect a mismatch ahead of time; a stalled roll is recovered by fixing the bundle in the Secret and, until Memgraph reloads on its own, running `RELOAD INTRA_CLUSTER TLS` on the pods not yet restarted. +Pointing `secretRef` at another Secret is allowed and rolls the cluster normally, because every member speaks TLS throughout. The two Secrets must satisfy each other during the roll: a restarted member presents the new leaf to members still on the old one and verifies their old leaf against the new `ca.crt`. Same CA, new leaf, is the ordinary case and always fine. A CA cut-over needs `ca.crt` in **both** Secrets to be a bundle holding the old and the new CA, or the roll stalls at the first restarted pod. The operator cannot detect a mismatch ahead of time; a stalled roll is recovered by fixing the bundle in the Secret and, until Memgraph reloads on its own, running `RELOAD INTRA_CLUSTER TLS` on the pods not yet restarted. ### Renewing @@ -143,7 +145,7 @@ As for Bolt: the mount has no `subPath`, so an in-place update to the Secret rea RELOAD INTRA_CLUSTER TLS; ``` -issued on every instance, until Memgraph extends the per-handshake reload to them. Nothing restarts, and a `secretName` change is the roll, not the renewal. +issued on every instance, until Memgraph extends the per-handshake reload to them. Nothing restarts, and a `secretRef` change is the roll, not the renewal. ### What the operator itself does diff --git a/internal/controller/events.go b/internal/controller/events.go new file mode 100644 index 0000000..b3a511d --- /dev/null +++ b/internal/controller/events.go @@ -0,0 +1,198 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" + "github.com/memgraph/kubernetes-operator/internal/memgraph" +) + +// Event reasons, one per action the operator takes on a cluster. Every Event +// is recorded on the MemgraphCluster, with the pod or claim the action touched +// as the related object where there is one. They record what the operator +// did, never what it is waiting for: waiting is what the conditions say, and +// an Event repeated every pass would bury the actions. +// +// The Bolt commands are the reason Events exist at all: the Kubernetes audit +// log sees every write the operator makes to the API server, but nothing it +// sends to Memgraph. +const ( + EventReasonCoordinatorAdded = "CoordinatorAdded" + EventReasonInstanceRegistered = "InstanceRegistered" + EventReasonMainPromoted = "MainPromoted" + EventReasonMainPromotionRefused = "MainPromotionRefused" + EventReasonMainDemoted = "MainDemoted" + EventReasonInstanceUnregistered = "InstanceUnregistered" + EventReasonCoordinatorRemoved = "CoordinatorRemoved" + EventReasonLeadershipYielded = "LeadershipYielded" + EventReasonBoltServerUpdated = "BoltServerUpdated" + EventReasonRegistrationFailed = "RegistrationFailed" + EventReasonSettingApplied = "SettingApplied" + EventReasonSettingRejected = "SettingRejected" + EventReasonCoordinatorSettingApplied = "CoordinatorSettingApplied" + EventReasonPodRestarted = "PodRestarted" + EventReasonStatefulSetShrunk = "StatefulSetShrunk" + EventReasonVolumeExpansionRequested = "VolumeExpansionRequested" + EventReasonStatefulSetOrphaned = "StatefulSetOrphaned" +) + +// Event actions, the events.k8s.io field saying what the operator did to the +// regarding object's members; a reason says why it is worth knowing. +const ( + eventActionRegister = "Register" + eventActionPromote = "Promote" + eventActionDemote = "Demote" + eventActionUnregister = "Unregister" + eventActionYield = "YieldLeadership" + eventActionUpdateConfig = "UpdateConfig" + eventActionSetSetting = "SetSetting" + eventActionRestart = "Restart" + eventActionScaleDown = "ScaleDown" + eventActionExpandVolume = "ExpandVolume" + eventActionRecreate = "RecreateStatefulSet" +) + +// event records one Normal Event on the cluster. +func (r *MemgraphClusterReconciler) event( + cluster *memgraphcomv1alpha1.MemgraphCluster, + related runtime.Object, + reason, action, note string, + args ...any, +) { + r.Recorder.Eventf(cluster, related, corev1.EventTypeNormal, reason, action, truncateMessage(fmt.Sprintf(note, args...))) +} + +// warning records one Warning Event on the cluster. +func (r *MemgraphClusterReconciler) warning( + cluster *memgraphcomv1alpha1.MemgraphCluster, + related runtime.Object, + reason, action, note string, + args ...any, +) { + r.Recorder.Eventf(cluster, related, corev1.EventTypeWarning, reason, action, truncateMessage(fmt.Sprintf(note, args...))) +} + +// podRef is a pod of the cluster as an Event's related object. The recorder +// needs only the kind, name and namespace to reference it. +func podRef(cluster *memgraphcomv1alpha1.MemgraphCluster, name string) runtime.Object { + return &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: cluster.Namespace}} +} + +// recordingClient records an Event for every registration command the leader +// answers, accepted or refused, so the record is of what Memgraph was asked +// and said rather than of what the plan intended: a promotion tries its +// candidates in order inside one command, and only the client sees which of +// them the coordinators took. +type recordingClient struct { + memgraph.Client + r *MemgraphClusterReconciler + cluster *memgraphcomv1alpha1.MemgraphCluster + + // leader is the retiring coordinator a YieldLeadership in this plan moves + // leadership away from; the query names no one, so the plan says who. + leader string +} + +func (c recordingClient) AddCoordinator(ctx context.Context, coordinator memgraph.CoordinatorSpec) error { + err := c.Client.AddCoordinator(ctx, coordinator) + c.record(err, EventReasonCoordinatorAdded, eventActionRegister, + "Added coordinator %s to the Raft cluster", coordinator.Name()) + return err +} + +func (c recordingClient) RegisterInstance(ctx context.Context, instance memgraph.DataInstanceSpec) error { + err := c.Client.RegisterInstance(ctx, instance) + c.record(err, EventReasonInstanceRegistered, eventActionRegister, + "Registered data instance %s with the coordinators", instance.Name) + return err +} + +// SetInstanceToMain records a refusal as a Warning of its own rather than a +// failed registration: the command falls back to its next candidate, so a +// refusal is not the end of the promotion, and the instance that did become +// MAIN gets the MainPromoted. +func (c recordingClient) SetInstanceToMain(ctx context.Context, name string) error { + err := c.Client.SetInstanceToMain(ctx, name) + if err != nil { + c.r.warning(c.cluster, nil, EventReasonMainPromotionRefused, eventActionPromote, + "The coordinators refused to promote data instance %s to MAIN: %v", name, err) + return err + } + c.r.event(c.cluster, nil, EventReasonMainPromoted, eventActionPromote, "Promoted data instance %s to MAIN", name) + return nil +} + +func (c recordingClient) DemoteInstance(ctx context.Context, name string) error { + err := c.Client.DemoteInstance(ctx, name) + c.record(err, EventReasonMainDemoted, eventActionDemote, + "Demoted retiring data instance %s from MAIN to REPLICA", name) + return err +} + +func (c recordingClient) UnregisterInstance(ctx context.Context, name string) error { + err := c.Client.UnregisterInstance(ctx, name) + c.record(err, EventReasonInstanceUnregistered, eventActionUnregister, + "Unregistered retiring data instance %s from the coordinators", name) + return err +} + +func (c recordingClient) RemoveCoordinator(ctx context.Context, id int32) error { + err := c.Client.RemoveCoordinator(ctx, id) + c.record(err, EventReasonCoordinatorRemoved, eventActionUnregister, + "Removed retiring coordinator %s from the Raft cluster", memgraph.CoordinatorSpec{ID: id}.Name()) + return err +} + +func (c recordingClient) YieldLeadership(ctx context.Context) error { + err := c.Client.YieldLeadership(ctx) + c.record(err, EventReasonLeadershipYielded, eventActionYield, + "Yielded Raft leadership of retiring coordinator %s to another coordinator", c.leader) + return err +} + +func (c recordingClient) UpdateCoordinatorBoltServer(ctx context.Context, id int32, boltServer string) error { + err := c.Client.UpdateCoordinatorBoltServer(ctx, id, boltServer) + c.record(err, EventReasonBoltServerUpdated, eventActionUpdateConfig, + "Updated the bolt_server coordinator %s announces to %s", memgraph.CoordinatorSpec{ID: id}.Name(), boltServer) + return err +} + +func (c recordingClient) UpdateInstanceBoltServer(ctx context.Context, name, boltServer string) error { + err := c.Client.UpdateInstanceBoltServer(ctx, name, boltServer) + c.record(err, EventReasonBoltServerUpdated, eventActionUpdateConfig, + "Updated the bolt_server data instance %s announces to %s", name, boltServer) + return err +} + +// record is the Normal Event an accepted command earns, or the Warning a +// refused one does; the refusal carries what the action would have been and +// Memgraph's error. +func (c recordingClient) record(err error, reason, action, note string, args ...any) { + if err != nil { + c.r.warning(c.cluster, nil, EventReasonRegistrationFailed, action, + "%s refused: %v", fmt.Sprintf(note, args...), err) + return + } + c.r.event(c.cluster, nil, reason, action, note, args...) +} diff --git a/internal/controller/events_test.go b/internal/controller/events_test.go new file mode 100644 index 0000000..696ee83 --- /dev/null +++ b/internal/controller/events_test.go @@ -0,0 +1,69 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "fmt" + "strings" + "sync" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" +) + +// recordedEvents is the Event recorder the suite hands the reconciler: it keeps +// every Event in order instead of sending it, so a spec can assert what a pass +// told the user it did. client-go's FakeRecorder blocks once its channel is +// full, which a spec running a dozen passes would reach. +type recordedEvents struct { + mu sync.Mutex + events []string +} + +// Eventf renders an Event as " ", plus " []" naming +// the related object when there is one. +func (e *recordedEvents) Eventf( + _ runtime.Object, related runtime.Object, eventtype, reason, _, note string, args ...any, +) { + line := eventtype + " " + reason + " " + fmt.Sprintf(note, args...) + if related != nil { + if accessor, err := meta.Accessor(related); err == nil { + line += " [" + accessor.GetName() + "]" + } + } + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, line) +} + +// all returns every Event recorded so far. +func (e *recordedEvents) all() []string { + e.mu.Lock() + defer e.mu.Unlock() + return append([]string(nil), e.events...) +} + +// withReason returns the Events recorded with the given reason, in order. +func (e *recordedEvents) withReason(reason string) []string { + var matched []string + for _, event := range e.all() { + if strings.SplitN(event, " ", 3)[1] == reason { + matched = append(matched, event) + } + } + return matched +} diff --git a/internal/controller/memgraphcluster_controller.go b/internal/controller/memgraphcluster_controller.go index 661bd33..e7aadb1 100644 --- a/internal/controller/memgraphcluster_controller.go +++ b/internal/controller/memgraphcluster_controller.go @@ -37,6 +37,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/events" ctrl "sigs.k8s.io/controller-runtime" ctrlbuilder "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" @@ -109,6 +110,11 @@ type MemgraphClusterReconciler struct { // Bolt driver. Memgraph memgraph.Connector + // Recorder records an Event on the cluster for every action the operator + // takes on it, the Bolt commands above all, which no Kubernetes audit log + // sees. + Recorder events.EventRecorder + // GatewayAPI reports whether the cluster serves the Gateway API kinds the // operator builds, at the version it builds them, as discovered when the // manager started. The Gateway API CRDs belong to whoever installs a @@ -174,6 +180,7 @@ type MemgraphClusterReconciler struct { // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;patch;delete // +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;patch;delete // +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;delete +// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch // // The Gateway and its TCPRoutes are external objects like the Services above, // with the same lifecycle and the same verbs. The rules are granted whether or @@ -1019,6 +1026,8 @@ func (r *MemgraphClusterReconciler) reconcileVolumes( } log.Info("Patched a PersistentVolumeClaim to the grown storage size", "persistentvolumeclaim", patch.Claim, "size", patch.Size.String()) + r.event(cluster, pvcs[patch.Claim], EventReasonVolumeExpansionRequested, eventActionExpandVolume, + "Requested %s for PersistentVolumeClaim %s", patch.Size.String(), patch.Claim) } outcome.growing = append(outcome.growing, decision.Growing...) outcome.errors = append(outcome.errors, decision.Errors...) @@ -1037,6 +1046,9 @@ func (r *MemgraphClusterReconciler) reconcileVolumes( } log.Info("Deleted a StatefulSet with its pods and claims orphaned, to recreate it with grown claim templates", "statefulset", live.Name) + r.event(cluster, live, EventReasonStatefulSetOrphaned, eventActionRecreate, + "Deleted StatefulSet %s with its pods and claims orphaned, to recreate it with grown claim templates", + live.Name) return outcome, fmt.Sprintf("Recreating StatefulSet %s with the grown claim sizes; "+ "its pods and claims are kept", live.Name), nil } @@ -1452,6 +1464,8 @@ func (r *MemgraphClusterReconciler) reconcileRegistration( if err := r.restartPod(ctx, cluster, decision); err != nil { return ctrl.Result{}, err } + r.event(cluster, podRef(cluster, decision.Pod.Name), EventReasonPodRestarted, eventActionRestart, + "Deleted pod %s to restart it: %s", decision.Pod.Name, decision.Message) log.Info("Deleted a workload pod to restart it onto the current pod template", "pod", decision.Pod.Name, "reason", decision.Message) return ctrl.Result{RequeueAfter: requeueWhilePending}, nil @@ -1508,8 +1522,9 @@ func (r *MemgraphClusterReconciler) reconcileRegistration( // the rejection only ever reaches the operator's log. The serving verdict is // left describing what the cluster was last observed doing — a MAIN that is // serving keeps serving through a registration the coordinator refuses. + recorded := recordingClient{Client: leader, r: r, cluster: cluster, leader: yieldedLeader(commands)} for _, command := range commands { - if err := command.Run(ctx, leader); err != nil { + if err := command.Run(ctx, recorded); err != nil { commandErr := fmt.Errorf("executing registration command %q: %w", command, err) msg := truncateMessage(commandErr.Error()) setStatus(cluster, latest, servingOrNot(latest.main), @@ -1662,10 +1677,14 @@ func (r *MemgraphClusterReconciler) reconcileCoordinatorSettings( observed := classes.coordinatorSettings for _, change := range settings.Diff(desired, observed) { if err := leader.SetCoordinatorSetting(ctx, change.Setting, change.Value); err != nil { - return settingsOutcome{rejected: []string{fmt.Sprintf( - "the coordinators rejected SET COORDINATOR SETTING %q TO %q: %v", change.Setting, change.Value, err)}} + rejected := fmt.Sprintf( + "the coordinators rejected SET COORDINATOR SETTING %q TO %q: %v", change.Setting, change.Value, err) + r.warning(cluster, nil, EventReasonSettingRejected, eventActionSetSetting, "%s", rejected) + return settingsOutcome{rejected: []string{rejected}} } log.Info("Applied coordinator setting", "setting", change.Setting, "value", change.Value) + r.event(cluster, nil, EventReasonCoordinatorSettingApplied, eventActionSetSetting, + "Ran SET COORDINATOR SETTING %q TO %q", change.Setting, change.Value) } return settingsOutcome{} } @@ -1717,7 +1736,7 @@ func (r *MemgraphClusterReconciler) reconcileSettings( if !ready[endpoint.Pod] { continue } - changes, err := r.applySettings(ctx, reads, endpoint, func(observed map[string]string) []settings.Change { + changes, err := r.applySettings(ctx, cluster, reads, endpoint, func(observed map[string]string) []settings.Change { return settings.Plan(role.flags, observed) }, runTimeSetting) for _, change := range changes { @@ -1991,7 +2010,7 @@ func (r *MemgraphClusterReconciler) reconcileAWSCredentials( if !ready[endpoint.Pod] { continue } - changes, err := r.applySettings(ctx, reads, endpoint, func(observed map[string]string) []settings.Change { + changes, err := r.applySettings(ctx, cluster, reads, endpoint, func(observed map[string]string) []settings.Change { return settings.Diff(desired, observed) }, secretSetting) for _, change := range changes { @@ -2111,6 +2130,7 @@ func (r *MemgraphClusterReconciler) readInstance( // leave the value out; any other error is a pod that could not be reached. func (r *MemgraphClusterReconciler) applySettings( ctx context.Context, + cluster *memgraphcomv1alpha1.MemgraphCluster, reads podSettings, endpoint resources.Endpoint, plan func(observed map[string]string) []settings.Change, @@ -2136,17 +2156,21 @@ func (r *MemgraphClusterReconciler) applySettings( var applied []settings.Change for _, change := range changes { if err := conn.SetSetting(ctx, change.Setting, change.Value); err != nil { + r.warning(cluster, podRef(cluster, endpoint.Pod), EventReasonSettingRejected, eventActionSetSetting, + "Pod %s rejected %s: %v", endpoint.Pod, describe(change), err) return applied, &settingRejected{command: describe(change), err: err} } + r.event(cluster, podRef(cluster, endpoint.Pod), EventReasonSettingApplied, eventActionSetSetting, + "Ran %s on pod %s", describe(change), endpoint.Pod) observed[change.Setting] = change.Value applied = append(applied, change) } return applied, nil } -// runTimeSetting and secretSetting describe a refused SET: the first with its -// value, which comes from the spec, the second without, since it comes from a -// Secret. +// runTimeSetting and secretSetting describe a SET for an Event, and a refused +// one for the condition too: the first with its value, which comes from the +// spec, the second without, since it comes from a Secret. func runTimeSetting(change settings.Change) string { return fmt.Sprintf("SET DATABASE SETTING %q TO %q", change.Setting, change.Value) } @@ -2201,7 +2225,7 @@ func (r *MemgraphClusterReconciler) reconcileLicense( if !ready[endpoint.Pod] { continue } - changes, err := r.applySettings(ctx, reads, endpoint, func(observed map[string]string) []settings.Change { + changes, err := r.applySettings(ctx, cluster, reads, endpoint, func(observed map[string]string) []settings.Change { return settings.License(license, organization, observed) }, secretSetting) for _, change := range changes { @@ -2342,6 +2366,9 @@ func (r *MemgraphClusterReconciler) shedRetiredPods( written.uid, written.generation) log.Info("Shrank a StatefulSet to the declared replica count", "statefulset", role.replicas.name, "replicas", role.replicas.declared) + r.event(cluster, nil, EventReasonStatefulSetShrunk, eventActionScaleDown, + "Shrank StatefulSet %s to %d replicas, shedding the pods of members that left the cluster", + role.replicas.name, role.replicas.declared) } return nil } diff --git a/internal/controller/memgraphcluster_controller_test.go b/internal/controller/memgraphcluster_controller_test.go index f589e47..27be897 100644 --- a/internal/controller/memgraphcluster_controller_test.go +++ b/internal/controller/memgraphcluster_controller_test.go @@ -141,15 +141,18 @@ var _ = Describe("MemgraphCluster Controller", func() { var ( reconciler *MemgraphClusterReconciler fake *fakeMemgraph + recorded *recordedEvents ) BeforeEach(func() { fake = newFakeMemgraph() + recorded = &recordedEvents{} reconciler = &MemgraphClusterReconciler{ Client: k8sClient, Scheme: k8sClient.Scheme(), APIReader: k8sClient, Memgraph: fake, + Recorder: recorded, GatewayAPI: true, ServiceMonitorAPI: true, } @@ -522,10 +525,19 @@ var _ = Describe("MemgraphCluster Controller", func() { })) Expect(result.RequeueAfter).To(BeNumerically(">", 0), "registration was issued, so a follow-up reconcile must verify convergence") + Expect(recorded.all()).To(Equal([]string{ + "Normal CoordinatorAdded Added coordinator coordinator_0 to the Raft cluster", + "Normal CoordinatorAdded Added coordinator coordinator_1 to the Raft cluster", + "Normal CoordinatorAdded Added coordinator coordinator_2 to the Raft cluster", + "Normal InstanceRegistered Registered data instance instance_0 with the coordinators", + "Normal InstanceRegistered Registered data instance instance_1 with the coordinators", + "Normal MainPromoted Promoted data instance instance_0 to MAIN", + }), "every command Memgraph accepted is recorded on the cluster, in the order it ran") result = reconcileCluster(resourceName) Expect(fake.executedCommands()).To(HaveLen(6), "a converged cluster must not receive further commands") + Expect(recorded.all()).To(HaveLen(6), "a pass that does nothing records nothing") Expect(result.RequeueAfter).To(Equal(resyncInterval), "a converged cluster must still reschedule a resync to catch registration drift") }) @@ -900,9 +912,16 @@ var _ = Describe("MemgraphCluster Controller", func() { Expect(converged.Message).To(ContainSubstring("coordinator_3"), "the condition must name the coordinators being retired") + Expect(recorded.withReason(EventReasonCoordinatorRemoved)).To(Equal([]string{ + "Normal CoordinatorRemoved Removed retiring coordinator coordinator_3 from the Raft cluster", + "Normal CoordinatorRemoved Removed retiring coordinator coordinator_4 from the Raft cluster", + })) + By("shedding the pods once the members have left the Raft cluster") reconcileCluster(resourceName) Expect(replicas(coordinatorSuffix)).To(Equal(int32(3))) + Expect(recorded.withReason(EventReasonStatefulSetShrunk)).To(ConsistOf( + ContainSubstring("to 3 replicas"))) Expect(sinceBootstrap(baseline)).To(HaveLen(2), "the removals are not re-issued") Expect(convergedCondition().Reason).To(Equal(memgraphcomv1alpha1.ReasonRetirementInProgress)) @@ -935,6 +954,9 @@ var _ = Describe("MemgraphCluster Controller", func() { retiringLeader + ": REMOVE COORDINATOR 4", retiringLeader + ": YIELD LEADERSHIP", }), "the yield comes last, after the removal the planner could still order safely") + Expect(recorded.withReason(EventReasonLeadershipYielded)).To(Equal([]string{ + "Normal LeadershipYielded Yielded Raft leadership of retiring coordinator coordinator_3 to another coordinator", + }), "the query names no one, so the Event names the coordinator the plan moved leadership off") converged := convergedCondition() Expect(converged.Status).To(Equal(metav1.ConditionFalse)) Expect(converged.Reason).To(Equal(memgraphcomv1alpha1.ReasonLeadershipTransferInProgress)) @@ -1106,6 +1128,11 @@ var _ = Describe("MemgraphCluster Controller", func() { leader + ": SET INSTANCE instance_0 TO MAIN", leader + ": UNREGISTER INSTANCE instance_1", }), "demote, promote and unregister issue in one pass against one leader") + Expect(recorded.all()[len(recorded.all())-3:]).To(Equal([]string{ + "Normal MainDemoted Demoted retiring data instance instance_1 from MAIN to REPLICA", + "Normal MainPromoted Promoted data instance instance_0 to MAIN", + "Normal InstanceUnregistered Unregistered retiring data instance instance_1 from the coordinators", + })) Expect(replicas(dataSuffix)).To(Equal(int32(2)), "a pass with pending commands must never lower the replica count") @@ -1194,6 +1221,13 @@ var _ = Describe("MemgraphCluster Controller", func() { leader + ": SET INSTANCE instance_1 TO MAIN", leader + ": UNREGISTER INSTANCE instance_2", }), "the refused survivor is skipped and the retirement completes in the same pass") + Expect(recorded.withReason(EventReasonMainPromotionRefused)).To(Equal([]string{ + "Warning MainPromotionRefused The coordinators refused to promote data instance instance_0 to MAIN: " + + "instance is not registered", + })) + Expect(recorded.withReason(EventReasonMainPromoted)).To(ContainElement( + "Normal MainPromoted Promoted data instance instance_1 to MAIN"), + "the Event names the instance the coordinators took, not the plan's first choice") reconcileCluster(resourceName) Expect(replicas(dataSuffix)).To(Equal(int32(2))) @@ -1227,6 +1261,12 @@ var _ = Describe("MemgraphCluster Controller", func() { leader + ": DEMOTE INSTANCE instance_1", leader + ": SET INSTANCE instance_1 TO MAIN", }), "MAIN goes back to the demoted instance, and the unregistration never runs") + Expect(recorded.all()[len(recorded.all())-3:]).To(Equal([]string{ + "Normal MainDemoted Demoted retiring data instance instance_1 from MAIN to REPLICA", + "Warning MainPromotionRefused The coordinators refused to promote data instance instance_0 to MAIN: " + + "instance is down", + "Normal MainPromoted Promoted data instance instance_1 to MAIN", + }), "a handover that went back is visible as one: demoted, refused, promoted back") Expect(replicas(dataSuffix)).To(Equal(int32(2)), "the retiring pod outlives a retirement that did not finish") @@ -1808,6 +1848,10 @@ var _ = Describe("MemgraphCluster Controller", func() { // instance_0 is MAIN, so the replica on ordinal 1 goes first. reconcileCluster(resourceName) Expect(podExists(dataSuffix, 1)).To(BeFalse(), "the non-MAIN data pod is restarted first") + Expect(recorded.withReason(EventReasonPodRestarted)).To(ConsistOf( + And(HavePrefix("Normal PodRestarted Deleted pod "+resourceName+"-data-1 to restart it: "), + HaveSuffix("["+resourceName+"-data-1]"))), + "the restart names the pod as the related object and carries why it was chosen") Expect(podExists(dataSuffix, 0)).To(BeTrue(), "the MAIN's pod is not touched yet") Expect(podExists(coordinatorSuffix, 2)).To(BeTrue(), "coordinators wait for the data plane") updated := condition(memgraphcomv1alpha1.ConditionUpdated) @@ -2152,10 +2196,17 @@ var _ = Describe("MemgraphCluster Controller", func() { Expect(converged.Status).To(Equal(metav1.ConditionFalse)) Expect(converged.Reason).To(Equal(memgraphcomv1alpha1.ReasonVolumeExpansionInProgress)) Expect(converged.Message).To(ContainSubstring(claimName(0))) + Expect(recorded.withReason(EventReasonVolumeExpansionRequested)).To(ConsistOf( + "Normal VolumeExpansionRequested Requested 10Gi for PersistentVolumeClaim "+claimName(0)+" ["+claimName(0)+"]", + "Normal VolumeExpansionRequested Requested 10Gi for PersistentVolumeClaim "+claimName(1)+" ["+claimName(1)+"]", + "Normal VolumeExpansionRequested Requested 10Gi for PersistentVolumeClaim "+claimName(3)+" ["+claimName(3)+"]", + )) // Every claim asks for the size: the StatefulSet is deleted with // its dependents orphaned, and the pass ends there. reconcileCluster(resourceName) + Expect(recorded.withReason(EventReasonStatefulSetOrphaned)).To(ConsistOf( + HavePrefix("Normal StatefulSetOrphaned Deleted StatefulSet " + resourceName + dataSuffix + " with its pods and claims orphaned"))) sts = dataSet() Expect(sts.DeletionTimestamp).NotTo(BeNil()) Expect(sts.Finalizers).To(ContainElement(metav1.FinalizerOrphanDependents)) @@ -2504,6 +2555,13 @@ var _ = Describe("MemgraphCluster Controller", func() { podAddress(dataSuffix, 0)+`: SET DATABASE SETTING "log.level" TO "DEBUG"`, podAddress(dataSuffix, 1)+`: SET DATABASE SETTING "log.level" TO "DEBUG"`, ), "exactly the run-time flag that differs, once per instance; the startup-only one is never SET") + dataPod := func(ordinal int) string { return fmt.Sprintf("%s%s-%d", resourceName, dataSuffix, ordinal) } + Expect(recorded.withReason(EventReasonSettingApplied)).To(ConsistOf( + `Normal SettingApplied Ran SET DATABASE SETTING "log.level" TO "DEBUG" on pod `+ + dataPod(0)+" ["+dataPod(0)+"]", + `Normal SettingApplied Ran SET DATABASE SETTING "log.level" TO "DEBUG" on pod `+ + dataPod(1)+" ["+dataPod(1)+"]", + )) Expect(flagsAnnotation(dataSuffix)).To(Equal(hashBefore), "a run-time flag is not part of the pod template") Expect(condition(memgraphcomv1alpha1.ConditionConverged).Status).To(Equal(metav1.ConditionTrue)) Expect(condition(memgraphcomv1alpha1.ConditionUpdated).Status).To(Equal(metav1.ConditionTrue)) @@ -2578,6 +2636,9 @@ var _ = Describe("MemgraphCluster Controller", func() { Expect(converged.Message).To(ContainSubstring(resourceName + dataSuffix + "-0")) Expect(converged.Message).To(ContainSubstring(`SET DATABASE SETTING "log.to_stderr" TO "1"`)) Expect(converged.Message).To(ContainSubstring("Boolean value supports only")) + Expect(recorded.withReason(EventReasonSettingRejected)).To(ContainElement(And( + HavePrefix("Warning SettingRejected Pod "+resourceName+dataSuffix+"-0"+` rejected SET DATABASE SETTING "log.to_stderr" TO "1"`), + ContainSubstring("Boolean value supports only")))) Expect(result.RequeueAfter).To(Equal(requeueWhilePending)) expectServingButNotReady(resourceName, memgraphcomv1alpha1.ReasonSettingsRejected) @@ -2751,6 +2812,11 @@ var _ = Describe("MemgraphCluster Controller", func() { }) Expect(licenseCommands()).To(HaveLen(5), "one SET of the license per pod, and nothing for the unchanged organization") Expect(licenseCommands()).To(HaveEach(ContainSubstring(licenseSetting))) + Expect(recorded.withReason(EventReasonSettingApplied)).To(HaveLen(5)) + Expect(recorded.withReason(EventReasonSettingApplied)).To(HaveEach( + ContainSubstring(`Ran SET DATABASE SETTING "enterprise.license" from the Secret on pod`))) + Expect(recorded.all()).NotTo(ContainElement(ContainSubstring(renewedLicense)), + "an Event names the setting, never the license") Expect(podUIDs()).To(Equal(uids), "a renewal restarts no pod") condition := licenseCondition() Expect(condition.Status).To(Equal(metav1.ConditionTrue)) @@ -2981,7 +3047,7 @@ var _ = Describe("MemgraphCluster Controller", func() { resource := &memgraphcomv1alpha1.MemgraphCluster{ ObjectMeta: metav1.ObjectMeta{Name: resourceName, Namespace: resourceNamespace}, Spec: memgraphcomv1alpha1.MemgraphClusterSpec{ - AWSCredentials: &memgraphcomv1alpha1.AWSCredentialsSpec{SecretName: secretName}, + AWSCredentials: &memgraphcomv1alpha1.AWSCredentialsSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: secretName}}, }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) @@ -3056,6 +3122,12 @@ var _ = Describe("MemgraphCluster Controller", func() { } Expect(awsCommands()[6:]).To(HaveLen(4)) Expect(awsCommands()[6:]).To(HaveEach(Or(ContainSubstring(secretKeySET), ContainSubstring(endpointSET)))) + Expect(recorded.withReason(EventReasonSettingApplied)).To(ContainElement( + ContainSubstring(`Ran SET DATABASE SETTING "aws.secret_key" from the Secret on pod`))) + for _, credential := range []string{accessKey, secretKey, rotatedKey} { + Expect(recorded.all()).NotTo(ContainElement(ContainSubstring(credential)), + "an Event names the setting, never a credential") + } Expect(podUIDs()).To(Equal(uids), "a rotation restarts no pod") Expect(converged().Status).To(Equal(metav1.ConditionTrue)) }) @@ -3524,6 +3596,9 @@ var _ = Describe("MemgraphCluster Controller", func() { observedCoordinator(1, memgraph.RoleLeader).BoltServer + `: SET COORDINATOR SETTING "` + downTimeoutSetting + `" TO "7"`, )) + Expect(recorded.withReason(EventReasonCoordinatorSettingApplied)).To(Equal([]string{ + `Normal CoordinatorSettingApplied Ran SET COORDINATOR SETTING "` + downTimeoutSetting + `" TO "7"`, + })) Expect(condition(memgraphcomv1alpha1.ConditionConverged).Status).To(Equal(metav1.ConditionTrue)) // The builder cannot know a key is a coordinator setting, so the @@ -3898,7 +3973,7 @@ var _ = Describe("MemgraphCluster Controller", func() { ObjectMeta: metav1.ObjectMeta{Name: resourceName, Namespace: resourceNamespace}, Spec: memgraphcomv1alpha1.MemgraphClusterSpec{ TLS: &memgraphcomv1alpha1.TLSSpec{ - Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretName: "bolt-tls"}, + Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: "bolt-tls"}}, }, }, } @@ -4517,7 +4592,7 @@ var _ = Describe("MemgraphCluster Controller", func() { VMAgentRemote: &memgraphcomv1alpha1.VMAgentRemoteSpec{ RemoteWrite: memgraphcomv1alpha1.RemoteWriteSpec{ URL: remoteWriteURL, - BasicAuth: &memgraphcomv1alpha1.BasicAuthSecretSpec{SecretName: "monitoring-basic-auth"}, + BasicAuth: &memgraphcomv1alpha1.BasicAuthSecretSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: "monitoring-basic-auth"}}, }, ExternalLabels: map[string]string{"cluster": "envtest"}, }, @@ -4629,7 +4704,7 @@ var _ = Describe("MemgraphCluster Controller", func() { Monitoring: &memgraphcomv1alpha1.MonitoringSpec{ VectorRemote: &memgraphcomv1alpha1.VectorRemoteSpec{ LogsEndpoint: logsEndpoint, - Auth: &memgraphcomv1alpha1.BasicAuthSecretSpec{SecretName: "logs-basic-auth"}, + Auth: &memgraphcomv1alpha1.BasicAuthSecretSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: "logs-basic-auth"}}, ExtraLabels: map[string]string{"cluster_id": "envtest"}, }, }, diff --git a/internal/controller/memgraphcluster_validation_test.go b/internal/controller/memgraphcluster_validation_test.go index e373bd9..cd70ba9 100644 --- a/internal/controller/memgraphcluster_validation_test.go +++ b/internal/controller/memgraphcluster_validation_test.go @@ -618,7 +618,7 @@ var _ = Describe("MemgraphCluster CRD validation", func() { }, }, }, - "spec.monitoring.vmagentRemote.remoteWrite.basicAuth.secretName"), + "spec.monitoring.vmagentRemote.remoteWrite.basicAuth.secretRef.name"), Entry("an external label that is not a Prometheus label name", "invalid-vmagent-external-label", memgraphcomv1alpha1.MemgraphClusterSpec{ Monitoring: &memgraphcomv1alpha1.MonitoringSpec{ @@ -652,7 +652,7 @@ var _ = Describe("MemgraphCluster CRD validation", func() { }, }, }, - "spec.monitoring.vectorRemote.auth.secretName"), + "spec.monitoring.vectorRemote.auth.secretRef.name"), Entry("a Vector extra label that is not a label name", "invalid-vector-extra-label", memgraphcomv1alpha1.MemgraphClusterSpec{ Monitoring: &memgraphcomv1alpha1.MonitoringSpec{ @@ -835,7 +835,7 @@ var _ = Describe("MemgraphCluster CRD validation", func() { memgraphcomv1alpha1.MemgraphClusterSpec{ AWSCredentials: &memgraphcomv1alpha1.AWSCredentialsSpec{}, }, - "spec.awsCredentials.secretName"), + "spec.awsCredentials.secretRef.name"), Entry("an env var shadowing the pod's own identity", "invalid-env-pod-name", memgraphcomv1alpha1.MemgraphClusterSpec{ ExtraEnv: memgraphcomv1alpha1.ExtraEnvSpec{ @@ -1172,7 +1172,7 @@ var _ = Describe("MemgraphCluster CRD validation", func() { withIntraCluster := memgraphcomv1alpha1.MemgraphClusterSpec{ TLS: &memgraphcomv1alpha1.TLSSpec{ - IntraCluster: &memgraphcomv1alpha1.IntraClusterTLSSpec{SecretName: intraSecret}, + IntraCluster: &memgraphcomv1alpha1.IntraClusterTLSSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: intraSecret}}, }, } @@ -1180,7 +1180,7 @@ var _ = Describe("MemgraphCluster CRD validation", func() { createAccepted("intra-add-no-tls", memgraphcomv1alpha1.MemgraphClusterSpec{}) expectRejectedUpdate("intra-add-no-tls", func(c *memgraphcomv1alpha1.MemgraphCluster) { c.Spec.TLS = &memgraphcomv1alpha1.TLSSpec{ - IntraCluster: &memgraphcomv1alpha1.IntraClusterTLSSpec{SecretName: intraSecret}, + IntraCluster: &memgraphcomv1alpha1.IntraClusterTLSSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: intraSecret}}, } }, pinMessage) }) @@ -1188,11 +1188,11 @@ var _ = Describe("MemgraphCluster CRD validation", func() { It("should reject adding the block beside an existing bolt block", func() { createAccepted("intra-add-beside-bolt", memgraphcomv1alpha1.MemgraphClusterSpec{ TLS: &memgraphcomv1alpha1.TLSSpec{ - Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretName: boltSecret}, + Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: boltSecret}}, }, }) expectRejectedUpdate("intra-add-beside-bolt", func(c *memgraphcomv1alpha1.MemgraphCluster) { - c.Spec.TLS.IntraCluster = &memgraphcomv1alpha1.IntraClusterTLSSpec{SecretName: intraSecret} + c.Spec.TLS.IntraCluster = &memgraphcomv1alpha1.IntraClusterTLSSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: intraSecret}} }, pinMessage) }) @@ -1213,8 +1213,8 @@ var _ = Describe("MemgraphCluster CRD validation", func() { It("should accept a different Secret, and bolt coming and going", func() { createAccepted("intra-secret-change", withIntraCluster) Expect(update("intra-secret-change", func(c *memgraphcomv1alpha1.MemgraphCluster) { - c.Spec.TLS.IntraCluster.SecretName = "intra-cluster-tls-2027" - c.Spec.TLS.Bolt = &memgraphcomv1alpha1.BoltTLSSpec{SecretName: boltSecret} + c.Spec.TLS.IntraCluster.SecretRef.Name = "intra-cluster-tls-2027" + c.Spec.TLS.Bolt = &memgraphcomv1alpha1.BoltTLSSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: boltSecret}} })).To(Succeed()) Expect(update("intra-secret-change", func(c *memgraphcomv1alpha1.MemgraphCluster) { c.Spec.TLS.Bolt = nil diff --git a/internal/resources/monitoring_test.go b/internal/resources/monitoring_test.go index 6244332..25b97bb 100644 --- a/internal/resources/monitoring_test.go +++ b/internal/resources/monitoring_test.go @@ -261,7 +261,7 @@ func TestUsesGrafanaDashboard(t *testing.T) { func TestServiceMonitorBoltTLS(t *testing.T) { cluster := monitoredCluster() cluster.Spec.TLS = &memgraphcomv1alpha1.TLSSpec{ - Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretName: boltTLSSecretName}, + Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: boltTLSSecretName}}, } want := expectedServiceMonitor() diff --git a/internal/resources/resources.go b/internal/resources/resources.go index 3168d33..01c2094 100644 --- a/internal/resources/resources.go +++ b/internal/resources/resources.go @@ -447,13 +447,13 @@ func normalize(spec memgraphcomv1alpha1.MemgraphClusterSpec) normalizedSpec { n.monitoring.vector = normalizeVector(spec.Monitoring.VectorRemote) } if spec.TLS != nil && spec.TLS.Bolt != nil { - n.boltTLSSecret = spec.TLS.Bolt.SecretName + n.boltTLSSecret = spec.TLS.Bolt.SecretRef.Name } if spec.TLS != nil && spec.TLS.IntraCluster != nil { - n.intraClusterTLSSecret = spec.TLS.IntraCluster.SecretName + n.intraClusterTLSSecret = spec.TLS.IntraCluster.SecretRef.Name } if spec.AWSCredentials != nil { - n.awsCredentialsSecret = spec.AWSCredentials.SecretName + n.awsCredentialsSecret = spec.AWSCredentials.SecretRef.Name } if rule := spec.Scheduling.PodAntiAffinity; rule != nil { n.podAntiAffinity = &normalizedPodAntiAffinity{ @@ -629,7 +629,7 @@ func normalizeVMAgent(block *memgraphcomv1alpha1.VMAgentRemoteSpec) *normalizedV resources: block.Resources, } if block.RemoteWrite.BasicAuth != nil { - n.basicAuthSecret = block.RemoteWrite.BasicAuth.SecretName + n.basicAuthSecret = block.RemoteWrite.BasicAuth.SecretRef.Name } return n } @@ -657,7 +657,7 @@ func normalizeVector(block *memgraphcomv1alpha1.VectorRemoteSpec) *normalizedVec resources: block.Resources, } if block.Auth != nil { - n.authSecret = block.Auth.SecretName + n.authSecret = block.Auth.SecretRef.Name } return n } diff --git a/internal/resources/statefulset_test.go b/internal/resources/statefulset_test.go index 76c43b5..fadf642 100644 --- a/internal/resources/statefulset_test.go +++ b/internal/resources/statefulset_test.go @@ -1712,7 +1712,7 @@ func TestStatefulSetExtraEnv(t *testing.T) { func TestStatefulSetAWSCredentials(t *testing.T) { const secretName = "aws-s3-credentials" cluster := minimalCluster() - cluster.Spec.AWSCredentials = &memgraphcomv1alpha1.AWSCredentialsSpec{SecretName: secretName} + cluster.Spec.AWSCredentials = &memgraphcomv1alpha1.AWSCredentialsSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: secretName}} fromSecret := func(name, key string) corev1.EnvVar { return corev1.EnvVar{ @@ -1770,7 +1770,7 @@ func TestStatefulSetAWSCredentials(t *testing.T) { func TestStatefulSetBoltTLS(t *testing.T) { cluster := minimalCluster() cluster.Spec.TLS = &memgraphcomv1alpha1.TLSSpec{ - Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretName: boltTLSSecretName}, + Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: boltTLSSecretName}}, } wantVolumes := func(component string) []corev1.Volume { @@ -1832,7 +1832,7 @@ func TestStatefulSetBoltTLS(t *testing.T) { func TestStatefulSetFlagFileComesFirst(t *testing.T) { cluster := minimalCluster() cluster.Spec.TLS = &memgraphcomv1alpha1.TLSSpec{ - Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretName: boltTLSSecretName}, + Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: boltTLSSecretName}}, } for _, tc := range []struct { @@ -1862,7 +1862,7 @@ func TestStatefulSetFlagFileComesFirst(t *testing.T) { func TestStatefulSetIntraClusterTLS(t *testing.T) { cluster := minimalCluster() cluster.Spec.TLS = &memgraphcomv1alpha1.TLSSpec{ - IntraCluster: &memgraphcomv1alpha1.IntraClusterTLSSpec{SecretName: intraTLSSecretName}, + IntraCluster: &memgraphcomv1alpha1.IntraClusterTLSSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: intraTLSSecretName}}, } wantVolumes := func(component string) []corev1.Volume { @@ -1921,8 +1921,8 @@ func TestStatefulSetIntraClusterTLS(t *testing.T) { func TestStatefulSetBothTLSModes(t *testing.T) { cluster := minimalCluster() cluster.Spec.TLS = &memgraphcomv1alpha1.TLSSpec{ - Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretName: boltTLSSecretName}, - IntraCluster: &memgraphcomv1alpha1.IntraClusterTLSSpec{SecretName: intraTLSSecretName}, + Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: boltTLSSecretName}}, + IntraCluster: &memgraphcomv1alpha1.IntraClusterTLSSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: intraTLSSecretName}}, } podSpec := dataStatefulSet(cluster).Spec.Template.Spec diff --git a/internal/resources/topology_test.go b/internal/resources/topology_test.go index 5f2444d..2dd401b 100644 --- a/internal/resources/topology_test.go +++ b/internal/resources/topology_test.go @@ -411,7 +411,7 @@ func TestCoordinatorEndpoints(t *testing.T) { func TestDataEndpoints(t *testing.T) { cluster := minimalCluster() cluster.Spec.TLS = &memgraphcomv1alpha1.TLSSpec{ - Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretName: boltTLSSecretName}, + Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: boltTLSSecretName}}, } want := make([]resources.Endpoint, 0, 3) diff --git a/internal/resources/vector_test.go b/internal/resources/vector_test.go index c623db0..9e8b65c 100644 --- a/internal/resources/vector_test.go +++ b/internal/resources/vector_test.go @@ -55,7 +55,7 @@ func vectorCluster() *memgraphcomv1alpha1.MemgraphCluster { PullPolicy: corev1.PullAlways, }, LogsEndpoint: logsEndpoint, - Auth: &memgraphcomv1alpha1.BasicAuthSecretSpec{SecretName: logsAuthSecret}, + Auth: &memgraphcomv1alpha1.BasicAuthSecretSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: logsAuthSecret}}, ExtraLabels: map[string]string{"cluster_id": "production", "role": "overridden"}, Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("64Mi")}, @@ -169,7 +169,7 @@ func TestVectorConfigMapUnauthenticated(t *testing.T) { func TestVectorConfigMapBoltTLS(t *testing.T) { cluster := vectorCluster() cluster.Spec.TLS = &memgraphcomv1alpha1.TLSSpec{ - Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretName: boltTLSSecretName}, + Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: boltTLSSecretName}}, } want := expectedVectorConfigMap() diff --git a/internal/resources/vmagent_test.go b/internal/resources/vmagent_test.go index 45b0a0b..2ddb593 100644 --- a/internal/resources/vmagent_test.go +++ b/internal/resources/vmagent_test.go @@ -56,7 +56,7 @@ func vmagentCluster() *memgraphcomv1alpha1.MemgraphCluster { }, RemoteWrite: memgraphcomv1alpha1.RemoteWriteSpec{ URL: remoteWriteURL, - BasicAuth: &memgraphcomv1alpha1.BasicAuthSecretSpec{SecretName: basicAuthSecret}, + BasicAuth: &memgraphcomv1alpha1.BasicAuthSecretSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: basicAuthSecret}}, }, ScrapeInterval: "30s", ExternalLabels: map[string]string{"cluster": "production", "team": "platform"}, @@ -154,7 +154,7 @@ scrape_configs: func TestVMAgentConfigMapBoltTLS(t *testing.T) { cluster := vmagentCluster() cluster.Spec.TLS = &memgraphcomv1alpha1.TLSSpec{ - Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretName: boltTLSSecretName}, + Bolt: &memgraphcomv1alpha1.BoltTLSSpec{SecretRef: memgraphcomv1alpha1.SecretReference{Name: boltTLSSecretName}}, } want := expectedVMAgentConfigMap() diff --git a/test/e2e/memgraphcluster_test.go b/test/e2e/memgraphcluster_test.go index 66d593c..d3aa27d 100644 --- a/test/e2e/memgraphcluster_test.go +++ b/test/e2e/memgraphcluster_test.go @@ -467,7 +467,7 @@ var _ = Describe("MemgraphCluster", Ordered, func() { By("adding the vmagentRemote block") cmd = exec.Command("kubectl", "patch", "memgraphcluster", quickstartCluster.name, "-n", clusterNamespace, "--type=merge", "-p", - fmt.Sprintf(`{"spec":{"monitoring":{"vmagentRemote":{"remoteWrite":{"url":%q,"basicAuth":{"secretName":%q}},`+ + fmt.Sprintf(`{"spec":{"monitoring":{"vmagentRemote":{"remoteWrite":{"url":%q,"basicAuth":{"secretRef":{"name":%q}}},`+ `"externalLabels":{"cluster":"e2e"}}}}}`, remoteWriteURL, basicAuthSecret)) _, err = utils.Run(cmd) Expect(err).NotTo(HaveOccurred()) @@ -571,7 +571,7 @@ var _ = Describe("MemgraphCluster", Ordered, func() { By("adding the vectorRemote block") cmd = exec.Command("kubectl", "patch", "memgraphcluster", quickstartCluster.name, "-n", clusterNamespace, "--type=merge", "-p", - fmt.Sprintf(`{"spec":{"monitoring":{"vectorRemote":{"logsEndpoint":%q,"auth":{"secretName":%q},`+ + fmt.Sprintf(`{"spec":{"monitoring":{"vectorRemote":{"logsEndpoint":%q,"auth":{"secretRef":{"name":%q}},`+ `"extraLabels":{"cluster_id":"e2e"}}}}}`, logsEndpoint, authSecret)) _, err = utils.Run(cmd) Expect(err).NotTo(HaveOccurred()) diff --git a/test/e2e/tls_test.go b/test/e2e/tls_test.go index 388a907..c308295 100644 --- a/test/e2e/tls_test.go +++ b/test/e2e/tls_test.go @@ -128,7 +128,8 @@ spec: serviceMonitor: {} tls: intraCluster: - secretName: %s + secretRef: + name: %s `, tlsClusterName, tlsNamespace, plain.coordinators, plain.dataInstances, example.Spec.Image.Repository, example.Spec.Image.Tag, licenseSecretName, intraSecretName) cmd := exec.Command("kubectl", "apply", "-f", "-") @@ -174,7 +175,7 @@ spec: By("adding the tls.bolt block") cmd := exec.Command("kubectl", "patch", "memgraphcluster", tlsClusterName, "-n", tlsNamespace, "--type=merge", "-p", - fmt.Sprintf(`{"spec":{"tls":{"bolt":{"secretName":"%s"}}}}`, tlsSecretName)) + fmt.Sprintf(`{"spec":{"tls":{"bolt":{"secretRef":{"name":"%s"}}}}}`, tlsSecretName)) _, err = utils.Run(cmd) Expect(err).NotTo(HaveOccurred(), "the operator must accept the tls.bolt block")