diff --git a/.claude/skills/grill-me/SKILL.md b/.claude/skills/grill-me/SKILL.md new file mode 100644 index 0000000..abbf089 --- /dev/null +++ b/.claude/skills/grill-me/SKILL.md @@ -0,0 +1,12 @@ +--- +name: grill-me +description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me". +--- + +Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. + +Ask the questions one at a time, waiting for the feedback on each question before continuing. Asking multiple questions at once is bewildering. + +If a *fact* can be answered by exploring the codebase, look it up rather than asking me. The *decisions*, though, are mine - put each one to me and wait for my answer. + +Do not enact the plan until I confirm we have reached a shared understanding. diff --git a/.custom-gcl.yml b/.custom-gcl.yml new file mode 100644 index 0000000..d9ae33b --- /dev/null +++ b/.custom-gcl.yml @@ -0,0 +1,11 @@ +# This file configures golangci-lint with module plugins. +# When you run 'make lint', it will automatically build a custom golangci-lint binary +# with all the plugins listed below. +# +# See: https://golangci-lint.run/plugins/module-plugins/ +version: v2.12.2 +plugins: + # logcheck validates structured logging calls and parameters (e.g., balanced key-value pairs) + - module: "sigs.k8s.io/logtools" + import: "sigs.k8s.io/logtools/logcheck/gclplugin" + version: latest diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..a96838b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,35 @@ +{ + "name": "Kubebuilder DevContainer", + "image": "golang:1.26", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "moby": false, + "dockerDefaultAddressPool": "base=172.30.0.0/16,size=24" + }, + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/common-utils:2": { + "upgradePackages": true + } + }, + + "runArgs": ["--privileged", "--init"], + + "customizations": { + "vscode": { + "settings": { + "terminal.integrated.shell.linux": "/bin/bash" + }, + "extensions": [ + "ms-kubernetes-tools.vscode-kubernetes-tools", + "ms-azuretools.vscode-docker" + ] + } + }, + + "remoteEnv": { + "GO111MODULE": "on" + }, + + "onCreateCommand": "bash .devcontainer/post-install.sh" +} + diff --git a/.devcontainer/post-install.sh b/.devcontainer/post-install.sh new file mode 100644 index 0000000..6d75a49 --- /dev/null +++ b/.devcontainer/post-install.sh @@ -0,0 +1,153 @@ +#!/bin/bash +set -euo pipefail + +echo "====================================" +echo "Kubebuilder DevContainer Setup" +echo "====================================" + +# Verify running as root (required for installing to /usr/local/bin and /etc) +if [ "$(id -u)" -ne 0 ]; then + echo "ERROR: This script must be run as root" + exit 1 +fi + +echo "" +echo "Detecting system architecture..." +# Detect architecture using uname +MACHINE=$(uname -m) +case "${MACHINE}" in + x86_64) + ARCH="amd64" + ;; + aarch64|arm64) + ARCH="arm64" + ;; + *) + echo "WARNING: Unsupported architecture ${MACHINE}, defaulting to amd64" + ARCH="amd64" + ;; +esac +echo "Architecture: ${ARCH}" + +echo "" +echo "------------------------------------" +echo "Setting up bash completion..." +echo "------------------------------------" + +BASH_COMPLETIONS_DIR="/usr/share/bash-completion/completions" + +# Enable bash-completion in root's .bashrc (devcontainer runs as root) +if ! grep -q "source /usr/share/bash-completion/bash_completion" ~/.bashrc 2>/dev/null; then + echo 'source /usr/share/bash-completion/bash_completion' >> ~/.bashrc + echo "Added bash-completion to .bashrc" +fi + +echo "" +echo "------------------------------------" +echo "Installing development tools..." +echo "------------------------------------" + +# Install kind +if ! command -v kind &> /dev/null; then + echo "Installing kind..." + curl -Lo /usr/local/bin/kind "https://kind.sigs.k8s.io/dl/latest/kind-linux-${ARCH}" + chmod +x /usr/local/bin/kind + echo "kind installed successfully" +fi + +# Generate kind bash completion +if command -v kind &> /dev/null; then + if kind completion bash > "${BASH_COMPLETIONS_DIR}/kind" 2>/dev/null; then + echo "kind completion installed" + else + echo "WARNING: Failed to generate kind completion" + fi +fi + +# Install kubebuilder +if ! command -v kubebuilder &> /dev/null; then + echo "Installing kubebuilder..." + curl -Lo /usr/local/bin/kubebuilder "https://go.kubebuilder.io/dl/latest/linux/${ARCH}" + chmod +x /usr/local/bin/kubebuilder + echo "kubebuilder installed successfully" +fi + +# Generate kubebuilder bash completion +if command -v kubebuilder &> /dev/null; then + if kubebuilder completion bash > "${BASH_COMPLETIONS_DIR}/kubebuilder" 2>/dev/null; then + echo "kubebuilder completion installed" + else + echo "WARNING: Failed to generate kubebuilder completion" + fi +fi + +# Install kubectl +if ! command -v kubectl &> /dev/null; then + echo "Installing kubectl..." + KUBECTL_VERSION=$(curl -Ls https://dl.k8s.io/release/stable.txt) + curl -Lo /usr/local/bin/kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl" + chmod +x /usr/local/bin/kubectl + echo "kubectl installed successfully" +fi + +# Generate kubectl bash completion +if command -v kubectl &> /dev/null; then + if kubectl completion bash > "${BASH_COMPLETIONS_DIR}/kubectl" 2>/dev/null; then + echo "kubectl completion installed" + else + echo "WARNING: Failed to generate kubectl completion" + fi +fi + +# Generate Docker bash completion +if command -v docker &> /dev/null; then + if docker completion bash > "${BASH_COMPLETIONS_DIR}/docker" 2>/dev/null; then + echo "docker completion installed" + else + echo "WARNING: Failed to generate docker completion" + fi +fi + +echo "" +echo "------------------------------------" +echo "Configuring Docker environment..." +echo "------------------------------------" + +# Wait for Docker to be ready +echo "Waiting for Docker to be ready..." +for i in {1..30}; do + if docker info >/dev/null 2>&1; then + echo "Docker is ready" + break + fi + if [ "$i" -eq 30 ]; then + echo "WARNING: Docker not ready after 30s" + fi + sleep 1 +done + +# Create kind network (ignore if already exists) +if ! docker network inspect kind >/dev/null 2>&1; then + if docker network create kind >/dev/null 2>&1; then + echo "Created kind network" + else + echo "WARNING: Failed to create kind network (may already exist)" + fi +fi + +echo "" +echo "------------------------------------" +echo "Verifying installations..." +echo "------------------------------------" +kind version +kubebuilder version +kubectl version --client +docker --version +go version + +echo "" +echo "====================================" +echo "DevContainer ready!" +echo "====================================" +echo "All development tools installed successfully." +echo "You can now start building Kubernetes operators." diff --git a/.dockerignore b/.dockerignore index a3aab7a..9af8280 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,11 @@ # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file -# Ignore build and test binaries. -bin/ +# Ignore everything by default and re-include only needed files +** + +# Re-include Go source files (but not *_test.go) +!**/*.go +**/*_test.go + +# Re-include Go module files +!go.mod +!go.sum diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index adcfaf6..0000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -as51340 diff --git a/.github/config_files/config_lint.yaml b/.github/config_files/config_lint.yaml deleted file mode 100644 index ac873af..0000000 --- a/.github/config_files/config_lint.yaml +++ /dev/null @@ -1,11 +0,0 @@ -checks: - addAllBuiltIn: true - exclude: - - "non-existent-service-account" # because the service account is created in another file - - "minimum-three-replicas" # because the deployment contains only 1 replica of the operator - - "no-liveness-probe" # not necessary - - "no-readiness-probe" # no necessary - - "use-namespace" - - "dnsconfig-options" - - "no-node-affinity" - - "non-isolated-pod" diff --git a/.github/workflows/kubelint.yaml b/.github/workflows/kubelint.yaml deleted file mode 100644 index cc12160..0000000 --- a/.github/workflows/kubelint.yaml +++ /dev/null @@ -1,65 +0,0 @@ -name: Kubelinter-check - -on: - push: - branches: - - main - paths-ignore: - - docs/** - pull_request: - branches: - - main - workflow_dispatch: {} - -env: - KUBELINTER_VERSION: "143183121" - -jobs: - Kubelinter-check: - name: Run Kube-linter check - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v2 - - - name: Scan directory ./config/crd/ with kube-linter - uses: stackrox/kube-linter-action@v1.0.3 - with: - directory: config/crd - config: ${GITHUB_WORKSPACE}/.github/config_files/config_lint.yaml - version: $KUBELINTER_VERSION - - - name: Scan directory ./config/default/ with kube-linter - uses: stackrox/kube-linter-action@v1.0.3 - with: - directory: config/default - config: ${GITHUB_WORKSPACE}/.github/config_files/config_lint.yaml - version: $KUBELINTER_VERSION - - - name: Scan directory ./config/manager/ with kube-linter - uses: stackrox/kube-linter-action@v1.0.3 - with: - directory: config/manager - config: ${GITHUB_WORKSPACE}/.github/config_files/config_lint.yaml - version: $KUBELINTER_VERSION - - - name: Scan directory ./config/manifests/ with kube-linter - uses: stackrox/kube-linter-action@v1.0.3 - with: - directory: config/manifests - config: ${GITHUB_WORKSPACE}/.github/config_files/config_lint.yaml - version: $KUBELINTER_VERSION - - - name: Scan directory ./config/samples/ with kube-linter - uses: stackrox/kube-linter-action@v1.0.3 - with: - directory: config/samples - config: ${GITHUB_WORKSPACE}/.github/config_files/config_lint.yaml - version: $KUBELINTER_VERSION - - - name: Scan directory ./config/scorecard/ with kube-linter - uses: stackrox/kube-linter-action@v1.0.3 - with: - directory: config/scorecard - config: ${GITHUB_WORKSPACE}/.github/config_files/config_lint.yaml - version: $KUBELINTER_VERSION diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..06b19df --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,31 @@ +name: Lint + +on: + push: + branches: + - main + pull_request: + +permissions: {} + +jobs: + lint: + permissions: + contents: read + name: Run on Ubuntu + runs-on: ubuntu-latest + steps: + - name: Clone the code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: go.mod + + - name: Check linter configuration + run: make lint-config + - name: Run linter + run: make lint diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml deleted file mode 100644 index 3e45bf4..0000000 --- a/.github/workflows/pre-commit.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: Pre-commit - -on: pull_request - -jobs: - pre-commit: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 2 # fetches all history so pre-commit can run properly - - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: '3.9' # Use Python 3.9 - - - name: Install pre-commit - run: pip install pre-commit - - - name: Run pre-commit - run: pre-commit run --all-files --show-diff-on-failure - - - name: Cache the pre-commit environment - uses: actions/cache@v3 - with: - path: ~/.cache/pre-commit - key: ${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e7dc7f6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,571 @@ +name: Release + +# The path from a version tag to installable artifacts: the operator image on +# Docker Hub, and the install chart in the memgraph.github.io/helm-charts index +# users already have configured. +# +# The chart version and the operator version move independently, so a tag names +# which of the two it releases: +# +# v0.2.0 an operator release -- builds and publishes the image, and +# publishes the chart alongside it. Must equal the appVersion. +# chart-0.4.2 a chart-only release -- no image is built, and the chart keeps +# pointing at the operator its appVersion already names. Must +# equal the chart version. +# +# Running this from the Actions tab instead is a dry run: everything is built, +# packaged and installed on Kind, and nothing is published. Tagging a SemVer +# prerelease (v0.2.0-rc.1) is the other rehearsal -- it publishes for real, but +# helm hides prereleases from `helm install` unless --devel is passed, so the +# public index is exercised without being disturbed. +# +# See docs/releasing.md for the procedure and the credentials this needs. + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-*' + - 'chart-[0-9]+.[0-9]+.[0-9]+' + - 'chart-[0-9]+.[0-9]+.[0-9]+-*' + workflow_dispatch: + inputs: + publish: + description: 'Publish for real, instead of dry-running the pipeline.' + type: boolean + default: false + +permissions: {} + +# Releases are never cancelled halfway: a run stopped between pushing the image +# and indexing the chart leaves the two out of step. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + IMAGE_REPOSITORY: docker.io/memgraph/kubernetes-operator + CHARTS_REPO: memgraph/helm-charts + CHARTS_INDEX_URL: https://memgraph.github.io/helm-charts + CHART_NAME: memgraph-operator + CHART_NAMESPACE: memgraph-operator-system + +jobs: + resolve: + name: Resolve the release + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + kind: ${{ steps.resolve.outputs.kind }} + chart_version: ${{ steps.resolve.outputs.chart_version }} + app_version: ${{ steps.resolve.outputs.app_version }} + version: ${{ steps.resolve.outputs.version }} + prerelease: ${{ steps.resolve.outputs.prerelease }} + publish: ${{ steps.resolve.outputs.publish }} + steps: + - name: Clone the code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: go.mod + + # The chart declares both versions; a tag may only name one of them, and + # has to agree with what the chart says. Publishing a version the tagged + # tree does not declare is the one way to produce artifacts nobody can + # trace back to a commit, so it fails here, before anything is built. + - name: Resolve the versions + id: resolve + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + INPUT_PUBLISH: ${{ inputs.publish }} + run: | + set -euo pipefail + chart_version=$(make -s chart-version) + app_version=$(make -s chart-app-version) + + if [ "${REF_TYPE}" = tag ]; then + publish=true + case "${REF_NAME}" in + chart-*) kind=chart; tag_version=${REF_NAME#chart-} ;; + *) kind=operator; tag_version=${REF_NAME#v} ;; + esac + else + # No tag to go on, so the run releases the operator the chart + # currently declares. Dry runs take this path. + publish=${INPUT_PUBLISH:-false} + kind=operator + tag_version="" + fi + + if [ "${kind}" = operator ]; then + version=${app_version} + declared="appVersion" + else + version=${chart_version} + declared="version" + fi + + if [ -n "${tag_version}" ] && [ "${tag_version}" != "${version}" ]; then + echo "::error::Tag ${REF_NAME} releases the ${kind}, but the chart's ${declared} is ${version}, not ${tag_version}. Bump the chart on a pull request first, then tag the merge commit." + exit 1 + fi + + case "${version}" in + *-*) prerelease=true ;; + *) prerelease=false ;; + esac + + { + echo "kind=${kind}" + echo "chart_version=${chart_version}" + echo "app_version=${app_version}" + echo "version=${version}" + echo "prerelease=${prerelease}" + echo "publish=${publish}" + } >>"${GITHUB_OUTPUT}" + + { + echo "### ${kind} release ${version}" + echo + echo "| | |" + echo "|---|---|" + echo "| chart version | \`${chart_version}\` |" + echo "| operator version (appVersion) | \`${app_version}\` |" + echo "| prerelease | \`${prerelease}\` |" + echo "| publishing | \`${publish}\` |" + } >>"${GITHUB_STEP_SUMMARY}" + + # Failing here beats failing after the image is already on Docker Hub. + - name: Check the release credentials are present + if: steps.resolve.outputs.publish == 'true' + env: + KIND: ${{ steps.resolve.outputs.kind }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + HELM_CHARTS_TOKEN: ${{ secrets.HELM_CHARTS_TOKEN }} + run: | + set -euo pipefail + missing=() + [ -n "${HELM_CHARTS_TOKEN}" ] || missing+=(HELM_CHARTS_TOKEN) + if [ "${KIND}" = operator ]; then + [ -n "${DOCKERHUB_USERNAME}" ] || missing+=(DOCKERHUB_USERNAME) + [ -n "${DOCKERHUB_TOKEN}" ] || missing+=(DOCKERHUB_TOKEN) + fi + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Missing repository secrets: ${missing[*]}. See docs/releasing.md for what they are and how to create them." + exit 1 + fi + + image: + name: Operator image + needs: resolve + if: needs.resolve.outputs.kind == 'operator' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Clone the code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # Published tags are immutable, by convention and by the trust users place + # in them -- including the 0.0.x and 1.0.0 tags in this repository, which + # belong to the operator attempt archived on archive/pre-operator-mvp. + # + # An image built from this very commit is the exception: it is this same + # release's earlier attempt, from a run that failed after the push. That + # one is left alone and the build skipped, so re-running finishes the + # release instead of being stopped by its own progress. The revision + # label is what tells the two apart. + - name: Check whether the image tag is already published + id: existing + if: needs.resolve.outputs.publish == 'true' + env: + IMAGE: ${{ env.IMAGE_REPOSITORY }}:${{ needs.resolve.outputs.version }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + if ! docker manifest inspect "${IMAGE}" >/dev/null 2>&1; then + echo "published=false" >>"${GITHUB_OUTPUT}" + echo "${IMAGE} is free." + exit 0 + fi + + # Single-platform images inspect as a config object; multi-platform + # ones as a map keyed by platform. + revision=$(docker buildx imagetools inspect "${IMAGE}" --format '{{ json .Image }}' 2>/dev/null | + jq -r 'if has("config") then . else (to_entries | .[0].value) end + | .config.Labels["org.opencontainers.image.revision"] // empty' 2>/dev/null || true) + + if [ "${revision}" = "${SHA}" ]; then + echo "published=true" >>"${GITHUB_OUTPUT}" + echo "::notice::${IMAGE} was already pushed from ${SHA}; skipping the build and carrying on." + exit 0 + fi + + echo "::error::${IMAGE} already exists and was not built from ${SHA}. Bump the chart's appVersion, or release the chart alone with a chart- tag." + exit 1 + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to Docker Hub + if: needs.resolve.outputs.publish == 'true' + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # latest follows stable releases only, so a release candidate never + # becomes what an unqualified pull resolves to. + - name: Work out the image tags + id: tags + env: + VERSION: ${{ needs.resolve.outputs.version }} + PRERELEASE: ${{ needs.resolve.outputs.prerelease }} + run: | + set -euo pipefail + { + echo "tags<>"${GITHUB_OUTPUT}" + + - name: Build and push the operator image + if: needs.resolve.outputs.publish == 'true' && steps.existing.outputs.published != 'true' + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.tags.outputs.tags }} + labels: | + org.opencontainers.image.title=memgraph-operator + org.opencontainers.image.description=Kubernetes operator for Memgraph high-availability clusters + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ needs.resolve.outputs.version }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # A dry run still builds both architectures, because a broken arm64 build + # is exactly the kind of thing a rehearsal is for. + - name: Build the operator image without pushing + if: needs.resolve.outputs.publish != 'true' + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: false + tags: ${{ steps.tags.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # ... and then exports the amd64 image alone, so the verify job can load + # it into Kind and install the chart with its own default image tag. The + # layers are already cached by the build above. + - name: Export the image for the verify job + if: needs.resolve.outputs.publish != 'true' + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: linux/amd64 + tags: ${{ env.IMAGE_REPOSITORY }}:${{ needs.resolve.outputs.version }} + outputs: type=docker,dest=/tmp/operator-image.tar + cache-from: type=gha + + - name: Upload the image + if: needs.resolve.outputs.publish != 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: operator-image + path: /tmp/operator-image.tar + retention-days: 1 + + chart: + name: Install chart + needs: [resolve, image] + # The image job is skipped for a chart-only release; the chart still ships. + if: always() && needs.resolve.result == 'success' && needs.image.result != 'failure' && needs.image.result != 'cancelled' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Clone the code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # The chart-only path compares the generated manifests against the + # tag its appVersion names, which needs the tags and their history. + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: go.mod + + # The chart ships CRDs and RBAC rules generated from the Go sources. At a + # release this is not a style check: it is what makes the chart's CRD the + # one this operator's types describe. + - name: Verify the generated chart manifests + run: make chart-verify + + - name: Lint the chart + run: make helm-lint + + - name: Test the cross-publish path + run: make test-chart-publish + + # A chart is only installable if the operator image its appVersion names + # is actually on Docker Hub. For an operator release the image job just + # pushed it; for a chart-only release it has to already be there. + - name: Check the operator image the chart installs exists + if: needs.resolve.outputs.kind == 'chart' && needs.resolve.outputs.publish == 'true' + env: + IMAGE: ${{ env.IMAGE_REPOSITORY }}:${{ needs.resolve.outputs.app_version }} + run: | + set -euo pipefail + if ! docker manifest inspect "${IMAGE}" >/dev/null 2>&1; then + echo "::error::The chart's appVersion names ${IMAGE}, which does not exist. Release the operator first with a v tag." + exit 1 + fi + echo "${IMAGE} exists." + + # The chart's CRDs come from the commit being released, its appVersion + # from whenever the operator was last released. Nothing forces those to + # be the same commit, so a chart-only release can ship a CRD the operator + # it installs has never seen. This reports it rather than refusing it -- + # the two versions are deliberately independent -- but it is worth a look + # before the release goes out. + - name: Report CRD drift against the operator being installed + if: needs.resolve.outputs.kind == 'chart' + env: + APP_VERSION: ${{ needs.resolve.outputs.app_version }} + run: | + set -euo pipefail + tag="v${APP_VERSION}" + if ! git rev-parse --verify --quiet "${tag}^{commit}" >/dev/null; then + echo "No ${tag} tag to compare against; skipping." >>"${GITHUB_STEP_SUMMARY}" + exit 0 + fi + if git diff --quiet "${tag}" -- charts/memgraph-operator/crds; then + echo "CRDs are unchanged since operator ${APP_VERSION}." >>"${GITHUB_STEP_SUMMARY}" + else + echo "::warning::The chart's CRDs differ from those released with operator ${APP_VERSION}, which is the operator this chart installs." + { + echo + echo "> **CRD drift**: this chart ships CRDs that changed since \`${tag}\`," + echo "> but installs operator \`${APP_VERSION}\`. Check the operator understands them." + echo + echo '```' + git diff --stat "${tag}" -- charts/memgraph-operator/crds + echo '```' + } >>"${GITHUB_STEP_SUMMARY}" + fi + + - name: Package the chart + if: needs.resolve.outputs.publish != 'true' + env: + VERSION: ${{ needs.resolve.outputs.chart_version }} + run: ./hack/chart-publish.sh --version "${VERSION}" + + - name: Package the chart and publish it to the Memgraph helm repository + if: needs.resolve.outputs.publish == 'true' + env: + VERSION: ${{ needs.resolve.outputs.chart_version }} + # Scoped to contents write on the charts repository only; see + # docs/releasing.md for its scope and rotation. + GH_TOKEN: ${{ secrets.HELM_CHARTS_TOKEN }} + run: | + set -euo pipefail + # Lets git push to the charts repository with the same token, without + # it ever appearing in a URL or a log line. + gh auth setup-git + ./hack/chart-publish.sh --version "${VERSION}" --publish + + - name: Upload the packaged chart + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: chart-package + path: dist/chart/*.tgz + retention-days: 7 + + verify: + name: Verify the install + needs: [resolve, image, chart] + if: always() && needs.chart.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Clone the code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: go.mod + + - name: Install Kind + run: go install sigs.k8s.io/kind@v0.32.0 + + - name: Create a Kind cluster + run: kind create cluster --name release-verify + + # The published path: install exactly as a user would, from the helm + # repository they already have configured, pulling the image that was + # just pushed. GitHub Pages takes a minute or two to serve a new index. + - name: Wait for the chart to appear in the Memgraph helm repository + if: needs.resolve.outputs.publish == 'true' + env: + VERSION: ${{ needs.resolve.outputs.chart_version }} + run: | + set -euo pipefail + helm repo add memgraph "${CHARTS_INDEX_URL}" + for attempt in $(seq 1 30); do + helm repo update memgraph >/dev/null + if helm search repo "memgraph/${CHART_NAME}" --version "${VERSION}" --devel | + grep -q "${VERSION}"; then + echo "The index serves ${CHART_NAME} ${VERSION} (attempt ${attempt})." + exit 0 + fi + sleep 20 + done + echo "::error::${CHARTS_INDEX_URL} did not serve ${CHART_NAME} ${VERSION} within 10 minutes." + exit 1 + + # The dry-run path: the image was never pushed, so it is loaded into Kind + # under the exact tag the chart's appVersion resolves to. The chart is + # then installed with its own defaults, image reference included. + - name: Download the locally built image + if: needs.resolve.outputs.publish != 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: operator-image + path: /tmp + + - name: Load the image into Kind + if: needs.resolve.outputs.publish != 'true' + run: | + set -euo pipefail + docker load --input /tmp/operator-image.tar + kind load docker-image "${IMAGE_REPOSITORY}:${{ needs.resolve.outputs.app_version }}" \ + --name release-verify + + - name: Download the packaged chart + if: needs.resolve.outputs.publish != 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: chart-package + path: dist/chart + + - name: Install the operator + env: + PUBLISH: ${{ needs.resolve.outputs.publish }} + VERSION: ${{ needs.resolve.outputs.chart_version }} + run: | + set -euo pipefail + if [ "${PUBLISH}" = true ]; then + chart="memgraph/${CHART_NAME}" + set -- --version "${VERSION}" --devel + else + chart="dist/chart/${CHART_NAME}-${VERSION}.tgz" + set -- + fi + helm install "${CHART_NAME}" "${chart}" "$@" \ + --namespace "${CHART_NAMESPACE}" --create-namespace \ + --wait --timeout 5m + + # What the release actually promises: installing chart runs the + # operator its appVersion names. A template bug would satisfy every string + # comparison made so far and still fail here. + - name: Check the running operator is the released image + env: + EXPECTED: ${{ env.IMAGE_REPOSITORY }}:${{ needs.resolve.outputs.app_version }} + run: | + set -euo pipefail + actual=$(kubectl get deployment "${CHART_NAME}-controller-manager" \ + --namespace "${CHART_NAMESPACE}" \ + -o jsonpath='{.spec.template.spec.containers[?(@.name=="manager")].image}') + if [ "${actual}" != "${EXPECTED}" ]; then + echo "::error::The chart installed ${actual}, expected ${EXPECTED}." + exit 1 + fi + kubectl get crd memgraphclusters.memgraph.com >/dev/null + echo "Installed ${actual}, with the MemgraphCluster CRD." + + - name: Uninstall + if: always() + run: | + helm uninstall "${CHART_NAME}" --namespace "${CHART_NAMESPACE}" --ignore-not-found + kind delete cluster --name release-verify + + github-release: + name: GitHub release + needs: [resolve, chart, verify] + if: needs.resolve.outputs.publish == 'true' && github.ref_type == 'tag' + runs-on: ubuntu-latest + permissions: + # Creating the release for this tag. + contents: write + steps: + - name: Clone the code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download the packaged chart + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: chart-package + path: dist/chart + + - name: Create the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + KIND: ${{ needs.resolve.outputs.kind }} + CHART_VERSION: ${{ needs.resolve.outputs.chart_version }} + APP_VERSION: ${{ needs.resolve.outputs.app_version }} + PRERELEASE: ${{ needs.resolve.outputs.prerelease }} + run: | + set -euo pipefail + notes=$(cat </*_types.go CRD schemas (+kubebuilder markers) +api//zz_generated.* Auto-generated (DO NOT EDIT) +internal/controller/* Reconciliation logic +internal/webhook/* Validation/defaulting (if present) +config/crd/bases/* Generated CRDs (DO NOT EDIT) +config/rbac/role.yaml Generated RBAC (DO NOT EDIT) +config/samples/* Example CRs (edit these) +Makefile Build/test/deploy commands +PROJECT Kubebuilder metadata Auto-generated (DO NOT EDIT) +``` + +**Multi-group layout** (for projects with multiple API groups): +``` +api///*_types.go CRD schemas by group +internal/controller//* Controllers by group +internal/webhook///* Webhooks by group and version (if present) +``` + +Multi-group layout organizes APIs by group name (e.g., `batch`, `apps`). Check the `PROJECT` file for `multigroup: true`. + +**To convert to multi-group layout:** +1. Run: `kubebuilder edit --multigroup=true` +2. Move APIs: `mkdir -p api/ && mv api/ api//` +3. Move controllers: `mkdir -p internal/controller/ && mv internal/controller/*.go internal/controller//` +4. Move webhooks (if present): `mkdir -p internal/webhook/ && mv internal/webhook/ internal/webhook//` +5. Update import paths in all files +6. Fix `path` in `PROJECT` file for each resource +7. Update test suite CRD paths (add one more `..` to relative paths) + +## Critical Rules + +### Never Edit These (Auto-Generated) +- `config/crd/bases/*.yaml` - from `make manifests` +- `config/rbac/role.yaml` - from `make manifests` +- `config/webhook/manifests.yaml` - from `make manifests` +- `**/zz_generated.*.go` - from `make generate` +- `PROJECT` - from `kubebuilder [OPTIONS]` + +### Never Remove Scaffold Markers +Do NOT delete `// +kubebuilder:scaffold:*` comments. CLI injects code at these markers. + +### Keep Project Structure +Do not move files around. The CLI expects files in specific locations. + +### Always Use CLI Commands +Always use `kubebuilder create api` and `kubebuilder create webhook` to scaffold. Do NOT create files manually. + +### E2E Tests Require an Isolated Kind Cluster +The e2e tests are designed to validate the solution in an isolated environment (similar to GitHub Actions CI). +Ensure you run them against a dedicated [Kind](https://kind.sigs.k8s.io/) cluster (not your “real” dev/prod cluster). + +## After Making Changes + +**After editing `*_types.go` or markers:** +``` +make manifests # Regenerate CRDs/RBAC from markers +make generate # Regenerate DeepCopy methods +``` + +**After editing `*.go` files:** +``` +make lint-fix # Auto-fix code style +make test # Run unit tests +``` + +## CLI Commands Cheat Sheet + +### Create API (your own types) +```bash +kubebuilder create api --group --version --kind +``` + +### Deploy Image Plugin (scaffold to deploy/manage ANY container image) + +Generate a controller that deploys and manages a container image (nginx, redis, memcached, your app, etc.): + +```bash +# Example: deploying memcached +kubebuilder create api --group example.com --version v1alpha1 --kind Memcached \ + --image=memcached:alpine \ + --plugins=deploy-image.go.kubebuilder.io/v1-alpha +``` + +Scaffolds good-practice code: reconciliation logic, status conditions, finalizers, RBAC. Use as a reference implementation. + + +### Create Webhooks +```bash +# Validation + defaulting +kubebuilder create webhook --group --version --kind \ + --defaulting --programmatic-validation + +# Conversion webhook (for multi-version APIs) +kubebuilder create webhook --group --version v1 --kind \ + --conversion --spoke v2 +``` + +### Controller for Core Kubernetes Types +```bash +# Watch Pods +kubebuilder create api --group core --version v1 --kind Pod \ + --controller=true --resource=false + +# Watch Deployments +kubebuilder create api --group apps --version v1 --kind Deployment \ + --controller=true --resource=false +``` + +### Controller for External Types (e.g., from other operators) + +Watch resources from external APIs (cert-manager, Argo CD, Istio, etc.): + +```bash +# Example: watching cert-manager Certificate resources +kubebuilder create api \ + --group cert-manager --version v1 --kind Certificate \ + --controller=true --resource=false \ + --external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 \ + --external-api-domain=io \ + --external-api-module=github.com/cert-manager/cert-manager +``` + +**Note:** Use `--external-api-module=@` only if you need a specific version. Otherwise, omit `@` to use what's in go.mod. + +### Webhook for External Types + +```bash +# Example: validating external resources +kubebuilder create webhook \ + --group cert-manager --version v1 --kind Issuer \ + --defaulting \ + --external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 \ + --external-api-domain=io \ + --external-api-module=github.com/cert-manager/cert-manager +``` + +## Testing & Development + +```bash +make test # Run unit tests (uses envtest: real K8s API + etcd) +make run # Run locally (uses current kubeconfig context) +``` + +Tests use **Ginkgo + Gomega** (BDD style). Check `suite_test.go` for setup. + +## Deployment Workflow + +```bash +# 1. Regenerate manifests +make manifests generate + +# 2. Build & deploy +export IMG=/:tag +make docker-build docker-push IMG=$IMG # Or: kind load docker-image $IMG --name +make deploy IMG=$IMG + +# 3. Test +kubectl apply -k config/samples/ + +# 4. Debug +kubectl logs -n -system deployment/-controller-manager -c manager -f +``` + +### API Design + +**Key markers for** `api//*_types.go`: + +```go +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=".status.conditions[?(@.type=='Ready')].status" + +// On fields: +// +kubebuilder:validation:Required +// +kubebuilder:validation:Minimum=1 +// +kubebuilder:validation:MaxLength=100 +// +kubebuilder:validation:Pattern="^[a-z]+$" +// +kubebuilder:default="value" +``` + +- **Use** `metav1.Condition` for status (not custom string fields) +- **Use predefined types**: `metav1.Time` instead of `string` for dates +- **Follow K8s API conventions**: Standard field names (`spec`, `status`, `metadata`) + +### Controller Design + +**RBAC markers in** `internal/controller/*_controller.go`: + +```go +// +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds/finalizers,verbs=update +// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete +``` + +**Implementation rules:** +- **Idempotent reconciliation**: Safe to run multiple times +- **Re-fetch before updates**: `r.Get(ctx, req.NamespacedName, obj)` before `r.Update` to avoid conflicts +- **Structured logging**: `log := log.FromContext(ctx); log.Info("msg", "key", val)` +- **Owner references**: Enable automatic garbage collection (`SetControllerReference`) +- **Watch secondary resources**: Use `.Owns()` or `.Watches()`, not just `RequeueAfter` +- **Finalizers**: Clean up external resources (buckets, VMs, DNS entries) + +### Logging + +**Follow Kubernetes logging message style guidelines:** + +- Start from a capital letter +- Do not end the message with a period +- Active voice: subject present (`"Deployment could not create Pod"`) or omitted (`"Could not create Pod"`) +- Past tense: `"Could not delete Pod"` not `"Cannot delete Pod"` +- Specify object type: `"Deleted Pod"` not `"Deleted"` +- Balanced key-value pairs + +```go +log.Info("Starting reconciliation") +log.Info("Created Deployment", "name", deploy.Name) +log.Error(err, "Failed to create Pod", "name", name) +``` + +**Reference:** https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md#message-style-guidelines + +### Webhooks +- **Create all types together**: `--defaulting --programmatic-validation --conversion` +- **When`--force`is used**: Backup custom logic first, then restore after scaffolding +- **For multi-version APIs**: Use hub-and-spoke pattern (`--conversion --spoke v2`) + - Hub version: Usually oldest stable version (v1) + - Spoke versions: Newer versions that convert to/from hub (v2, v3) + - Example: `--group crew --version v1 --kind Captain --conversion --spoke v2` (v1 is hub, v2 is spoke) + +### Learning from Examples + +The **deploy-image plugin** scaffolds a complete controller following good practices. Use it as a reference implementation: + +```bash +kubebuilder create api --group example --version v1alpha1 --kind MyApp \ + --image= --plugins=deploy-image.go.kubebuilder.io/v1-alpha +``` + +Generated code includes: status conditions (`metav1.Condition`), finalizers, owner references, events, idempotent reconciliation. + +## Distribution Options + +### Option 1: YAML Bundle (Kustomize) + +```bash +# Generate dist/install.yaml from Kustomize manifests +make build-installer IMG=/:tag +``` + +**Key points:** +- The `dist/install.yaml` is generated from Kustomize manifests (CRDs, RBAC, Deployment) +- Commit this file to your repository for easy distribution +- Users only need `kubectl` to install (no additional tools required) + +**Example:** Users install with a single command: +```bash +kubectl apply -f https://raw.githubusercontent.com////dist/install.yaml +``` + +### Option 2: Helm Chart + +```bash +kubebuilder edit --plugins=helm/v2-alpha # Generates dist/chart/ (default) +kubebuilder edit --plugins=helm/v2-alpha --output-dir=charts # Generates charts/chart/ +``` + +**For development:** +```bash +make helm-deploy IMG=/: # Deploy manager via Helm +make helm-deploy IMG=$IMG HELM_EXTRA_ARGS="--set ..." # Deploy with custom values +make helm-status # Show release status +make helm-uninstall # Remove release +make helm-history # View release history +make helm-rollback # Rollback to previous version +``` + +**For end users/production:** +```bash +helm install my-release .//chart/ --namespace --create-namespace +``` + +**Important:** If you add webhooks or modify manifests after initial chart generation: +1. Backup any customizations in `/chart/values.yaml` and `/chart/manager/manager.yaml` +2. Re-run: `kubebuilder edit --plugins=helm/v2-alpha --force` (use same `--output-dir` if customized) +3. Manually restore your custom values from the backup + +### Publish Container Image + +```bash +export IMG=/: +make docker-build docker-push IMG=$IMG +``` + +## References + +### Essential Reading +- **Kubebuilder Book**: https://book.kubebuilder.io (comprehensive guide) +- **controller-runtime FAQ**: https://github.com/kubernetes-sigs/controller-runtime/blob/main/FAQ.md (common patterns and questions) +- **Good Practices**: https://book.kubebuilder.io/reference/good-practices.html (why reconciliation is idempotent, status conditions, etc.) +- **Logging Conventions**: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md#message-style-guidelines (message style, verbosity levels) + +### API Design & Implementation +- **API Conventions**: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md +- **Operator Pattern**: https://kubernetes.io/docs/concepts/extend-kubernetes/operator/ +- **Markers Reference**: https://book.kubebuilder.io/reference/markers.html + +### Tools & Libraries +- **controller-runtime**: https://github.com/kubernetes-sigs/controller-runtime +- **controller-tools**: https://github.com/kubernetes-sigs/controller-tools +- **Kubebuilder Repo**: https://github.com/kubernetes-sigs/kubebuilder diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0f3546e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,71 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A kubebuilder-based Go operator for Memgraph high-availability clusters. It exposes one CRD, `MemgraphCluster` (group `memgraph.com`, version `v1alpha1`, short name `mgc`), and replaces the `memgraph-high-availability` Helm chart's fire-and-forget registration Job with continuous, state-aware reconciliation over Bolt. + +The repository was reset for this effort (prior attempt is on `archive/pre-operator-mvp`). Work is driven by `specs/operator-mvp/PRD.md` and sliced into PR-gated issues in `specs/operator-mvp/issues/` — read the PRD before making design decisions; it records what is in scope (provision, bootstrap, observe) and what is deliberately out (day-2 ops, scaling, TLS, external access, monitoring). + +`AGENTS.md` contains the generic kubebuilder agent guide (scaffolding commands, marker reference, never-edit rules). Follow it, especially: never hand-edit `config/crd/bases/*`, `config/rbac/role.yaml`, `zz_generated.*.go`, or `PROJECT`; never delete `// +kubebuilder:scaffold:*` markers. + +## Commands + +```sh +make test-unit # unit tests only (pure packages; excludes e2e and internal/controller) +make test # unit + envtest (downloads envtest binaries into bin/ on first run) +make lint # golangci-lint (lint-fix to auto-fix, lint-config to verify config) +make manifests generate # regenerate CRDs/RBAC + DeepCopy after editing *_types.go or markers +make build # build manager binary +make run # run controller locally against current kubeconfig +make test-e2e # KinD e2e suite — creates/deletes a dedicated Kind cluster; never run against a real cluster +make chart-sync # regenerate the install chart's CRDs + RBAC rules from the Go sources +make chart-verify # fail if those generated chart files are stale (CI gate) +make helm-lint # lint the install chart and render it with defaults and toggles flipped +make test-chart # helm install/uninstall the chart on a throwaway Kind cluster +make chart-package # package the chart into dist/chart (publishes nothing) +make test-chart-publish # exercise the cross-publish path offline, against a fake helm-charts repo +``` + +Run a single test (Ginkgo suites): + +```sh +go test ./internal/controller/ -ginkgo.focus="" # needs KUBEBUILDER_ASSETS for envtest, see below +go test ./api/... -run TestName +``` + +Envtest packages need `KUBEBUILDER_ASSETS`; outside of `make test` set it with: +`KUBEBUILDER_ASSETS=$(bin/setup-envtest use --bin-dir bin -p path)` + +CI (`.github/workflows/`) runs `make lint-config`, `make lint`, `make test-unit`, `make test`, `make chart-verify`, `make helm-lint`, `make chart-version-check`, `make test-chart-publish`, `make test-chart`, and `make test-e2e` on every PR — all must be green. The e2e job boots a licensed Memgraph cluster on a multi-node Kind cluster, with the license flowing from the `MEMGRAPH_ENTERPRISE_LICENSE` / `MEMGRAPH_ORGANIZATION_NAME` repository secrets (set the same env vars to run it locally). + +### Toolchain quirks (do not "fix" these) + +- Coverage is opt-in (`make test COVER_FLAGS="-coverprofile cover.out"`). Plain `go test -cover` breaks under Go toolchain auto-switching (covdata tool build fails), so `make test` must stay coverage-free by default. +- The golangci-lint Makefile install pins `GOTOOLCHAIN` to the project's Go version; `go install` otherwise builds it with an older toolchain that refuses to lint this project. + +## Architecture + +The PRD defines seven modules with two pure cores and one mock seam; an eighth (the rolling-restart decision) was added post-v1 as a third pure core. Keep this separation — it's what makes the logic testable without a cluster: + +1. **API types** (`api/v1alpha1/`) — spec/status with kubebuilder + CEL markers. Replica counts (coordinators, data instances) are **mutable in both directions**, bounded by schema floors alone (coordinators ≥ 3 and odd, data instances ≥ 1) — enforced by the CRD, not a webhook. Defaults are declared as CRD schema defaults *and* mirrored as Go constants in `memgraphcluster_types.go` so resource builders behave correctly on specs that never passed admission (unit tests). +2. **Resource builders** — pure functions: spec in, desired Kubernetes objects out (one StatefulSet per role + headless Services; per-pod identity such as coordinator ID and advertised addresses derived from pod ordinals). No API calls, no side effects. Tested golden-style. +3. **Memgraph HA client** — a narrow Go interface (show instances, show replication lag, register instance, add coordinator, set main, demote/unregister instance, remove coordinator, yield leadership) over the Bolt driver. Everything above depends on the interface, never the driver — this is the mock seam. +4. **Registration planner** — pure diff: declared topology + observed `SHOW INSTANCES` in, ordered registration commands out (empty when converged). Reconciliation semantics live here: read-before-write, idempotent, re-issue only missing registrations. A MAIN is promoted only when the cluster has none — at bootstrap, and after the planner itself demoted a data instance that is retiring; a MAIN that is staying is never overridden, because failover belongs to the Raft coordinators. A lowered count is the one removal the planner drives: `DEMOTE INSTANCE` the retiring MAIN, promote a survivor, `UNREGISTER INSTANCE` the retiring data instances, `REMOVE COORDINATOR` the retiring coordinators, and only then does the controller shed their pods (see `specs/operator-mvp/issues/15`, `16`). Moving MAIN off a retiring instance is the one step that can lose data, so it has a precondition nothing else does: a survivor that is both reachable and reported by `SHOW REPLICATION LAG` as holding every transaction the MAIN committed, in every database. Without one, the demotion, the promotion and that instance's unregistration are all left out of the plan and the retiring MAIN keeps serving — a scale-down that pauses, not one that drops writes. Because that state plans *nothing*, an empty plan is not proof a retirement finished: `planner.Retired` is what gates shedding the pods. One command breaks the pure-diff mould: `YIELD LEADERSHIP`, needed because Raft refuses to remove its own leader. It names no successor, so it is always a plan's **last** command and terminal — the controller requeues and re-observes under whichever coordinator won the election. +5. **Controller** (`internal/controller/`) — fetch CR, server-side-apply builder output, gate on pod readiness, run planner against the HA client, write status/conditions. +6. **Operator install chart** (`charts/memgraph-operator/`) — lives in this repo, cross-published to `memgraph.github.io/helm-charts` at release. Its `crds/` and `rbac/manager-rules.yaml` are **generated** (`make chart-sync`, verified by `make chart-verify`): the manager's ClusterRole comes from the `+kubebuilder:rbac` markers, so tightening or widening the controller's permissions means editing the markers, never the chart. The e2e suite installs the operator through this chart, so every scenario runs under the RBAC users get. The chart's `version` and its `appVersion` (the operator image tag) move **independently**: tag `v` releases the operator, `chart-` releases the chart alone — see `docs/releasing.md`. +7. **Rolling-restart decision** (`internal/rollout/`) — the third pure core: both roles' pods reduced to `{name, revisionHash, ready}` plus each StatefulSet's `UpdateRevision`, the `SHOW INSTANCES` view and `SHOW REPLICATION LAG` in, **exactly one** action out (`Done` / `Wait(reason)` / `Delete(pod)`). Both StatefulSets use `updateStrategy: OnDelete`, so the operator owns every pod restart and this decides which pod is next: data instances before coordinators, the observed MAIN last of its role, the Raft leader last of its. Nothing is persisted — pods already carrying the new revision *are* the ones already restarted, so a mid-roll spec revert or a Raft-driven MAIN move self-corrects. One action per pass and never a list, because every step re-gates on fresh lag. It issues no Bolt commands at all; a roll is invisible to the planner. +8. **E2E harness** (`test/e2e/`, build tag `e2e`) — multi-node KinD with real Memgraph images. + +Test philosophy (from the PRD): assert external behavior, never internal call ordering or private state. Builders get golden tests, planner gets pure topology-diff cases, the rolling-restart decision gets pure cases over pod revisions and observed cluster state, controller gets envtest with the HA client mocked. + +## Conventions + +- Spec knob names mirror the HA Helm chart's vocabulary where the concept carries over (e.g. the `secrets.name` / `secrets.licenseKey` / `secrets.organizationKey` block) — check the chart before inventing a name. +- No secret material in spec or status; secrets are consumed by reference only. +- 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, 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). +- Both StatefulSets use `updateStrategy: OnDelete`, so **nothing but the operator ever restarts a workload pod**. A pod-template change no reconcile acts on takes effect never, which is what the `Updated` condition exists to report. `RollingUpdate` cannot express the required order (it sweeps highest ordinal to lowest, and `partition` is a descending cutoff, not a set), so a MAIN on any ordinal but 0 would be restarted mid-sweep and each such restart buys another failover. +- 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. +- Log messages follow Kubernetes style: capital first letter, no trailing period, past tense, object type named (see AGENTS.md for examples). diff --git a/Dockerfile b/Dockerfile index a48973e..5b59f51 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build the manager binary -FROM golang:1.22 AS builder +FROM golang:1.26 AS builder ARG TARGETOS ARG TARGETARCH @@ -11,13 +11,11 @@ COPY go.sum go.sum # and so that source changes don't invalidate our downloaded layer RUN go mod download -# Copy the go source -COPY cmd/main.go cmd/main.go -COPY api/ api/ -COPY internal/controller/ internal/controller/ +# Copy the Go source (relies on .dockerignore to filter) +COPY . . # Build -# the GOARCH has not a default value to allow the binary be built according to the host where the command +# the GOARCH has no default value to allow the binary to be built according to the host where the command # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. diff --git a/Makefile b/Makefile index a4234e4..09df2f9 100644 --- a/Makefile +++ b/Makefile @@ -1,59 +1,7 @@ -# VERSION defines the project version for the bundle. -# Update this value when you upgrade the version of your project. -# To re-generate a bundle for another specific version without changing the standard setup, you can: -# - use the VERSION as arg of the bundle target (e.g make bundle VERSION=0.0.2) -# - use environment variables to overwrite this value (e.g export VERSION=0.0.2) -VERSION ?= 1.0.0 - -# CHANNELS define the bundle channels used in the bundle. -# Add a new line here if you would like to change its default config. (E.g CHANNELS = "candidate,fast,stable") -# To re-generate a bundle for other specific channels without changing the standard setup, you can: -# - use the CHANNELS as arg of the bundle target (e.g make bundle CHANNELS=candidate,fast,stable) -# - use environment variables to overwrite this value (e.g export CHANNELS="candidate,fast,stable") -ifneq ($(origin CHANNELS), undefined) -BUNDLE_CHANNELS := --channels=$(CHANNELS) -endif - -# DEFAULT_CHANNEL defines the default channel used in the bundle. -# Add a new line here if you would like to change its default config. (E.g DEFAULT_CHANNEL = "stable") -# To re-generate a bundle for any other default channel without changing the default setup, you can: -# - use the DEFAULT_CHANNEL as arg of the bundle target (e.g make bundle DEFAULT_CHANNEL=stable) -# - use environment variables to overwrite this value (e.g export DEFAULT_CHANNEL="stable") -ifneq ($(origin DEFAULT_CHANNEL), undefined) -BUNDLE_DEFAULT_CHANNEL := --default-channel=$(DEFAULT_CHANNEL) -endif -BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL) - -# IMAGE_TAG_BASE defines the docker.io namespace and part of the image name for remote images. -# This variable is used to construct full image tags for bundle and catalog images. -# -# For example, running 'make bundle-build bundle-push catalog-build catalog-push' will build and push both -# com/kubernetes-operator-bundle:$VERSION and com/kubernetes-operator-catalog:$VERSION. -IMAGE_TAG_BASE ?= memgraph/kubernetes-operator - -# BUNDLE_IMG defines the image:tag used for the bundle. -# You can use it as an arg. (E.g make bundle-build BUNDLE_IMG=/:) -BUNDLE_IMG ?= $(IMAGE_TAG_BASE)-bundle:v$(VERSION) - -# BUNDLE_GEN_FLAGS are the flags passed to the operator-sdk generate bundle command -BUNDLE_GEN_FLAGS ?= -q --overwrite --version $(VERSION) $(BUNDLE_METADATA_OPTS) - -# USE_IMAGE_DIGESTS defines if images are resolved via tags or digests -# You can enable this value if you would like to use SHA Based Digests -# To enable set flag to true -USE_IMAGE_DIGESTS ?= false -ifeq ($(USE_IMAGE_DIGESTS), true) - BUNDLE_GEN_FLAGS += --use-image-digests -endif - -# Set the Operator SDK version to use. By default, what is installed on the system is used. -# This is useful for CI or a project to utilize a specific version of the operator-sdk toolkit. -OPERATOR_SDK_VERSION ?= v1.35.0 - # Image URL to use all building/pushing image targets -IMG ?= $(IMAGE_TAG_BASE):$(VERSION) -# ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. -ENVTEST_K8S_VERSION = 1.28.3 +IMG ?= controller:latest +# YEAR defines the year value used for substituting the YEAR placeholder in the boilerplate header. +YEAR ?= $(shell date +%Y) # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) ifeq (,$(shell go env GOBIN)) @@ -96,12 +44,12 @@ help: ## Display this help. ##@ Development .PHONY: manifests -manifests: controller-gen ## Generate WebhookConfiguration and CustomResourceDefinition objects. - $(CONTROLLER_GEN) crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases +manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. + "$(CONTROLLER_GEN)" rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases .PHONY: generate generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. - $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." + "$(CONTROLLER_GEN)" object:headerFile="hack/boilerplate.go.txt",year=$(YEAR) paths="./..." .PHONY: fmt fmt: ## Run go fmt against code. @@ -111,30 +59,69 @@ fmt: ## Run go fmt against code. vet: ## Run go vet against code. go vet ./... +# Coverage is opt-in (e.g. make test COVER_FLAGS="-coverprofile cover.out"): +# `go test -cover` needs the covdata tool, whose on-demand build fails when the +# go command auto-switches toolchains (base Go older than go.mod's version), +# which would break `make test` on stock distro Go installs. +COVER_FLAGS ?= + +.PHONY: test-unit +test-unit: manifests generate fmt vet ## Run unit tests (pure packages, no envtest binaries required). + go test $$(go list ./... | grep -v /e2e | grep -v /internal/controller) $(COVER_FLAGS) + .PHONY: test -test: manifests generate fmt vet envtest ## Run tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out - -# Utilize Kind or modify the e2e tests to load the image locally, enabling compatibility with other vendors. -.PHONY: test-e2e # Run the e2e tests against a Kind k8s instance that is spun up. -test-e2e: - go test ./test/e2e/ -v -ginkgo.v - -GOLANGCI_LINT = $(shell pwd)/bin/golangci-lint -GOLANGCI_LINT_VERSION ?= v1.54.2 -golangci-lint: - @[ -f $(GOLANGCI_LINT) ] || { \ - set -e ;\ - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(shell dirname $(GOLANGCI_LINT)) $(GOLANGCI_LINT_VERSION) ;\ +test: manifests generate fmt vet setup-envtest ## Run tests. + KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" go test $$(go list ./... | grep -v /e2e) $(COVER_FLAGS) + +# TODO(user): To use a different vendor for e2e tests, modify the setup under 'tests/e2e'. +# The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally. +# kubectl kuberc is disabled by default for test isolation; enable with: +# - KUBECTL_KUBERC=true +# CertManager is installed by default; skip with: +# - CERT_MANAGER_INSTALL_SKIP=true +KIND_CLUSTER ?= kubernetes-operator-test-e2e +KIND_CONFIG ?= test/e2e/kind-config.yaml + +.PHONY: setup-test-e2e +setup-test-e2e: ## Set up a multi-node Kind cluster for e2e tests if it does not exist + @command -v $(KIND) >/dev/null 2>&1 || { \ + echo "Kind is not installed. Please install Kind manually."; \ + exit 1; \ + } + @command -v $(HELM) >/dev/null 2>&1 || { \ + echo "Helm is not installed. Please install Helm manually: the suite installs the operator from charts/memgraph-operator."; \ + exit 1; \ } + @case "$$($(KIND) get clusters)" in \ + *"$(KIND_CLUSTER)"*) \ + echo "Kind cluster '$(KIND_CLUSTER)' already exists. Skipping creation." ;; \ + *) \ + echo "Creating Kind cluster '$(KIND_CLUSTER)'..."; \ + $(KIND) create cluster --name $(KIND_CLUSTER) --config $(KIND_CONFIG) ;; \ + esac + +# The generous timeout covers the whole suite end to end: building the manager +# image, pulling real Memgraph images, and bootstrapping an HA cluster in Kind. +.PHONY: test-e2e +test-e2e: setup-test-e2e manifests generate fmt vet ## Run the e2e tests. Expected an isolated environment using Kind. + KIND=$(KIND) KIND_CLUSTER=$(KIND_CLUSTER) go test -tags=e2e ./test/e2e/ -v -ginkgo.v -timeout 40m + $(MAKE) cleanup-test-e2e + +.PHONY: cleanup-test-e2e +cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests + @$(KIND) delete cluster --name $(KIND_CLUSTER) .PHONY: lint -lint: golangci-lint ## Run golangci-lint linter & yamllint - $(GOLANGCI_LINT) run +lint: golangci-lint ## Run golangci-lint linter + "$(GOLANGCI_LINT)" run .PHONY: lint-fix lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes - $(GOLANGCI_LINT) run --fix + "$(GOLANGCI_LINT)" run --fix + +.PHONY: lint-config +lint-config: golangci-lint ## Verify golangci-lint linter configuration + "$(GOLANGCI_LINT)" config verify ##@ Build @@ -168,12 +155,133 @@ PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le docker-buildx: ## Build and push docker image for the manager for cross-platform support # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross - - $(CONTAINER_TOOL) buildx create --name project-v3-builder - $(CONTAINER_TOOL) buildx use project-v3-builder + - $(CONTAINER_TOOL) buildx create --name kubernetes-operator-builder + $(CONTAINER_TOOL) buildx use kubernetes-operator-builder - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . - - $(CONTAINER_TOOL) buildx rm project-v3-builder + - $(CONTAINER_TOOL) buildx rm kubernetes-operator-builder rm Dockerfile.cross +.PHONY: build-installer +build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. + mkdir -p dist + cd config/manager && "$(KUSTOMIZE)" edit set image controller=${IMG} + "$(KUSTOMIZE)" build config/default > dist/install.yaml + +##@ Install chart + +# The operator install chart lives next to the generated manifests so it can +# never drift from the controller version it installs: its CRDs and its RBAC +# rules are generated, and chart-verify fails when they are stale. +CHART_DIR ?= charts/memgraph-operator +CHART_RELEASE ?= memgraph-operator +CHART_NAMESPACE ?= memgraph-operator-system + +.PHONY: chart-sync +chart-sync: manifests ## Regenerate the chart's CRDs and RBAC rules from the Go sources. + rm -f "$(CHART_DIR)"/crds/*.yaml + @for crd in config/crd/bases/*.yaml; do \ + out="$(CHART_DIR)/crds/$$(basename $$crd)"; \ + echo "Writing $$out"; \ + { echo "# Generated by controller-gen from the Go types in api/."; \ + echo "# Regenerate with 'make chart-sync'; do not edit by hand."; \ + cat "$$crd"; } > "$$out"; \ + done + @echo "Writing $(CHART_DIR)/rbac/manager-rules.yaml" + @{ echo "# The rule list of the operator's manager ClusterRole: every API call the"; \ + echo "# reconciler makes is authorized against exactly these rules, because the"; \ + echo "# manager Pod authenticates as the ServiceAccount they are bound to."; \ + echo "#"; \ + echo "# templates/manager-rbac.yaml inlines this file under 'rules:' with .Files.Get"; \ + echo "# (Helm cannot read config/rbac/role.yaml from outside the chart directory)."; \ + echo "#"; \ + echo "# Note what is absent: no 'delete' anywhere, so the operator cannot remove a"; \ + echo "# StatefulSet or PVC; no 'secrets', because license and auth material reaches"; \ + echo "# the workload Pods via the kubelet, never through the operator; no 'pods',"; \ + echo "# because readiness gating reads StatefulSet status instead."; \ + echo "#"; \ + echo "# Generated from the +kubebuilder:rbac markers in the controller sources."; \ + echo "# Regenerate with 'make chart-sync'; do not edit by hand. To widen or tighten"; \ + echo "# the permissions, edit the markers -- 'make chart-verify' fails if the two drift."; \ + awk 'found { print } /^rules:$$/ { found = 1 }' config/rbac/role.yaml; \ + } > "$(CHART_DIR)/rbac/manager-rules.yaml" + +# Generates into a scratch directory and diffs, so the check answers "do the +# committed chart manifests match the Go sources" regardless of what else is +# uncommitted in the working tree. +.PHONY: chart-verify +chart-verify: manifests ## Fail if the chart's generated CRDs and RBAC rules are out of date. + @tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \ + mkdir -p "$$tmp/crds" "$$tmp/rbac"; \ + $(MAKE) --no-print-directory chart-sync CHART_DIR="$$tmp" >/dev/null; \ + if ! diff -ru "$$tmp/crds" "$(CHART_DIR)/crds" \ + || ! diff -u "$$tmp/rbac/manager-rules.yaml" "$(CHART_DIR)/rbac/manager-rules.yaml"; then \ + echo "$(CHART_DIR) is out of date with the Go sources."; \ + echo "Run 'make chart-sync' and commit the result."; \ + exit 1; \ + fi; \ + echo "$(CHART_DIR) is in sync with the Go sources." + +.PHONY: helm-lint +helm-lint: ## Lint the install chart and render it with defaults and with the toggles flipped. + "$(HELM)" lint --strict "$(CHART_DIR)" + "$(HELM)" template "$(CHART_RELEASE)" "$(CHART_DIR)" --namespace "$(CHART_NAMESPACE)" > /dev/null + "$(HELM)" template "$(CHART_RELEASE)" "$(CHART_DIR)" --namespace "$(CHART_NAMESPACE)" \ + --set metrics.enabled=false --set leaderElection.enabled=false \ + --set-string namespaceOverride=elsewhere --set-string image.tag=v9.9.9 > /dev/null + +.PHONY: test-chart +test-chart: chart-sync ## Install/uninstall the chart on a throwaway Kind cluster; never run against a real cluster. + KIND=$(KIND) KUBECTL=$(KUBECTL) HELM=$(HELM) CONTAINER_TOOL=$(CONTAINER_TOOL) \ + CHART_DIR=$(CHART_DIR) CHART_RELEASE=$(CHART_RELEASE) CHART_NAMESPACE=$(CHART_NAMESPACE) \ + ./hack/chart-install-test.sh + +.PHONY: helm-install +helm-install: ## Install the operator from the local chart, running the image specified by IMG. + img="$(IMG)"; "$(HELM)" upgrade --install "$(CHART_RELEASE)" "$(CHART_DIR)" \ + --namespace "$(CHART_NAMESPACE)" --create-namespace \ + --set-string image.repository="$${img%:*}" \ + --set-string image.tag="$${img##*:}" \ + --wait --timeout 5m $(HELM_EXTRA_ARGS) + +.PHONY: helm-uninstall +helm-uninstall: ## Uninstall the operator, including the CRDs helm leaves behind. + "$(HELM)" uninstall "$(CHART_RELEASE)" --namespace "$(CHART_NAMESPACE)" --ignore-not-found + "$(KUBECTL)" delete --ignore-not-found=true -f "$(CHART_DIR)/crds" + +##@ Release + +# Two versions, moving independently: the chart's own version, and the +# appVersion naming the operator image it installs by default. A tag releases +# one of them -- v the operator, chart- the chart alone -- +# and the release workflow refuses a tag the chart does not declare. +# See docs/releasing.md. +CHART_VERSION = $(shell sed -n 's/^version:[[:space:]]*//p' $(CHART_DIR)/Chart.yaml | tr -d '"' | head -1) +CHART_APP_VERSION = $(shell sed -n 's/^appVersion:[[:space:]]*//p' $(CHART_DIR)/Chart.yaml | tr -d '"' | head -1) + +.PHONY: chart-version +chart-version: ## Print the chart version. + @echo "$(CHART_VERSION)" + +.PHONY: chart-app-version +chart-app-version: ## Print the operator version the chart installs (its appVersion). + @echo "$(CHART_APP_VERSION)" + +.PHONY: chart-version-check +chart-version-check: ## Fail if the chart changed without its version being bumped (against BASE_REF). + @./hack/chart-version-check.sh + +.PHONY: chart-package +chart-package: chart-verify ## Package the install chart into dist/chart, publishing nothing. + HELM=$(HELM) ./hack/chart-publish.sh + +.PHONY: chart-publish +chart-publish: chart-verify ## Publish the packaged chart into the Memgraph helm repository. Needs GH_TOKEN. + HELM=$(HELM) ./hack/chart-publish.sh --publish + +.PHONY: test-chart-publish +test-chart-publish: ## Test the cross-publish path against a local stand-in for the helm-charts repository. + HELM=$(HELM) ./hack/chart-publish-test.sh + ##@ Deployment ifndef ignore-not-found @@ -182,127 +290,108 @@ endif .PHONY: install install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. - $(KUSTOMIZE) build config/crd | $(KUBECTL) apply -f - + @out="$$( "$(KUSTOMIZE)" build config/crd 2>/dev/null || true )"; \ + if [ -n "$$out" ]; then echo "$$out" | "$(KUBECTL)" apply -f -; else echo "No CRDs to install; skipping."; fi .PHONY: uninstall uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. - $(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - + @out="$$( "$(KUSTOMIZE)" build config/crd 2>/dev/null || true )"; \ + if [ -n "$$out" ]; then echo "$$out" | "$(KUBECTL)" delete --ignore-not-found=$(ignore-not-found) -f -; else echo "No CRDs to delete; skipping."; fi .PHONY: deploy deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. - cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} - $(KUSTOMIZE) build config/default | $(KUBECTL) apply -f - + cd config/manager && "$(KUSTOMIZE)" edit set image controller=${IMG} + "$(KUSTOMIZE)" build config/default | "$(KUBECTL)" apply -f - .PHONY: undeploy -undeploy: ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. - $(KUSTOMIZE) build config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - +undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. + "$(KUSTOMIZE)" build config/default | "$(KUBECTL)" delete --ignore-not-found=$(ignore-not-found) -f - -##@ Build Dependencies +##@ Dependencies ## Location to install dependencies to LOCALBIN ?= $(shell pwd)/bin $(LOCALBIN): - mkdir -p $(LOCALBIN) + mkdir -p "$(LOCALBIN)" ## Tool Binaries KUBECTL ?= kubectl +KIND ?= kind +HELM ?= helm KUSTOMIZE ?= $(LOCALBIN)/kustomize CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen ENVTEST ?= $(LOCALBIN)/setup-envtest +GOLANGCI_LINT = $(LOCALBIN)/golangci-lint ## Tool Versions -KUSTOMIZE_VERSION ?= v5.2.1 -CONTROLLER_TOOLS_VERSION ?= v0.15.0 +KUSTOMIZE_VERSION ?= v5.8.1 +CONTROLLER_TOOLS_VERSION ?= v0.21.0 +#ENVTEST_VERSION is the controller-runtime version to use for setup-envtest, derived from go.mod +ENVTEST_VERSION ?= $(shell v='$(call gomodver,sigs.k8s.io/controller-runtime)'; \ + [ -n "$$v" ] || { echo "Set ENVTEST_VERSION manually (controller-runtime replace has no tag)" >&2; exit 1; }; \ + printf '%s\n' "$$v") + +#ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31) +ENVTEST_K8S_VERSION ?= $(shell v='$(call gomodver,k8s.io/api)'; \ + [ -n "$$v" ] || { echo "Set ENVTEST_K8S_VERSION manually (k8s.io/api replace has no tag)" >&2; exit 1; }; \ + printf '%s\n' "$$v" | sed -E 's/^v?[0-9]+\.([0-9]+).*/1.\1/') + +GOLANGCI_LINT_VERSION ?= v2.12.2 .PHONY: kustomize -kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. If wrong version is installed, it will be removed before downloading. +kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. $(KUSTOMIZE): $(LOCALBIN) - @if test -x $(LOCALBIN)/kustomize && ! $(LOCALBIN)/kustomize version | grep -q $(KUSTOMIZE_VERSION); then \ - echo "$(LOCALBIN)/kustomize version is not expected $(KUSTOMIZE_VERSION). Removing it before installing."; \ - rm -rf $(LOCALBIN)/kustomize; \ - fi - test -s $(LOCALBIN)/kustomize || GOBIN=$(LOCALBIN) GO111MODULE=on go install sigs.k8s.io/kustomize/kustomize/v5@$(KUSTOMIZE_VERSION) + $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION)) .PHONY: controller-gen -controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. If wrong version is installed, it will be overwritten. +controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. $(CONTROLLER_GEN): $(LOCALBIN) - test -s $(LOCALBIN)/controller-gen && $(LOCALBIN)/controller-gen --version | grep -q $(CONTROLLER_TOOLS_VERSION) || \ - GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_TOOLS_VERSION) + $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION)) + +.PHONY: setup-envtest +setup-envtest: envtest ## Download the binaries required for ENVTEST in the local bin directory. + @echo "Setting up envtest binaries for Kubernetes version $(ENVTEST_K8S_VERSION)..." + @"$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path || { \ + echo "Error: Failed to set up envtest binaries for version $(ENVTEST_K8S_VERSION)."; \ + exit 1; \ + } .PHONY: envtest -envtest: $(ENVTEST) ## Download envtest-setup locally if necessary. +envtest: $(ENVTEST) ## Download setup-envtest locally if necessary. $(ENVTEST): $(LOCALBIN) - test -s $(LOCALBIN)/setup-envtest || GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest - -.PHONY: operator-sdk -OPERATOR_SDK ?= $(LOCALBIN)/operator-sdk -operator-sdk: ## Download operator-sdk locally if necessary. -ifeq (,$(wildcard $(OPERATOR_SDK))) -ifeq (, $(shell which operator-sdk 2>/dev/null)) - @{ \ - set -e ;\ - mkdir -p $(dir $(OPERATOR_SDK)) ;\ - OS=$(shell go env GOOS) && ARCH=$(shell go env GOARCH) && \ - curl -sSLo $(OPERATOR_SDK) https://github.com/operator-framework/operator-sdk/releases/download/$(OPERATOR_SDK_VERSION)/operator-sdk_$${OS}_$${ARCH} ;\ - chmod +x $(OPERATOR_SDK) ;\ - } -else -OPERATOR_SDK = $(shell which operator-sdk) -endif -endif - -.PHONY: bundle -bundle: manifests kustomize operator-sdk ## Generate bundle manifests and metadata, then validate generated files. - $(OPERATOR_SDK) generate kustomize manifests -q - cd config/manager && $(KUSTOMIZE) edit set image controller=$(IMG) - $(KUSTOMIZE) build config/manifests | $(OPERATOR_SDK) generate bundle $(BUNDLE_GEN_FLAGS) - $(OPERATOR_SDK) bundle validate ./bundle - -.PHONY: bundle-build -bundle-build: ## Build the bundle image. - docker build -f bundle.Dockerfile -t $(BUNDLE_IMG) . - -.PHONY: bundle-push -bundle-push: ## Push the bundle image. - $(MAKE) docker-push IMG=$(BUNDLE_IMG) - -.PHONY: opm -OPM = $(LOCALBIN)/opm -opm: ## Download opm locally if necessary. -ifeq (,$(wildcard $(OPM))) -ifeq (,$(shell which opm 2>/dev/null)) - @{ \ - set -e ;\ - mkdir -p $(dir $(OPM)) ;\ - OS=$(shell go env GOOS) && ARCH=$(shell go env GOARCH) && \ - curl -sSLo $(OPM) https://github.com/operator-framework/operator-registry/releases/download/v1.23.0/$${OS}-$${ARCH}-opm ;\ - chmod +x $(OPM) ;\ - } -else -OPM = $(shell which opm) -endif -endif - -# A comma-separated list of bundle images (e.g. make catalog-build BUNDLE_IMGS=example.com/operator-bundle:v0.1.0,example.com/operator-bundle:v0.2.0). -# These images MUST exist in a registry and be pull-able. -BUNDLE_IMGS ?= $(BUNDLE_IMG) - -# The image tag given to the resulting catalog image (e.g. make catalog-build CATALOG_IMG=example.com/operator-catalog:v0.2.0). -CATALOG_IMG ?= $(IMAGE_TAG_BASE)-catalog:v$(VERSION) - -# Set CATALOG_BASE_IMG to an existing catalog image tag to add $BUNDLE_IMGS to that image. -ifneq ($(origin CATALOG_BASE_IMG), undefined) -FROM_INDEX_OPT := --from-index $(CATALOG_BASE_IMG) -endif - -# Build a catalog image by adding bundle images to an empty catalog using the operator package manager tool, 'opm'. -# This recipe invokes 'opm' in 'semver' bundle add mode. For more information on add modes, see: -# https://github.com/operator-framework/community-operators/blob/7f1438c/docs/packaging-operator.md#updating-your-existing-operator -.PHONY: catalog-build -catalog-build: opm ## Build a catalog image. - $(OPM) index add --container-tool docker --mode semver --tag $(CATALOG_IMG) --bundles $(BUNDLE_IMGS) $(FROM_INDEX_OPT) - -# Push the catalog image. -.PHONY: catalog-push -catalog-push: ## Push a catalog image. - $(MAKE) docker-push IMG=$(CATALOG_IMG) + $(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION)) + +.PHONY: golangci-lint +golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. +# Build golangci-lint with the project's Go toolchain: `go install pkg@version` +# ignores go.mod, so under GOTOOLCHAIN=auto it picks golangci-lint's own (older) +# minimum toolchain, and a golangci-lint built with a lower Go version refuses +# to lint a project targeting a higher one. +$(GOLANGCI_LINT): export GOTOOLCHAIN := $(shell go env GOVERSION) +$(GOLANGCI_LINT): $(LOCALBIN) + $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION)) + @test -f .custom-gcl.yml && { \ + echo "Building custom golangci-lint with plugins..." && \ + $(GOLANGCI_LINT) custom --destination $(LOCALBIN) --name golangci-lint-custom && \ + mv -f $(LOCALBIN)/golangci-lint-custom $(GOLANGCI_LINT); \ + } || true + +# go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist +# $1 - target path with name of binary +# $2 - package url which can be installed +# $3 - specific version of package +define go-install-tool +@[ -f "$(1)-$(3)" ] && [ "$$(readlink -- "$(1)" 2>/dev/null)" = "$(1)-$(3)" ] || { \ +set -e; \ +package=$(2)@$(3) ;\ +echo "Downloading $${package}" ;\ +rm -f "$(1)" ;\ +GOBIN="$(LOCALBIN)" go install $${package} ;\ +mv "$(LOCALBIN)/$$(basename "$(1)")" "$(1)-$(3)" ;\ +} ;\ +ln -sf "$$(realpath "$(1)-$(3)")" "$(1)" +endef + +define gomodver +$(shell go list -m -f '{{if .Replace}}{{.Replace.Version}}{{else}}{{.Version}}{{end}}' $(1) 2>/dev/null) +endef diff --git a/PROJECT b/PROJECT index be3ed36..5f1df6f 100644 --- a/PROJECT +++ b/PROJECT @@ -2,12 +2,10 @@ # This file is used to track the info used to scaffold your project # and allow the plugins properly work. # More info: https://book.kubebuilder.io/reference/project-config.html -domain: com +cliVersion: 4.15.0 +domain: memgraph.com layout: - go.kubebuilder.io/v4 -plugins: - manifests.sdk.operatorframework.io/v2: {} - scorecard.sdk.operatorframework.io/v2: {} projectName: kubernetes-operator repo: github.com/memgraph/kubernetes-operator resources: @@ -15,9 +13,8 @@ resources: crdVersion: v1 namespaced: true controller: true - domain: com - group: memgraph - kind: MemgraphHA - path: github.com/memgraph/kubernetes-operator/api/v1 - version: v1 + domain: memgraph.com + kind: MemgraphCluster + path: github.com/memgraph/kubernetes-operator/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/README.md b/README.md index 4e63d94..d9d791f 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,251 @@ # Memgraph Kubernetes Operator -## Introduction +A Kubernetes operator for running [Memgraph](https://memgraph.com) high-availability clusters. It exposes a `MemgraphCluster` custom resource (API group `memgraph.com/v1alpha1`, short name `mgc`): declare the cluster topology in a single resource and the operator provisions the workloads, bootstraps HA registration, and continuously reconciles registration state. -Memgraph Kubernetes Operator is WIP. You can currently install the operator and manage the deployment of Memgraph's High Availability cluster -through it. +> **Status: early development (v1alpha1).** The API and its guarantees can still change between releases. Read [what v1alpha1 does and does not do](#what-v1alpha1-does-and-does-not-do) before running it anywhere that matters. The previous attempt at this operator is preserved on the `archive/pre-operator-mvp` branch; the requirements and issue slices driving the current work live in [`specs/operator-mvp/`](specs/operator-mvp/PRD.md). -## Table of Contents +## Description -- [Prerequisites](#prerequisites) -- [Documentation](#documentation) -- [License](#license) +The operator replaces the `memgraph-high-availability` Helm chart's fire-and-forget registration Job with a controller that continuously drives the cluster toward its declared topology: one StatefulSet per role (coordinators, data instances), automatic bootstrap and MAIN promotion, and automatic re-registration of instances that lose their registration state. See the [PRD](specs/operator-mvp/PRD.md) for the full design. -## Prerequisites +## Quickstart -We use Go version 1.22.5 (not needed at the moment). Check out here how to [install Go](https://go.dev/doc/install). -The current Helm version used is v3.14.4. +From an empty cluster to a registered, MAIN-elected Memgraph HA cluster. -## Documentation +**You need:** a Kubernetes cluster (v1.25 or newer — the CRD uses CEL validation), `kubectl`, `helm` v3, and a Memgraph enterprise license, which high availability requires. Three coordinators and two data instances need five schedulable pods and ten PersistentVolumeClaims of 1Gi each. -Check our [Documentation](/docs) to start using our Kubernetes operator. +### 1. Install the operator -1. [Install the Memgraph Kubernetes Operator](docs/installation.md) +The install chart ships the `MemgraphCluster` CRD, a least-privilege RBAC set, and the controller Deployment. One release per cluster is enough — the operator watches every namespace. + +```sh +helm repo add memgraph https://memgraph.github.io/helm-charts +helm repo update +helm install memgraph-operator memgraph/memgraph-operator \ + --namespace memgraph-operator-system --create-namespace --wait +``` + +### 2. Create the license Secret + +The cluster reads its license from a Secret you own; no license material ever goes into the `MemgraphCluster` resource, which keeps it safe to commit to git. + +```sh +kubectl create namespace memgraph +kubectl create secret generic memgraph-secrets \ + --namespace memgraph \ + --from-literal=MEMGRAPH_ENTERPRISE_LICENSE='' \ + --from-literal=MEMGRAPH_ORGANIZATION_NAME='' +``` + +### 3. Declare the cluster + +This is the whole resource — counts, image, and the Secret from the previous step. Everything else (storage, ports, probes, resources, cluster domain) takes its default: + +```yaml +apiVersion: memgraph.com/v1alpha1 +kind: MemgraphCluster +metadata: + name: memgraph +spec: + coordinators: 3 + dataInstances: 2 + image: + repository: docker.io/memgraph/memgraph + tag: "3.12.0" + secrets: + name: memgraph-secrets + licenseKey: MEMGRAPH_ENTERPRISE_LICENSE + organizationKey: MEMGRAPH_ORGANIZATION_NAME +``` + +It is [`examples/minimal-cluster.yaml`](examples/minimal-cluster.yaml) in this repository, and the end-to-end suite applies that file unmodified on every pull request, so it stays a manifest that works: + +```sh +kubectl apply -n memgraph \ + -f https://raw.githubusercontent.com/memgraph/kubernetes-operator/main/examples/minimal-cluster.yaml +``` + +If your Secret has a different name, or stores the license under different keys, change the `secrets` block to match — that is the only edit the example needs. + +### 4. Watch it converge + +The operator creates one StatefulSet and one headless Service per role, waits for the pods to become ready, then registers the coordinators and data instances with each other and promotes the initial MAIN. On a cluster that has to pull the Memgraph image, expect a few minutes. + +```sh +kubectl get mgc -n memgraph -w +``` + +While the pods are still starting, `MAIN` is empty and both conditions are `False`; the converged cluster looks like this: + +``` +NAME COORDINATORS DATA MAIN READY CONVERGED AGE +memgraph 3 2 instance_0 True True 4m12s +``` + +- **`MAIN`** is the data instance the coordinators elected as MAIN — the one that accepts writes. It is observed, not decided by the operator, so it changes on failover. +- **`READY`** is True once a MAIN is elected, i.e. the cluster serves writes. +- **`CONVERGED`** is True once every declared coordinator and data instance is registered and reported healthy, and both StatefulSets run the declared number of replicas. + +`kubectl get mgc -n memgraph -o wide` adds how many of each role's declared members the cluster actually has registered (`REGISTERED-COORDINATORS`, `REGISTERED-DATA`), which is what a scale is watched through. + +To block a script or a GitOps step on the cluster being usable: + +```sh +kubectl wait --namespace memgraph --for=condition=Converged \ + memgraphcluster/memgraph --timeout=10m +``` + +If it does not converge, `kubectl describe mgc memgraph -n memgraph` gives the condition messages (which pods are not ready, whether a coordinator is unreachable, or — with reason `ApplyFailed` — what the API server refused about the workloads), and the operator logs the rest: + +```sh +kubectl logs -n memgraph-operator-system deploy/memgraph-operator-controller-manager +``` + +The resource's identities follow the pod ordinals. For a cluster named `memgraph`: + +| Pod | Registered as | Role | +| --- | --- | --- | +| `memgraph-coordinator-0`, `-1`, `-2` | `coordinator_1`, `coordinator_2`, `coordinator_3` | Raft coordinators | +| `memgraph-data-0`, `-1` | `instance_0`, `instance_1` | data instances (one MAIN, the rest replicas) | + +Ask a coordinator for the cluster's own view of itself: + +```sh +kubectl exec -n memgraph memgraph-coordinator-0 -c memgraph -- \ + bash -c "echo 'SHOW INSTANCES;' | mgconsole" +``` + +### 5. Connect over Bolt + +Every pod runs Bolt on port 7687, and the Memgraph image ships `mgconsole`, so the shortest path to a query is to pipe Cypher into the MAIN pod (`instance_0` above is `memgraph-data-0`): + +```sh +kubectl exec -i -n memgraph memgraph-data-0 -c memgraph -- mgconsole <<'EOF' +CREATE (:Greeting {text: "hello from the operator"}); +MATCH (n:Greeting) RETURN n; +EOF +``` + +`kubectl exec -it -n memgraph memgraph-data-0 -c memgraph -- mgconsole` opens the same client interactively. + +Writes only succeed against the MAIN — the other data instances are replicas and accept reads. Check `.status.main` to find it: + +```sh +kubectl get mgc memgraph -n memgraph -o jsonpath='{.status.main}' +``` + +Applications inside the cluster reach an instance at its stable DNS name in the role's headless Service: + +``` +memgraph-data-0.memgraph-data.memgraph.svc.cluster.local:7687 +``` + +v1alpha1 ships no external access (see below), so to point a local client such as [Memgraph Lab](https://memgraph.com/docs/data-visualization) at the cluster while evaluating, forward the port: + +```sh +kubectl port-forward -n memgraph pod/memgraph-data-0 7687:7687 +``` + +### 6. Clean up + +```sh +kubectl delete mgc memgraph -n memgraph +``` + +Deleting the resource removes the StatefulSets and Services through garbage collection, but **the PersistentVolumeClaims are kept** — the default retention policy protects data against an accidental delete. Remove them (and with them the data) explicitly, or set `spec.storage.retentionPolicy: Delete` on dev clusters that should clean up after themselves: + +```sh +kubectl delete pvc -n memgraph --all +kubectl delete namespace memgraph +``` + +Uninstall the operator with `helm uninstall memgraph-operator --namespace memgraph-operator-system`. Helm never deletes CRDs it installed, so `kubectl delete crd memgraphclusters.memgraph.com` once no cluster needs it — see the [chart README](charts/memgraph-operator/README.md) for the values and the upgrade caveat. + +## Configuration + +Beyond the quickstart's four fields, v1alpha1 exposes storage (PVC size, access mode, storage class, whether the log claim is created at all, retention) per role, optional core dump collection with an uploader sidecar of your choice per role, resource requests and limits per role, probe timings per role, custom labels on pods, StatefulSets and Services, the internal ports, the cluster domain used in advertised addresses, and a freeform passthrough per role for environment variables, Memgraph flags, and extra volumes and volume mounts. + +[`config/samples/v1alpha1_memgraphcluster.yaml`](config/samples/v1alpha1_memgraphcluster.yaml) spells the full surface out with every default and the reasoning behind it. `kubectl explain mgc.spec --recursive` documents the same fields from the installed CRD. + +## What v1alpha1 does and does not do + +The MVP is deliberately "provision, bootstrap, observe". It does: + +- provision one StatefulSet and headless Service per role, with per-pod identity derived from the pod ordinal; +- bootstrap HA: add the coordinators, register the data instances, and promote the initial MAIN once; +- re-register continuously: every reconcile compares `SHOW INSTANCES` on the coordinator leader against the declared topology and issues only the missing registrations, so an instance that loses its registration state (say, after being rescheduled onto a fresh node) rejoins without human action; +- **grow a live cluster**: raise `coordinators` or `dataInstances` (both in one edit if you like, in any step size) and the added pods are provisioned and registered by the same diff that restores a lost registration — no manual `ADD COORDINATOR` or `REGISTER INSTANCE`; +- **shrink a live cluster**: lower `dataInstances` or `coordinators` and the members above the new count are retired before their pods are shed — a data instance has MAIN moved off it if it holds it and is then `UNREGISTER INSTANCE`d, a coordinator is `REMOVE COORDINATOR`ed out of the Raft cluster — so the coordinators never expect an instance whose pod is gone, and no removed member's pod outlives its vote; +- report the observed MAIN, the registered member counts, and the readiness and convergence conditions on the resource's status. + +Scaling is one edit, and `Converged` tells you when it is finished: + +```sh +kubectl patch mgc memgraph -n memgraph --type=merge -p '{"spec":{"coordinators":5,"dataInstances":3}}' +kubectl wait --namespace memgraph --for=condition=Converged memgraphcluster/memgraph --timeout=10m +``` + +Both counts have a floor the schema enforces at creation and on every update: `coordinators` must stay odd and at or above three, `dataInstances` at or above one. A scale-down reports `Converged=False` with reason `RetirementInProgress`, naming the members on their way out, until their pods are gone. Three things to know about it: + +- **A retiring pod that cannot become ready blocks its own removal.** The operator only touches the cluster when every pod of both StatefulSets is ready, and until the shrink is applied the retiring pods still belong to their StatefulSet. So a member that is stuck (crash-looping, unschedulable, wedged in a snapshot restore) keeps its own retirement waiting, and the resource reports `WorkloadsNotReady` rather than the operator writing to a cluster whose state it only half knows. Fix the pod, or delete it if it is genuinely unrecoverable, and the retirement continues. +- **The claims of a retired member follow `spec.storage.retentionPolicy`**, the same knob that decides what happens to storage when the cluster is deleted — `Retain` (the default) keeps them, so a shrink made by accident loses no data, and re-raising the count reattaches them. A coordinator removed from Raft keeps running and keeps its state on purpose, which is what makes re-growing onto a retained volume safe: it is in the same position as one whose pod crashed and stayed down, and a later `ADD COORDINATOR` brings it back in. +- **A coordinator shrink may have to wait for a Raft election.** Raft refuses to remove its own leader, and a StatefulSet sheds only its highest ordinals, so a leader sitting in the retiring range is asked to `YIELD LEADERSHIP` first — which cannot name a successor. The resource reports `Converged=False` with reason `LeadershipTransferInProgress` while that is pending, and the operator asks again if the election happens to pick another retiring coordinator. + +What it does not do yet: + +- **Failover.** The operator promotes a MAIN only when the cluster has none: once at bootstrap, and once more when it demotes an instance that is retiring. It never overrides a MAIN that is staying — leadership belongs to the Raft coordinators, so two control systems never fight over which instance is MAIN. +- **Other day-2 operations**: orchestrated or rolling version upgrades, backup and restore, storage-mode changes. +- **Deleting storage**: the operator owns no finalizer and runs no cleanup of its own — deleting a volume is left entirely to the StatefulSet's own retention policy. +- **External access** of any kind — no LoadBalancer, NodePort, ingress or gateway. Access is in-cluster (or `kubectl port-forward`) only; the approach is expected to change, so it was deliberately deferred rather than shipped and broken later. +- **TLS**, for Bolt or intra-cluster traffic. +- **Bolt authentication** — the operator connects to the coordinators unauthenticated, so clusters must not enable auth yet. +- **Monitoring** of the Memgraph cluster: no exporter, ServiceMonitor or dashboards. (The operator itself serves controller-runtime metrics; see the [chart README](charts/memgraph-operator/README.md).) +- **Standalone (non-HA) topology.** The API is shaped to grow one without a breaking change, but v1alpha1 provisions HA clusters only. +- **Affinity, tolerations, init containers, sidecars, snapshot-restore fields** and the rest of the HA chart's surface — parity roadmap, not MVP. + +## Relationship to the memgraph-high-availability chart + +This operator is the successor to the [`memgraph-high-availability`](https://github.com/memgraph/helm-charts) Helm chart. The plan is to grow it to functional parity with the chart, publish a migration guide, and then freeze the chart (security fixes only) with a deprecation timeline. Until then the chart remains the supported way to run HA in production, and this operator is an alpha for evaluating the reconciliation core. The standalone `memgraph` and `memgraph-lab` charts are unaffected and continue independently. + +Where a concept carries over, the operator borrows the chart's vocabulary — the `secrets.name` / `secrets.licenseKey` / `secrets.organizationKey` block is the chart's block — so translating a values file is mechanical. The topology is where they deliberately differ: the chart's per-instance blocks and StatefulSet-per-instance model become two integers and one StatefulSet per role. + +**Migration is fresh-cluster only.** The operator will never adopt a chart-deployed cluster in place: the resources are shaped differently and the ownership handover cannot be made safe. Moving means standing up a new cluster and transferring the data (backup/restore, or a replication cutover). The step-by-step guide lands when the operator reaches parity — there is nothing to migrate to before then. + +## Development + +```sh +make test-unit # unit tests (pure packages) +make test # unit + envtest +make lint # golangci-lint +make run # run the controller locally against the current kubeconfig +make test-e2e # KinD end-to-end suite; creates and deletes its own Kind cluster +``` + +Every pull request runs lint, unit, envtest, chart and end-to-end suites. The e2e job boots a licensed Memgraph cluster on a multi-node Kind cluster, with the license coming from repository secrets; set `MEMGRAPH_ENTERPRISE_LICENSE` and `MEMGRAPH_ORGANIZATION_NAME` to run it locally. + +Run a development build against a cluster with `make docker-build docker-push IMG=/kubernetes-operator:tag` followed by `make install` (CRDs) and `make deploy IMG=/kubernetes-operator:tag`, or install the local chart: + +```sh +helm install memgraph-operator ./charts/memgraph-operator \ + --namespace memgraph-operator-system --create-namespace --wait +``` + +The install chart is maintained in this repository under [`charts/memgraph-operator`](charts/memgraph-operator/README.md), next to the manifests it ships: its CRDs and the manager's RBAC rules are generated from the Go types and the `+kubebuilder:rbac` markers (`make chart-sync`, verified in CI by `make chart-verify`), so the chart can never drift from the controller version it installs. Pushing a version tag cross-publishes the packaged chart into the [`memgraph.github.io/helm-charts`](https://memgraph.github.io/helm-charts) index. The chart version and the operator version move independently — `v0.2.0` releases the operator, `chart-0.4.2` releases the chart alone. See [`docs/releasing.md`](docs/releasing.md). + +Development is sliced into PR-gated issues under [`specs/operator-mvp/issues/`](specs/operator-mvp/issues). Run `make help` for all targets, and see the [Kubebuilder documentation](https://book.kubebuilder.io/introduction.html) for the scaffolding conventions this project follows. ## License -Please check the [LICENSE](LICENSE) file +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. diff --git a/api/v1/memgraphha_types.go b/api/v1/memgraphha_types.go deleted file mode 100644 index ade40e6..0000000 --- a/api/v1/memgraphha_types.go +++ /dev/null @@ -1,135 +0,0 @@ -/* -Copyright 2024 Memgraph Ltd. - -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. -*/ - -/* -In some way this file simulates types.go from k8s.io/api/apps/v1 to define new resources -we are using. -*/ - -package v1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! -// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. - -// MemgraphHASpec defines the desired state of MemgraphHA -type MemgraphHASpec struct { - Coordinators []Coordinator `json:"coordinators"` - Data []DataItem `json:"data"` - Memgraph MemgraphConfig `json:"memgraph"` -} - -type Coordinator struct { - ID string `json:"id"` - BoltPort int `json:"boltPort"` - ManagementPort int `json:"managementPort"` - CoordinatorPort int `json:"coordinatorPort"` - Args []string `json:"args"` -} - -type DataItem struct { - ID string `json:"id"` - BoltPort int `json:"boltPort"` - ManagementPort int `json:"managementPort"` - ReplicationPort int `json:"replicationPort"` - Args []string `json:"args"` -} - -type MemgraphConfig struct { - Data MemgraphDataConfig `json:"data"` - Coordinators MemgraphCoordinatorsConfig `json:"coordinators"` - Env map[string]string `json:"env"` - Image ImageConfig `json:"image"` - Probes MemgraphProbesConfig `json:"probes"` -} - -type MemgraphDataConfig struct { - VolumeClaim VolumeClaimConfig `json:"volumeClaim"` -} - -type MemgraphCoordinatorsConfig struct { - VolumeClaim VolumeClaimConfig `json:"volumeClaim"` -} - -type VolumeClaimConfig struct { - StoragePVCClassName string `json:"storagePVCClassName"` - StoragePVC bool `json:"storagePVC"` - StoragePVCSize string `json:"storagePVCSize"` - LogPVCClassName string `json:"logPVCClassName"` - LogPVC bool `json:"logPVC"` - LogPVCSize string `json:"logPVCSize"` -} - -type ImageConfig struct { - PullPolicy string `json:"pullPolicy"` - Repository string `json:"repository"` - Tag string `json:"tag"` -} - -type MemgraphProbesConfig struct { - Liveness ProbeConfig `json:"liveness"` - Readiness ProbeConfig `json:"readiness"` - Startup ProbeConfig `json:"startup"` -} - -// ProbeConfig configures individual probes -type ProbeConfig struct { - InitialDelaySeconds int `json:"initialDelaySeconds"` - PeriodSeconds int `json:"periodSeconds"` - FailureThreshold int `json:"failureThreshold,omitempty"` -} - -// MemgraphHAStatus defines the observed state of MemgraphHA -type MemgraphHAStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file -} - -//+kubebuilder:object:root=true -//+kubebuilder:subresource:status - -// MemgraphHA is the Schema for the memgraphhas API -/* -Every Kind needs to have two structures: metav1.TypeMeta and metav1.ObjectMeta. -TypeMeta structure contains information about the GVK of the Kind. -ObjectMeta contains metadata for the Kind. -*/ -type MemgraphHA struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec MemgraphHASpec `json:"spec,omitempty"` - Status MemgraphHAStatus `json:"status,omitempty"` -} - -//+kubebuilder:object:root=true - -// MemgraphHAList contains a list of MemgraphHA -type MemgraphHAList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - Items []MemgraphHA `json:"items"` -} - -func init() { - // A Scheme is an abstraction used to register the API objects - // as Group-Version-Kinds, convert between API Objects of various - // versions and serialize/deserialize API Objects - SchemeBuilder.Register(&MemgraphHA{}, &MemgraphHAList{}) -} diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go deleted file mode 100644 index 160c81e..0000000 --- a/api/v1/zz_generated.deepcopy.go +++ /dev/null @@ -1,298 +0,0 @@ -//go:build !ignore_autogenerated - -/* -Copyright 2024 Memgraph Ltd. - -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. -*/ - -/* -This file is generated by the deepcopy-gen generator. It contains the generated definition -of the DeepCopyObject method for each type defined in the package. This method is necessary -for the structures to implement the runtime.Object interface, which is defined in the API -Machinery Library and the API Machinery expects that all Kind structures will implement -this runtime.Object interface. -*/ - -// Code generated by controller-gen. DO NOT EDIT. - -package v1 - -import ( - runtime "k8s.io/apimachinery/pkg/runtime" -) - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Coordinator) DeepCopyInto(out *Coordinator) { - *out = *in - if in.Args != nil { - in, out := &in.Args, &out.Args - *out = make([]string, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Coordinator. -func (in *Coordinator) DeepCopy() *Coordinator { - if in == nil { - return nil - } - out := new(Coordinator) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DataItem) DeepCopyInto(out *DataItem) { - *out = *in - if in.Args != nil { - in, out := &in.Args, &out.Args - *out = make([]string, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataItem. -func (in *DataItem) DeepCopy() *DataItem { - if in == nil { - return nil - } - out := new(DataItem) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ImageConfig) DeepCopyInto(out *ImageConfig) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageConfig. -func (in *ImageConfig) DeepCopy() *ImageConfig { - if in == nil { - return nil - } - out := new(ImageConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MemgraphConfig) DeepCopyInto(out *MemgraphConfig) { - *out = *in - out.Data = in.Data - out.Coordinators = in.Coordinators - if in.Env != nil { - in, out := &in.Env, &out.Env - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - out.Image = in.Image - out.Probes = in.Probes -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemgraphConfig. -func (in *MemgraphConfig) DeepCopy() *MemgraphConfig { - if in == nil { - return nil - } - out := new(MemgraphConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MemgraphCoordinatorsConfig) DeepCopyInto(out *MemgraphCoordinatorsConfig) { - *out = *in - out.VolumeClaim = in.VolumeClaim -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemgraphCoordinatorsConfig. -func (in *MemgraphCoordinatorsConfig) DeepCopy() *MemgraphCoordinatorsConfig { - if in == nil { - return nil - } - out := new(MemgraphCoordinatorsConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MemgraphDataConfig) DeepCopyInto(out *MemgraphDataConfig) { - *out = *in - out.VolumeClaim = in.VolumeClaim -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemgraphDataConfig. -func (in *MemgraphDataConfig) DeepCopy() *MemgraphDataConfig { - if in == nil { - return nil - } - out := new(MemgraphDataConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MemgraphHA) DeepCopyInto(out *MemgraphHA) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemgraphHA. -func (in *MemgraphHA) DeepCopy() *MemgraphHA { - if in == nil { - return nil - } - out := new(MemgraphHA) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MemgraphHA) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MemgraphHAList) DeepCopyInto(out *MemgraphHAList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]MemgraphHA, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemgraphHAList. -func (in *MemgraphHAList) DeepCopy() *MemgraphHAList { - if in == nil { - return nil - } - out := new(MemgraphHAList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MemgraphHAList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MemgraphHASpec) DeepCopyInto(out *MemgraphHASpec) { - *out = *in - if in.Coordinators != nil { - in, out := &in.Coordinators, &out.Coordinators - *out = make([]Coordinator, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.Data != nil { - in, out := &in.Data, &out.Data - *out = make([]DataItem, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - in.Memgraph.DeepCopyInto(&out.Memgraph) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemgraphHASpec. -func (in *MemgraphHASpec) DeepCopy() *MemgraphHASpec { - if in == nil { - return nil - } - out := new(MemgraphHASpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MemgraphHAStatus) DeepCopyInto(out *MemgraphHAStatus) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemgraphHAStatus. -func (in *MemgraphHAStatus) DeepCopy() *MemgraphHAStatus { - if in == nil { - return nil - } - out := new(MemgraphHAStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MemgraphProbesConfig) DeepCopyInto(out *MemgraphProbesConfig) { - *out = *in - out.Liveness = in.Liveness - out.Readiness = in.Readiness - out.Startup = in.Startup -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemgraphProbesConfig. -func (in *MemgraphProbesConfig) DeepCopy() *MemgraphProbesConfig { - if in == nil { - return nil - } - out := new(MemgraphProbesConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ProbeConfig) DeepCopyInto(out *ProbeConfig) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProbeConfig. -func (in *ProbeConfig) DeepCopy() *ProbeConfig { - if in == nil { - return nil - } - out := new(ProbeConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *VolumeClaimConfig) DeepCopyInto(out *VolumeClaimConfig) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VolumeClaimConfig. -func (in *VolumeClaimConfig) DeepCopy() *VolumeClaimConfig { - if in == nil { - return nil - } - out := new(VolumeClaimConfig) - in.DeepCopyInto(out) - return out -} diff --git a/api/v1/groupversion_info.go b/api/v1alpha1/groupversion_info.go similarity index 50% rename from api/v1/groupversion_info.go rename to api/v1alpha1/groupversion_info.go index 7ac142d..f41a982 100644 --- a/api/v1/groupversion_info.go +++ b/api/v1alpha1/groupversion_info.go @@ -1,5 +1,5 @@ /* -Copyright 2024 Memgraph Ltd. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,26 +14,31 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package v1 contains API Schema definitions for the memgraph v1 API group +// Package v1alpha1 contains API Schema definitions for the v1alpha1 API group. // +kubebuilder:object:generate=true // +groupName=memgraph.com -package v1 +package v1alpha1 import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-runtime/pkg/scheme" ) var ( - // GroupVersion is group version used to register these objects - GroupVersion = schema.GroupVersion{Group: "memgraph.com", Version: "v1"} + // SchemeGroupVersion is group version used to register these objects. + // This name is used by applyconfiguration generators (e.g. controller-gen). + SchemeGroupVersion = schema.GroupVersion{Group: "memgraph.com", Version: "v1alpha1"} - // SchemeBuilder is used to add go types to the GroupVersionKind scheme - SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + // GroupVersion is an alias for SchemeGroupVersion, for backward compatibility. + GroupVersion = SchemeGroupVersion - /* AddToScheme adds the types in this group-version to the given scheme. - Scheme is an abstraction used in the API Machinery to create a mapping between Go - structures and Group-Version-Kinds. - */ + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. + SchemeBuilder = runtime.NewSchemeBuilder(func(scheme *runtime.Scheme) error { + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil + }) + + // AddToScheme adds the types in this group-version to the given scheme. AddToScheme = SchemeBuilder.AddToScheme ) diff --git a/api/v1alpha1/memgraphcluster_types.go b/api/v1alpha1/memgraphcluster_types.go new file mode 100644 index 0000000..fab6db1 --- /dev/null +++ b/api/v1alpha1/memgraphcluster_types.go @@ -0,0 +1,992 @@ +/* +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 v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// Defaults for optional spec fields. They are declared as CRD schema defaults +// on the field markers below and mirrored here so resource builders behave +// correctly on specs that never passed admission (e.g. in unit tests). +const ( + DefaultCoordinatorCount int32 = 3 + DefaultDataInstanceCount int32 = 2 + + DefaultImageRepository = "docker.io/memgraph/memgraph" + DefaultImageTag = "3.12.0-relwithdebinfo" + DefaultImagePullPolicy = corev1.PullIfNotPresent + + DefaultSecretName = "memgraph-secrets" + DefaultLicenseSecretKey = "MEMGRAPH_ENTERPRISE_LICENSE" + DefaultOrganizationSecretKey = "MEMGRAPH_ORGANIZATION_NAME" + + DefaultCoreDumpsSize = "10Gi" + DefaultConfigureCorePattern = true + + DefaultLibPVCSize = "1Gi" + DefaultLogPVCSize = "1Gi" + DefaultCreateLogStorageClaim = true + DefaultStorageAccessMode = corev1.ReadWriteOnce + DefaultStorageRetention = RetentionPolicyRetain + + DefaultClusterDomain = "cluster.local" + + DefaultBoltPort int32 = 7687 + DefaultManagementPort int32 = 10000 + DefaultReplicationPort int32 = 20000 + DefaultCoordinatorPort int32 = 12000 +) + +// Probe timing defaults. Unlike the other defaults these are Go constants only: +// a CRD schema default is per-field, and the data instances' startup budget +// deliberately differs from every other probe's, which one shared schema +// default cannot express. The doc comments on ProbeSpec name them. +const ( + // DefaultProbeFailureThreshold is the failure budget of every probe except + // the data instances' startup probe. + DefaultProbeFailureThreshold int32 = 20 + + // DefaultDataStartupProbeFailureThreshold gives data instances a generous + // startup budget so a large snapshot restore is not killed mid-load: 1440 + // failures at the default 5s period is 2h, mirroring the + // memgraph-high-availability Helm chart's default. + DefaultDataStartupProbeFailureThreshold int32 = 1440 + + DefaultProbeTimeoutSeconds int32 = 10 + DefaultProbePeriodSeconds int32 = 5 +) + +// Names of the environment variables the operator itself sets on the Memgraph +// container, which extraEnv therefore may not carry. +const ( + // EnvLicense holds the enterprise license, wired from the secrets block. + EnvLicense = "MEMGRAPH_ENTERPRISE_LICENSE" + + // EnvOrganization holds the organization name, wired from the secrets block. + EnvOrganization = "MEMGRAPH_ORGANIZATION_NAME" + + // EnvPodName carries the pod's own name, from which a coordinator derives + // its ordinal-dependent identity at startup. + EnvPodName = "POD_NAME" + + // EnvCoreDumpsDir carries the core dumps mount path into the uploader + // sidecar, which therefore may not set it itself. It is the only variable + // the operator sets on a container other than Memgraph's. + EnvCoreDumpsDir = "CORE_DUMPS_DIR" +) + +// StorageRetentionPolicy decides what happens to a PersistentVolumeClaim of +// this cluster once nothing runs on it any more: either because the +// MemgraphCluster was deleted, or because a lowered replica count retired the +// pod that used it. +// +kubebuilder:validation:Enum=Retain;Delete +type StorageRetentionPolicy string + +const ( + // RetentionPolicyRetain leaves the PVCs behind, so the data survives both an + // accidental deletion of the MemgraphCluster and an accidental scale-down — + // raising the count again reattaches the retained volume. + RetentionPolicyRetain StorageRetentionPolicy = "Retain" + + // RetentionPolicyDelete lets the StatefulSet controller garbage-collect the + // PVCs: all of them when the MemgraphCluster is deleted, and a retiring + // pod's when a replica count is lowered. The data is not recoverable. + RetentionPolicyDelete StorageRetentionPolicy = "Delete" +) + +// Condition types reported on MemgraphCluster status. Both use normal-True +// polarity: True is the healthy state. Ready answers "is the cluster serving" +// (a MAIN is elected and reachable); Converged answers "does the cluster match +// the declared topology" (every declared coordinator and data instance is +// registered, and both StatefulSets run the declared number of replicas). A +// cluster can be Ready but not Converged — a MAIN still serves while a lost +// replica registration is being restored, or while a scale finishes. +const ( + // ConditionReady is True when a MAIN data instance is elected and the + // coordinator leader is reachable. + ConditionReady = "Ready" + + // ConditionConverged is True when the observed cluster matches the declared + // topology: no registration commands are pending and both StatefulSets' + // replica counts equal the declared counts. `kubectl wait + // --for=condition=Converged` therefore means a scale is genuinely finished, + // not merely accepted. + ConditionConverged = "Converged" + + // ConditionUpdated is True when every workload pod runs the pod template the + // spec currently describes. Because both StatefulSets use updateStrategy + // OnDelete, Kubernetes replaces no pod on its own: the operator restarts them + // one at a time, data instances before coordinators, MAIN and the Raft leader + // last. It is kept apart from Converged deliberately — Converged answers + // "does the cluster have the declared members", this one answers "do they run + // the declared template", and a user looking at a False condition needs to + // know which of the two is happening. + ConditionUpdated = "Updated" +) + +// Condition reasons reported on MemgraphCluster status. Reasons are CamelCase +// per Kubernetes API conventions and are stable enough for tooling to gate on. +const ( + // ReasonApplyFailed is set when the API server rejected one of the desired + // workload objects, so the cluster does not run the declared spec. The + // condition message carries the rejection verbatim: the operator cannot act + // on it, but it names exactly what a human has to change. + ReasonApplyFailed = "ApplyFailed" + + // ReasonWorkloadsNotReady is set while not every workload pod is ready, so + // registration has not been attempted. + ReasonWorkloadsNotReady = "WorkloadsNotReady" + + // ReasonCoordinatorUnreachable is set when no coordinator answered + // SHOW INSTANCES, so the cluster state cannot be observed. + ReasonCoordinatorUnreachable = "CoordinatorUnreachable" + + // ReasonNoCoordinatorLeader is set when coordinators answer SHOW INSTANCES + // but none of them reports a leader, so their views come from stale state + // machines and no management query would be accepted anyway. It is kept + // apart from CoordinatorUnreachable because the remedy differs: the pods are + // up and serving Bolt, what is missing is a Raft quorum. + ReasonNoCoordinatorLeader = "NoCoordinatorLeader" + + // ReasonRegistrationInProgress is set while registration commands are being + // issued to converge the cluster toward the declared topology. + ReasonRegistrationInProgress = "RegistrationInProgress" + + // ReasonRegistrationFailed is set when the coordinator leader rejected a + // registration command, so the cluster does not have the declared topology. + // The condition message carries the command and the rejection verbatim, for + // the reason ApplyFailed does: the command is retried forever, and nothing the + // operator can do will clear a rejection it does not understand, so the + // resource has to name it rather than leaving it in the operator's log. + ReasonRegistrationFailed = "RegistrationFailed" + + // ReasonAllInstancesRegistered is set when the observed cluster matches the + // declared topology. + ReasonAllInstancesRegistered = "AllInstancesRegistered" + + // ReasonRetirementInProgress is set while a lowered count of either role is + // being carried out: the members beyond the declared count are still part of + // the cluster, or their pods are still being shed. The message names them, so + // a scale-down that stalls says which member it is waiting on. + ReasonRetirementInProgress = "RetirementInProgress" + + // ReasonLeadershipTransferInProgress is set while a lowered coordinators + // count is waiting on Raft leadership to move: Raft refuses to remove its own + // leader, so a retiring coordinator holding leadership is asked to yield it + // first. YIELD LEADERSHIP cannot name a successor, so the operator re-observes + // the cluster under whichever coordinator won the election and may have to ask + // again — which is exactly what this reason means when it persists. + ReasonLeadershipTransferInProgress = "LeadershipTransferInProgress" + + // ReasonNoCaughtUpSurvivor is set while a lowered dataInstances count is + // waiting to move MAIN off the instance it retires: no surviving instance is + // both reachable and holding every transaction the MAIN has committed, so + // demoting it now would drop those writes. The retiring MAIN keeps serving + // until one catches up, which is a scale-down that pauses rather than one that + // loses data. It persisting means replication is not progressing — the + // survivors are down, or too far behind to catch up. + ReasonNoCaughtUpSurvivor = "NoCaughtUpSurvivor" + + // ReasonRollingRestartInProgress is set while the operator is restarting pods + // to bring them onto the pod template the spec currently describes. The + // message names the pod being restarted and why it is that one's turn, because + // the order is the whole safety argument: every data instance except MAIN + // first, then MAIN, then the coordinators with the Raft leader last. + ReasonRollingRestartInProgress = "RollingRestartInProgress" + + // ReasonWaitingForCatchUp is set while a rolling restart waits for the data + // instance it restarted last to hold every transaction the MAIN has committed + // again. Until it does, restarting the next pod would leave recent writes on + // the MAIN alone. + ReasonWaitingForCatchUp = "WaitingForCatchUp" + + // ReasonAllPodsUpdated is set when every workload pod runs the pod template + // the spec currently describes. + ReasonAllPodsUpdated = "AllPodsUpdated" + + // ReasonMainElected is set when a data instance is observed as MAIN. + ReasonMainElected = "MainElected" + + // ReasonNoMainElected is set when the cluster is reachable but no data + // instance has yet been promoted to MAIN. + ReasonNoMainElected = "NoMainElected" +) + +// ImageSpec selects the Memgraph container image run by all cluster pods. +type ImageSpec struct { + // repository is the Memgraph container image repository. It carries the + // optional registry host and the image path only; the version belongs in + // tag. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=255 + // +kubebuilder:validation:XValidation:rule="!self.contains('@')",message="repository must not contain a digest; pin the image with tag instead" + // +kubebuilder:validation:XValidation:rule="!self.substring(self.lastIndexOf('/') + 1).contains(':')",message="repository must not contain a tag; set image.tag instead" + // +kubebuilder:default="docker.io/memgraph/memgraph" + // +optional + Repository string `json:"repository,omitempty"` + + // tag is the Memgraph container image tag. Prefer pinning a specific + // Memgraph version over mutable tags such as "latest". + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=128 + // +kubebuilder:validation:Pattern=`^[a-zA-Z0-9_][a-zA-Z0-9._-]*$` + // +kubebuilder:default="3.12.0-relwithdebinfo" + // +optional + Tag string `json:"tag,omitempty"` + + // pullPolicy is the image pull policy applied to all cluster pods. + // +kubebuilder:validation:Enum=Always;IfNotPresent;Never + // +kubebuilder:default=IfNotPresent + // +optional + PullPolicy corev1.PullPolicy `json:"pullPolicy,omitempty"` +} + +// SecretsSpec references an existing Kubernetes Secret holding the Memgraph +// enterprise license and organization name. The block mirrors the +// memgraph-high-availability Helm chart's secrets vocabulary; secret material +// is consumed by reference only and never appears in the CR. +// +// The has() guards keep the rule evaluable against the block's empty object +// default, which the API server checks before nested field defaults apply. +// +// +kubebuilder:validation:XValidation:rule="!has(self.licenseKey) || !has(self.organizationKey) || self.licenseKey != self.organizationKey",message="licenseKey and organizationKey must name different keys of the Secret" +type SecretsSpec 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])?)*$` + // +kubebuilder:default="memgraph-secrets" + // +optional + Name string `json:"name,omitempty"` + + // licenseKey is the key within the Secret holding the enterprise license. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[-._a-zA-Z0-9]+$` + // +kubebuilder:default="MEMGRAPH_ENTERPRISE_LICENSE" + // +optional + LicenseKey string `json:"licenseKey,omitempty"` + + // organizationKey is the key within the Secret holding the organization + // name the license was issued to. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[-._a-zA-Z0-9]+$` + // +kubebuilder:default="MEMGRAPH_ORGANIZATION_NAME" + // +optional + OrganizationKey string `json:"organizationKey,omitempty"` +} + +// RoleStorageSpec configures the two PersistentVolumeClaims every pod of a +// role gets: lib storage backing Memgraph's data directory, and log storage +// backing its log file. The knob names mirror the +// memgraph-high-availability Helm chart's storage. block so translating +// a values file is mechanical. +// +// The fields below become StatefulSet volumeClaimTemplates, which Kubernetes +// treats as immutable: changing them on a live MemgraphCluster is rejected by +// the StatefulSet controller, not silently applied. Storage changes are a +// day-2 operation and out of scope for v1alpha1. +type RoleStorageSpec struct { + // libPVCSize is the requested size of the lib storage claim, which backs + // Memgraph's data directory (snapshots, WAL, and durability metadata). + // +kubebuilder:default="1Gi" + // +optional + LibPVCSize *resource.Quantity `json:"libPVCSize,omitempty"` + + // libStorageAccessMode is the access mode requested for the lib storage + // claim. + // +kubebuilder:validation:Enum=ReadWriteOnce;ReadOnlyMany;ReadWriteMany;ReadWriteOncePod + // +kubebuilder:default=ReadWriteOnce + // +optional + LibStorageAccessMode corev1.PersistentVolumeAccessMode `json:"libStorageAccessMode,omitempty"` + + // libStorageClassName is the StorageClass backing the lib storage claim. + // Leave it unset to use the cluster's default StorageClass; set it to the + // empty string to disable dynamic provisioning and bind a pre-created + // PersistentVolume. + // +kubebuilder:validation:MaxLength=253 + // +optional + LibStorageClassName *string `json:"libStorageClassName,omitempty"` + + // createLogStorageClaim decides whether every pod of the role gets a log + // storage claim at all. With it disabled the operator drops the claim and + // passes an empty --log-file, which turns file logging off, so stderr and + // `kubectl logs` (plus whatever collects it) become the single log sink. Use + // it to avoid a second PersistentVolumeClaim per pod on clusters that ship + // logs off-node anyway. + // + // The remaining log* knobs below are ignored while this is false. + // + // Like the sizes and classes around it this is effectively a create-time + // choice: flipping it adds or removes a volumeClaimTemplate, which + // Kubernetes forbids on a live StatefulSet, so the operator's apply is + // rejected until the StatefulSet is recreated (delete it with + // --cascade=orphan and the operator rebuilds it around the running pods). + // +kubebuilder:default=true + // +optional + CreateLogStorageClaim *bool `json:"createLogStorageClaim,omitempty"` + + // logPVCSize is the requested size of the log storage claim, which backs + // Memgraph's log file. + // +kubebuilder:default="1Gi" + // +optional + LogPVCSize *resource.Quantity `json:"logPVCSize,omitempty"` + + // logStorageAccessMode is the access mode requested for the log storage + // claim. + // +kubebuilder:validation:Enum=ReadWriteOnce;ReadOnlyMany;ReadWriteMany;ReadWriteOncePod + // +kubebuilder:default=ReadWriteOnce + // +optional + LogStorageAccessMode corev1.PersistentVolumeAccessMode `json:"logStorageAccessMode,omitempty"` + + // logStorageClassName is the StorageClass backing the log storage claim. + // Leave it unset to use the cluster's default StorageClass; set it to the + // empty string to disable dynamic provisioning and bind a pre-created + // PersistentVolume. + // +kubebuilder:validation:MaxLength=253 + // +optional + LogStorageClassName *string `json:"logStorageClassName,omitempty"` +} + +// StorageSpec configures persistence for both roles plus what happens to a +// claim once nothing runs on it any more. +type StorageSpec struct { + // retentionPolicy decides whether a PersistentVolumeClaim of this cluster + // survives being orphaned, which happens two ways: the MemgraphCluster is + // deleted, or a lowered replica count retires the pod that used it. Both are + // the same question — keep this cluster's data, or do not — so the policy + // maps onto both halves of the StatefulSets' + // persistentVolumeClaimRetentionPolicy, whenDeleted and whenScaled. That + // makes the StatefulSet controller the only thing that ever deletes storage; + // the operator owns no finalizer and runs no cleanup of its own. The default + // keeps production data safe from an accidental delete or shrink; dev + // clusters can opt into self-cleanup. + // + // Delete therefore makes lowering spec.coordinators or spec.dataInstances + // destructive: the retiring pods' claims go with them. + // +kubebuilder:default=Retain + // +optional + RetentionPolicy StorageRetentionPolicy `json:"retentionPolicy,omitempty"` + + // coordinators configures the storage of every coordinator pod. + // +kubebuilder:default={} + // +optional + Coordinators RoleStorageSpec `json:"coordinators,omitzero"` + + // data configures the storage of every data instance pod. + // +kubebuilder:default={} + // +optional + Data RoleStorageSpec `json:"data,omitzero"` +} + +// RoleCoreDumpsSpec is the part of core dump collection that genuinely differs +// between the roles: whether they collect at all, and how much room a dump +// needs. Everything else — the storage class, the kernel setup, the uploader — +// is the same decision for both and lives on CoreDumpsSpec. +type RoleCoreDumpsSpec struct { + // enabled provisions a core dumps volume for every pod of the role and + // mounts it at /var/core/memgraph. It is off by default: a crashing Memgraph + // is not the normal case, and the volume costs a third + // PersistentVolumeClaim per pod. + // +kubebuilder:default=false + // +optional + Enabled bool `json:"enabled,omitempty"` + + // size is the requested size of the role's core dumps claim. A dump is + // roughly as large as the crashing process' resident memory, which is why + // this is per role: a data instance holds the graph, a coordinator holds + // Raft state. Size it against the role's memory limit, not its data. + // +kubebuilder:default="10Gi" + // +optional + Size *resource.Quantity `json:"size,omitempty"` +} + +// CoreDumpsUploaderSpec is a sidecar that reads the core dumps volume. It is +// deliberately not a full core/v1 Container: the narrow shape keeps the +// operator's pod security posture non-negotiable (no privileged sidecar, no +// extra volume mounts, no valueFrom smuggling secret material into the CR) and +// keeps the CRD small enough to apply client-side, which one inlined Container +// per role does not. +// +// +kubebuilder:validation:XValidation:rule="!has(self.env) || self.env.all(e, e.name != 'CORE_DUMPS_DIR')",message="env must not set CORE_DUMPS_DIR: the operator passes the core dumps mount path in it" +type CoreDumpsUploaderSpec struct { + // image is the full sidecar image reference including its tag, for example + // "amazon/aws-cli:2.33.28". Unlike the Memgraph image the operator has no + // default for it, so a tag belongs here rather than in a separate field. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=383 + Image string `json:"image"` + + // pullPolicy is the image pull policy of the sidecar. + // +kubebuilder:validation:Enum=Always;IfNotPresent;Never + // +kubebuilder:default=IfNotPresent + // +optional + PullPolicy corev1.PullPolicy `json:"pullPolicy,omitempty"` + + // command overrides the image's entrypoint. + // +optional + Command []string `json:"command,omitempty"` + + // args are the arguments passed to the sidecar's entrypoint. + // +optional + Args []string `json:"args,omitempty"` + + // env passes literal, non-secret environment variables to the sidecar — + // bucket names, prefixes, regions. Credentials belong in envFromSecrets. + // +listType=map + // +listMapKey=name + // +optional + Env []EnvVar `json:"env,omitempty"` + + // envFromSecrets names Secrets in the cluster's namespace whose keys become + // environment variables of the sidecar. This is how credentials reach it: + // by reference, so no secret material ever appears in this resource. + // +listType=set + // +optional + EnvFromSecrets []string `json:"envFromSecrets,omitempty"` + + // resources sets the sidecar's compute resources. Leave it unset and the + // sidecar schedules without requests or limits. + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitzero"` +} + +// CoreDumpsSpec configures core dump collection: what the two roles decide for +// themselves below, and above that the settings that are one decision for the +// whole cluster — where the volumes come from, whether the operator configures +// the node, and what ships the dumps away. +// +// The memgraph-high-availability Helm chart spreads the same feature across +// storage..coreDumps* and a separate top-level coreDumpUploader block +// that silently does nothing unless the per-role claim is enabled too. Here the +// dependency is structural: an uploader with no role collecting dumps is +// rejected, not ignored. +// +// Dumps are for debugging a crash, not for the cluster to run: nothing in the +// operator reads them, and the claims follow the same storage.retentionPolicy +// as the rest of the cluster's volumes. +// +// The has() guards keep the rule evaluable against the block's empty object +// default, which the API server checks before nested field defaults apply. +// +// +kubebuilder:validation:XValidation:rule="!has(self.uploader) || (has(self.coordinators) && has(self.coordinators.enabled) && self.coordinators.enabled) || (has(self.data) && has(self.data.enabled) && self.data.enabled)",message="uploader requires core dumps enabled for at least one role — there would be no volume for it to read" +type CoreDumpsSpec struct { + // coordinators decides whether every coordinator pod collects dumps, and + // how much room it gets for them. + // +kubebuilder:default={} + // +optional + Coordinators RoleCoreDumpsSpec `json:"coordinators,omitzero"` + + // data decides whether every data instance pod collects dumps, and how much + // room it gets for them. + // +kubebuilder:default={} + // +optional + Data RoleCoreDumpsSpec `json:"data,omitzero"` + + // storageClassName is the StorageClass backing every core dumps claim of + // this cluster. Leave it unset to use the cluster's default StorageClass; + // set it to the empty string to disable dynamic provisioning and bind + // pre-created PersistentVolumes. + // +kubebuilder:validation:MaxLength=253 + // +optional + StorageClassName *string `json:"storageClassName,omitempty"` + + // configureCorePattern lets the operator point the kernel at + // /var/core/memgraph by running a privileged init container that writes + // /proc/sys/kernel/core_pattern. It uses the cluster's own Memgraph image, + // so no second image has to be pulled. + // + // It is cluster-wide rather than per role for two reasons: core_pattern is a + // property of the **node**, so it applies to every process that crashes + // there regardless of which role asked for it, and what really decides this + // is whether the namespace tolerates a privileged container at all. + // PodSecurity "restricted" does not — set this to false there, or wherever + // the platform manages core_pattern itself, and the operator only provisions + // and mounts the volumes, trusting the node to already point at them. + // +kubebuilder:default=true + // +optional + ConfigureCorePattern *bool `json:"configureCorePattern,omitempty"` + + // uploader is an optional sidecar that ships collected dumps off the volume + // — to object storage, a debug host, wherever. Any image and destination + // works, so no provider or credential vocabulary has to live in this API: + // the operator mounts the core dumps volume into the sidecar read-only, + // passes the directory as CORE_DUMPS_DIR, and gives it the same locked-down + // security context as the Memgraph container. See + // config/samples/v1alpha1_memgraphcluster.yaml for an S3 uploader + // equivalent to the Helm chart's. + // + // One definition serves both roles — the destination and credentials do not + // differ between them, and the pods are already distinguishable by hostname + // — and it joins the pods of every role that collects dumps. It counts + // toward pod readiness, so a sidecar that crash-loops keeps those roles from + // ever being registered. + // +optional + Uploader *CoreDumpsUploaderSpec `json:"uploader,omitempty"` +} + +// PortsSpec configures the internal ports Memgraph listens on. The knob names +// mirror the memgraph-high-availability Helm chart's ports block. +// +// These ports are load-bearing beyond the container: they are part of every +// advertised address the operator registers with the cluster (bolt_server, +// coordinator_server, management_server, replication_server), so a change +// reaches container ports, Services, and registration commands together. +// Changing a port on a live cluster is a day-2 operation and out of scope for +// v1alpha1: the pods restart on the new ports while the coordinators keep the +// addresses they were registered with. +// +// The has() guards keep the rule evaluable against the block's empty object +// default, which the API server checks before nested field defaults apply. +// +// +kubebuilder:validation:XValidation:rule="!(has(self.boltPort) && has(self.managementPort) && has(self.replicationPort) && has(self.coordinatorPort)) || [self.boltPort, self.managementPort, self.replicationPort, self.coordinatorPort].all(p, [self.boltPort, self.managementPort, self.replicationPort, self.coordinatorPort].exists_one(q, q == p))",message="boltPort, managementPort, replicationPort and coordinatorPort must all be different ports" +type PortsSpec struct { + // boltPort is the port Memgraph serves the Bolt protocol on. Clients and + // the operator's own management queries both use it. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + // +kubebuilder:default=7687 + // +optional + BoltPort *int32 `json:"boltPort,omitempty"` + + // managementPort is the port instances exchange HA management traffic on. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + // +kubebuilder:default=10000 + // +optional + ManagementPort *int32 `json:"managementPort,omitempty"` + + // replicationPort is the port data instances replicate over. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + // +kubebuilder:default=20000 + // +optional + ReplicationPort *int32 `json:"replicationPort,omitempty"` + + // coordinatorPort is the port coordinators run their Raft protocol on. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + // +kubebuilder:default=12000 + // +optional + CoordinatorPort *int32 `json:"coordinatorPort,omitempty"` +} + +// ProbeSpec tunes the timings of one probe. The probe type itself is not +// configurable: every probe is a TCP-socket check against the role's own port +// (the coordinator port for coordinators, the Bolt port for data instances), +// which is the memgraph-high-availability Helm chart's established convention. +// +// Every field defaults to the value named in its doc comment. +type ProbeSpec struct { + // failureThreshold is how many consecutive failures the probe tolerates + // before acting. Defaults to 1440 for the data instances' startup probe — + // 2h at the default period, so a large snapshot restore is not killed + // mid-load — and to 20 for every other probe. + // +kubebuilder:validation:Minimum=1 + // +optional + FailureThreshold *int32 `json:"failureThreshold,omitempty"` + + // timeoutSeconds is how long a single probe attempt may take. Defaults to + // 10. + // +kubebuilder:validation:Minimum=1 + // +optional + TimeoutSeconds *int32 `json:"timeoutSeconds,omitempty"` + + // periodSeconds is how often the probe runs. Defaults to 5. + // +kubebuilder:validation:Minimum=1 + // +optional + PeriodSeconds *int32 `json:"periodSeconds,omitempty"` +} + +// RoleProbesSpec tunes all three probes of one role. +type RoleProbesSpec struct { + // startupProbe gates the other two probes until the instance has started. + // +optional + StartupProbe ProbeSpec `json:"startupProbe,omitzero"` + + // readinessProbe decides whether the pod receives traffic and whether the + // operator considers the workloads ready to register. + // +optional + ReadinessProbe ProbeSpec `json:"readinessProbe,omitzero"` + + // livenessProbe decides whether the container is restarted. + // +optional + LivenessProbe ProbeSpec `json:"livenessProbe,omitzero"` +} + +// ProbesSpec tunes probe timings per role. +type ProbesSpec struct { + // coordinators tunes the probes of every coordinator pod. + // +optional + Coordinators RoleProbesSpec `json:"coordinators,omitzero"` + + // data tunes the probes of every data instance pod. + // +optional + Data RoleProbesSpec `json:"data,omitzero"` +} + +// ResourcesSpec sets the compute resources of the Memgraph container per role. +// When setting Memgraph's own --memory-limit through extraArgs, keep it below +// the pod's memory limit: Memgraph must hit its own limit and raise a query +// exception before the kubelet evicts the pod. +type ResourcesSpec struct { + // coordinators are the resource requests and limits of every coordinator + // pod's Memgraph container. + // +optional + Coordinators corev1.ResourceRequirements `json:"coordinators,omitzero"` + + // data are the resource requests and limits of every data instance pod's + // Memgraph container. + // +optional + Data corev1.ResourceRequirements `json:"data,omitzero"` +} + +// RoleLabelsSpec adds custom labels to one role's objects. The operator's own +// identity labels (app.kubernetes.io/name, /instance, /component, /managed-by) +// always win a key collision: they are what the StatefulSets and Services +// select on, so a custom label can never detach a pod from its cluster. +type RoleLabelsSpec struct { + // podLabels are added to the role's pods. + // +optional + PodLabels map[string]string `json:"podLabels,omitempty"` + + // statefulSetLabels are added to the role's StatefulSet. + // +optional + StatefulSetLabels map[string]string `json:"statefulSetLabels,omitempty"` + + // serviceLabels are added to the role's headless Service. + // +optional + ServiceLabels map[string]string `json:"serviceLabels,omitempty"` +} + +// LabelsSpec adds custom labels per role, mirroring the +// memgraph-high-availability Helm chart's labels block. +type LabelsSpec struct { + // coordinators labels the coordinator objects. + // +optional + Coordinators RoleLabelsSpec `json:"coordinators,omitzero"` + + // data labels the data instance objects. + // +optional + Data RoleLabelsSpec `json:"data,omitzero"` +} + +// EnvVar is one non-secret environment variable set on a role's Memgraph +// container. Only literal values are supported — there is deliberately no +// valueFrom — so secret material stays confined to the secrets block and the CR +// remains safe to commit. +type EnvVar struct { + // name is the environment variable's name. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[A-Za-z_][A-Za-z0-9_]*$` + // +required + Name string `json:"name"` + + // value is the literal, non-secret value. + // +kubebuilder:validation:MaxLength=4096 + // +optional + Value string `json:"value,omitempty"` +} + +// ExtraEnvSpec passes additional non-secret environment variables to a role's +// Memgraph container, mirroring the memgraph-high-availability Helm chart's +// extraEnv block. +type ExtraEnvSpec struct { + // coordinators are added to every coordinator pod's Memgraph container. + // +listType=map + // +listMapKey=name + // +kubebuilder:validation:MaxItems=64 + // +kubebuilder:validation:XValidation:rule="self.all(e, !(e.name in ['MEMGRAPH_ENTERPRISE_LICENSE', 'MEMGRAPH_ORGANIZATION_NAME', 'POD_NAME']))",message="extraEnv must not set MEMGRAPH_ENTERPRISE_LICENSE or MEMGRAPH_ORGANIZATION_NAME (they come from the secrets block) or POD_NAME (it carries the pod's own identity)" + // +optional + Coordinators []EnvVar `json:"coordinators,omitempty"` + + // data are added to every data instance pod's Memgraph container. + // +listType=map + // +listMapKey=name + // +kubebuilder:validation:MaxItems=64 + // +kubebuilder:validation:XValidation:rule="self.all(e, !(e.name in ['MEMGRAPH_ENTERPRISE_LICENSE', 'MEMGRAPH_ORGANIZATION_NAME', 'POD_NAME']))",message="extraEnv must not set MEMGRAPH_ENTERPRISE_LICENSE or MEMGRAPH_ORGANIZATION_NAME (they come from the secrets block) or POD_NAME (it carries the pod's own identity)" + // +optional + Data []EnvVar `json:"data,omitempty"` +} + +// ExtraVolumesSpec adds pod volumes to a role beyond the ones the operator +// provisions, mirroring the memgraph-high-availability Helm chart's +// storage..extraVolumes block. Each entry is a core/v1 Volume: a Secret +// holding certificates, a ConfigMap, a CSI volume, an emptyDir, whatever the +// pod needs. extraVolumeMounts is what puts them in the Memgraph container. +// +// The entries are deliberately schemaless. A core/v1 Volume carries every +// volume source Kubernetes has, and inlining that schema twice grows this CRD +// past the size a client-side kubectl apply can carry — so the field accepts +// the same arbitrary volume YAML the Helm chart does, and the API server keeps +// it verbatim without validating its contents. What that costs: kubectl +// explain says nothing about the entries, and a malformed or misspelled volume +// source is caught when the operator applies the StatefulSet, surfacing on this +// resource as the ApplyFailed condition rather than as an admission error. Two +// mistakes that arrive that way in particular: reusing one of the volume names +// the operator owns (lib-storage, log-storage, core-dumps, tmp), and naming a +// volume source that does not exist. +type ExtraVolumesSpec struct { + // coordinators are added to every coordinator pod. + // +kubebuilder:validation:Schemaless + // +kubebuilder:pruning:PreserveUnknownFields + // +optional + Coordinators []corev1.Volume `json:"coordinators,omitempty"` + + // data are added to every data instance pod. + // +kubebuilder:validation:Schemaless + // +kubebuilder:pruning:PreserveUnknownFields + // +optional + Data []corev1.Volume `json:"data,omitempty"` +} + +// ExtraVolumeMountsSpec mounts volumes into a role's Memgraph container beyond +// the ones the operator mounts, mirroring the memgraph-high-availability Helm +// chart's storage..extraVolumeMounts block. Each entry names a volume the +// pod has — usually one from extraVolumes. +// +// The paths the operator already mounts are off limits: two mounts cannot share +// a path, and mounting over Memgraph's data or log directory would hide it. +type ExtraVolumeMountsSpec struct { + // coordinators are added to every coordinator pod's Memgraph container. + // +listType=map + // +listMapKey=mountPath + // +kubebuilder:validation:MaxItems=64 + // +kubebuilder:validation:XValidation:rule="self.all(m, !(m.mountPath in ['/var/lib/memgraph', '/var/log/memgraph', '/var/core/memgraph', '/tmp']))",message="extraVolumeMounts must not mount over a path the operator already mounts (/var/lib/memgraph, /var/log/memgraph, /var/core/memgraph, /tmp)" + // +optional + Coordinators []corev1.VolumeMount `json:"coordinators,omitempty"` + + // data are added to every data instance pod's Memgraph container. + // +listType=map + // +listMapKey=mountPath + // +kubebuilder:validation:MaxItems=64 + // +kubebuilder:validation:XValidation:rule="self.all(m, !(m.mountPath in ['/var/lib/memgraph', '/var/log/memgraph', '/var/core/memgraph', '/tmp']))",message="extraVolumeMounts must not mount over a path the operator already mounts (/var/lib/memgraph, /var/log/memgraph, /var/core/memgraph, /tmp)" + // +optional + Data []corev1.VolumeMount `json:"data,omitempty"` +} + +// ExtraArgsSpec passes additional Memgraph flags to a role, so any flag is +// usable without waiting for a typed field. The flags are appended after the +// ones the operator derives, and Memgraph takes the last occurrence of a +// repeated flag, so a flag set here overrides the operator's value. +// +// The ports and the coordinator identity are excluded from that override: they +// must stay consistent with the advertised addresses the operator registers +// with the cluster. Configure ports through spec.ports instead. +type ExtraArgsSpec struct { + // coordinators are appended to every coordinator pod's Memgraph flags. + // +kubebuilder:validation:MaxItems=64 + // +kubebuilder:validation:items:MinLength=1 + // +kubebuilder:validation:items:MaxLength=4096 + // +kubebuilder:validation:XValidation:rule="self.all(a, !a.replace('-', '_').matches('^_{1,2}(bolt_port|management_port|coordinator_id|coordinator_hostname|coordinator_port)($|[= ])'))",message="extraArgs must not set a port or the coordinator identity the operator derives (bolt-port, management-port, coordinator-id, coordinator-hostname, coordinator-port), in any spelling gflags accepts; configure ports through spec.ports" + // +optional + Coordinators []string `json:"coordinators,omitempty"` + + // data are appended to every data instance pod's Memgraph flags. + // +kubebuilder:validation:MaxItems=64 + // +kubebuilder:validation:items:MinLength=1 + // +kubebuilder:validation:items:MaxLength=4096 + // +kubebuilder:validation:XValidation:rule="self.all(a, !a.replace('-', '_').matches('^_{1,2}(bolt_port|management_port|coordinator_id|coordinator_hostname|coordinator_port)($|[= ])'))",message="extraArgs must not set a port or the coordinator identity the operator derives (bolt-port, management-port, coordinator-id, coordinator-hostname, coordinator-port), in any spelling gflags accepts; configure ports through spec.ports" + // +optional + Data []string `json:"data,omitempty"` +} + +// MemgraphClusterSpec defines the desired state of MemgraphCluster. +type MemgraphClusterSpec struct { + // coordinators is the number of Raft coordinator instances. It must be odd + // so the Raft quorum cannot split, and at least three, which is the + // smallest quorum that survives losing a coordinator — this operator + // builds real HA clusters, so the floor holds at creation as well as on an + // update. Raising the count on a live cluster grows it: the operator adds + // the new coordinators to the Raft cluster as their pods become ready. + // +kubebuilder:validation:Minimum=3 + // +kubebuilder:validation:XValidation:rule="self % 2 == 1",message="coordinators must be an odd number so the Raft quorum cannot split" + // +kubebuilder:default=3 + // +optional + Coordinators *int32 `json:"coordinators,omitempty"` + + // dataInstances is the number of data instances. Raising the count on a + // live cluster grows it: the operator registers the new instances as their + // pods become ready. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:default=2 + // +optional + DataInstances *int32 `json:"dataInstances,omitempty"` + + // image selects the Memgraph container image run by all cluster pods. + // +kubebuilder:default={} + // +optional + Image ImageSpec `json:"image,omitzero"` + + // secrets references the Secret holding the enterprise license and + // organization name. + // +kubebuilder:default={} + // +optional + Secrets SecretsSpec `json:"secrets,omitzero"` + + // storage configures the persistent volumes backing both roles and their + // retention on cluster deletion. + // +kubebuilder:default={} + // +optional + Storage StorageSpec `json:"storage,omitzero"` + + // coreDumps optionally collects crash dumps of either role onto a volume of + // its own. + // +kubebuilder:default={} + // +optional + CoreDumps CoreDumpsSpec `json:"coreDumps,omitzero"` + + // clusterDomain is the Kubernetes cluster domain the advertised FQDN + // addresses are built from: ...svc.. + // Override it on clusters configured with a domain other than the default. + // +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])?)*$` + // +kubebuilder:default="cluster.local" + // +optional + ClusterDomain string `json:"clusterDomain,omitempty"` + + // ports configures the internal ports Memgraph listens on. + // +kubebuilder:default={} + // +optional + Ports PortsSpec `json:"ports,omitzero"` + + // probes tunes the probe timings of both roles. + // +optional + Probes ProbesSpec `json:"probes,omitzero"` + + // resources sets the compute resources of both roles' Memgraph containers. + // +optional + Resources ResourcesSpec `json:"resources,omitzero"` + + // labels adds custom labels to both roles' pods, StatefulSets and Services. + // +optional + Labels LabelsSpec `json:"labels,omitzero"` + + // extraEnv passes additional non-secret environment variables to both + // roles' Memgraph containers. + // +optional + ExtraEnv ExtraEnvSpec `json:"extraEnv,omitzero"` + + // extraArgs passes additional Memgraph flags to both roles. + // +optional + ExtraArgs ExtraArgsSpec `json:"extraArgs,omitzero"` + + // extraVolumes adds pod volumes to both roles beyond the ones the operator + // provisions. + // +optional + ExtraVolumes ExtraVolumesSpec `json:"extraVolumes,omitzero"` + + // extraVolumeMounts mounts volumes into both roles' Memgraph containers + // beyond the ones the operator mounts. + // +optional + ExtraVolumeMounts ExtraVolumeMountsSpec `json:"extraVolumeMounts,omitzero"` +} + +// MemgraphClusterStatus defines the observed state of MemgraphCluster. +// +// Status is observation only: it carries no secret material and is never read +// back as reconcile input state. +type MemgraphClusterStatus struct { + // main is the name of the data instance currently observed as MAIN, as + // reported by SHOW INSTANCES on the coordinator leader. It is empty before + // the initial MAIN is elected and updates when the Raft coordinators fail + // over to a different instance. + // +optional + Main string `json:"main,omitempty"` + + // coordinators is how many of the declared coordinators the coordinator + // leader reports as registered members of the Raft cluster. It reaches + // spec.coordinators once registration has converged, so it is what a scale + // is watched through. + // +optional + Coordinators int32 `json:"coordinators,omitempty"` + + // dataInstances is how many of the declared data instances the coordinator + // leader reports as registered. It reaches spec.dataInstances once + // registration has converged. + // +optional + DataInstances int32 `json:"dataInstances,omitempty"` + + // conditions represent the current state of the MemgraphCluster resource. + // Each condition has a unique type and reflects the status of a specific aspect of the resource. + // + // Standard condition types include: + // - "Available": the resource is fully functional + // - "Progressing": the resource is being created or updated + // - "Degraded": the resource failed to reach or maintain its desired state + // + // The status of each condition is one of True, False, or Unknown. + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=mgc +// +kubebuilder:printcolumn:name="Coordinators",type=integer,JSONPath=`.spec.coordinators` +// +kubebuilder:printcolumn:name="Data",type=integer,JSONPath=`.spec.dataInstances` +// +kubebuilder:printcolumn:name="Registered-Coordinators",type=integer,JSONPath=`.status.coordinators`,priority=1 +// +kubebuilder:printcolumn:name="Registered-Data",type=integer,JSONPath=`.status.dataInstances`,priority=1 +// +kubebuilder:printcolumn:name="Main",type=string,JSONPath=`.status.main` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Converged",type=string,JSONPath=`.status.conditions[?(@.type=="Converged")].status` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// MemgraphCluster is the Schema for the memgraphclusters API +type MemgraphCluster struct { + metav1.TypeMeta `json:",inline"` + + // metadata is a standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + + // spec defines the desired state of MemgraphCluster + // +required + Spec MemgraphClusterSpec `json:"spec"` + + // status defines the observed state of MemgraphCluster + // +optional + Status MemgraphClusterStatus `json:"status,omitzero"` +} + +// +kubebuilder:object:root=true + +// MemgraphClusterList contains a list of MemgraphCluster +type MemgraphClusterList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []MemgraphCluster `json:"items"` +} + +func init() { + SchemeBuilder.Register(func(s *runtime.Scheme) error { + s.AddKnownTypes(SchemeGroupVersion, &MemgraphCluster{}, &MemgraphClusterList{}) + return nil + }) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..c4530ba --- /dev/null +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,613 @@ +//go:build !ignore_autogenerated + +/* +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. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CoreDumpsSpec) DeepCopyInto(out *CoreDumpsSpec) { + *out = *in + in.Coordinators.DeepCopyInto(&out.Coordinators) + in.Data.DeepCopyInto(&out.Data) + if in.StorageClassName != nil { + in, out := &in.StorageClassName, &out.StorageClassName + *out = new(string) + **out = **in + } + if in.ConfigureCorePattern != nil { + in, out := &in.ConfigureCorePattern, &out.ConfigureCorePattern + *out = new(bool) + **out = **in + } + if in.Uploader != nil { + in, out := &in.Uploader, &out.Uploader + *out = new(CoreDumpsUploaderSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CoreDumpsSpec. +func (in *CoreDumpsSpec) DeepCopy() *CoreDumpsSpec { + if in == nil { + return nil + } + out := new(CoreDumpsSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CoreDumpsUploaderSpec) DeepCopyInto(out *CoreDumpsUploaderSpec) { + *out = *in + if in.Command != nil { + in, out := &in.Command, &out.Command + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Args != nil { + in, out := &in.Args, &out.Args + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make([]EnvVar, len(*in)) + copy(*out, *in) + } + if in.EnvFromSecrets != nil { + in, out := &in.EnvFromSecrets, &out.EnvFromSecrets + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Resources.DeepCopyInto(&out.Resources) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CoreDumpsUploaderSpec. +func (in *CoreDumpsUploaderSpec) DeepCopy() *CoreDumpsUploaderSpec { + if in == nil { + return nil + } + out := new(CoreDumpsUploaderSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EnvVar) DeepCopyInto(out *EnvVar) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvVar. +func (in *EnvVar) DeepCopy() *EnvVar { + if in == nil { + return nil + } + out := new(EnvVar) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExtraArgsSpec) DeepCopyInto(out *ExtraArgsSpec) { + *out = *in + if in.Coordinators != nil { + in, out := &in.Coordinators, &out.Coordinators + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Data != nil { + in, out := &in.Data, &out.Data + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtraArgsSpec. +func (in *ExtraArgsSpec) DeepCopy() *ExtraArgsSpec { + if in == nil { + return nil + } + out := new(ExtraArgsSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExtraEnvSpec) DeepCopyInto(out *ExtraEnvSpec) { + *out = *in + if in.Coordinators != nil { + in, out := &in.Coordinators, &out.Coordinators + *out = make([]EnvVar, len(*in)) + copy(*out, *in) + } + if in.Data != nil { + in, out := &in.Data, &out.Data + *out = make([]EnvVar, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtraEnvSpec. +func (in *ExtraEnvSpec) DeepCopy() *ExtraEnvSpec { + if in == nil { + return nil + } + out := new(ExtraEnvSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExtraVolumeMountsSpec) DeepCopyInto(out *ExtraVolumeMountsSpec) { + *out = *in + if in.Coordinators != nil { + in, out := &in.Coordinators, &out.Coordinators + *out = make([]v1.VolumeMount, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Data != nil { + in, out := &in.Data, &out.Data + *out = make([]v1.VolumeMount, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtraVolumeMountsSpec. +func (in *ExtraVolumeMountsSpec) DeepCopy() *ExtraVolumeMountsSpec { + if in == nil { + return nil + } + out := new(ExtraVolumeMountsSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExtraVolumesSpec) DeepCopyInto(out *ExtraVolumesSpec) { + *out = *in + if in.Coordinators != nil { + in, out := &in.Coordinators, &out.Coordinators + *out = make([]v1.Volume, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Data != nil { + in, out := &in.Data, &out.Data + *out = make([]v1.Volume, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtraVolumesSpec. +func (in *ExtraVolumesSpec) DeepCopy() *ExtraVolumesSpec { + if in == nil { + return nil + } + out := new(ExtraVolumesSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageSpec) DeepCopyInto(out *ImageSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageSpec. +func (in *ImageSpec) DeepCopy() *ImageSpec { + if in == nil { + return nil + } + out := new(ImageSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LabelsSpec) DeepCopyInto(out *LabelsSpec) { + *out = *in + in.Coordinators.DeepCopyInto(&out.Coordinators) + in.Data.DeepCopyInto(&out.Data) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LabelsSpec. +func (in *LabelsSpec) DeepCopy() *LabelsSpec { + if in == nil { + return nil + } + out := new(LabelsSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MemgraphCluster) DeepCopyInto(out *MemgraphCluster) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemgraphCluster. +func (in *MemgraphCluster) DeepCopy() *MemgraphCluster { + if in == nil { + return nil + } + out := new(MemgraphCluster) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MemgraphCluster) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MemgraphClusterList) DeepCopyInto(out *MemgraphClusterList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MemgraphCluster, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemgraphClusterList. +func (in *MemgraphClusterList) DeepCopy() *MemgraphClusterList { + if in == nil { + return nil + } + out := new(MemgraphClusterList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MemgraphClusterList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MemgraphClusterSpec) DeepCopyInto(out *MemgraphClusterSpec) { + *out = *in + if in.Coordinators != nil { + in, out := &in.Coordinators, &out.Coordinators + *out = new(int32) + **out = **in + } + if in.DataInstances != nil { + in, out := &in.DataInstances, &out.DataInstances + *out = new(int32) + **out = **in + } + out.Image = in.Image + out.Secrets = in.Secrets + in.Storage.DeepCopyInto(&out.Storage) + in.CoreDumps.DeepCopyInto(&out.CoreDumps) + in.Ports.DeepCopyInto(&out.Ports) + in.Probes.DeepCopyInto(&out.Probes) + in.Resources.DeepCopyInto(&out.Resources) + in.Labels.DeepCopyInto(&out.Labels) + in.ExtraEnv.DeepCopyInto(&out.ExtraEnv) + in.ExtraArgs.DeepCopyInto(&out.ExtraArgs) + in.ExtraVolumes.DeepCopyInto(&out.ExtraVolumes) + in.ExtraVolumeMounts.DeepCopyInto(&out.ExtraVolumeMounts) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemgraphClusterSpec. +func (in *MemgraphClusterSpec) DeepCopy() *MemgraphClusterSpec { + if in == nil { + return nil + } + out := new(MemgraphClusterSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MemgraphClusterStatus) DeepCopyInto(out *MemgraphClusterStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemgraphClusterStatus. +func (in *MemgraphClusterStatus) DeepCopy() *MemgraphClusterStatus { + if in == nil { + return nil + } + out := new(MemgraphClusterStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PortsSpec) DeepCopyInto(out *PortsSpec) { + *out = *in + if in.BoltPort != nil { + in, out := &in.BoltPort, &out.BoltPort + *out = new(int32) + **out = **in + } + if in.ManagementPort != nil { + in, out := &in.ManagementPort, &out.ManagementPort + *out = new(int32) + **out = **in + } + if in.ReplicationPort != nil { + in, out := &in.ReplicationPort, &out.ReplicationPort + *out = new(int32) + **out = **in + } + if in.CoordinatorPort != nil { + in, out := &in.CoordinatorPort, &out.CoordinatorPort + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PortsSpec. +func (in *PortsSpec) DeepCopy() *PortsSpec { + if in == nil { + return nil + } + out := new(PortsSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProbeSpec) DeepCopyInto(out *ProbeSpec) { + *out = *in + if in.FailureThreshold != nil { + in, out := &in.FailureThreshold, &out.FailureThreshold + *out = new(int32) + **out = **in + } + if in.TimeoutSeconds != nil { + in, out := &in.TimeoutSeconds, &out.TimeoutSeconds + *out = new(int32) + **out = **in + } + if in.PeriodSeconds != nil { + in, out := &in.PeriodSeconds, &out.PeriodSeconds + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProbeSpec. +func (in *ProbeSpec) DeepCopy() *ProbeSpec { + if in == nil { + return nil + } + out := new(ProbeSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProbesSpec) DeepCopyInto(out *ProbesSpec) { + *out = *in + in.Coordinators.DeepCopyInto(&out.Coordinators) + in.Data.DeepCopyInto(&out.Data) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProbesSpec. +func (in *ProbesSpec) DeepCopy() *ProbesSpec { + if in == nil { + return nil + } + out := new(ProbesSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourcesSpec) DeepCopyInto(out *ResourcesSpec) { + *out = *in + in.Coordinators.DeepCopyInto(&out.Coordinators) + in.Data.DeepCopyInto(&out.Data) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourcesSpec. +func (in *ResourcesSpec) DeepCopy() *ResourcesSpec { + if in == nil { + return nil + } + out := new(ResourcesSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RoleCoreDumpsSpec) DeepCopyInto(out *RoleCoreDumpsSpec) { + *out = *in + if in.Size != nil { + in, out := &in.Size, &out.Size + x := (*in).DeepCopy() + *out = &x + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RoleCoreDumpsSpec. +func (in *RoleCoreDumpsSpec) DeepCopy() *RoleCoreDumpsSpec { + if in == nil { + return nil + } + out := new(RoleCoreDumpsSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RoleLabelsSpec) DeepCopyInto(out *RoleLabelsSpec) { + *out = *in + if in.PodLabels != nil { + in, out := &in.PodLabels, &out.PodLabels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.StatefulSetLabels != nil { + in, out := &in.StatefulSetLabels, &out.StatefulSetLabels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.ServiceLabels != nil { + in, out := &in.ServiceLabels, &out.ServiceLabels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RoleLabelsSpec. +func (in *RoleLabelsSpec) DeepCopy() *RoleLabelsSpec { + if in == nil { + return nil + } + out := new(RoleLabelsSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RoleProbesSpec) DeepCopyInto(out *RoleProbesSpec) { + *out = *in + in.StartupProbe.DeepCopyInto(&out.StartupProbe) + in.ReadinessProbe.DeepCopyInto(&out.ReadinessProbe) + in.LivenessProbe.DeepCopyInto(&out.LivenessProbe) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RoleProbesSpec. +func (in *RoleProbesSpec) DeepCopy() *RoleProbesSpec { + if in == nil { + return nil + } + out := new(RoleProbesSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RoleStorageSpec) DeepCopyInto(out *RoleStorageSpec) { + *out = *in + if in.LibPVCSize != nil { + in, out := &in.LibPVCSize, &out.LibPVCSize + x := (*in).DeepCopy() + *out = &x + } + if in.LibStorageClassName != nil { + in, out := &in.LibStorageClassName, &out.LibStorageClassName + *out = new(string) + **out = **in + } + if in.CreateLogStorageClaim != nil { + in, out := &in.CreateLogStorageClaim, &out.CreateLogStorageClaim + *out = new(bool) + **out = **in + } + if in.LogPVCSize != nil { + in, out := &in.LogPVCSize, &out.LogPVCSize + x := (*in).DeepCopy() + *out = &x + } + if in.LogStorageClassName != nil { + in, out := &in.LogStorageClassName, &out.LogStorageClassName + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RoleStorageSpec. +func (in *RoleStorageSpec) DeepCopy() *RoleStorageSpec { + if in == nil { + return nil + } + out := new(RoleStorageSpec) + 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 +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretsSpec. +func (in *SecretsSpec) DeepCopy() *SecretsSpec { + if in == nil { + return nil + } + out := new(SecretsSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StorageSpec) DeepCopyInto(out *StorageSpec) { + *out = *in + in.Coordinators.DeepCopyInto(&out.Coordinators) + in.Data.DeepCopyInto(&out.Data) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StorageSpec. +func (in *StorageSpec) DeepCopy() *StorageSpec { + if in == nil { + return nil + } + out := new(StorageSpec) + in.DeepCopyInto(out) + return out +} diff --git a/charts/memgraph-operator/.helmignore b/charts/memgraph-operator/.helmignore new file mode 100644 index 0000000..dac0bdd --- /dev/null +++ b/charts/memgraph-operator/.helmignore @@ -0,0 +1,12 @@ +# Patterns to ignore when building packages. +.DS_Store +.git/ +.gitignore +*.swp +*.bak +*.tmp +*.orig +*~ +.project +.idea/ +.vscode/ diff --git a/charts/memgraph-operator/Chart.yaml b/charts/memgraph-operator/Chart.yaml new file mode 100644 index 0000000..984aff3 --- /dev/null +++ b/charts/memgraph-operator/Chart.yaml @@ -0,0 +1,23 @@ +apiVersion: v2 +name: memgraph-operator +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: 0.1.0 +appVersion: "0.1.0" +# The CustomResourceDefinition uses CEL validation rules +# (x-kubernetes-validations), available from Kubernetes 1.25. +kubeVersion: ">=1.25.0-0" +home: https://memgraph.com +icon: https://memgraph.com/images/logo-memgraph-purple.svg +sources: + - https://github.com/memgraph/kubernetes-operator +keywords: + - memgraph + - graph-database + - high-availability + - operator +maintainers: + - name: Memgraph + url: https://memgraph.com diff --git a/charts/memgraph-operator/README.md b/charts/memgraph-operator/README.md new file mode 100644 index 0000000..1f7c0c5 --- /dev/null +++ b/charts/memgraph-operator/README.md @@ -0,0 +1,95 @@ +# memgraph-operator + +Installs the Memgraph Kubernetes operator: the `MemgraphCluster` CustomResourceDefinition, a +least-privilege RBAC set, and the controller Deployment. Declaring a cluster is then a single +resource — see the [repository README](../../README.md) and the +[PRD](../../specs/operator-mvp/PRD.md). + +The chart lives in the operator repository next to the generated manifests: the CRDs under +`crds/` and the manager's RBAC rules under `rbac/` are generated from the Go types and the +`+kubebuilder:rbac` markers (`make chart-sync`), and CI fails if they drift from the controller +they ship with. + +## Install + +```sh +helm repo add memgraph https://memgraph.github.io/helm-charts +helm repo update +helm install memgraph-operator memgraph/memgraph-operator \ + --namespace memgraph-operator-system --create-namespace --wait +``` + +The operator watches every namespace, so one release per cluster is enough. + +Releases cross-publish the packaged chart into that index; the source stays here. `helm search +repo memgraph/memgraph-operator --versions` lists what is available, and the chart's `appVersion` +is the operator version an install runs by default. The two version numbers move independently — +see [`docs/releasing.md`](../../docs/releasing.md). + +Installing from a checkout works the same way: + +```sh +helm install memgraph-operator ./charts/memgraph-operator \ + --namespace memgraph-operator-system --create-namespace --wait +``` + +## Uninstall + +```sh +helm uninstall memgraph-operator --namespace memgraph-operator-system +``` + +Helm never deletes CRDs it installed, so the `MemgraphCluster` CRD — and with it your clusters +and their PersistentVolumeClaims — survive the uninstall. Remove it explicitly when no cluster +needs it any more: + +```sh +kubectl delete crd memgraphclusters.memgraph.com +``` + +For the same reason, `helm upgrade` does not update the CRD. Apply the new one before upgrading +to a chart version that changes the API: + +```sh +kubectl apply -f charts/memgraph-operator/crds/ +``` + +## Permissions + +The manager's ClusterRole is generated from the controller's own RBAC markers, so it grants +exactly what the reconciler issues: read MemgraphClusters, patch their status, and create and +patch (server-side apply) the StatefulSets and Services it provisions. It cannot delete +workloads — deleting a `MemgraphCluster` removes them through garbage collection of the owner +references. Leader election adds a Lease and Events in the operator's own namespace, and the +metrics endpoint adds the TokenReview/SubjectAccessReview permissions it authorizes scrapes +with. Nothing grants read access to Secrets: the license Secret is referenced from the +`MemgraphCluster` and mounted by the kubelet into the Memgraph pods, never read by the operator. + +## Values + +| Key | Default | Description | +| --- | --- | --- | +| `image.repository` | `docker.io/memgraph/kubernetes-operator` | Operator image repository | +| `image.tag` | `""` | Operator image tag; defaults to the chart's `appVersion` | +| `image.pullPolicy` | `IfNotPresent` | Operator image pull policy | +| `imagePullSecrets` | `[]` | Secrets used to pull the operator image | +| `replicaCount` | `1` | Controller replicas | +| `leaderElection.enabled` | `true` | Elect a leader, so a second replica can stand by | +| `resources` | 10m/64Mi requests, 500m/128Mi limits | Controller container resources | +| `metrics.enabled` | `true` | Serve the controller-runtime metrics endpoint and create its Service | +| `metrics.port` | `8443` | Port the metrics endpoint binds to | +| `metrics.secure` | `true` | Serve metrics over HTTPS with authenticated, authorized scrapes | +| `rbac.create` | `true` | Create the operator's Roles and bindings | +| `serviceAccount.create` | `true` | Create the operator's ServiceAccount | +| `serviceAccount.name` | `""` | ServiceAccount name; required when `create` is false | +| `serviceAccount.annotations` | `{}` | Annotations for the ServiceAccount | +| `namespaceOverride` | `""` | Install the namespaced objects outside the release namespace | +| `nameOverride` / `fullnameOverride` | `""` | Override the generated object names | +| `podAnnotations` / `podLabels` | `{}` | Extra metadata on the controller pod | +| `nodeSelector` / `tolerations` / `affinity` | `{}` / `[]` / `{}` | Scheduling of the controller pod | +| `priorityClassName` | `""` | PriorityClass of the controller pod | +| `terminationGracePeriodSeconds` | `10` | Grace period of the controller pod | +| `podSecurityContext` | non-root uid/gid 65532, seccomp `RuntimeDefault` | Pod security context | +| `securityContext` | no privilege escalation, read-only root, all capabilities dropped | Container security context | +| `extraArgs` | `[]` | Additional controller command-line arguments | +| `extraEnv` | `[]` | Additional controller environment variables | diff --git a/charts/memgraph-operator/crds/memgraph.com_memgraphclusters.yaml b/charts/memgraph-operator/crds/memgraph.com_memgraphclusters.yaml new file mode 100644 index 0000000..ecfb946 --- /dev/null +++ b/charts/memgraph-operator/crds/memgraph.com_memgraphclusters.yaml @@ -0,0 +1,1390 @@ +# Generated by controller-gen from the Go types in api/. +# Regenerate with 'make chart-sync'; do not edit by hand. +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: memgraphclusters.memgraph.com +spec: + group: memgraph.com + names: + kind: MemgraphCluster + listKind: MemgraphClusterList + plural: memgraphclusters + shortNames: + - mgc + singular: memgraphcluster + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.coordinators + name: Coordinators + type: integer + - jsonPath: .spec.dataInstances + name: Data + type: integer + - jsonPath: .status.coordinators + name: Registered-Coordinators + priority: 1 + type: integer + - jsonPath: .status.dataInstances + name: Registered-Data + priority: 1 + type: integer + - jsonPath: .status.main + name: Main + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Converged")].status + name: Converged + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: MemgraphCluster is the Schema for the memgraphclusters API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of MemgraphCluster + properties: + clusterDomain: + default: cluster.local + description: |- + clusterDomain is the Kubernetes cluster domain the advertised FQDN + addresses are built from: ...svc.. + Override it on clusters configured with a domain other than the default. + 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 + coordinators: + default: 3 + description: |- + coordinators is the number of Raft coordinator instances. It must be odd + so the Raft quorum cannot split, and at least three, which is the + smallest quorum that survives losing a coordinator — this operator + builds real HA clusters, so the floor holds at creation as well as on an + update. Raising the count on a live cluster grows it: the operator adds + the new coordinators to the Raft cluster as their pods become ready. + format: int32 + minimum: 3 + type: integer + x-kubernetes-validations: + - message: coordinators must be an odd number so the Raft quorum cannot + split + rule: self % 2 == 1 + coreDumps: + default: {} + description: |- + coreDumps optionally collects crash dumps of either role onto a volume of + its own. + properties: + configureCorePattern: + default: true + description: |- + configureCorePattern lets the operator point the kernel at + /var/core/memgraph by running a privileged init container that writes + /proc/sys/kernel/core_pattern. It uses the cluster's own Memgraph image, + so no second image has to be pulled. + + It is cluster-wide rather than per role for two reasons: core_pattern is a + property of the **node**, so it applies to every process that crashes + there regardless of which role asked for it, and what really decides this + is whether the namespace tolerates a privileged container at all. + PodSecurity "restricted" does not — set this to false there, or wherever + the platform manages core_pattern itself, and the operator only provisions + and mounts the volumes, trusting the node to already point at them. + type: boolean + coordinators: + default: {} + description: |- + coordinators decides whether every coordinator pod collects dumps, and + how much room it gets for them. + properties: + enabled: + default: false + description: |- + enabled provisions a core dumps volume for every pod of the role and + mounts it at /var/core/memgraph. It is off by default: a crashing Memgraph + is not the normal case, and the volume costs a third + PersistentVolumeClaim per pod. + type: boolean + size: + anyOf: + - type: integer + - type: string + default: 10Gi + description: |- + size is the requested size of the role's core dumps claim. A dump is + roughly as large as the crashing process' resident memory, which is why + this is per role: a data instance holds the graph, a coordinator holds + Raft state. Size it against the role's memory limit, not its data. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + data: + default: {} + description: |- + data decides whether every data instance pod collects dumps, and how much + room it gets for them. + properties: + enabled: + default: false + description: |- + enabled provisions a core dumps volume for every pod of the role and + mounts it at /var/core/memgraph. It is off by default: a crashing Memgraph + is not the normal case, and the volume costs a third + PersistentVolumeClaim per pod. + type: boolean + size: + anyOf: + - type: integer + - type: string + default: 10Gi + description: |- + size is the requested size of the role's core dumps claim. A dump is + roughly as large as the crashing process' resident memory, which is why + this is per role: a data instance holds the graph, a coordinator holds + Raft state. Size it against the role's memory limit, not its data. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + storageClassName: + description: |- + storageClassName is the StorageClass backing every core dumps claim of + this cluster. Leave it unset to use the cluster's default StorageClass; + set it to the empty string to disable dynamic provisioning and bind + pre-created PersistentVolumes. + maxLength: 253 + type: string + uploader: + description: |- + uploader is an optional sidecar that ships collected dumps off the volume + — to object storage, a debug host, wherever. Any image and destination + works, so no provider or credential vocabulary has to live in this API: + the operator mounts the core dumps volume into the sidecar read-only, + passes the directory as CORE_DUMPS_DIR, and gives it the same locked-down + security context as the Memgraph container. See + config/samples/v1alpha1_memgraphcluster.yaml for an S3 uploader + equivalent to the Helm chart's. + + One definition serves both roles — the destination and credentials do not + differ between them, and the pods are already distinguishable by hostname + — and it joins the pods of every role that collects dumps. It counts + toward pod readiness, so a sidecar that crash-loops keeps those roles from + ever being registered. + properties: + args: + description: args are the arguments passed to the sidecar's + entrypoint. + items: + type: string + type: array + command: + description: command overrides the image's entrypoint. + items: + type: string + type: array + env: + description: |- + env passes literal, non-secret environment variables to the sidecar — + bucket names, prefixes, regions. Credentials belong in envFromSecrets. + items: + description: |- + EnvVar is one non-secret environment variable set on a role's Memgraph + container. Only literal values are supported — there is deliberately no + valueFrom — so secret material stays confined to the secrets block and the CR + remains safe to commit. + properties: + name: + description: name is the environment variable's name. + maxLength: 253 + minLength: 1 + pattern: ^[A-Za-z_][A-Za-z0-9_]*$ + type: string + value: + description: value is the literal, non-secret value. + maxLength: 4096 + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + envFromSecrets: + description: |- + envFromSecrets names Secrets in the cluster's namespace whose keys become + environment variables of the sidecar. This is how credentials reach it: + by reference, so no secret material ever appears in this resource. + items: + type: string + type: array + x-kubernetes-list-type: set + image: + description: |- + image is the full sidecar image reference including its tag, for example + "amazon/aws-cli:2.33.28". Unlike the Memgraph image the operator has no + default for it, so a tag belongs here rather than in a separate field. + maxLength: 383 + minLength: 1 + type: string + pullPolicy: + default: IfNotPresent + description: pullPolicy is the image pull policy of the sidecar. + enum: + - Always + - IfNotPresent + - Never + type: string + resources: + description: |- + resources sets the sidecar's compute resources. Leave it unset and the + sidecar schedules without requests or limits. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + required: + - image + type: object + x-kubernetes-validations: + - message: 'env must not set CORE_DUMPS_DIR: the operator passes + the core dumps mount path in it' + rule: '!has(self.env) || self.env.all(e, e.name != ''CORE_DUMPS_DIR'')' + type: object + x-kubernetes-validations: + - message: uploader requires core dumps enabled for at least one role + — there would be no volume for it to read + rule: '!has(self.uploader) || (has(self.coordinators) && has(self.coordinators.enabled) + && self.coordinators.enabled) || (has(self.data) && has(self.data.enabled) + && self.data.enabled)' + dataInstances: + default: 2 + description: |- + dataInstances is the number of data instances. Raising the count on a + live cluster grows it: the operator registers the new instances as their + pods become ready. + format: int32 + minimum: 1 + type: integer + extraArgs: + description: extraArgs passes additional Memgraph flags to both roles. + properties: + coordinators: + description: coordinators are appended to every coordinator pod's + Memgraph flags. + items: + maxLength: 4096 + minLength: 1 + type: string + maxItems: 64 + type: array + x-kubernetes-validations: + - message: extraArgs must not set a port or the coordinator identity + the operator derives (bolt-port, management-port, coordinator-id, + coordinator-hostname, coordinator-port), in any spelling gflags + accepts; configure ports through spec.ports + rule: self.all(a, !a.replace('-', '_').matches('^_{1,2}(bolt_port|management_port|coordinator_id|coordinator_hostname|coordinator_port)($|[= + ])')) + data: + description: data are appended to every data instance pod's Memgraph + flags. + items: + maxLength: 4096 + minLength: 1 + type: string + maxItems: 64 + type: array + x-kubernetes-validations: + - message: extraArgs must not set a port or the coordinator identity + the operator derives (bolt-port, management-port, coordinator-id, + coordinator-hostname, coordinator-port), in any spelling gflags + accepts; configure ports through spec.ports + rule: self.all(a, !a.replace('-', '_').matches('^_{1,2}(bolt_port|management_port|coordinator_id|coordinator_hostname|coordinator_port)($|[= + ])')) + type: object + extraEnv: + description: |- + extraEnv passes additional non-secret environment variables to both + roles' Memgraph containers. + properties: + coordinators: + description: coordinators are added to every coordinator pod's + Memgraph container. + items: + description: |- + EnvVar is one non-secret environment variable set on a role's Memgraph + container. Only literal values are supported — there is deliberately no + valueFrom — so secret material stays confined to the secrets block and the CR + remains safe to commit. + properties: + name: + description: name is the environment variable's name. + maxLength: 253 + minLength: 1 + pattern: ^[A-Za-z_][A-Za-z0-9_]*$ + type: string + value: + description: value is the literal, non-secret value. + maxLength: 4096 + type: string + required: + - name + type: object + maxItems: 64 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + x-kubernetes-validations: + - message: extraEnv must not set MEMGRAPH_ENTERPRISE_LICENSE or + MEMGRAPH_ORGANIZATION_NAME (they come from the secrets block) + or POD_NAME (it carries the pod's own identity) + rule: self.all(e, !(e.name in ['MEMGRAPH_ENTERPRISE_LICENSE', + 'MEMGRAPH_ORGANIZATION_NAME', 'POD_NAME'])) + data: + description: data are added to every data instance pod's Memgraph + container. + items: + description: |- + EnvVar is one non-secret environment variable set on a role's Memgraph + container. Only literal values are supported — there is deliberately no + valueFrom — so secret material stays confined to the secrets block and the CR + remains safe to commit. + properties: + name: + description: name is the environment variable's name. + maxLength: 253 + minLength: 1 + pattern: ^[A-Za-z_][A-Za-z0-9_]*$ + type: string + value: + description: value is the literal, non-secret value. + maxLength: 4096 + type: string + required: + - name + type: object + maxItems: 64 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + x-kubernetes-validations: + - message: extraEnv must not set MEMGRAPH_ENTERPRISE_LICENSE or + MEMGRAPH_ORGANIZATION_NAME (they come from the secrets block) + or POD_NAME (it carries the pod's own identity) + rule: self.all(e, !(e.name in ['MEMGRAPH_ENTERPRISE_LICENSE', + 'MEMGRAPH_ORGANIZATION_NAME', 'POD_NAME'])) + type: object + extraVolumeMounts: + description: |- + extraVolumeMounts mounts volumes into both roles' Memgraph containers + beyond the ones the operator mounts. + properties: + coordinators: + description: coordinators are added to every coordinator pod's + Memgraph container. + items: + description: VolumeMount describes a mounting of a Volume within + a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + When not set, MountPropagationNone is used. + This field is beta in 1.10. + When RecursiveReadOnly is set to IfPossible or to Enabled, MountPropagation must be None or unspecified + (which defaults to None). + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + + If ReadOnly is false, this field has no meaning and must be unspecified. + + If ReadOnly is true, and this field is set to Disabled, the mount is not made + recursively read-only. If this field is set to IfPossible, the mount is made + recursively read-only, if it is supported by the container runtime. If this + field is set to Enabled, the mount is made recursively read-only if it is + supported by the container runtime, otherwise the pod will not be started and + an error will be generated to indicate the reason. + + If this field is set to IfPossible or Enabled, MountPropagation must be set to + None (or be unspecified, which defaults to None). + + If this field is not specified, it is treated as an equivalent of Disabled. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: |- + Expanded path within the volume from which the container's volume should be mounted. + Behaves similarly to SubPath but environment variable references $(VAR_NAME) are expanded using the container's environment. + Defaults to "" (volume's root). + SubPathExpr and SubPath are mutually exclusive. + type: string + required: + - mountPath + - name + type: object + maxItems: 64 + type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map + x-kubernetes-validations: + - message: extraVolumeMounts must not mount over a path the operator + already mounts (/var/lib/memgraph, /var/log/memgraph, /var/core/memgraph, + /tmp) + rule: self.all(m, !(m.mountPath in ['/var/lib/memgraph', '/var/log/memgraph', + '/var/core/memgraph', '/tmp'])) + data: + description: data are added to every data instance pod's Memgraph + container. + items: + description: VolumeMount describes a mounting of a Volume within + a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + When not set, MountPropagationNone is used. + This field is beta in 1.10. + When RecursiveReadOnly is set to IfPossible or to Enabled, MountPropagation must be None or unspecified + (which defaults to None). + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + + If ReadOnly is false, this field has no meaning and must be unspecified. + + If ReadOnly is true, and this field is set to Disabled, the mount is not made + recursively read-only. If this field is set to IfPossible, the mount is made + recursively read-only, if it is supported by the container runtime. If this + field is set to Enabled, the mount is made recursively read-only if it is + supported by the container runtime, otherwise the pod will not be started and + an error will be generated to indicate the reason. + + If this field is set to IfPossible or Enabled, MountPropagation must be set to + None (or be unspecified, which defaults to None). + + If this field is not specified, it is treated as an equivalent of Disabled. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: |- + Expanded path within the volume from which the container's volume should be mounted. + Behaves similarly to SubPath but environment variable references $(VAR_NAME) are expanded using the container's environment. + Defaults to "" (volume's root). + SubPathExpr and SubPath are mutually exclusive. + type: string + required: + - mountPath + - name + type: object + maxItems: 64 + type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map + x-kubernetes-validations: + - message: extraVolumeMounts must not mount over a path the operator + already mounts (/var/lib/memgraph, /var/log/memgraph, /var/core/memgraph, + /tmp) + rule: self.all(m, !(m.mountPath in ['/var/lib/memgraph', '/var/log/memgraph', + '/var/core/memgraph', '/tmp'])) + type: object + extraVolumes: + description: |- + extraVolumes adds pod volumes to both roles beyond the ones the operator + provisions. + properties: + coordinators: + description: coordinators are added to every coordinator pod. + x-kubernetes-preserve-unknown-fields: true + data: + description: data are added to every data instance pod. + x-kubernetes-preserve-unknown-fields: true + type: object + image: + default: {} + description: image selects the Memgraph container image run by all + cluster pods. + properties: + pullPolicy: + default: IfNotPresent + description: pullPolicy is the image pull policy applied to all + cluster pods. + enum: + - Always + - IfNotPresent + - Never + type: string + repository: + default: docker.io/memgraph/memgraph + description: |- + repository is the Memgraph container image repository. It carries the + optional registry host and the image path only; the version belongs in + tag. + maxLength: 255 + minLength: 1 + type: string + x-kubernetes-validations: + - message: repository must not contain a digest; pin the image + with tag instead + rule: '!self.contains(''@'')' + - message: repository must not contain a tag; set image.tag instead + rule: '!self.substring(self.lastIndexOf(''/'') + 1).contains('':'')' + tag: + default: 3.12.0-relwithdebinfo + description: |- + tag is the Memgraph container image tag. Prefer pinning a specific + Memgraph version over mutable tags such as "latest". + maxLength: 128 + minLength: 1 + pattern: ^[a-zA-Z0-9_][a-zA-Z0-9._-]*$ + type: string + type: object + labels: + description: labels adds custom labels to both roles' pods, StatefulSets + and Services. + properties: + coordinators: + description: coordinators labels the coordinator objects. + properties: + podLabels: + additionalProperties: + type: string + description: podLabels are added to the role's pods. + type: object + serviceLabels: + additionalProperties: + type: string + description: serviceLabels are added to the role's headless + Service. + type: object + statefulSetLabels: + additionalProperties: + type: string + description: statefulSetLabels are added to the role's StatefulSet. + type: object + type: object + data: + description: data labels the data instance objects. + properties: + podLabels: + additionalProperties: + type: string + description: podLabels are added to the role's pods. + type: object + serviceLabels: + additionalProperties: + type: string + description: serviceLabels are added to the role's headless + Service. + type: object + statefulSetLabels: + additionalProperties: + type: string + description: statefulSetLabels are added to the role's StatefulSet. + type: object + type: object + type: object + ports: + default: {} + description: ports configures the internal ports Memgraph listens + on. + properties: + boltPort: + default: 7687 + description: |- + boltPort is the port Memgraph serves the Bolt protocol on. Clients and + the operator's own management queries both use it. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + coordinatorPort: + default: 12000 + description: coordinatorPort is the port coordinators run their + Raft protocol on. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + managementPort: + default: 10000 + description: managementPort is the port instances exchange HA + management traffic on. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + replicationPort: + default: 20000 + description: replicationPort is the port data instances replicate + over. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + type: object + x-kubernetes-validations: + - message: boltPort, managementPort, replicationPort and coordinatorPort + must all be different ports + rule: '!(has(self.boltPort) && has(self.managementPort) && has(self.replicationPort) + && has(self.coordinatorPort)) || [self.boltPort, self.managementPort, + self.replicationPort, self.coordinatorPort].all(p, [self.boltPort, + self.managementPort, self.replicationPort, self.coordinatorPort].exists_one(q, + q == p))' + probes: + description: probes tunes the probe timings of both roles. + properties: + coordinators: + description: coordinators tunes the probes of every coordinator + pod. + properties: + livenessProbe: + description: livenessProbe decides whether the container is + restarted. + properties: + failureThreshold: + description: |- + failureThreshold is how many consecutive failures the probe tolerates + before acting. Defaults to 1440 for the data instances' startup probe — + 2h at the default period, so a large snapshot restore is not killed + mid-load — and to 20 for every other probe. + format: int32 + minimum: 1 + type: integer + periodSeconds: + description: periodSeconds is how often the probe runs. + Defaults to 5. + format: int32 + minimum: 1 + type: integer + timeoutSeconds: + description: |- + timeoutSeconds is how long a single probe attempt may take. Defaults to + 10. + format: int32 + minimum: 1 + type: integer + type: object + readinessProbe: + description: |- + readinessProbe decides whether the pod receives traffic and whether the + operator considers the workloads ready to register. + properties: + failureThreshold: + description: |- + failureThreshold is how many consecutive failures the probe tolerates + before acting. Defaults to 1440 for the data instances' startup probe — + 2h at the default period, so a large snapshot restore is not killed + mid-load — and to 20 for every other probe. + format: int32 + minimum: 1 + type: integer + periodSeconds: + description: periodSeconds is how often the probe runs. + Defaults to 5. + format: int32 + minimum: 1 + type: integer + timeoutSeconds: + description: |- + timeoutSeconds is how long a single probe attempt may take. Defaults to + 10. + format: int32 + minimum: 1 + type: integer + type: object + startupProbe: + description: startupProbe gates the other two probes until + the instance has started. + properties: + failureThreshold: + description: |- + failureThreshold is how many consecutive failures the probe tolerates + before acting. Defaults to 1440 for the data instances' startup probe — + 2h at the default period, so a large snapshot restore is not killed + mid-load — and to 20 for every other probe. + format: int32 + minimum: 1 + type: integer + periodSeconds: + description: periodSeconds is how often the probe runs. + Defaults to 5. + format: int32 + minimum: 1 + type: integer + timeoutSeconds: + description: |- + timeoutSeconds is how long a single probe attempt may take. Defaults to + 10. + format: int32 + minimum: 1 + type: integer + type: object + type: object + data: + description: data tunes the probes of every data instance pod. + properties: + livenessProbe: + description: livenessProbe decides whether the container is + restarted. + properties: + failureThreshold: + description: |- + failureThreshold is how many consecutive failures the probe tolerates + before acting. Defaults to 1440 for the data instances' startup probe — + 2h at the default period, so a large snapshot restore is not killed + mid-load — and to 20 for every other probe. + format: int32 + minimum: 1 + type: integer + periodSeconds: + description: periodSeconds is how often the probe runs. + Defaults to 5. + format: int32 + minimum: 1 + type: integer + timeoutSeconds: + description: |- + timeoutSeconds is how long a single probe attempt may take. Defaults to + 10. + format: int32 + minimum: 1 + type: integer + type: object + readinessProbe: + description: |- + readinessProbe decides whether the pod receives traffic and whether the + operator considers the workloads ready to register. + properties: + failureThreshold: + description: |- + failureThreshold is how many consecutive failures the probe tolerates + before acting. Defaults to 1440 for the data instances' startup probe — + 2h at the default period, so a large snapshot restore is not killed + mid-load — and to 20 for every other probe. + format: int32 + minimum: 1 + type: integer + periodSeconds: + description: periodSeconds is how often the probe runs. + Defaults to 5. + format: int32 + minimum: 1 + type: integer + timeoutSeconds: + description: |- + timeoutSeconds is how long a single probe attempt may take. Defaults to + 10. + format: int32 + minimum: 1 + type: integer + type: object + startupProbe: + description: startupProbe gates the other two probes until + the instance has started. + properties: + failureThreshold: + description: |- + failureThreshold is how many consecutive failures the probe tolerates + before acting. Defaults to 1440 for the data instances' startup probe — + 2h at the default period, so a large snapshot restore is not killed + mid-load — and to 20 for every other probe. + format: int32 + minimum: 1 + type: integer + periodSeconds: + description: periodSeconds is how often the probe runs. + Defaults to 5. + format: int32 + minimum: 1 + type: integer + timeoutSeconds: + description: |- + timeoutSeconds is how long a single probe attempt may take. Defaults to + 10. + format: int32 + minimum: 1 + type: integer + type: object + type: object + type: object + resources: + description: resources sets the compute resources of both roles' Memgraph + containers. + properties: + coordinators: + description: |- + coordinators are the resource requests and limits of every coordinator + pod's Memgraph container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + data: + description: |- + data are the resource requests and limits of every data instance pod's + Memgraph container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object + secrets: + default: {} + description: |- + secrets references the Secret holding the enterprise license and + organization name. + properties: + licenseKey: + default: MEMGRAPH_ENTERPRISE_LICENSE + description: licenseKey is the key within the Secret holding the + enterprise license. + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + default: memgraph-secrets + 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 + organizationKey: + default: MEMGRAPH_ORGANIZATION_NAME + description: |- + organizationKey is the key within the Secret holding the organization + name the license was issued to. + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + type: object + x-kubernetes-validations: + - message: licenseKey and organizationKey must name different keys + of the Secret + rule: '!has(self.licenseKey) || !has(self.organizationKey) || self.licenseKey + != self.organizationKey' + storage: + default: {} + description: |- + storage configures the persistent volumes backing both roles and their + retention on cluster deletion. + properties: + coordinators: + default: {} + description: coordinators configures the storage of every coordinator + pod. + properties: + createLogStorageClaim: + default: true + description: |- + createLogStorageClaim decides whether every pod of the role gets a log + storage claim at all. With it disabled the operator drops the claim and + passes an empty --log-file, which turns file logging off, so stderr and + `kubectl logs` (plus whatever collects it) become the single log sink. Use + it to avoid a second PersistentVolumeClaim per pod on clusters that ship + logs off-node anyway. + + The remaining log* knobs below are ignored while this is false. + + Like the sizes and classes around it this is effectively a create-time + choice: flipping it adds or removes a volumeClaimTemplate, which + Kubernetes forbids on a live StatefulSet, so the operator's apply is + rejected until the StatefulSet is recreated (delete it with + --cascade=orphan and the operator rebuilds it around the running pods). + type: boolean + libPVCSize: + anyOf: + - type: integer + - type: string + default: 1Gi + description: |- + libPVCSize is the requested size of the lib storage claim, which backs + Memgraph's data directory (snapshots, WAL, and durability metadata). + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + libStorageAccessMode: + default: ReadWriteOnce + description: |- + libStorageAccessMode is the access mode requested for the lib storage + claim. + enum: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + - ReadWriteOncePod + type: string + libStorageClassName: + description: |- + libStorageClassName is the StorageClass backing the lib storage claim. + Leave it unset to use the cluster's default StorageClass; set it to the + empty string to disable dynamic provisioning and bind a pre-created + PersistentVolume. + maxLength: 253 + type: string + logPVCSize: + anyOf: + - type: integer + - type: string + default: 1Gi + description: |- + logPVCSize is the requested size of the log storage claim, which backs + Memgraph's log file. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + logStorageAccessMode: + default: ReadWriteOnce + description: |- + logStorageAccessMode is the access mode requested for the log storage + claim. + enum: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + - ReadWriteOncePod + type: string + logStorageClassName: + description: |- + logStorageClassName is the StorageClass backing the log storage claim. + Leave it unset to use the cluster's default StorageClass; set it to the + empty string to disable dynamic provisioning and bind a pre-created + PersistentVolume. + maxLength: 253 + type: string + type: object + data: + default: {} + description: data configures the storage of every data instance + pod. + properties: + createLogStorageClaim: + default: true + description: |- + createLogStorageClaim decides whether every pod of the role gets a log + storage claim at all. With it disabled the operator drops the claim and + passes an empty --log-file, which turns file logging off, so stderr and + `kubectl logs` (plus whatever collects it) become the single log sink. Use + it to avoid a second PersistentVolumeClaim per pod on clusters that ship + logs off-node anyway. + + The remaining log* knobs below are ignored while this is false. + + Like the sizes and classes around it this is effectively a create-time + choice: flipping it adds or removes a volumeClaimTemplate, which + Kubernetes forbids on a live StatefulSet, so the operator's apply is + rejected until the StatefulSet is recreated (delete it with + --cascade=orphan and the operator rebuilds it around the running pods). + type: boolean + libPVCSize: + anyOf: + - type: integer + - type: string + default: 1Gi + description: |- + libPVCSize is the requested size of the lib storage claim, which backs + Memgraph's data directory (snapshots, WAL, and durability metadata). + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + libStorageAccessMode: + default: ReadWriteOnce + description: |- + libStorageAccessMode is the access mode requested for the lib storage + claim. + enum: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + - ReadWriteOncePod + type: string + libStorageClassName: + description: |- + libStorageClassName is the StorageClass backing the lib storage claim. + Leave it unset to use the cluster's default StorageClass; set it to the + empty string to disable dynamic provisioning and bind a pre-created + PersistentVolume. + maxLength: 253 + type: string + logPVCSize: + anyOf: + - type: integer + - type: string + default: 1Gi + description: |- + logPVCSize is the requested size of the log storage claim, which backs + Memgraph's log file. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + logStorageAccessMode: + default: ReadWriteOnce + description: |- + logStorageAccessMode is the access mode requested for the log storage + claim. + enum: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + - ReadWriteOncePod + type: string + logStorageClassName: + description: |- + logStorageClassName is the StorageClass backing the log storage claim. + Leave it unset to use the cluster's default StorageClass; set it to the + empty string to disable dynamic provisioning and bind a pre-created + PersistentVolume. + maxLength: 253 + type: string + type: object + retentionPolicy: + default: Retain + description: |- + retentionPolicy decides whether a PersistentVolumeClaim of this cluster + survives being orphaned, which happens two ways: the MemgraphCluster is + deleted, or a lowered replica count retires the pod that used it. Both are + the same question — keep this cluster's data, or do not — so the policy + maps onto both halves of the StatefulSets' + persistentVolumeClaimRetentionPolicy, whenDeleted and whenScaled. That + makes the StatefulSet controller the only thing that ever deletes storage; + the operator owns no finalizer and runs no cleanup of its own. The default + keeps production data safe from an accidental delete or shrink; dev + clusters can opt into self-cleanup. + + Delete therefore makes lowering spec.coordinators or spec.dataInstances + destructive: the retiring pods' claims go with them. + enum: + - Retain + - Delete + type: string + type: object + type: object + status: + description: status defines the observed state of MemgraphCluster + properties: + conditions: + description: |- + conditions represent the current state of the MemgraphCluster resource. + Each condition has a unique type and reflects the status of a specific aspect of the resource. + + Standard condition types include: + - "Available": the resource is fully functional + - "Progressing": the resource is being created or updated + - "Degraded": the resource failed to reach or maintain its desired state + + The status of each condition is one of True, False, or Unknown. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + coordinators: + description: |- + coordinators is how many of the declared coordinators the coordinator + leader reports as registered members of the Raft cluster. It reaches + spec.coordinators once registration has converged, so it is what a scale + is watched through. + format: int32 + type: integer + dataInstances: + description: |- + dataInstances is how many of the declared data instances the coordinator + leader reports as registered. It reaches spec.dataInstances once + registration has converged. + format: int32 + type: integer + main: + description: |- + main is the name of the data instance currently observed as MAIN, as + reported by SHOW INSTANCES on the coordinator leader. It is empty before + the initial MAIN is elected and updates when the Raft coordinators fail + over to a different instance. + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/charts/memgraph-operator/rbac/manager-rules.yaml b/charts/memgraph-operator/rbac/manager-rules.yaml new file mode 100644 index 0000000..fb4f0ab --- /dev/null +++ b/charts/memgraph-operator/rbac/manager-rules.yaml @@ -0,0 +1,65 @@ +# The rule list of the operator's manager ClusterRole: every API call the +# reconciler makes is authorized against exactly these rules, because the +# manager Pod authenticates as the ServiceAccount they are bound to. +# +# templates/manager-rbac.yaml inlines this file under 'rules:' with .Files.Get +# (Helm cannot read config/rbac/role.yaml from outside the chart directory). +# +# Note what is absent: no 'delete' anywhere, so the operator cannot remove a +# StatefulSet or PVC; no 'secrets', because license and auth material reaches +# the workload Pods via the kubelet, never through the operator; no 'pods', +# because readiness gating reads StatefulSet status instead. +# +# Generated from the +kubebuilder:rbac markers in the controller sources. +# Regenerate with 'make chart-sync'; do not edit by hand. To widen or tighten +# the permissions, edit the markers -- 'make chart-verify' fails if the two drift. +- apiGroups: + - "" + resources: + - pods + verbs: + - delete + - get + - list + - watch +- apiGroups: + - "" + resources: + - services + verbs: + - create + - get + - list + - patch + - watch +- apiGroups: + - apps + resources: + - statefulsets + verbs: + - create + - get + - list + - patch + - watch +- apiGroups: + - memgraph.com + resources: + - memgraphclusters + verbs: + - get + - list + - watch +- apiGroups: + - memgraph.com + resources: + - memgraphclusters/finalizers + verbs: + - update +- apiGroups: + - memgraph.com + resources: + - memgraphclusters/status + verbs: + - get + - patch diff --git a/charts/memgraph-operator/templates/NOTES.txt b/charts/memgraph-operator/templates/NOTES.txt new file mode 100644 index 0000000..824c3ea --- /dev/null +++ b/charts/memgraph-operator/templates/NOTES.txt @@ -0,0 +1,38 @@ +{{ .Chart.Name }} {{ .Chart.AppVersion }} is installed in namespace {{ include "memgraph-operator.namespace" . }}. + +Watch the operator come up: + + kubectl rollout status deployment/{{ include "memgraph-operator.managerName" . }} -n {{ include "memgraph-operator.namespace" . }} + +Then declare a cluster (3 coordinators, 2 data instances) in any namespace, +referencing a Secret that holds your Memgraph enterprise license: + + apiVersion: memgraph.com/v1alpha1 + kind: MemgraphCluster + metadata: + name: memgraph + spec: + coordinators: 3 + dataInstances: 2 + image: + repository: docker.io/memgraph/memgraph + tag: 3.12.0-relwithdebinfo + secrets: + name: memgraph-secrets + licenseKey: MEMGRAPH_ENTERPRISE_LICENSE + organizationKey: MEMGRAPH_ORGANIZATION_NAME + +Save it as cluster.yaml and create it with apply, so that later edits — growing +the cluster, for instance — stay a clean apply too: + + kubectl apply -f cluster.yaml + +Follow the cluster reaching a registered, MAIN-elected state with: + + kubectl get mgc -w + +Note that `helm uninstall` leaves the MemgraphCluster CustomResourceDefinition +(and therefore your clusters) in place, as Helm never removes CRDs it installed. +Remove it explicitly once no cluster needs it: + + kubectl delete crd memgraphclusters.memgraph.com diff --git a/charts/memgraph-operator/templates/_helpers.tpl b/charts/memgraph-operator/templates/_helpers.tpl new file mode 100644 index 0000000..e1919f7 --- /dev/null +++ b/charts/memgraph-operator/templates/_helpers.tpl @@ -0,0 +1,79 @@ +{{/* +Chart name, overridable with nameOverride. +*/}} +{{- define "memgraph-operator.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Fully qualified release name, the prefix of every object this chart creates. +*/}} +{{- define "memgraph-operator.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Name of the controller Deployment, its ServiceAccount and its metrics Service. +*/}} +{{- define "memgraph-operator.managerName" -}} +{{- printf "%s-controller-manager" (include "memgraph-operator.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Name of the Service in front of the metrics endpoint. +*/}} +{{- define "memgraph-operator.metricsServiceName" -}} +{{- printf "%s-metrics-service" (include "memgraph-operator.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Namespace the namespaced objects are installed into. +*/}} +{{- define "memgraph-operator.namespace" -}} +{{- default .Release.Namespace .Values.namespaceOverride }} +{{- end }} + +{{- define "memgraph-operator.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Selector labels. The Deployment selector is immutable, so these must stay +stable across chart versions. +*/}} +{{- define "memgraph-operator.selectorLabels" -}} +app.kubernetes.io/name: {{ include "memgraph-operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +control-plane: controller-manager +{{- end }} + +{{- define "memgraph-operator.labels" -}} +helm.sh/chart: {{ include "memgraph-operator.chart" . }} +{{ include "memgraph-operator.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/component: controller +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +ServiceAccount the operator runs as. An externally managed account must be +named explicitly, because the chart cannot bind a name it does not know. +*/}} +{{- define "memgraph-operator.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "memgraph-operator.managerName" .) .Values.serviceAccount.name }} +{{- else }} +{{- required "serviceAccount.name is required when serviceAccount.create is false" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/charts/memgraph-operator/templates/deployment.yaml b/charts/memgraph-operator/templates/deployment.yaml new file mode 100644 index 0000000..88d17ac --- /dev/null +++ b/charts/memgraph-operator/templates/deployment.yaml @@ -0,0 +1,97 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "memgraph-operator.managerName" . }} + namespace: {{ include "memgraph-operator.namespace" . }} + labels: + {{- include "memgraph-operator.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "memgraph-operator.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "memgraph-operator.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "memgraph-operator.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: manager + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - /manager + args: + - --health-probe-bind-address=:8081 + {{- if .Values.leaderElection.enabled }} + - --leader-elect + {{- end }} + {{- if .Values.metrics.enabled }} + - --metrics-bind-address=:{{ .Values.metrics.port }} + {{- if not .Values.metrics.secure }} + - --metrics-secure=false + {{- end }} + {{- end }} + {{- with .Values.extraArgs }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.extraEnv }} + env: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: health + containerPort: 8081 + protocol: TCP + {{- if .Values.metrics.enabled }} + - name: metrics + containerPort: {{ .Values.metrics.port }} + protocol: TCP + {{- end }} + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.resources | nindent 12 }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.priorityClassName }} + priorityClassName: {{ . }} + {{- end }} + terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} diff --git a/charts/memgraph-operator/templates/leader-election-rbac.yaml b/charts/memgraph-operator/templates/leader-election-rbac.yaml new file mode 100644 index 0000000..0e1bd6f --- /dev/null +++ b/charts/memgraph-operator/templates/leader-election-rbac.yaml @@ -0,0 +1,46 @@ +{{- if and .Values.rbac.create .Values.leaderElection.enabled }} +{{/* +Leader election is namespaced: one Lease in the operator's own namespace, plus +the Events the election records on it. Nothing here is needed to reconcile a +MemgraphCluster, so it is gated on leaderElection.enabled. +*/}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "memgraph-operator.fullname" . }}-leader-election-role + namespace: {{ include "memgraph-operator.namespace" . }} + labels: + {{- include "memgraph-operator.labels" . | nindent 4 }} +rules: + - apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - create + - update + - apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "memgraph-operator.fullname" . }}-leader-election-rolebinding + namespace: {{ include "memgraph-operator.namespace" . }} + labels: + {{- include "memgraph-operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "memgraph-operator.fullname" . }}-leader-election-role +subjects: + - kind: ServiceAccount + name: {{ include "memgraph-operator.serviceAccountName" . }} + namespace: {{ include "memgraph-operator.namespace" . }} +{{- end }} diff --git a/charts/memgraph-operator/templates/manager-rbac.yaml b/charts/memgraph-operator/templates/manager-rbac.yaml new file mode 100644 index 0000000..40c73d9 --- /dev/null +++ b/charts/memgraph-operator/templates/manager-rbac.yaml @@ -0,0 +1,32 @@ +{{- if .Values.rbac.create }} +{{/* +The rules come from rbac/manager-rules.yaml, which `make chart-sync` generates +from the +kubebuilder:rbac markers in the controller sources — the chart cannot +grant a permission the controller does not declare, and CI fails when the two +drift apart. A ClusterRole, because the operator watches MemgraphClusters in +every namespace. +*/}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "memgraph-operator.fullname" . }}-manager-role + labels: + {{- include "memgraph-operator.labels" . | nindent 4 }} +rules: +{{- .Files.Get "rbac/manager-rules.yaml" | nindent 0 }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "memgraph-operator.fullname" . }}-manager-rolebinding + labels: + {{- include "memgraph-operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "memgraph-operator.fullname" . }}-manager-role +subjects: + - kind: ServiceAccount + name: {{ include "memgraph-operator.serviceAccountName" . }} + namespace: {{ include "memgraph-operator.namespace" . }} +{{- end }} diff --git a/charts/memgraph-operator/templates/metrics-rbac.yaml b/charts/memgraph-operator/templates/metrics-rbac.yaml new file mode 100644 index 0000000..ea482f9 --- /dev/null +++ b/charts/memgraph-operator/templates/metrics-rbac.yaml @@ -0,0 +1,56 @@ +{{- if and .Values.rbac.create .Values.metrics.enabled }} +{{/* +The metrics endpoint authenticates and authorizes every scrape against the API +server, which is what the TokenReview and SubjectAccessReview permissions are +for. metrics-reader is the role a scraper (e.g. Prometheus) binds its own +ServiceAccount to; the operator itself never uses it. +*/}} +{{- if .Values.metrics.secure }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "memgraph-operator.fullname" . }}-metrics-auth-role + labels: + {{- include "memgraph-operator.labels" . | nindent 4 }} +rules: + - apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create + - apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "memgraph-operator.fullname" . }}-metrics-auth-rolebinding + labels: + {{- include "memgraph-operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "memgraph-operator.fullname" . }}-metrics-auth-role +subjects: + - kind: ServiceAccount + name: {{ include "memgraph-operator.serviceAccountName" . }} + namespace: {{ include "memgraph-operator.namespace" . }} +--- +{{- end }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "memgraph-operator.fullname" . }}-metrics-reader + labels: + {{- include "memgraph-operator.labels" . | nindent 4 }} +rules: + - nonResourceURLs: + - /metrics + verbs: + - get +{{- end }} diff --git a/charts/memgraph-operator/templates/metrics-service.yaml b/charts/memgraph-operator/templates/metrics-service.yaml new file mode 100644 index 0000000..d654128 --- /dev/null +++ b/charts/memgraph-operator/templates/metrics-service.yaml @@ -0,0 +1,17 @@ +{{- if .Values.metrics.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "memgraph-operator.metricsServiceName" . }} + namespace: {{ include "memgraph-operator.namespace" . }} + labels: + {{- include "memgraph-operator.labels" . | nindent 4 }} +spec: + ports: + - name: {{ ternary "https" "http" .Values.metrics.secure }} + port: {{ .Values.metrics.port }} + protocol: TCP + targetPort: metrics + selector: + {{- include "memgraph-operator.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/charts/memgraph-operator/templates/serviceaccount.yaml b/charts/memgraph-operator/templates/serviceaccount.yaml new file mode 100644 index 0000000..a78cd6e --- /dev/null +++ b/charts/memgraph-operator/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "memgraph-operator.serviceAccountName" . }} + namespace: {{ include "memgraph-operator.namespace" . }} + labels: + {{- include "memgraph-operator.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/memgraph-operator/values.yaml b/charts/memgraph-operator/values.yaml new file mode 100644 index 0000000..3aee892 --- /dev/null +++ b/charts/memgraph-operator/values.yaml @@ -0,0 +1,88 @@ +# Default values for the memgraph-operator chart. + +# The operator container image. tag defaults to the chart's appVersion, so a +# chart version always installs the operator version it was released with. +image: + repository: docker.io/memgraph/kubernetes-operator + tag: "" + pullPolicy: IfNotPresent + +# References to Secrets used to pull the operator image from a private registry. +imagePullSecrets: [] + # - name: my-registry-credentials + +# One replica reconciles at a time; leader election lets a second replica stand +# by (and makes a rolling restart safe) by taking over the Lease. +replicaCount: 1 +leaderElection: + enabled: true + +# Requests and limits of the operator container. The operator holds a cache of +# the objects it watches, so memory scales with the number of MemgraphClusters +# and their workloads. +resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 10m + memory: 64Mi + +# The controller-runtime metrics endpoint. Serving is HTTPS with a self-signed +# certificate and every request is authenticated and authorized against the +# API server, so scraping requires a token bound to the metrics-reader +# ClusterRole this chart installs. Set secure to false to serve plain HTTP +# without authorization — do that only on a trusted network. +metrics: + enabled: true + port: 8443 + secure: true + +# RBAC: the operator gets exactly the permissions its controller declares — +# MemgraphClusters, the StatefulSets and Services it provisions, plus a Lease +# and Events in its own namespace for leader election. Set create to false to +# bind an existing role set yourself; serviceAccount.name is then required. +rbac: + create: true + +serviceAccount: + create: true + # Defaults to -controller-manager when empty. + name: "" + annotations: {} + +# Install the operator's namespaced objects into a namespace other than the +# release namespace. Empty means the release namespace. +namespaceOverride: "" + +nameOverride: "" +fullnameOverride: "" + +# Pod-level knobs. +podAnnotations: {} +podLabels: {} +nodeSelector: {} +tolerations: [] +affinity: {} +priorityClassName: "" +terminationGracePeriodSeconds: 10 + +# The operator image is distroless and runs as uid/gid 65532, which satisfies +# the restricted Pod Security Standard. Overriding these is unsupported. +podSecurityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + seccompProfile: + type: RuntimeDefault +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + +# Additional command-line arguments and environment variables for the operator +# container, appended after the ones this chart derives. +extraArgs: [] +extraEnv: [] diff --git a/cmd/main.go b/cmd/main.go index d9afff3..56cac9f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,5 +1,5 @@ /* -Copyright 2024 Memgraph Ltd. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -25,18 +25,25 @@ import ( // to ensure that exec-entrypoint and run can make use of them. _ "k8s.io/client-go/plugin/pkg/client/auth" + corev1 "k8s.io/api/core/v1" + klabels "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/metrics/filters" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" - memgraphv1 "github.com/memgraph/kubernetes-operator/api/v1" + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" "github.com/memgraph/kubernetes-operator/internal/controller" - //+kubebuilder:scaffold:imports + "github.com/memgraph/kubernetes-operator/internal/memgraph" + "github.com/memgraph/kubernetes-operator/internal/resources" + // +kubebuilder:scaffold:imports ) var ( @@ -47,19 +54,35 @@ var ( func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - utilruntime.Must(memgraphv1.AddToScheme(scheme)) - //+kubebuilder:scaffold:scheme + utilruntime.Must(memgraphcomv1alpha1.AddToScheme(scheme)) + // +kubebuilder:scaffold:scheme } +// nolint:gocyclo func main() { var metricsAddr string + var metricsCertPath, metricsCertName, metricsCertKey string + var webhookCertPath, webhookCertName, webhookCertKey string + var enableLeaderElection bool var probeAddr string var secureMetrics bool var enableHTTP2 bool - flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") + var tlsOpts []func(*tls.Config) + flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ + "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") - flag.BoolVar(&secureMetrics, "metrics-secure", false, - "If set the metrics endpoint is served securely") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, + "Enable leader election for controller manager. "+ + "Enabling this will ensure there is only one active controller manager.") + flag.BoolVar(&secureMetrics, "metrics-secure", true, + "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.") + flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.") + flag.StringVar(&webhookCertName, "webhook-cert-name", "tls.crt", "The name of the webhook certificate file.") + flag.StringVar(&webhookCertKey, "webhook-cert-key", "tls.key", "The name of the webhook key file.") + flag.StringVar(&metricsCertPath, "metrics-cert-path", "", + "The directory that contains the metrics server certificate.") + flag.StringVar(&metricsCertName, "metrics-cert-name", "tls.crt", "The name of the metrics server certificate file.") + flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") opts := zap.Options{ @@ -72,60 +95,130 @@ func main() { // if the enable-http2 flag is false (the default), http/2 should be disabled // due to its vulnerabilities. More specifically, disabling http/2 will - // prevent from being vulnerable to the HTTP/2 Stream Cancelation and + // prevent from being vulnerable to the HTTP/2 Stream Cancellation and // Rapid Reset CVEs. For more information see: // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 // - https://github.com/advisories/GHSA-4374-p667-p6c8 disableHTTP2 := func(c *tls.Config) { - setupLog.Info("disabling http/2") + setupLog.Info("Disabling HTTP/2") c.NextProtos = []string{"http/1.1"} } - tlsOpts := []func(*tls.Config){} if !enableHTTP2 { tlsOpts = append(tlsOpts, disableHTTP2) } - webhookServer := webhook.NewServer(webhook.Options{ - TLSOpts: tlsOpts, - }) + // Initial webhook TLS options + webhookTLSOpts := tlsOpts + webhookServerOptions := webhook.Options{ + TLSOpts: webhookTLSOpts, + } + + if len(webhookCertPath) > 0 { + setupLog.Info("Initializing webhook certificate watcher using provided certificates", + "webhook-cert-path", webhookCertPath, "webhook-cert-name", webhookCertName, "webhook-cert-key", webhookCertKey) + + webhookServerOptions.CertDir = webhookCertPath + webhookServerOptions.CertName = webhookCertName + webhookServerOptions.KeyName = webhookCertKey + } + + webhookServer := webhook.NewServer(webhookServerOptions) + + // Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server. + // More info: + // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.24.1/pkg/metrics/server + // - https://book.kubebuilder.io/reference/metrics.html + metricsServerOptions := metricsserver.Options{ + BindAddress: metricsAddr, + SecureServing: secureMetrics, + TLSOpts: tlsOpts, + } + + if secureMetrics { + // FilterProvider is used to protect the metrics endpoint with authn/authz. + // These configurations ensure that only authorized users and service accounts + // can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info: + // https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.24.1/pkg/metrics/filters#WithAuthenticationAndAuthorization + metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization + } + + // If the certificate is not specified, controller-runtime will automatically + // generate self-signed certificates for the metrics server. While convenient for development and testing, + // this setup is not recommended for production. + // + // TODO(user): If you enable certManager, uncomment the following lines: + // - [METRICS-WITH-CERTS] at config/default/kustomization.yaml to generate and use certificates + // managed by cert-manager for the metrics server. + // - [PROMETHEUS-WITH-CERTS] at config/prometheus/kustomization.yaml for TLS certification. + if len(metricsCertPath) > 0 { + setupLog.Info("Initializing metrics certificate watcher using provided certificates", + "metrics-cert-path", metricsCertPath, "metrics-cert-name", metricsCertName, "metrics-cert-key", metricsCertKey) + + metricsServerOptions.CertDir = metricsCertPath + metricsServerOptions.CertName = metricsCertName + metricsServerOptions.KeyName = metricsCertKey + } mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ - Scheme: scheme, - Metrics: metricsserver.Options{ - BindAddress: metricsAddr, - SecureServing: secureMetrics, - TLSOpts: tlsOpts, - }, + Scheme: scheme, + Metrics: metricsServerOptions, WebhookServer: webhookServer, HealthProbeBindAddress: probeAddr, + // Pods are cached, because the rolling restart needs each one's + // controller-revision-hash and readiness on every pass — but only this + // operator's own pods are. Watching every pod in the cluster to find them + // would cost memory proportional to somebody else's workload. + Cache: cache.Options{ + ByObject: map[client.Object]cache.ByObject{ + &corev1.Pod{}: { + Label: klabels.SelectorFromSet(klabels.Set{ + resources.ManagedByLabel: resources.ManagedByValue, + }), + }, + }, + }, + LeaderElection: enableLeaderElection, + LeaderElectionID: "a5adec69.memgraph.com", + // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily + // when the Manager ends. This requires the binary to immediately end when the + // Manager is stopped, otherwise, this setting is unsafe. Setting this significantly + // speeds up voluntary leader transitions as the new leader don't have to wait + // LeaseDuration time first. + // + // In the default scaffold provided, the program ends immediately after + // the manager stops, so would be fine to enable this option. However, + // if you are doing or is intended to do any operation such as perform cleanups + // after the manager stops then its usage might be unsafe. + // LeaderElectionReleaseOnCancel: true, }) if err != nil { - setupLog.Error(err, "unable to start manager") + setupLog.Error(err, "Failed to start manager") os.Exit(1) } - if err = (&controller.MemgraphHAReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + if err := (&controller.MemgraphClusterReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Memgraph: memgraph.NewBoltConnector(), }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "MemgraphHA") + setupLog.Error(err, "Failed to create controller", "controller", "memgraphcluster") os.Exit(1) } - //+kubebuilder:scaffold:builder + // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { - setupLog.Error(err, "unable to set up health check") + setupLog.Error(err, "Failed to set up health check") os.Exit(1) } if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { - setupLog.Error(err, "unable to set up ready check") + setupLog.Error(err, "Failed to set up ready check") os.Exit(1) } - setupLog.Info("starting manager") + setupLog.Info("Starting manager") if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { - setupLog.Error(err, "problem running manager") + setupLog.Error(err, "Failed to run manager") os.Exit(1) } } diff --git a/config/crd/bases/memgraph.com_memgraphclusters.yaml b/config/crd/bases/memgraph.com_memgraphclusters.yaml new file mode 100644 index 0000000..87db4b1 --- /dev/null +++ b/config/crd/bases/memgraph.com_memgraphclusters.yaml @@ -0,0 +1,1388 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: memgraphclusters.memgraph.com +spec: + group: memgraph.com + names: + kind: MemgraphCluster + listKind: MemgraphClusterList + plural: memgraphclusters + shortNames: + - mgc + singular: memgraphcluster + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.coordinators + name: Coordinators + type: integer + - jsonPath: .spec.dataInstances + name: Data + type: integer + - jsonPath: .status.coordinators + name: Registered-Coordinators + priority: 1 + type: integer + - jsonPath: .status.dataInstances + name: Registered-Data + priority: 1 + type: integer + - jsonPath: .status.main + name: Main + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Converged")].status + name: Converged + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: MemgraphCluster is the Schema for the memgraphclusters API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of MemgraphCluster + properties: + clusterDomain: + default: cluster.local + description: |- + clusterDomain is the Kubernetes cluster domain the advertised FQDN + addresses are built from: ...svc.. + Override it on clusters configured with a domain other than the default. + 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 + coordinators: + default: 3 + description: |- + coordinators is the number of Raft coordinator instances. It must be odd + so the Raft quorum cannot split, and at least three, which is the + smallest quorum that survives losing a coordinator — this operator + builds real HA clusters, so the floor holds at creation as well as on an + update. Raising the count on a live cluster grows it: the operator adds + the new coordinators to the Raft cluster as their pods become ready. + format: int32 + minimum: 3 + type: integer + x-kubernetes-validations: + - message: coordinators must be an odd number so the Raft quorum cannot + split + rule: self % 2 == 1 + coreDumps: + default: {} + description: |- + coreDumps optionally collects crash dumps of either role onto a volume of + its own. + properties: + configureCorePattern: + default: true + description: |- + configureCorePattern lets the operator point the kernel at + /var/core/memgraph by running a privileged init container that writes + /proc/sys/kernel/core_pattern. It uses the cluster's own Memgraph image, + so no second image has to be pulled. + + It is cluster-wide rather than per role for two reasons: core_pattern is a + property of the **node**, so it applies to every process that crashes + there regardless of which role asked for it, and what really decides this + is whether the namespace tolerates a privileged container at all. + PodSecurity "restricted" does not — set this to false there, or wherever + the platform manages core_pattern itself, and the operator only provisions + and mounts the volumes, trusting the node to already point at them. + type: boolean + coordinators: + default: {} + description: |- + coordinators decides whether every coordinator pod collects dumps, and + how much room it gets for them. + properties: + enabled: + default: false + description: |- + enabled provisions a core dumps volume for every pod of the role and + mounts it at /var/core/memgraph. It is off by default: a crashing Memgraph + is not the normal case, and the volume costs a third + PersistentVolumeClaim per pod. + type: boolean + size: + anyOf: + - type: integer + - type: string + default: 10Gi + description: |- + size is the requested size of the role's core dumps claim. A dump is + roughly as large as the crashing process' resident memory, which is why + this is per role: a data instance holds the graph, a coordinator holds + Raft state. Size it against the role's memory limit, not its data. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + data: + default: {} + description: |- + data decides whether every data instance pod collects dumps, and how much + room it gets for them. + properties: + enabled: + default: false + description: |- + enabled provisions a core dumps volume for every pod of the role and + mounts it at /var/core/memgraph. It is off by default: a crashing Memgraph + is not the normal case, and the volume costs a third + PersistentVolumeClaim per pod. + type: boolean + size: + anyOf: + - type: integer + - type: string + default: 10Gi + description: |- + size is the requested size of the role's core dumps claim. A dump is + roughly as large as the crashing process' resident memory, which is why + this is per role: a data instance holds the graph, a coordinator holds + Raft state. Size it against the role's memory limit, not its data. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + storageClassName: + description: |- + storageClassName is the StorageClass backing every core dumps claim of + this cluster. Leave it unset to use the cluster's default StorageClass; + set it to the empty string to disable dynamic provisioning and bind + pre-created PersistentVolumes. + maxLength: 253 + type: string + uploader: + description: |- + uploader is an optional sidecar that ships collected dumps off the volume + — to object storage, a debug host, wherever. Any image and destination + works, so no provider or credential vocabulary has to live in this API: + the operator mounts the core dumps volume into the sidecar read-only, + passes the directory as CORE_DUMPS_DIR, and gives it the same locked-down + security context as the Memgraph container. See + config/samples/v1alpha1_memgraphcluster.yaml for an S3 uploader + equivalent to the Helm chart's. + + One definition serves both roles — the destination and credentials do not + differ between them, and the pods are already distinguishable by hostname + — and it joins the pods of every role that collects dumps. It counts + toward pod readiness, so a sidecar that crash-loops keeps those roles from + ever being registered. + properties: + args: + description: args are the arguments passed to the sidecar's + entrypoint. + items: + type: string + type: array + command: + description: command overrides the image's entrypoint. + items: + type: string + type: array + env: + description: |- + env passes literal, non-secret environment variables to the sidecar — + bucket names, prefixes, regions. Credentials belong in envFromSecrets. + items: + description: |- + EnvVar is one non-secret environment variable set on a role's Memgraph + container. Only literal values are supported — there is deliberately no + valueFrom — so secret material stays confined to the secrets block and the CR + remains safe to commit. + properties: + name: + description: name is the environment variable's name. + maxLength: 253 + minLength: 1 + pattern: ^[A-Za-z_][A-Za-z0-9_]*$ + type: string + value: + description: value is the literal, non-secret value. + maxLength: 4096 + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + envFromSecrets: + description: |- + envFromSecrets names Secrets in the cluster's namespace whose keys become + environment variables of the sidecar. This is how credentials reach it: + by reference, so no secret material ever appears in this resource. + items: + type: string + type: array + x-kubernetes-list-type: set + image: + description: |- + image is the full sidecar image reference including its tag, for example + "amazon/aws-cli:2.33.28". Unlike the Memgraph image the operator has no + default for it, so a tag belongs here rather than in a separate field. + maxLength: 383 + minLength: 1 + type: string + pullPolicy: + default: IfNotPresent + description: pullPolicy is the image pull policy of the sidecar. + enum: + - Always + - IfNotPresent + - Never + type: string + resources: + description: |- + resources sets the sidecar's compute resources. Leave it unset and the + sidecar schedules without requests or limits. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + required: + - image + type: object + x-kubernetes-validations: + - message: 'env must not set CORE_DUMPS_DIR: the operator passes + the core dumps mount path in it' + rule: '!has(self.env) || self.env.all(e, e.name != ''CORE_DUMPS_DIR'')' + type: object + x-kubernetes-validations: + - message: uploader requires core dumps enabled for at least one role + — there would be no volume for it to read + rule: '!has(self.uploader) || (has(self.coordinators) && has(self.coordinators.enabled) + && self.coordinators.enabled) || (has(self.data) && has(self.data.enabled) + && self.data.enabled)' + dataInstances: + default: 2 + description: |- + dataInstances is the number of data instances. Raising the count on a + live cluster grows it: the operator registers the new instances as their + pods become ready. + format: int32 + minimum: 1 + type: integer + extraArgs: + description: extraArgs passes additional Memgraph flags to both roles. + properties: + coordinators: + description: coordinators are appended to every coordinator pod's + Memgraph flags. + items: + maxLength: 4096 + minLength: 1 + type: string + maxItems: 64 + type: array + x-kubernetes-validations: + - message: extraArgs must not set a port or the coordinator identity + the operator derives (bolt-port, management-port, coordinator-id, + coordinator-hostname, coordinator-port), in any spelling gflags + accepts; configure ports through spec.ports + rule: self.all(a, !a.replace('-', '_').matches('^_{1,2}(bolt_port|management_port|coordinator_id|coordinator_hostname|coordinator_port)($|[= + ])')) + data: + description: data are appended to every data instance pod's Memgraph + flags. + items: + maxLength: 4096 + minLength: 1 + type: string + maxItems: 64 + type: array + x-kubernetes-validations: + - message: extraArgs must not set a port or the coordinator identity + the operator derives (bolt-port, management-port, coordinator-id, + coordinator-hostname, coordinator-port), in any spelling gflags + accepts; configure ports through spec.ports + rule: self.all(a, !a.replace('-', '_').matches('^_{1,2}(bolt_port|management_port|coordinator_id|coordinator_hostname|coordinator_port)($|[= + ])')) + type: object + extraEnv: + description: |- + extraEnv passes additional non-secret environment variables to both + roles' Memgraph containers. + properties: + coordinators: + description: coordinators are added to every coordinator pod's + Memgraph container. + items: + description: |- + EnvVar is one non-secret environment variable set on a role's Memgraph + container. Only literal values are supported — there is deliberately no + valueFrom — so secret material stays confined to the secrets block and the CR + remains safe to commit. + properties: + name: + description: name is the environment variable's name. + maxLength: 253 + minLength: 1 + pattern: ^[A-Za-z_][A-Za-z0-9_]*$ + type: string + value: + description: value is the literal, non-secret value. + maxLength: 4096 + type: string + required: + - name + type: object + maxItems: 64 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + x-kubernetes-validations: + - message: extraEnv must not set MEMGRAPH_ENTERPRISE_LICENSE or + MEMGRAPH_ORGANIZATION_NAME (they come from the secrets block) + or POD_NAME (it carries the pod's own identity) + rule: self.all(e, !(e.name in ['MEMGRAPH_ENTERPRISE_LICENSE', + 'MEMGRAPH_ORGANIZATION_NAME', 'POD_NAME'])) + data: + description: data are added to every data instance pod's Memgraph + container. + items: + description: |- + EnvVar is one non-secret environment variable set on a role's Memgraph + container. Only literal values are supported — there is deliberately no + valueFrom — so secret material stays confined to the secrets block and the CR + remains safe to commit. + properties: + name: + description: name is the environment variable's name. + maxLength: 253 + minLength: 1 + pattern: ^[A-Za-z_][A-Za-z0-9_]*$ + type: string + value: + description: value is the literal, non-secret value. + maxLength: 4096 + type: string + required: + - name + type: object + maxItems: 64 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + x-kubernetes-validations: + - message: extraEnv must not set MEMGRAPH_ENTERPRISE_LICENSE or + MEMGRAPH_ORGANIZATION_NAME (they come from the secrets block) + or POD_NAME (it carries the pod's own identity) + rule: self.all(e, !(e.name in ['MEMGRAPH_ENTERPRISE_LICENSE', + 'MEMGRAPH_ORGANIZATION_NAME', 'POD_NAME'])) + type: object + extraVolumeMounts: + description: |- + extraVolumeMounts mounts volumes into both roles' Memgraph containers + beyond the ones the operator mounts. + properties: + coordinators: + description: coordinators are added to every coordinator pod's + Memgraph container. + items: + description: VolumeMount describes a mounting of a Volume within + a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + When not set, MountPropagationNone is used. + This field is beta in 1.10. + When RecursiveReadOnly is set to IfPossible or to Enabled, MountPropagation must be None or unspecified + (which defaults to None). + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + + If ReadOnly is false, this field has no meaning and must be unspecified. + + If ReadOnly is true, and this field is set to Disabled, the mount is not made + recursively read-only. If this field is set to IfPossible, the mount is made + recursively read-only, if it is supported by the container runtime. If this + field is set to Enabled, the mount is made recursively read-only if it is + supported by the container runtime, otherwise the pod will not be started and + an error will be generated to indicate the reason. + + If this field is set to IfPossible or Enabled, MountPropagation must be set to + None (or be unspecified, which defaults to None). + + If this field is not specified, it is treated as an equivalent of Disabled. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: |- + Expanded path within the volume from which the container's volume should be mounted. + Behaves similarly to SubPath but environment variable references $(VAR_NAME) are expanded using the container's environment. + Defaults to "" (volume's root). + SubPathExpr and SubPath are mutually exclusive. + type: string + required: + - mountPath + - name + type: object + maxItems: 64 + type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map + x-kubernetes-validations: + - message: extraVolumeMounts must not mount over a path the operator + already mounts (/var/lib/memgraph, /var/log/memgraph, /var/core/memgraph, + /tmp) + rule: self.all(m, !(m.mountPath in ['/var/lib/memgraph', '/var/log/memgraph', + '/var/core/memgraph', '/tmp'])) + data: + description: data are added to every data instance pod's Memgraph + container. + items: + description: VolumeMount describes a mounting of a Volume within + a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + When not set, MountPropagationNone is used. + This field is beta in 1.10. + When RecursiveReadOnly is set to IfPossible or to Enabled, MountPropagation must be None or unspecified + (which defaults to None). + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + + If ReadOnly is false, this field has no meaning and must be unspecified. + + If ReadOnly is true, and this field is set to Disabled, the mount is not made + recursively read-only. If this field is set to IfPossible, the mount is made + recursively read-only, if it is supported by the container runtime. If this + field is set to Enabled, the mount is made recursively read-only if it is + supported by the container runtime, otherwise the pod will not be started and + an error will be generated to indicate the reason. + + If this field is set to IfPossible or Enabled, MountPropagation must be set to + None (or be unspecified, which defaults to None). + + If this field is not specified, it is treated as an equivalent of Disabled. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: |- + Expanded path within the volume from which the container's volume should be mounted. + Behaves similarly to SubPath but environment variable references $(VAR_NAME) are expanded using the container's environment. + Defaults to "" (volume's root). + SubPathExpr and SubPath are mutually exclusive. + type: string + required: + - mountPath + - name + type: object + maxItems: 64 + type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map + x-kubernetes-validations: + - message: extraVolumeMounts must not mount over a path the operator + already mounts (/var/lib/memgraph, /var/log/memgraph, /var/core/memgraph, + /tmp) + rule: self.all(m, !(m.mountPath in ['/var/lib/memgraph', '/var/log/memgraph', + '/var/core/memgraph', '/tmp'])) + type: object + extraVolumes: + description: |- + extraVolumes adds pod volumes to both roles beyond the ones the operator + provisions. + properties: + coordinators: + description: coordinators are added to every coordinator pod. + x-kubernetes-preserve-unknown-fields: true + data: + description: data are added to every data instance pod. + x-kubernetes-preserve-unknown-fields: true + type: object + image: + default: {} + description: image selects the Memgraph container image run by all + cluster pods. + properties: + pullPolicy: + default: IfNotPresent + description: pullPolicy is the image pull policy applied to all + cluster pods. + enum: + - Always + - IfNotPresent + - Never + type: string + repository: + default: docker.io/memgraph/memgraph + description: |- + repository is the Memgraph container image repository. It carries the + optional registry host and the image path only; the version belongs in + tag. + maxLength: 255 + minLength: 1 + type: string + x-kubernetes-validations: + - message: repository must not contain a digest; pin the image + with tag instead + rule: '!self.contains(''@'')' + - message: repository must not contain a tag; set image.tag instead + rule: '!self.substring(self.lastIndexOf(''/'') + 1).contains('':'')' + tag: + default: 3.12.0-relwithdebinfo + description: |- + tag is the Memgraph container image tag. Prefer pinning a specific + Memgraph version over mutable tags such as "latest". + maxLength: 128 + minLength: 1 + pattern: ^[a-zA-Z0-9_][a-zA-Z0-9._-]*$ + type: string + type: object + labels: + description: labels adds custom labels to both roles' pods, StatefulSets + and Services. + properties: + coordinators: + description: coordinators labels the coordinator objects. + properties: + podLabels: + additionalProperties: + type: string + description: podLabels are added to the role's pods. + type: object + serviceLabels: + additionalProperties: + type: string + description: serviceLabels are added to the role's headless + Service. + type: object + statefulSetLabels: + additionalProperties: + type: string + description: statefulSetLabels are added to the role's StatefulSet. + type: object + type: object + data: + description: data labels the data instance objects. + properties: + podLabels: + additionalProperties: + type: string + description: podLabels are added to the role's pods. + type: object + serviceLabels: + additionalProperties: + type: string + description: serviceLabels are added to the role's headless + Service. + type: object + statefulSetLabels: + additionalProperties: + type: string + description: statefulSetLabels are added to the role's StatefulSet. + type: object + type: object + type: object + ports: + default: {} + description: ports configures the internal ports Memgraph listens + on. + properties: + boltPort: + default: 7687 + description: |- + boltPort is the port Memgraph serves the Bolt protocol on. Clients and + the operator's own management queries both use it. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + coordinatorPort: + default: 12000 + description: coordinatorPort is the port coordinators run their + Raft protocol on. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + managementPort: + default: 10000 + description: managementPort is the port instances exchange HA + management traffic on. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + replicationPort: + default: 20000 + description: replicationPort is the port data instances replicate + over. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + type: object + x-kubernetes-validations: + - message: boltPort, managementPort, replicationPort and coordinatorPort + must all be different ports + rule: '!(has(self.boltPort) && has(self.managementPort) && has(self.replicationPort) + && has(self.coordinatorPort)) || [self.boltPort, self.managementPort, + self.replicationPort, self.coordinatorPort].all(p, [self.boltPort, + self.managementPort, self.replicationPort, self.coordinatorPort].exists_one(q, + q == p))' + probes: + description: probes tunes the probe timings of both roles. + properties: + coordinators: + description: coordinators tunes the probes of every coordinator + pod. + properties: + livenessProbe: + description: livenessProbe decides whether the container is + restarted. + properties: + failureThreshold: + description: |- + failureThreshold is how many consecutive failures the probe tolerates + before acting. Defaults to 1440 for the data instances' startup probe — + 2h at the default period, so a large snapshot restore is not killed + mid-load — and to 20 for every other probe. + format: int32 + minimum: 1 + type: integer + periodSeconds: + description: periodSeconds is how often the probe runs. + Defaults to 5. + format: int32 + minimum: 1 + type: integer + timeoutSeconds: + description: |- + timeoutSeconds is how long a single probe attempt may take. Defaults to + 10. + format: int32 + minimum: 1 + type: integer + type: object + readinessProbe: + description: |- + readinessProbe decides whether the pod receives traffic and whether the + operator considers the workloads ready to register. + properties: + failureThreshold: + description: |- + failureThreshold is how many consecutive failures the probe tolerates + before acting. Defaults to 1440 for the data instances' startup probe — + 2h at the default period, so a large snapshot restore is not killed + mid-load — and to 20 for every other probe. + format: int32 + minimum: 1 + type: integer + periodSeconds: + description: periodSeconds is how often the probe runs. + Defaults to 5. + format: int32 + minimum: 1 + type: integer + timeoutSeconds: + description: |- + timeoutSeconds is how long a single probe attempt may take. Defaults to + 10. + format: int32 + minimum: 1 + type: integer + type: object + startupProbe: + description: startupProbe gates the other two probes until + the instance has started. + properties: + failureThreshold: + description: |- + failureThreshold is how many consecutive failures the probe tolerates + before acting. Defaults to 1440 for the data instances' startup probe — + 2h at the default period, so a large snapshot restore is not killed + mid-load — and to 20 for every other probe. + format: int32 + minimum: 1 + type: integer + periodSeconds: + description: periodSeconds is how often the probe runs. + Defaults to 5. + format: int32 + minimum: 1 + type: integer + timeoutSeconds: + description: |- + timeoutSeconds is how long a single probe attempt may take. Defaults to + 10. + format: int32 + minimum: 1 + type: integer + type: object + type: object + data: + description: data tunes the probes of every data instance pod. + properties: + livenessProbe: + description: livenessProbe decides whether the container is + restarted. + properties: + failureThreshold: + description: |- + failureThreshold is how many consecutive failures the probe tolerates + before acting. Defaults to 1440 for the data instances' startup probe — + 2h at the default period, so a large snapshot restore is not killed + mid-load — and to 20 for every other probe. + format: int32 + minimum: 1 + type: integer + periodSeconds: + description: periodSeconds is how often the probe runs. + Defaults to 5. + format: int32 + minimum: 1 + type: integer + timeoutSeconds: + description: |- + timeoutSeconds is how long a single probe attempt may take. Defaults to + 10. + format: int32 + minimum: 1 + type: integer + type: object + readinessProbe: + description: |- + readinessProbe decides whether the pod receives traffic and whether the + operator considers the workloads ready to register. + properties: + failureThreshold: + description: |- + failureThreshold is how many consecutive failures the probe tolerates + before acting. Defaults to 1440 for the data instances' startup probe — + 2h at the default period, so a large snapshot restore is not killed + mid-load — and to 20 for every other probe. + format: int32 + minimum: 1 + type: integer + periodSeconds: + description: periodSeconds is how often the probe runs. + Defaults to 5. + format: int32 + minimum: 1 + type: integer + timeoutSeconds: + description: |- + timeoutSeconds is how long a single probe attempt may take. Defaults to + 10. + format: int32 + minimum: 1 + type: integer + type: object + startupProbe: + description: startupProbe gates the other two probes until + the instance has started. + properties: + failureThreshold: + description: |- + failureThreshold is how many consecutive failures the probe tolerates + before acting. Defaults to 1440 for the data instances' startup probe — + 2h at the default period, so a large snapshot restore is not killed + mid-load — and to 20 for every other probe. + format: int32 + minimum: 1 + type: integer + periodSeconds: + description: periodSeconds is how often the probe runs. + Defaults to 5. + format: int32 + minimum: 1 + type: integer + timeoutSeconds: + description: |- + timeoutSeconds is how long a single probe attempt may take. Defaults to + 10. + format: int32 + minimum: 1 + type: integer + type: object + type: object + type: object + resources: + description: resources sets the compute resources of both roles' Memgraph + containers. + properties: + coordinators: + description: |- + coordinators are the resource requests and limits of every coordinator + pod's Memgraph container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + data: + description: |- + data are the resource requests and limits of every data instance pod's + Memgraph container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object + secrets: + default: {} + description: |- + secrets references the Secret holding the enterprise license and + organization name. + properties: + licenseKey: + default: MEMGRAPH_ENTERPRISE_LICENSE + description: licenseKey is the key within the Secret holding the + enterprise license. + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + default: memgraph-secrets + 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 + organizationKey: + default: MEMGRAPH_ORGANIZATION_NAME + description: |- + organizationKey is the key within the Secret holding the organization + name the license was issued to. + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + type: object + x-kubernetes-validations: + - message: licenseKey and organizationKey must name different keys + of the Secret + rule: '!has(self.licenseKey) || !has(self.organizationKey) || self.licenseKey + != self.organizationKey' + storage: + default: {} + description: |- + storage configures the persistent volumes backing both roles and their + retention on cluster deletion. + properties: + coordinators: + default: {} + description: coordinators configures the storage of every coordinator + pod. + properties: + createLogStorageClaim: + default: true + description: |- + createLogStorageClaim decides whether every pod of the role gets a log + storage claim at all. With it disabled the operator drops the claim and + passes an empty --log-file, which turns file logging off, so stderr and + `kubectl logs` (plus whatever collects it) become the single log sink. Use + it to avoid a second PersistentVolumeClaim per pod on clusters that ship + logs off-node anyway. + + The remaining log* knobs below are ignored while this is false. + + Like the sizes and classes around it this is effectively a create-time + choice: flipping it adds or removes a volumeClaimTemplate, which + Kubernetes forbids on a live StatefulSet, so the operator's apply is + rejected until the StatefulSet is recreated (delete it with + --cascade=orphan and the operator rebuilds it around the running pods). + type: boolean + libPVCSize: + anyOf: + - type: integer + - type: string + default: 1Gi + description: |- + libPVCSize is the requested size of the lib storage claim, which backs + Memgraph's data directory (snapshots, WAL, and durability metadata). + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + libStorageAccessMode: + default: ReadWriteOnce + description: |- + libStorageAccessMode is the access mode requested for the lib storage + claim. + enum: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + - ReadWriteOncePod + type: string + libStorageClassName: + description: |- + libStorageClassName is the StorageClass backing the lib storage claim. + Leave it unset to use the cluster's default StorageClass; set it to the + empty string to disable dynamic provisioning and bind a pre-created + PersistentVolume. + maxLength: 253 + type: string + logPVCSize: + anyOf: + - type: integer + - type: string + default: 1Gi + description: |- + logPVCSize is the requested size of the log storage claim, which backs + Memgraph's log file. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + logStorageAccessMode: + default: ReadWriteOnce + description: |- + logStorageAccessMode is the access mode requested for the log storage + claim. + enum: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + - ReadWriteOncePod + type: string + logStorageClassName: + description: |- + logStorageClassName is the StorageClass backing the log storage claim. + Leave it unset to use the cluster's default StorageClass; set it to the + empty string to disable dynamic provisioning and bind a pre-created + PersistentVolume. + maxLength: 253 + type: string + type: object + data: + default: {} + description: data configures the storage of every data instance + pod. + properties: + createLogStorageClaim: + default: true + description: |- + createLogStorageClaim decides whether every pod of the role gets a log + storage claim at all. With it disabled the operator drops the claim and + passes an empty --log-file, which turns file logging off, so stderr and + `kubectl logs` (plus whatever collects it) become the single log sink. Use + it to avoid a second PersistentVolumeClaim per pod on clusters that ship + logs off-node anyway. + + The remaining log* knobs below are ignored while this is false. + + Like the sizes and classes around it this is effectively a create-time + choice: flipping it adds or removes a volumeClaimTemplate, which + Kubernetes forbids on a live StatefulSet, so the operator's apply is + rejected until the StatefulSet is recreated (delete it with + --cascade=orphan and the operator rebuilds it around the running pods). + type: boolean + libPVCSize: + anyOf: + - type: integer + - type: string + default: 1Gi + description: |- + libPVCSize is the requested size of the lib storage claim, which backs + Memgraph's data directory (snapshots, WAL, and durability metadata). + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + libStorageAccessMode: + default: ReadWriteOnce + description: |- + libStorageAccessMode is the access mode requested for the lib storage + claim. + enum: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + - ReadWriteOncePod + type: string + libStorageClassName: + description: |- + libStorageClassName is the StorageClass backing the lib storage claim. + Leave it unset to use the cluster's default StorageClass; set it to the + empty string to disable dynamic provisioning and bind a pre-created + PersistentVolume. + maxLength: 253 + type: string + logPVCSize: + anyOf: + - type: integer + - type: string + default: 1Gi + description: |- + logPVCSize is the requested size of the log storage claim, which backs + Memgraph's log file. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + logStorageAccessMode: + default: ReadWriteOnce + description: |- + logStorageAccessMode is the access mode requested for the log storage + claim. + enum: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + - ReadWriteOncePod + type: string + logStorageClassName: + description: |- + logStorageClassName is the StorageClass backing the log storage claim. + Leave it unset to use the cluster's default StorageClass; set it to the + empty string to disable dynamic provisioning and bind a pre-created + PersistentVolume. + maxLength: 253 + type: string + type: object + retentionPolicy: + default: Retain + description: |- + retentionPolicy decides whether a PersistentVolumeClaim of this cluster + survives being orphaned, which happens two ways: the MemgraphCluster is + deleted, or a lowered replica count retires the pod that used it. Both are + the same question — keep this cluster's data, or do not — so the policy + maps onto both halves of the StatefulSets' + persistentVolumeClaimRetentionPolicy, whenDeleted and whenScaled. That + makes the StatefulSet controller the only thing that ever deletes storage; + the operator owns no finalizer and runs no cleanup of its own. The default + keeps production data safe from an accidental delete or shrink; dev + clusters can opt into self-cleanup. + + Delete therefore makes lowering spec.coordinators or spec.dataInstances + destructive: the retiring pods' claims go with them. + enum: + - Retain + - Delete + type: string + type: object + type: object + status: + description: status defines the observed state of MemgraphCluster + properties: + conditions: + description: |- + conditions represent the current state of the MemgraphCluster resource. + Each condition has a unique type and reflects the status of a specific aspect of the resource. + + Standard condition types include: + - "Available": the resource is fully functional + - "Progressing": the resource is being created or updated + - "Degraded": the resource failed to reach or maintain its desired state + + The status of each condition is one of True, False, or Unknown. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + coordinators: + description: |- + coordinators is how many of the declared coordinators the coordinator + leader reports as registered members of the Raft cluster. It reaches + spec.coordinators once registration has converged, so it is what a scale + is watched through. + format: int32 + type: integer + dataInstances: + description: |- + dataInstances is how many of the declared data instances the coordinator + leader reports as registered. It reaches spec.dataInstances once + registration has converged. + format: int32 + type: integer + main: + description: |- + main is the name of the data instance currently observed as MAIN, as + reported by SHOW INSTANCES on the coordinator leader. It is empty before + the initial MAIN is elected and updates when the Raft coordinators fail + over to a different instance. + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/memgraph.com_memgraphhas.yaml b/config/crd/bases/memgraph.com_memgraphhas.yaml deleted file mode 100644 index becc8f3..0000000 --- a/config/crd/bases/memgraph.com_memgraphhas.yaml +++ /dev/null @@ -1,232 +0,0 @@ ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.15.0 - name: memgraphhas.memgraph.com -spec: - group: memgraph.com - names: - kind: MemgraphHA - listKind: MemgraphHAList - plural: memgraphhas - singular: memgraphha - scope: Namespaced - versions: - - name: v1 - schema: - openAPIV3Schema: - description: |- - MemgraphHA is the Schema for the memgraphhas API - - - Every Kind needs to have two structures: metav1.TypeMeta and metav1.ObjectMeta. - TypeMeta structure contains information about the GVK of the Kind. - ObjectMeta contains metadata for the Kind. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: MemgraphHASpec defines the desired state of MemgraphHA - properties: - coordinators: - items: - properties: - args: - items: - type: string - type: array - boltPort: - type: integer - coordinatorPort: - type: integer - id: - type: string - managementPort: - type: integer - required: - - args - - boltPort - - coordinatorPort - - id - - managementPort - type: object - type: array - data: - items: - properties: - args: - items: - type: string - type: array - boltPort: - type: integer - id: - type: string - managementPort: - type: integer - replicationPort: - type: integer - required: - - args - - boltPort - - id - - managementPort - - replicationPort - type: object - type: array - memgraph: - properties: - coordinators: - properties: - volumeClaim: - properties: - logPVC: - type: boolean - logPVCClassName: - type: string - logPVCSize: - type: string - storagePVC: - type: boolean - storagePVCClassName: - type: string - storagePVCSize: - type: string - required: - - logPVC - - logPVCClassName - - logPVCSize - - storagePVC - - storagePVCClassName - - storagePVCSize - type: object - required: - - volumeClaim - type: object - data: - properties: - volumeClaim: - properties: - logPVC: - type: boolean - logPVCClassName: - type: string - logPVCSize: - type: string - storagePVC: - type: boolean - storagePVCClassName: - type: string - storagePVCSize: - type: string - required: - - logPVC - - logPVCClassName - - logPVCSize - - storagePVC - - storagePVCClassName - - storagePVCSize - type: object - required: - - volumeClaim - type: object - env: - additionalProperties: - type: string - type: object - image: - properties: - pullPolicy: - type: string - repository: - type: string - tag: - type: string - required: - - pullPolicy - - repository - - tag - type: object - probes: - properties: - liveness: - description: ProbeConfig configures individual probes - properties: - failureThreshold: - type: integer - initialDelaySeconds: - type: integer - periodSeconds: - type: integer - required: - - initialDelaySeconds - - periodSeconds - type: object - readiness: - description: ProbeConfig configures individual probes - properties: - failureThreshold: - type: integer - initialDelaySeconds: - type: integer - periodSeconds: - type: integer - required: - - initialDelaySeconds - - periodSeconds - type: object - startup: - description: ProbeConfig configures individual probes - properties: - failureThreshold: - type: integer - initialDelaySeconds: - type: integer - periodSeconds: - type: integer - required: - - initialDelaySeconds - - periodSeconds - type: object - required: - - liveness - - readiness - - startup - type: object - required: - - coordinators - - data - - env - - image - - probes - type: object - required: - - coordinators - - data - - memgraph - type: object - status: - description: MemgraphHAStatus defines the observed state of MemgraphHA - type: object - type: object - served: true - storage: true - subresources: - status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index f059d30..4507dd8 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -2,22 +2,15 @@ # since it depends on service name and namespace that are out of this kustomize package. # It should be run by config/default resources: -- bases/memgraph.com_memgraphhas.yaml -#+kubebuilder:scaffold:crdkustomizeresource +- bases/memgraph.com_memgraphclusters.yaml +# +kubebuilder:scaffold:crdkustomizeresource patches: # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. # patches here are for enabling the conversion webhook for each CRD -#- path: patches/webhook_in_memgraphhas.yaml -#+kubebuilder:scaffold:crdkustomizewebhookpatch - -# [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. -# patches here are for enabling the CA injection for each CRD -#- path: patches/cainjection_in_memgraphhas.yaml -#+kubebuilder:scaffold:crdkustomizecainjectionpatch +# +kubebuilder:scaffold:crdkustomizewebhookpatch # [WEBHOOK] To enable webhook, uncomment the following section # the following config is for teaching kustomize how to do kustomization for CRDs. - #configurations: #- kustomizeconfig.yaml diff --git a/config/crd/kustomizeconfig.yaml b/config/crd/kustomizeconfig.yaml index ec5c150..61361ff 100644 --- a/config/crd/kustomizeconfig.yaml +++ b/config/crd/kustomizeconfig.yaml @@ -8,12 +8,5 @@ nameReference: group: apiextensions.k8s.io path: spec/conversion/webhook/clientConfig/service/name -namespace: -- kind: CustomResourceDefinition - version: v1 - group: apiextensions.k8s.io - path: spec/conversion/webhook/clientConfig/service/namespace - create: false - varReference: - path: metadata/annotations diff --git a/config/default/cert_metrics_manager_patch.yaml b/config/default/cert_metrics_manager_patch.yaml new file mode 100644 index 0000000..d975015 --- /dev/null +++ b/config/default/cert_metrics_manager_patch.yaml @@ -0,0 +1,30 @@ +# This patch adds the args, volumes, and ports to allow the manager to use the metrics-server certs. + +# Add the volumeMount for the metrics-server certs +- op: add + path: /spec/template/spec/containers/0/volumeMounts/- + value: + mountPath: /tmp/k8s-metrics-server/metrics-certs + name: metrics-certs + readOnly: true + +# Add the --metrics-cert-path argument for the metrics server +- op: add + path: /spec/template/spec/containers/0/args/- + value: --metrics-cert-path=/tmp/k8s-metrics-server/metrics-certs + +# Add the metrics-server certs volume configuration +- op: add + path: /spec/template/spec/volumes/- + value: + name: metrics-certs + secret: + secretName: metrics-server-cert + optional: false + items: + - key: ca.crt + path: ca.crt + - key: tls.crt + path: tls.crt + - key: tls.key + path: tls.key diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index da8437b..5415254 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -1,8 +1,234 @@ -namespace: memgraph-operator-system +# Adds namespace to all resources. +namespace: kubernetes-operator-system -namePrefix: "" +# Value of this field is prepended to the +# names of all resources, e.g. a deployment named +# "wordpress" becomes "alices-wordpress". +# Note that it should also match with the prefix (text before '-') of the namespace +# field above. +namePrefix: kubernetes-operator- + +# Labels to add to all resources and selectors. +#labels: +#- includeSelectors: true +# pairs: +# someName: someValue resources: - ../crd - ../rbac - ../manager +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in +# crd/kustomization.yaml +#- ../webhook +# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. +#- ../certmanager +# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. +#- ../prometheus +# [METRICS] Expose the controller manager metrics service. +- metrics_service.yaml +# [NETWORK POLICY] Protect the /metrics endpoint and Webhook Server with NetworkPolicy. +# Only Pod(s) running a namespace labeled with 'metrics: enabled' will be able to gather the metrics. +# Only CR(s) which requires webhooks and are applied on namespaces labeled with 'webhooks: enabled' will +# be able to communicate with the Webhook Server. +#- ../network-policy + +# Uncomment the patches line if you enable Metrics +patches: +# [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443. +# More info: https://book.kubebuilder.io/reference/metrics +- path: manager_metrics_patch.yaml + target: + kind: Deployment + +# Uncomment the patches line if you enable Metrics and CertManager +# [METRICS-WITH-CERTS] To enable metrics protected with certManager, uncomment the following line. +# This patch will protect the metrics with certManager self-signed certs. +#- path: cert_metrics_manager_patch.yaml +# target: +# kind: Deployment + +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in +# crd/kustomization.yaml +#- path: manager_webhook_patch.yaml +# target: +# kind: Deployment + +# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. +# Uncomment the following replacements to add the cert-manager CA injection annotations +#replacements: +# - source: # Uncomment the following block to enable certificates for metrics +# kind: Service +# version: v1 +# name: controller-manager-metrics-service +# fieldPath: metadata.name +# targets: +# - select: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: metrics-certs +# fieldPaths: +# - spec.dnsNames.0 +# - spec.dnsNames.1 +# options: +# delimiter: '.' +# index: 0 +# create: true +# - select: # Uncomment the following to set the Service name for TLS config in Prometheus ServiceMonitor +# kind: ServiceMonitor +# group: monitoring.coreos.com +# version: v1 +# name: controller-manager-metrics-monitor +# fieldPaths: +# - spec.endpoints.0.tlsConfig.serverName +# options: +# delimiter: '.' +# index: 0 +# create: true + +# - source: +# kind: Service +# version: v1 +# name: controller-manager-metrics-service +# fieldPath: metadata.namespace +# targets: +# - select: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: metrics-certs +# fieldPaths: +# - spec.dnsNames.0 +# - spec.dnsNames.1 +# options: +# delimiter: '.' +# index: 1 +# create: true +# - select: # Uncomment the following to set the Service namespace for TLS in Prometheus ServiceMonitor +# kind: ServiceMonitor +# group: monitoring.coreos.com +# version: v1 +# name: controller-manager-metrics-monitor +# fieldPaths: +# - spec.endpoints.0.tlsConfig.serverName +# options: +# delimiter: '.' +# index: 1 +# create: true + +# - source: # Uncomment the following block if you have any webhook +# kind: Service +# version: v1 +# name: webhook-service +# fieldPath: .metadata.name # Name of the service +# targets: +# - select: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# fieldPaths: +# - .spec.dnsNames.0 +# - .spec.dnsNames.1 +# options: +# delimiter: '.' +# index: 0 +# create: true +# - source: +# kind: Service +# version: v1 +# name: webhook-service +# fieldPath: .metadata.namespace # Namespace of the service +# targets: +# - select: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# fieldPaths: +# - .spec.dnsNames.0 +# - .spec.dnsNames.1 +# options: +# delimiter: '.' +# index: 1 +# create: true + +# - source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation) +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert # This name should match the one in certificate.yaml +# fieldPath: .metadata.namespace # Namespace of the certificate CR +# targets: +# - select: +# kind: ValidatingWebhookConfiguration +# fieldPaths: +# - .metadata.annotations.[cert-manager.io/inject-ca-from] +# options: +# delimiter: '/' +# index: 0 +# create: true +# - source: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# fieldPath: .metadata.name +# targets: +# - select: +# kind: ValidatingWebhookConfiguration +# fieldPaths: +# - .metadata.annotations.[cert-manager.io/inject-ca-from] +# options: +# delimiter: '/' +# index: 1 +# create: true + +# - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting ) +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# fieldPath: .metadata.namespace # Namespace of the certificate CR +# targets: +# - select: +# kind: MutatingWebhookConfiguration +# fieldPaths: +# - .metadata.annotations.[cert-manager.io/inject-ca-from] +# options: +# delimiter: '/' +# index: 0 +# create: true +# - source: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# fieldPath: .metadata.name +# targets: +# - select: +# kind: MutatingWebhookConfiguration +# fieldPaths: +# - .metadata.annotations.[cert-manager.io/inject-ca-from] +# options: +# delimiter: '/' +# index: 1 +# create: true + +# - source: # Uncomment the following block if you have a ConversionWebhook (--conversion) +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# fieldPath: .metadata.namespace # Namespace of the certificate CR +# targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD. +# +kubebuilder:scaffold:crdkustomizecainjectionns +# - source: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# fieldPath: .metadata.name +# targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD. +# +kubebuilder:scaffold:crdkustomizecainjectionname diff --git a/config/default/manager_metrics_patch.yaml b/config/default/manager_metrics_patch.yaml new file mode 100644 index 0000000..2aaef65 --- /dev/null +++ b/config/default/manager_metrics_patch.yaml @@ -0,0 +1,4 @@ +# This patch adds the args to allow exposing the metrics endpoint using HTTPS +- op: add + path: /spec/template/spec/containers/0/args/0 + value: --metrics-bind-address=:8443 diff --git a/config/default/metrics_service.yaml b/config/default/metrics_service.yaml new file mode 100644 index 0000000..fa7ebe2 --- /dev/null +++ b/config/default/metrics_service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: kubernetes-operator + app.kubernetes.io/managed-by: kustomize + name: controller-manager-metrics-service + namespace: system +spec: + ports: + - name: https + port: 8443 + protocol: TCP + targetPort: 8443 + selector: + control-plane: controller-manager + app.kubernetes.io/name: kubernetes-operator diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index c4879ff..a7b129f 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -1,12 +1,8 @@ resources: - manager.yaml -- namespace.yaml apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization images: - name: controller - newName: memgraph/kubernetes-operator - newTag: 1.0.0 -- name: memgraph-kubernetes-operator - newName: memgraph/kubernetes-operator - newTag: 1.0.0 + newName: example.com/kubernetes-operator + newTag: v0.0.1 diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 2455004..a727fcc 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -1,38 +1,94 @@ +apiVersion: v1 +kind: Namespace +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: kubernetes-operator + app.kubernetes.io/managed-by: kustomize + name: system +--- apiVersion: apps/v1 kind: Deployment metadata: - namespace: memgraph-operator-system - annotations: - email: engineering@memgraph.io + name: controller-manager + namespace: system labels: - owner: Memgraph - name: memgraph-kubernetes-operator + control-plane: controller-manager + app.kubernetes.io/name: kubernetes-operator + app.kubernetes.io/managed-by: kustomize spec: - replicas: 1 selector: matchLabels: - name: memgraph-kubernetes-operator - strategy: - rollingUpdate: - maxUnavailable: 1 - type: RollingUpdate + control-plane: controller-manager + app.kubernetes.io/name: kubernetes-operator + replicas: 1 template: metadata: + annotations: + kubectl.kubernetes.io/default-container: manager labels: - name: memgraph-kubernetes-operator + control-plane: controller-manager + app.kubernetes.io/name: kubernetes-operator spec: + # TODO(user): Uncomment the following code to configure the nodeAffinity expression + # according to the platforms which are supported by your solution. + # It is considered best practice to support multiple architectures. You can + # build your manager image using the makefile target docker-buildx. + # affinity: + # nodeAffinity: + # requiredDuringSchedulingIgnoredDuringExecution: + # nodeSelectorTerms: + # - matchExpressions: + # - key: kubernetes.io/arch + # operator: In + # values: + # - amd64 + # - arm64 + # - ppc64le + # - s390x + # - key: kubernetes.io/os + # operator: In + # values: + # - linux securityContext: + # Projects are configured by default to adhere to the "restricted" Pod Security Standards. + # This ensures that deployments meet the highest security requirements for Kubernetes. + # For more details, see: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted runAsNonRoot: true + seccompProfile: + type: RuntimeDefault containers: - - args: - image: memgraph/kubernetes-operator:1.0.0 + - command: + - /manager + args: + - --leader-elect + - --health-probe-bind-address=:8081 + image: controller:latest name: manager + ports: + - containerPort: 8081 + name: health + protocol: TCP securityContext: readOnlyRootFilesystem: true allowPrivilegeEscalation: false capabilities: drop: - "ALL" + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + # TODO(user): Configure the resources accordingly based on the project requirements. + # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ resources: limits: cpu: 500m @@ -40,5 +96,7 @@ spec: requests: cpu: 10m memory: 64Mi - serviceAccountName: memgraph-kubernetes-operator + volumeMounts: [] + volumes: [] + serviceAccountName: controller-manager terminationGracePeriodSeconds: 10 diff --git a/config/manager/namespace.yaml b/config/manager/namespace.yaml deleted file mode 100644 index 64458ee..0000000 --- a/config/manager/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: memgraph-operator-system diff --git a/config/manifests/kustomization.yaml b/config/manifests/kustomization.yaml deleted file mode 100644 index e8b968a..0000000 --- a/config/manifests/kustomization.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# These resources constitute the fully configured set of manifests -# used to generate the 'manifests/' directory in a bundle. -resources: -- bases/kubernetes-operator.clusterserviceversion.yaml -- ../default -- ../samples -- ../scorecard diff --git a/config/network-policy/allow-metrics-traffic.yaml b/config/network-policy/allow-metrics-traffic.yaml new file mode 100644 index 0000000..cee0b4f --- /dev/null +++ b/config/network-policy/allow-metrics-traffic.yaml @@ -0,0 +1,27 @@ +# This NetworkPolicy allows ingress traffic +# with Pods running on namespaces labeled with 'metrics: enabled'. Only Pods on those +# namespaces are able to gather data from the metrics endpoint. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + labels: + app.kubernetes.io/name: kubernetes-operator + app.kubernetes.io/managed-by: kustomize + name: allow-metrics-traffic + namespace: system +spec: + podSelector: + matchLabels: + control-plane: controller-manager + app.kubernetes.io/name: kubernetes-operator + policyTypes: + - Ingress + ingress: + # This allows ingress traffic from any namespace with the label metrics: enabled + - from: + - namespaceSelector: + matchLabels: + metrics: enabled # Only from namespaces with this label + ports: + - port: 8443 + protocol: TCP diff --git a/config/network-policy/kustomization.yaml b/config/network-policy/kustomization.yaml new file mode 100644 index 0000000..ec0fb5e --- /dev/null +++ b/config/network-policy/kustomization.yaml @@ -0,0 +1,2 @@ +resources: +- allow-metrics-traffic.yaml diff --git a/config/prometheus/kustomization.yaml b/config/prometheus/kustomization.yaml new file mode 100644 index 0000000..fdc5481 --- /dev/null +++ b/config/prometheus/kustomization.yaml @@ -0,0 +1,11 @@ +resources: +- monitor.yaml + +# [PROMETHEUS-WITH-CERTS] The following patch configures the ServiceMonitor in ../prometheus +# to securely reference certificates created and managed by cert-manager. +# Additionally, ensure that you uncomment the [METRICS WITH CERTMANAGER] patch under config/default/kustomization.yaml +# to mount the "metrics-server-cert" secret in the Manager Deployment. +#patches: +# - path: monitor_tls_patch.yaml +# target: +# kind: ServiceMonitor diff --git a/config/prometheus/monitor.yaml b/config/prometheus/monitor.yaml new file mode 100644 index 0000000..7b75f9b --- /dev/null +++ b/config/prometheus/monitor.yaml @@ -0,0 +1,27 @@ +# Prometheus Monitor Service (Metrics) +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: kubernetes-operator + app.kubernetes.io/managed-by: kustomize + name: controller-manager-metrics-monitor + namespace: system +spec: + endpoints: + - path: /metrics + port: https # Ensure this is the name of the port that exposes HTTPS metrics + scheme: https + bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token + tlsConfig: + # TODO(user): The option insecureSkipVerify: true is not recommended for production since it disables + # certificate verification, exposing the system to potential man-in-the-middle attacks. + # For production environments, it is recommended to use cert-manager for automatic TLS certificate management. + # To apply this configuration, enable cert-manager and use the patch located at config/prometheus/servicemonitor_tls_patch.yaml, + # which securely references the certificate from the 'metrics-server-cert' secret. + insecureSkipVerify: true + selector: + matchLabels: + control-plane: controller-manager + app.kubernetes.io/name: kubernetes-operator diff --git a/config/prometheus/monitor_tls_patch.yaml b/config/prometheus/monitor_tls_patch.yaml new file mode 100644 index 0000000..5bf84ce --- /dev/null +++ b/config/prometheus/monitor_tls_patch.yaml @@ -0,0 +1,19 @@ +# Patch for Prometheus ServiceMonitor to enable secure TLS configuration +# using certificates managed by cert-manager +- op: replace + path: /spec/endpoints/0/tlsConfig + value: + # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize + serverName: SERVICE_NAME.SERVICE_NAMESPACE.svc + insecureSkipVerify: false + ca: + secret: + name: metrics-server-cert + key: ca.crt + cert: + secret: + name: metrics-server-cert + key: tls.crt + keySecret: + name: metrics-server-cert + key: tls.key diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 664fcac..9169566 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -1,4 +1,28 @@ resources: +# All RBAC will be applied under this service account in +# the deployment namespace. You may comment out this resource +# if your manager will use a service account that exists at +# runtime. Be sure to update RoleBinding and ClusterRoleBinding +# subjects if changing service account names. - service_account.yaml - role.yaml - role_binding.yaml +- leader_election_role.yaml +- leader_election_role_binding.yaml +# The following RBAC configurations are used to protect +# the metrics endpoint with authn/authz. These configurations +# ensure that only authorized users and service accounts +# can access the metrics endpoint. Comment the following +# permissions if you want to disable this protection. +# More info: https://book.kubebuilder.io/reference/metrics.html +- metrics_auth_role.yaml +- metrics_auth_role_binding.yaml +- metrics_reader_role.yaml +# For each CRD, "Admin", "Editor" and "Viewer" roles are scaffolded by +# default, aiding admins in cluster management. Those roles are +# not used by the kubernetes-operator itself. You can comment the following lines +# if you do not want those helpers be installed with your Project. +- memgraphcluster_admin_role.yaml +- memgraphcluster_editor_role.yaml +- memgraphcluster_viewer_role.yaml + diff --git a/config/rbac/leader_election_role.yaml b/config/rbac/leader_election_role.yaml new file mode 100644 index 0000000..19c7f16 --- /dev/null +++ b/config/rbac/leader_election_role.yaml @@ -0,0 +1,40 @@ +# permissions to do leader election. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + app.kubernetes.io/name: kubernetes-operator + app.kubernetes.io/managed-by: kustomize + name: leader-election-role +rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch diff --git a/config/rbac/leader_election_role_binding.yaml b/config/rbac/leader_election_role_binding.yaml new file mode 100644 index 0000000..56d6f7b --- /dev/null +++ b/config/rbac/leader_election_role_binding.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app.kubernetes.io/name: kubernetes-operator + app.kubernetes.io/managed-by: kustomize + name: leader-election-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: leader-election-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/config/rbac/memgraphcluster_admin_role.yaml b/config/rbac/memgraphcluster_admin_role.yaml new file mode 100644 index 0000000..4931023 --- /dev/null +++ b/config/rbac/memgraphcluster_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project kubernetes-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over memgraph.com. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: kubernetes-operator + app.kubernetes.io/managed-by: kustomize + name: memgraphcluster-admin-role +rules: +- apiGroups: + - memgraph.com + resources: + - memgraphclusters + verbs: + - '*' +- apiGroups: + - memgraph.com + resources: + - memgraphclusters/status + verbs: + - get diff --git a/config/rbac/memgraphcluster_editor_role.yaml b/config/rbac/memgraphcluster_editor_role.yaml new file mode 100644 index 0000000..e36e59e --- /dev/null +++ b/config/rbac/memgraphcluster_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project kubernetes-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the memgraph.com. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: kubernetes-operator + app.kubernetes.io/managed-by: kustomize + name: memgraphcluster-editor-role +rules: +- apiGroups: + - memgraph.com + resources: + - memgraphclusters + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - memgraph.com + resources: + - memgraphclusters/status + verbs: + - get diff --git a/config/rbac/memgraphcluster_viewer_role.yaml b/config/rbac/memgraphcluster_viewer_role.yaml new file mode 100644 index 0000000..40a9c6a --- /dev/null +++ b/config/rbac/memgraphcluster_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project kubernetes-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to memgraph.com resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: kubernetes-operator + app.kubernetes.io/managed-by: kustomize + name: memgraphcluster-viewer-role +rules: +- apiGroups: + - memgraph.com + resources: + - memgraphclusters + verbs: + - get + - list + - watch +- apiGroups: + - memgraph.com + resources: + - memgraphclusters/status + verbs: + - get diff --git a/config/rbac/metrics_auth_role.yaml b/config/rbac/metrics_auth_role.yaml new file mode 100644 index 0000000..32d2e4e --- /dev/null +++ b/config/rbac/metrics_auth_role.yaml @@ -0,0 +1,17 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: metrics-auth-role +rules: +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create diff --git a/config/rbac/metrics_auth_role_binding.yaml b/config/rbac/metrics_auth_role_binding.yaml new file mode 100644 index 0000000..e775d67 --- /dev/null +++ b/config/rbac/metrics_auth_role_binding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: metrics-auth-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: metrics-auth-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/config/rbac/metrics_reader_role.yaml b/config/rbac/metrics_reader_role.yaml new file mode 100644 index 0000000..51a75db --- /dev/null +++ b/config/rbac/metrics_reader_role.yaml @@ -0,0 +1,9 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: metrics-reader +rules: +- nonResourceURLs: + - "/metrics" + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index e28a724..45cc667 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -2,70 +2,55 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: memgraph-kubernetes-operator + name: manager-role rules: - apiGroups: - "" resources: - pods - - services - - configmaps - - secrets verbs: - - create - delete - get - list - - patch - - update - watch - apiGroups: - - apps + - "" resources: - - statefulsets + - services verbs: - create - - delete - get - list - patch - - update - watch - apiGroups: - - batch + - apps resources: - - jobs + - statefulsets verbs: - create - - delete - get - list - patch - - update - watch - apiGroups: - memgraph.com resources: - - memgraphhas + - memgraphclusters verbs: - - create - - delete - get - list - - patch - - update - watch - apiGroups: - memgraph.com resources: - - memgraphhas/finalizers + - memgraphclusters/finalizers verbs: - update - apiGroups: - memgraph.com resources: - - memgraphhas/status + - memgraphclusters/status verbs: - get - patch - - update diff --git a/config/rbac/role_binding.yaml b/config/rbac/role_binding.yaml index 9fded4b..8619f0d 100644 --- a/config/rbac/role_binding.yaml +++ b/config/rbac/role_binding.yaml @@ -1,12 +1,15 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: memgraph-kubernetes-operator + labels: + app.kubernetes.io/name: kubernetes-operator + app.kubernetes.io/managed-by: kustomize + name: manager-rolebinding roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: memgraph-kubernetes-operator + name: manager-role subjects: - kind: ServiceAccount - name: memgraph-kubernetes-operator - namespace: memgraph-operator-system + name: controller-manager + namespace: system diff --git a/config/rbac/service_account.yaml b/config/rbac/service_account.yaml index f81938c..0a20477 100644 --- a/config/rbac/service_account.yaml +++ b/config/rbac/service_account.yaml @@ -1,5 +1,8 @@ apiVersion: v1 kind: ServiceAccount metadata: - name: memgraph-kubernetes-operator - namespace: memgraph-operator-system + labels: + app.kubernetes.io/name: kubernetes-operator + app.kubernetes.io/managed-by: kustomize + name: controller-manager + namespace: system diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index e0823f5..1ceab16 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -1,2 +1,4 @@ +## Append samples of your project ## resources: -- memgraph_v1_ha.yaml +- v1alpha1_memgraphcluster.yaml +# +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/memgraph_v1_ha.yaml b/config/samples/memgraph_v1_ha.yaml deleted file mode 100644 index b48bfcd..0000000 --- a/config/samples/memgraph_v1_ha.yaml +++ /dev/null @@ -1,114 +0,0 @@ -apiVersion: memgraph.com/v1 -kind: MemgraphHA -metadata: - name: memgraphha-sample -spec: - coordinators: - - id: "1" - boltPort: 7687 - managementPort: 10000 - coordinatorPort: 12000 - args: - - --experimental-enabled=high-availability - - --coordinator-id=1 - - --coordinator-port=12000 - - --management-port=10000 - - --bolt-port=7687 - - --also-log-to-stderr - - --log-level=TRACE - - --coordinator-hostname=memgraph-coordinator-1.default.svc.cluster.local - - --log-file=/var/log/memgraph/memgraph.log - - - id: "2" - boltPort: 7687 - managementPort: 10000 - coordinatorPort: 12000 - args: - - --experimental-enabled=high-availability - - --coordinator-id=2 - - --coordinator-port=12000 - - - --management-port=10000 - - --bolt-port=7687 - - --also-log-to-stderr - - --log-level=TRACE - - --coordinator-hostname=memgraph-coordinator-2.default.svc.cluster.local - - --log-file=/var/log/memgraph/memgraph.log - - - id: "3" - boltPort: 7687 - managementPort: 10000 - coordinatorPort: 12000 - args: - - --experimental-enabled=high-availability - - --coordinator-id=3 - - --coordinator-port=12000 - - --management-port=10000 - - --bolt-port=7687 - - --also-log-to-stderr - - --log-level=TRACE - - --coordinator-hostname=memgraph-coordinator-3.default.svc.cluster.local - - --log-file=/var/log/memgraph/memgraph.log - - - data: - - id: "0" - boltPort: 7687 - managementPort: 10000 - replicationPort: 20000 - args: - - --experimental-enabled=high-availability - - --management-port=10000 - - --bolt-port=7687 - - --also-log-to-stderr - - --log-level=TRACE - - --log-file=/var/log/memgraph/memgraph.log - - - id: "1" - boltPort: 7687 - managementPort: 10000 - replicationPort: 20000 - args: - - --experimental-enabled=high-availability - - --management-port=10000 - - --bolt-port=7687 - - --also-log-to-stderr - - --log-level=TRACE - - --log-file=/var/log/memgraph/memgraph.log - - memgraph: - data: - volumeClaim: - logPVCClassName: "" - logPVC: true - logPVCSize: 256Mi - storagePVCClassName: "" - storagePVC: true - storagePVCSize: 1Gi - coordinators: - volumeClaim: - logPVCClassName: "" - logPVC: true - logPVCSize: 256Mi - storagePVCClassName: "" - storagePVC: true - storagePVCSize: 1Gi - - env: # This can be removed I think - MEMGRAPH_ENTERPRISE_LICENSE: "${MEMGRAPH_ENTERPRISE_LICENSE}" - MEMGRAPH_ORGANIZATION_NAME: "${MEMGRAPH_ORGANIZATION_NAME}" - image: - pullPolicy: IfNotPresent - repository: memgraph/memgraph - tag: 2.18.1 # I think we should read this value in controller code. - probes: - liveness: - initialDelaySeconds: 30 - periodSeconds: 10 - readiness: - initialDelaySeconds: 5 - periodSeconds: 5 - startup: - initialDelaySeconds: 5 - failureThreshold: 30 - periodSeconds: 10 diff --git a/config/samples/v1alpha1_memgraphcluster.yaml b/config/samples/v1alpha1_memgraphcluster.yaml new file mode 100644 index 0000000..b69349d --- /dev/null +++ b/config/samples/v1alpha1_memgraphcluster.yaml @@ -0,0 +1,248 @@ +apiVersion: memgraph.com/v1alpha1 +kind: MemgraphCluster +metadata: + labels: + app.kubernetes.io/name: kubernetes-operator + app.kubernetes.io/managed-by: kustomize + name: memgraphcluster-sample +spec: + # Raising either count on a live cluster grows it: the operator provisions the + # new pods and registers them with the cluster. The coordinator count must be + # odd so the Raft quorum cannot split, and at least three — a quorum of one + # cannot survive losing itself. + # + # Lowering either count shrinks the cluster: the members above the new count are + # retired before their pods are shed — a data instance has MAIN moved off it and + # is then UNREGISTER INSTANCEd, a coordinator is REMOVE COORDINATORed out of the + # Raft cluster — reported as Converged=False with reason RetirementInProgress. + # Two notes. The operator only touches the cluster while every pod is ready, so + # a retiring pod that cannot become ready blocks its own removal. And Raft + # refuses to remove its own leader, so a leader in the retiring range is asked + # to YIELD LEADERSHIP first, reported as LeadershipTransferInProgress while that + # is pending. + coordinators: 3 + dataInstances: 3 + # repository carries the registry host and image path only — the version + # belongs in tag, and admission rejects a digest or a tag smuggled into the + # repository. + image: + repository: docker.io/memgraph/memgraph + tag: 3.12.0-relwithdebinfo + pullPolicy: IfNotPresent + # References an existing Secret holding the enterprise license; the CR + # carries no secret material itself. + secrets: + name: memgraph-secrets + licenseKey: MEMGRAPH_ENTERPRISE_LICENSE + organizationKey: MEMGRAPH_ORGANIZATION_NAME + storage: + # Retain (the default) leaves the PersistentVolumeClaims behind when this + # resource is deleted — or when a role is scaled down — so the data survives + # an accidental delete. Switch to Delete on dev clusters that should clean up + # after themselves. + retentionPolicy: Retain + # Each pod of a role gets a lib claim (Memgraph's data directory) and a log + # claim. The storage class names have no default and are commented out on + # purpose: left unset, each claim uses the cluster's default StorageClass, + # while the empty string turns dynamic provisioning off and binds a + # pre-created PersistentVolume. Sizes, access modes and classes become + # StatefulSet volumeClaimTemplates, which Kubernetes treats as immutable. + # Setting createLogStorageClaim to false drops the log claim for that role + # and passes an empty --log-file, leaving stderr (and so `kubectl logs`) as + # the only log sink; the other log* knobs are then ignored. Flipping it + # on a live cluster needs the role's StatefulSet recreated (patch this + # resource, then `kubectl delete statefulset - + # --cascade=orphan`) — adding or removing a claim template is not an update + # Kubernetes allows. + coordinators: + libPVCSize: 1Gi + libStorageAccessMode: ReadWriteOnce + # libStorageClassName: standard + createLogStorageClaim: true + logPVCSize: 1Gi + logStorageAccessMode: ReadWriteOnce + # logStorageClassName: standard + data: + libPVCSize: 1Gi + libStorageAccessMode: ReadWriteOnce + # libStorageClassName: standard + createLogStorageClaim: true + logPVCSize: 1Gi + logStorageAccessMode: ReadWriteOnce + # logStorageClassName: standard + # Core dumps are off by default. Enabling them for a role provisions a third + # claim per pod, mounts it at /var/core/memgraph — a fixed path, since only + # the kernel writes it and only the uploader reads it, and the operator points + # both at it — and, unless configureCorePattern is false, runs a privileged + # init container that sets the node's core pattern. Two things that catch + # people: core_pattern is a property of the node, so it applies to everything + # that crashes there, and the privileged init container is rejected in a + # namespace running PodSecurity "restricted". Set configureCorePattern: false + # there and have the platform point core_pattern at that directory itself. + coreDumps: + # Per role, because only these two answers differ between them: whether the + # role collects dumps at all, and how much room one needs — a dump is about + # as large as the crashing process' memory, and a data instance holds the + # graph while a coordinator holds Raft state. + coordinators: + enabled: false + size: 10Gi + data: + enabled: false + size: 10Gi + # The rest is one decision for the cluster, so it is stated once. + # storageClassName: standard + configureCorePattern: true + # An optional sidecar that ships the dumps somewhere durable, joining the + # pods of every role that collects them. The operator mounts the dumps into + # it read-only, passes the directory as CORE_DUMPS_DIR, mounts a writable + # /tmp, and runs it with the same locked-down security context as Memgraph + # (non-root uid 101, read-only root filesystem, no capabilities) — so pick an + # image that tolerates that. Credentials come from envFromSecrets, never from + # this resource. This is the operator's equivalent of the HA chart's + # coreDumpUploader, with the destination left entirely to you: + # + # uploader: + # image: amazon/aws-cli:2.33.28 + # envFromSecrets: + # - aws-s3-credentials # AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY + # env: + # - name: HOME # aws-cli needs a writable HOME + # value: /tmp + # - name: S3_TARGET + # value: s3://memgraph-ha-cluster/core-dumps + # - name: AWS_REGION + # value: eu-west-1 + # command: [/bin/sh, -c] + # args: + # - | + # while true; do + # aws s3 sync "$CORE_DUMPS_DIR" "$S3_TARGET/$HOSTNAME" --region "$AWS_REGION" + # sleep 30 + # done + # resources: + # requests: + # memory: 64Mi + # The cluster domain and the internal ports are part of every advertised + # address the operator registers with the cluster, so they reach the + # container ports, the Services and the registration commands together. + # Changing them on a live cluster is not supported in v1alpha1. + clusterDomain: cluster.local + ports: + boltPort: 7687 + managementPort: 10000 + replicationPort: 20000 + coordinatorPort: 12000 + # Every probe is a TCP-socket check against the role's own port (the + # coordinator port for coordinators, the Bolt port for data instances); only + # the timings are configurable. Timings left out keep their defaults: + # failureThreshold 20 (1440 for the data instances' startup probe, which is + # 2h at the default period, so a large snapshot restore is not killed + # mid-load), timeoutSeconds 10, periodSeconds 5. + probes: + coordinators: + # Gates the other two probes until the coordinator has started. + startupProbe: + failureThreshold: 20 + timeoutSeconds: 10 + periodSeconds: 5 + # Decides whether the pod receives traffic, and whether the operator + # considers the workloads ready to register. + readinessProbe: + failureThreshold: 20 + timeoutSeconds: 10 + periodSeconds: 5 + # Decides whether the container is restarted. + livenessProbe: + failureThreshold: 20 + timeoutSeconds: 10 + periodSeconds: 5 + data: + startupProbe: + failureThreshold: 1440 + timeoutSeconds: 10 + periodSeconds: 5 + readinessProbe: + failureThreshold: 20 + timeoutSeconds: 10 + periodSeconds: 5 + livenessProbe: + failureThreshold: 20 + timeoutSeconds: 10 + periodSeconds: 5 + # Requests and limits of the Memgraph container, per role, unset by default so + # the pods schedule anywhere. When setting Memgraph's own --memory-limit + # below, keep it under the pod's memory limit: Memgraph must hit its own limit + # and fail the query before the kubelet evicts the pod. (The block is the core + # ResourceRequirements type, so it also accepts claims — leave that one alone, + # it needs pod-level resourceClaims the operator does not set.) + resources: + coordinators: {} + # requests: + # cpu: 100m + # memory: 256Mi + # limits: + # memory: 1Gi + data: {} + # requests: + # cpu: 500m + # memory: 2Gi + # limits: + # memory: 4Gi + # Custom labels per object. The operator's own app.kubernetes.io identity + # labels always win a collision: they are what the StatefulSets and Services + # select on. + labels: + coordinators: + podLabels: {} + # team: platform + statefulSetLabels: {} + serviceLabels: {} + data: + podLabels: {} + statefulSetLabels: {} + serviceLabels: {} + # Literal, non-secret environment variables only: there is no valueFrom, so + # the CR stays safe to commit. The license and the organization name come from + # the secrets block above, and admission rejects an entry that tries to shadow + # either of them or POD_NAME, which carries the pod's own identity. + extraEnv: + coordinators: [] + # - name: MEMGRAPH_LOG_LEVEL + # value: TRACE + data: [] + # Any Memgraph flag, without waiting for a typed field. These are appended + # after the flags the operator derives, and Memgraph takes the last + # occurrence of a repeated flag — except for the ports and the coordinator + # identity, which admission rejects here because they must stay consistent + # with the registered addresses. + extraArgs: + coordinators: [] + data: + - --storage-snapshot-on-exit=false + # Any pod volume the operator does not provision itself, plus where to mount + # it in the Memgraph container. Entries are plain core/v1 volumes and volume + # mounts, appended after the operator's own — the usual reason to reach for + # them is a Secret holding certificates or a ConfigMap of extra + # configuration. + # + # extraVolumes entries are stored without schema validation: a core/v1 volume + # carries every volume source Kubernetes has, and inlining that schema per + # role would grow the CRD past what a client-side `kubectl apply` can carry. + # So any volume source works, exactly as in the Helm chart, but a typo in one + # is only caught when the operator applies the StatefulSet — where it shows up + # as the ApplyFailed condition on this resource. The mounts, being small, are + # fully validated: admission rejects a path the operator already mounts. + extraVolumes: + coordinators: [] + data: [] + # - name: bolt-certs + # secret: + # secretName: bolt-tls + # defaultMode: 0400 + extraVolumeMounts: + coordinators: [] + data: [] + # - name: bolt-certs + # mountPath: /etc/memgraph/ssl + # readOnly: true diff --git a/config/scorecard/bases/config.yaml b/config/scorecard/bases/config.yaml deleted file mode 100644 index c770478..0000000 --- a/config/scorecard/bases/config.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: scorecard.operatorframework.io/v1alpha3 -kind: Configuration -metadata: - name: config -stages: -- parallel: true - tests: [] diff --git a/config/scorecard/kustomization.yaml b/config/scorecard/kustomization.yaml deleted file mode 100644 index 50cd2d0..0000000 --- a/config/scorecard/kustomization.yaml +++ /dev/null @@ -1,16 +0,0 @@ -resources: -- bases/config.yaml -patchesJson6902: -- path: patches/basic.config.yaml - target: - group: scorecard.operatorframework.io - version: v1alpha3 - kind: Configuration - name: config -- path: patches/olm.config.yaml - target: - group: scorecard.operatorframework.io - version: v1alpha3 - kind: Configuration - name: config -#+kubebuilder:scaffold:patchesJson6902 diff --git a/config/scorecard/patches/basic.config.yaml b/config/scorecard/patches/basic.config.yaml deleted file mode 100644 index 893ebd2..0000000 --- a/config/scorecard/patches/basic.config.yaml +++ /dev/null @@ -1,10 +0,0 @@ -- op: add - path: /stages/0/tests/- - value: - entrypoint: - - scorecard-test - - basic-check-spec - image: quay.io/operator-framework/scorecard-test:v1.35.0 - labels: - suite: basic - test: basic-check-spec-test diff --git a/config/scorecard/patches/olm.config.yaml b/config/scorecard/patches/olm.config.yaml deleted file mode 100644 index 6cf777b..0000000 --- a/config/scorecard/patches/olm.config.yaml +++ /dev/null @@ -1,50 +0,0 @@ -- op: add - path: /stages/0/tests/- - value: - entrypoint: - - scorecard-test - - olm-bundle-validation - image: quay.io/operator-framework/scorecard-test:v1.35.0 - labels: - suite: olm - test: olm-bundle-validation-test -- op: add - path: /stages/0/tests/- - value: - entrypoint: - - scorecard-test - - olm-crds-have-validation - image: quay.io/operator-framework/scorecard-test:v1.35.0 - labels: - suite: olm - test: olm-crds-have-validation-test -- op: add - path: /stages/0/tests/- - value: - entrypoint: - - scorecard-test - - olm-crds-have-resources - image: quay.io/operator-framework/scorecard-test:v1.35.0 - labels: - suite: olm - test: olm-crds-have-resources-test -- op: add - path: /stages/0/tests/- - value: - entrypoint: - - scorecard-test - - olm-spec-descriptors - image: quay.io/operator-framework/scorecard-test:v1.35.0 - labels: - suite: olm - test: olm-spec-descriptors-test -- op: add - path: /stages/0/tests/- - value: - entrypoint: - - scorecard-test - - olm-status-descriptors - image: quay.io/operator-framework/scorecard-test:v1.35.0 - labels: - suite: olm - test: olm-status-descriptors-test diff --git a/docs/installation.md b/docs/installation.md deleted file mode 100644 index 820c431..0000000 --- a/docs/installation.md +++ /dev/null @@ -1,65 +0,0 @@ -# Install Memgraph Kubernetes Operator - -All described installation options will run the Operator inside the cluster. - -Make sure to clone this repository with its submodule (helm-charts). - -```bash -git clone --recurse-submodules git@github.com:memgraph/kubernetes-operator.git -``` - -## Install K8 Resources - -```bash -make deploy -``` - -This command will use operator's image from Memgraph's DockerHub and create all necessary Kubernetes resources for running an operator. - -## Verify Installation - -Installation using any of the options described above will create a Kubernetes ServiceAccount, RoleBinding, Role, Deployment, and Pods all in the newly created namespace `memgraph-operator-system`. You can check your resources with: - -```bash -kubectl get serviceaccounts -n memgraph-operator-system -kubectl get clusterrolebindings -n memgraph-operator-system -kubectl get clusterroles -n memgraph-operator-system -kubectl get deployments -n memgraph-operator-system -kubectl get pods -n memgraph-operator-system -kubectl get services -n memgraph-operator-system -``` - -CustomResourceDefinition `memgraphhas.memgraph.com`, whose job is to monitor CustomResource `MemgraphHA`, will also get created and you can verify -this with: - -```bash -kubectl get crds -A -``` - -## Start Memgraph High Availability Cluster - -We already provide a sample cluster in `config/samples/memgraph_v1_ha.yaml`. You only need to set your license information by -creating a Kubernetes Secret containing licensing info. You can do this in a following way: - -```bash - kubectl create secret generic memgraph-secrets \ ---from-literal=MEMGRAPH_ENTERPRISE_LICENSE="" \ ---from-literal=MEMGRAPH_ORGANIZATION_NAME="" -``` - -Start Memgraph HA cluster with `kubectl apply -f config/samples/memgraph_v1_ha.yaml`. - -After approximately 60 seconds, you should be able to see instances in the output of `kubectl get pods -A`. - -You can now find the URL of any coordinator instances by running e.g `minikube service list` and connect to see the state of the cluster by running -`show instances;`: -![image](https://github.com/memgraph/kubernetes-operator/assets/53269502/c68d52e2-19f7-4e45-8ff0-fc2ee662c64b) - -## Clear Resources - -```bash -kubectl delete -f config/samples/memgraph_v1_ha.yaml # For deleting cluster -kubectl delete pvc --all # Or leave them if you want to use persistent storage -kubectl delete secret memgraph-secrets -make undeploy -``` diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..151f84c --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,200 @@ +# Releasing + +A release turns a git tag into two artifacts: + +- the operator image, `docker.io/memgraph/kubernetes-operator:`, and +- the install chart, packaged and published into the + [`memgraph.github.io/helm-charts`](https://memgraph.github.io/helm-charts) index — the same + helm repository users already have configured for the Memgraph charts. + +Chart source, CRDs and RBAC stay in this repository. Only the packaged `.tgz` and its index entry +land in `memgraph/helm-charts`, as a GitHub release named `memgraph-operator-` plus a +line in `index.yaml` on that repository's `gh-pages` branch — the same layout chart-releaser +produces for the charts maintained there. + +## Two versions + +`charts/memgraph-operator/Chart.yaml` declares both, and they move independently: + +| Field | Names | Changes when | +| --- | --- | --- | +| `version` | the chart | anything under `charts/memgraph-operator` changes | +| `appVersion` | the operator image the chart installs by default | a new operator image is released | + +`appVersion` is what `deployment.yaml` uses when `image.tag` is left empty, so it is the operator +version a default install runs. + +Because the two are independent, a tag has to say which one it releases: + +| Tag | Releases | Must equal | +| --- | --- | --- | +| `v0.2.0` | the operator image, and the chart alongside it | `appVersion` | +| `chart-0.4.2` | the chart alone — no image is built | `version` | + +The workflow refuses a tag whose version the tagged tree does not declare, before anything is +built. Bump the chart on a pull request, then tag the merge commit. + +CI also refuses a pull request that changes the chart without bumping its `version`: a published +chart version is immutable, so a change that keeps its version is a change users never receive. + +### The one thing to watch + +The chart's `crds/` are generated from the Go types in the commit being released, but its +`appVersion` points at whenever the operator was last released. Nothing forces those to be the +same commit. A chart-only release cut after the API has moved on therefore ships a CRD with +fields the operator it installs has never seen. + +The release run reports this rather than refusing it — the versions are deliberately independent +— as a warning in the job summary listing what changed. When it fires, either release the +operator too (`v`, which realigns both) or cut the chart release from the commit its +`appVersion` names. + +## Releasing the operator + +1. Open a pull request bumping `appVersion` — and `version`, since the chart changed — in + `charts/memgraph-operator/Chart.yaml`: + + ```sh + $ make -s chart-version chart-app-version + ``` + +2. Merge it once CI is green. +3. Tag the merge commit and push: + + ```sh + git switch main && git pull + git tag v0.2.0 && git push origin v0.2.0 + ``` + +The [Release workflow](../.github/workflows/release.yml) then: + +- refuses to overwrite an existing image tag, then builds and pushes `linux/amd64` and + `linux/arm64` — plus `:latest`, for stable versions only; +- verifies the chart's generated CRDs and RBAC still match the Go sources, lints it, packages it, + cuts the `memgraph-operator-` release in `memgraph/helm-charts` and merges its entry + into the index; +- installs the published chart from `https://memgraph.github.io/helm-charts` on a Kind cluster + and asserts the running Deployment is the image just released; +- creates the GitHub release here, with the packaged chart attached. + +## Releasing the chart alone + +For a template fix, a new values knob, or chart documentation — no operator change: + +1. Bump only `version` in `Chart.yaml` on a pull request, and merge. +2. Tag with the `chart-` prefix: + + ```sh + git tag chart-0.4.2 && git push origin chart-0.4.2 + ``` + +No image is built. The run checks that the image named by `appVersion` exists on Docker Hub +before publishing, so the chart can never point at an operator that was never released. + +## Rehearsing a release + +Two ways, for different questions. + +**Dry run** — *does the pipeline work?* Run the workflow from the Actions tab with **publish** +off. Everything happens except publication: both architectures are built, the chart is packaged, +the index merge is computed and printed as a diff, and the chart is installed on Kind from the +local package with the locally built image. Nothing reaches Docker Hub or the index. + +**Prerelease** — *does publishing work?* Declare the prerelease version in `Chart.yaml` like any +other (`appVersion: "0.2.0-rc.1"`, or `version: 0.4.2-rc.1` for a chart-only one), then tag it: +`v0.2.0-rc.1`, `chart-0.4.2-rc.1`. The tag still has to name what the chart declares, so the +candidate is a commit like any other release. This publishes for real, against the real index, +but: + +- helm hides prerelease versions from `helm search` and `helm install` unless `--devel` is + passed, so nobody installs one by accident; +- the image is not tagged `:latest`; +- the GitHub releases are marked as prereleases. + +The public index gains an entry that ordinary use never sees. Prereleases are the only way to +exercise the credentials, the cross-repo push and GitHub Pages' propagation before a real +release depends on them. + +## Credentials + +Three repository secrets on `memgraph/kubernetes-operator`. The run checks all of them are +present before it builds anything. + +| Secret | Used for | +| --- | --- | +| `DOCKERHUB_USERNAME` | pushing the operator image | +| `DOCKERHUB_TOKEN` | pushing the operator image | +| `HELM_CHARTS_TOKEN` | creating the release in `memgraph/helm-charts` and pushing its index | + +`DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` are the same pair the other Memgraph repositories use to +publish images, and are typically inherited from the organization. + +### `HELM_CHARTS_TOKEN` + +A **fine-grained personal access token**, scoped to nothing but the charts repository: + +- **Resource owner**: `memgraph` +- **Repository access**: *Only select repositories* → `memgraph/helm-charts` +- **Repository permissions**: **Contents: Read and write** (this covers both creating the release + and pushing `index.yaml` to `gh-pages`). Metadata read is added automatically. Nothing else. +- **Expiration**: 90 days. + +A deploy key is not enough on its own: it can push to `gh-pages`, but cannot create the GitHub +release the index entry points at. A token owned by a machine account is preferable to a personal +one — releases in `memgraph/helm-charts` are attributed to whoever owns it, and a personal token +dies with the person's access. + +Creating the token requires admin on `memgraph/helm-charts`, which is why this part of the +release setup cannot be automated from here. + +**Rotation**, before expiry or whenever someone with access leaves: + +1. Create the replacement with the scope above. +2. Update the `HELM_CHARTS_TOKEN` secret on `memgraph/kubernetes-operator`. +3. Verify with a prerelease tag — it is the only path that exercises the token end to end. +4. Delete the old token. + +An expired token fails the run at the credential check with the secret named, before anything is +published; nothing is left half-done. + +## When a release goes wrong + +Every publishing step is skipped when its result already exists, so **re-running a failed release +is safe and finishes the job** rather than duplicating it. If a run fails between pushing the +image and indexing the chart, re-run it and it will complete: + +- an image tag already pushed **from that same commit** is left alone and the build skipped — + recognised by its `org.opencontainers.image.revision` label; +- an image tag that exists but came from anywhere else stops the run, because that is someone + else's tag, not this release's; +- an existing charts release has the package re-attached; +- an index that already lists the version is left untouched. + +What cannot be undone is a published version number. Chart versions and image tags are immutable +by convention and by the trust users place in them — to fix a bad release, release the next +version. + +## Doing it by hand + +The pipeline is a thin wrapper around targets you can run locally: + +```sh +make chart-version # the chart version +make chart-app-version # the operator version it installs +make chart-version-check # chart changed => version bumped (against BASE_REF) +make chart-package # package into dist/chart, publish nothing +make test-chart-publish # exercise the whole publish path offline +GH_TOKEN=... make chart-publish # publish for real +``` + +`hack/chart-publish.sh` publishes nothing without `--publish`; with it, it prints the exact index +diff it is about to push. `make test-chart-publish` runs the whole path — package, release, +index merge, re-run — against a local stand-in for `memgraph/helm-charts`, offline, and CI runs +it on every pull request. + +## A note on the legacy image tags + +`docker.io/memgraph/kubernetes-operator` already carries tags `0.0.1` through `1.0.0`, published +by the earlier operator attempt now archived on `archive/pre-operator-mvp`. They are unrelated to +this operator. The release run refuses to overwrite any existing tag, so they cannot be clobbered +by accident — but it does mean the current `0.x` line sorts below them on Docker Hub. diff --git a/examples/minimal-cluster.yaml b/examples/minimal-cluster.yaml new file mode 100644 index 0000000..9c13fe0 --- /dev/null +++ b/examples/minimal-cluster.yaml @@ -0,0 +1,32 @@ +# The quickstart cluster from the repository README: three coordinators, two +# data instances, a pinned Memgraph image, and a reference to the Secret +# holding the enterprise license. Everything else — storage, ports, probes, +# resources, cluster domain — takes its default; config/samples/ +# v1alpha1_memgraphcluster.yaml spells the full surface out. +# +# The e2e suite applies this file verbatim, so it is the manifest CI proves +# boots a registered, MAIN-elected cluster. +apiVersion: memgraph.com/v1alpha1 +kind: MemgraphCluster +metadata: + name: memgraph +spec: + # Raise either count later to grow the cluster: the operator provisions the + # new pods and registers them, no manual registration involved. The + # coordinator count must be odd so the Raft quorum cannot split, and at least + # three. Lower either count and the members above it are retired first — a data + # instance unregistered, a coordinator removed from the Raft cluster — before + # their pods go. + coordinators: 3 + dataInstances: 2 + image: + repository: docker.io/memgraph/memgraph + tag: "3.12.0" + # The Secret holding the enterprise license, which HA requires. Point name at + # your own Secret; the two key names are the defaults, spelled out here + # because they are what you change when your Secret stores them under + # different keys. The CR itself carries no secret material. + secrets: + name: memgraph-secrets + licenseKey: MEMGRAPH_ENTERPRISE_LICENSE + organizationKey: MEMGRAPH_ORGANIZATION_NAME diff --git a/go.mod b/go.mod index f541a53..b1e6e9f 100644 --- a/go.mod +++ b/go.mod @@ -1,72 +1,101 @@ module github.com/memgraph/kubernetes-operator -go 1.22.0 - -toolchain go1.22.5 +go 1.26.0 require ( - github.com/go-logr/logr v1.4.2 - github.com/onsi/ginkgo/v2 v2.19.0 - github.com/onsi/gomega v1.33.1 - k8s.io/api v0.30.3 - k8s.io/apimachinery v0.30.3 - k8s.io/client-go v0.30.3 - sigs.k8s.io/controller-runtime v0.18.4 + github.com/google/go-cmp v0.7.0 + github.com/neo4j/neo4j-go-driver/v5 v5.28.4 + github.com/onsi/ginkgo/v2 v2.27.4 + github.com/onsi/gomega v1.39.0 + k8s.io/api v0.36.0 + k8s.io/apimachinery v0.36.0 + k8s.io/client-go v0.36.0 + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 + sigs.k8s.io/controller-runtime v0.24.1 + sigs.k8s.io/yaml v1.6.0 ) require ( + cel.dev/expr v0.25.1 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/emicklei/go-restful/v3 v3.12.1 // indirect - github.com/evanphx/json-patch/v5 v5.9.0 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/google/gnostic-models v0.6.8 // indirect - github.com/google/go-cmp v0.6.0 // indirect - github.com/google/gofuzz v1.2.0 // indirect - github.com/google/pprof v0.0.0-20240722153945-304e4f0156b8 // indirect + github.com/google/cel-go v0.26.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/imdario/mergo v0.3.16 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/prometheus/client_golang v1.19.1 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.55.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/net v0.27.0 // indirect - golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/term v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect - golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.23.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.41.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.30.3 // indirect - k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20240709000822-3c01b740850f // indirect - k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect - sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect + k8s.io/apiextensions-apiserver v0.36.0 // indirect + k8s.io/apiserver v0.36.0 // indirect + k8s.io/component-base v0.36.0 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/streaming v0.36.0 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect ) diff --git a/go.sum b/go.sum index 244baea..ff062ed 100644 --- a/go.sum +++ b/go.sum @@ -1,175 +1,260 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= -github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= -github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= -github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= -github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20240722153945-304e4f0156b8 h1:ssNFCCVmib/GQSzx3uCWyfMgOamLGWuGqlMS77Y1m3Y= -github.com/google/pprof v0.0.0-20240722153945-304e4f0156b8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= -github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= -github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= -github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= -github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= +github.com/neo4j/neo4j-go-driver/v5 v5.28.4 h1:7toxehVcYkZbyxV4W3Ib9VcnyRBQPucF+VwNNmtSXi4= +github.com/neo4j/neo4j-go-driver/v5 v5.28.4/go.mod h1:Vff8OwT7QpLm7L2yYr85XNWe9Rbqlbeb9asNXJTHO4k= +github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= +github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= +github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= -github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= -golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= -golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= -golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= -golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.30.3 h1:ImHwK9DCsPA9uoU3rVh4QHAHHK5dTSv1nxJUapx8hoQ= -k8s.io/api v0.30.3/go.mod h1:GPc8jlzoe5JG3pb0KJCSLX5oAFIW3/qNJITlDj8BH04= -k8s.io/apiextensions-apiserver v0.30.3 h1:oChu5li2vsZHx2IvnGP3ah8Nj3KyqG3kRSaKmijhB9U= -k8s.io/apiextensions-apiserver v0.30.3/go.mod h1:uhXxYDkMAvl6CJw4lrDN4CPbONkF3+XL9cacCT44kV4= -k8s.io/apimachinery v0.30.3 h1:q1laaWCmrszyQuSQCfNB8cFgCuDAoPszKY4ucAjDwHc= -k8s.io/apimachinery v0.30.3/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= -k8s.io/client-go v0.30.3 h1:bHrJu3xQZNXIi8/MoxYtZBBWQQXwy16zqJwloXXfD3k= -k8s.io/client-go v0.30.3/go.mod h1:8d4pf8vYu665/kUbsxWAQ/JDBNWqfFeZnvFiVdmx89U= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20240709000822-3c01b740850f h1:2sXuKesAYbRHxL3aE2PN6zX/gcJr22cjrsej+W784Tc= -k8s.io/kube-openapi v0.0.0-20240709000822-3c01b740850f/go.mod h1:UxDHUPsUwTOOxSU+oXURfFBcAS6JwiRXTYqYwfuGowc= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/controller-runtime v0.18.4 h1:87+guW1zhvuPLh1PHybKdYFLU0YJp4FhJRmiHvm5BZw= -sigs.k8s.io/controller-runtime v0.18.4/go.mod h1:TVoGrfdpbA9VRFaRnKgk9P5/atA0pMwq+f+msb9M8Sg= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80= +k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34= +k8s.io/apiextensions-apiserver v0.36.0 h1:Wt7E8J+VBCbj4FjiBfDTK/neXDDjyJVJc7xfuOHImZ0= +k8s.io/apiextensions-apiserver v0.36.0/go.mod h1:kGDjH0msuiIB3tgsYRV0kS9GqpMYMUsQ3GHv7TApyug= +k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ= +k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc= +k8s.io/apiserver v0.36.0 h1:Jg5OFAENUACByUCg15CmhZAYrr5ZyJ+jodyA1mHl3YE= +k8s.io/apiserver v0.36.0/go.mod h1:mHvwdHf+qKEm+1/hYm756SV+oREOKSPnsjagOpx6Vho= +k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c= +k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y= +k8s.io/component-base v0.36.0 h1:hFjEktssxiJhrK1zfybkH4kJOi8iZuF+mIDCqS5+jRo= +k8s.io/component-base v0.36.0/go.mod h1:JZvIfcNHk+uck+8LhJzhSBtydWXaZNQwX2OdL+Mnwsk= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/streaming v0.36.0 h1:agnTxU+NFulUrtYzXUGKO3ndEa8jKwht1Kwn9nu9x+4= +k8s.io/streaming v0.36.0/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= +sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt index 759b82a..af737e6 100644 --- a/hack/boilerplate.go.txt +++ b/hack/boilerplate.go.txt @@ -1,5 +1,5 @@ /* -Copyright 2024 Memgraph Ltd. +Copyright YEAR. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -12,4 +12,4 @@ 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. -*/ +*/ \ No newline at end of file diff --git a/hack/chart-install-test.sh b/hack/chart-install-test.sh new file mode 100755 index 0000000..a7d9dc8 --- /dev/null +++ b/hack/chart-install-test.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# +# Install/uninstall test for the operator install chart: helm install on a +# clean Kind cluster, then assert the operator reconciles a MemgraphCluster +# under the chart's least-privilege RBAC, then uninstall everything again. +# +# This is deliberately license-free — it asserts what the chart is responsible +# for (the operator runs, its RBAC is sufficient, install and uninstall are +# clean), not what a booted Memgraph cluster does. The e2e suite covers that +# on top of the same chart install. +# +# Set CHART_TEST_KEEP=true to keep the Kind cluster for inspection. + +set -euo pipefail + +CLUSTER=${CHART_TEST_CLUSTER:-memgraph-operator-chart-test} +CHART_DIR=${CHART_DIR:-charts/memgraph-operator} +CHART_RELEASE=${CHART_RELEASE:-memgraph-operator} +CHART_NAMESPACE=${CHART_NAMESPACE:-memgraph-operator-system} +CLUSTER_NAMESPACE=${CLUSTER_NAMESPACE:-memgraph-chart-test} +IMG=${IMG:-example.com/kubernetes-operator:chart-test} +IMAGE_REPOSITORY=${IMG%:*} +IMAGE_TAG=${IMG##*:} +CRD_NAME=memgraphclusters.memgraph.com + +KIND=${KIND:-kind} +KUBECTL=${KUBECTL:-kubectl} +HELM=${HELM:-helm} +CONTAINER_TOOL=${CONTAINER_TOOL:-docker} + +# wait_for_object polls until an object exists, so the script does not depend +# on a kubectl new enough for `wait --for=create`. +wait_for_object() { + local object=$1 + for _ in $(seq 1 60); do + if "${KUBECTL}" get "${object}" -n "${CLUSTER_NAMESPACE}" >/dev/null 2>&1; then + echo "${object} exists" + return 0 + fi + sleep 2 + done + echo "FAIL: ${object} was not created in ${CLUSTER_NAMESPACE}" >&2 + "${KUBECTL}" get memgraphcluster chart-test -n "${CLUSTER_NAMESPACE}" -o yaml >&2 || true + "${KUBECTL}" logs -n "${CHART_NAMESPACE}" \ + "deployment/${CHART_RELEASE}-controller-manager" --tail=100 >&2 || true + return 1 +} + +cleanup() { + if [ "${CHART_TEST_KEEP:-false}" = "true" ]; then + echo "Keeping Kind cluster ${CLUSTER} (CHART_TEST_KEEP=true)" + return + fi + echo "==> Deleting Kind cluster ${CLUSTER}" + "${KIND}" delete cluster --name "${CLUSTER}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "==> Creating Kind cluster ${CLUSTER}" +"${KIND}" delete cluster --name "${CLUSTER}" >/dev/null 2>&1 || true +"${KIND}" create cluster --name "${CLUSTER}" + +echo "==> Building and loading the operator image ${IMG}" +"${CONTAINER_TOOL}" build -t "${IMG}" . +"${KIND}" load docker-image "${IMG}" --name "${CLUSTER}" + +# The restricted Pod Security Standard is what makes the operator's security +# context load-bearing: a pod that is not non-root is rejected outright. +echo "==> Creating namespace ${CHART_NAMESPACE} enforcing the restricted policy" +"${KUBECTL}" create namespace "${CHART_NAMESPACE}" +"${KUBECTL}" label --overwrite namespace "${CHART_NAMESPACE}" \ + pod-security.kubernetes.io/enforce=restricted + +echo "==> helm install ${CHART_RELEASE} from ${CHART_DIR}" +"${HELM}" install "${CHART_RELEASE}" "${CHART_DIR}" \ + --namespace "${CHART_NAMESPACE}" \ + --set-string "image.repository=${IMAGE_REPOSITORY}" \ + --set-string "image.tag=${IMAGE_TAG}" \ + --wait --timeout 5m + +echo "==> Asserting the operator runs as non-root" +run_as_non_root=$("${KUBECTL}" get pods -n "${CHART_NAMESPACE}" \ + -l control-plane=controller-manager \ + -o jsonpath='{.items[0].spec.securityContext.runAsNonRoot}') +if [ "${run_as_non_root}" != "true" ]; then + echo "FAIL: operator pod does not set runAsNonRoot" >&2 + exit 1 +fi + +# A MemgraphCluster with no license Secret never boots Memgraph, but the +# operator still has to provision the workloads and report status — which is +# exactly the set of API calls the chart's RBAC has to cover. +echo "==> Applying a MemgraphCluster in ${CLUSTER_NAMESPACE}" +"${KUBECTL}" create namespace "${CLUSTER_NAMESPACE}" +"${KUBECTL}" apply -f - < Waiting for the operator to provision the workloads" +for object in \ + statefulset/chart-test-coordinator statefulset/chart-test-data \ + service/chart-test-coordinator service/chart-test-data; do + wait_for_object "${object}" +done + +echo "==> Waiting for the operator to report status on the MemgraphCluster" +"${KUBECTL}" wait --for=condition=Converged=false --timeout=2m \ + memgraphcluster/chart-test -n "${CLUSTER_NAMESPACE}" + +# Any authorization failure means the generated ClusterRole is missing +# something the controller actually issues. +echo "==> Asserting the operator hit no authorization error" +logs=$("${KUBECTL}" logs -n "${CHART_NAMESPACE}" \ + "deployment/${CHART_RELEASE}-controller-manager" --tail=-1) +if grep -qi "is forbidden" <<<"${logs}"; then + echo "FAIL: the operator was denied an API call under the chart's RBAC:" >&2 + grep -i "is forbidden" <<<"${logs}" >&2 + exit 1 +fi + +echo "==> helm uninstall ${CHART_RELEASE}" +"${KUBECTL}" delete memgraphcluster chart-test -n "${CLUSTER_NAMESPACE}" --timeout=2m +"${HELM}" uninstall "${CHART_RELEASE}" --namespace "${CHART_NAMESPACE}" --wait + +# The release's own objects are gone when uninstall returns; the controller pod +# follows once it finishes terminating. +for _ in $(seq 1 30); do + remaining=$("${KUBECTL}" get all -n "${CHART_NAMESPACE}" \ + --no-headers --ignore-not-found | wc -l) + [ "${remaining}" -eq 0 ] && break + sleep 2 +done +if [ "${remaining}" -ne 0 ]; then + echo "FAIL: helm uninstall left objects behind in ${CHART_NAMESPACE}:" >&2 + "${KUBECTL}" get all -n "${CHART_NAMESPACE}" >&2 + exit 1 +fi + +# Helm leaves CRDs behind by design, so the documented uninstall removes them +# explicitly. Both halves are asserted: still present after uninstall, gone +# after the delete. +"${KUBECTL}" get crd "${CRD_NAME}" >/dev/null +"${KUBECTL}" delete -f "${CHART_DIR}/crds" +if "${KUBECTL}" get crd "${CRD_NAME}" >/dev/null 2>&1; then + echo "FAIL: ${CRD_NAME} survived the CRD deletion" >&2 + exit 1 +fi + +echo "==> Chart install/uninstall test passed" diff --git a/hack/chart-publish-test.sh b/hack/chart-publish-test.sh new file mode 100755 index 0000000..dfec725 --- /dev/null +++ b/hack/chart-publish-test.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# +# Test for the cross-publish path, run on every pull request. +# +# hack/chart-publish.sh writes into another repository, so the one thing that +# must not happen is discovering a mistake in it during a release. This runs it +# end to end against a local stand-in for memgraph/helm-charts -- a bare git +# repository holding a gh-pages branch with a realistic index.yaml, and a `gh` +# stub that records what it was asked to do -- and asserts on what came out: +# the index the charts repository ends up with, and the release that was cut. +# +# Offline and side-effect free: no network, nothing touched outside a temp +# directory. + +set -euo pipefail + +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +PUBLISH=${PUBLISH:-"${REPO_ROOT}/hack/chart-publish.sh"} +HELM=${HELM:-helm} + +WORK=$(mktemp -d) +trap 'rm -rf "${WORK}"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# assert_indexed fails unless the published index lists exactly one entry for +# the version, pointing at the release asset that would serve it. +assert_indexed() { + local version=$1 index=$2 + local url="https://github.com/memgraph/helm-charts/releases/download/memgraph-operator-${version}/memgraph-operator-${version}.tgz" + local count + count=$(grep -cF "${url}" "${index}" || true) + [ "${count}" = "1" ] || + fail "expected exactly one index entry for ${version}, found ${count}" +} + +# published_index checks out the stand-in's gh-pages branch as the world sees it. +published_index() { + rm -rf "${WORK}/published" + git clone --quiet --branch gh-pages "${CHARTS_URL}" "${WORK}/published" + echo "${WORK}/published/index.yaml" +} + +echo "==> Building a stand-in for memgraph/helm-charts" +# file:// rather than a bare path, so the clone behaves like a remote one -- +# git ignores --depth on local clones. +CHARTS_URL="file://${WORK}/helm-charts.git" +git init --quiet --bare --initial-branch=gh-pages "${WORK}/helm-charts.git" +git init --quiet --initial-branch=gh-pages "${WORK}/seed" + +# An abridged copy of the real index: one entry for an unrelated chart, which +# the merge has to carry through untouched. +cat >"${WORK}/seed/index.yaml" <<'EOF' +apiVersion: v1 +entries: + memgraph: + - apiVersion: v2 + appVersion: 3.12.0 + created: "2026-07-22T11:28:55.491533439Z" + description: MemgraphDB Helm Chart + digest: bef59ed4e202c17d8f84f5262e819d7b96d3ddc2fc92835c949ca9c21aa25dcb + name: memgraph + type: application + urls: + - https://github.com/memgraph/helm-charts/releases/download/memgraph-1.0.5/memgraph-1.0.5.tgz + version: 1.0.5 +generated: "2026-07-22T11:28:55.490899258Z" +EOF + +git -C "${WORK}/seed" add index.yaml +git -C "${WORK}/seed" -c user.name=test -c user.email=test@example.com \ + commit --quiet -m "Seed the index" +git -C "${WORK}/seed" push --quiet "${WORK}/helm-charts.git" gh-pages + +echo "==> Installing a gh stub" +mkdir -p "${WORK}/bin" "${WORK}/releases" +cat >"${WORK}/bin/gh" <<'EOF' +#!/usr/bin/env bash +# Records every invocation, and answers `release view` from the releases it has +# been asked to create, so the script's resume path is exercised for real. +set -euo pipefail +# One invocation per line: release notes are multi-line, and an argument that +# broke the log into several lines would break every assertion on it. +{ printf '%s ' "$@" | tr '\n' ' '; printf '\n'; } >>"${GH_LOG}" +case "${1:-} ${2:-}" in + "release view") + [ -f "${GH_RELEASES}/$3" ] || exit 1 + ;; + "release create") + tag=$3 + asset=$4 + [ -f "${asset}" ] || { echo "gh stub: no such asset: ${asset}" >&2; exit 1; } + printf '%s\n' "$*" >"${GH_RELEASES}/${tag}" + ;; + "release upload") + [ -f "${GH_RELEASES}/$3" ] || { echo "gh stub: no such release: $3" >&2; exit 1; } + ;; +esac +exit 0 +EOF +chmod +x "${WORK}/bin/gh" + +export GH_LOG="${WORK}/gh.log" +export GH_RELEASES="${WORK}/releases" +: >"${GH_LOG}" + +run_publish() { + env \ + CHARTS_REPO_URL="${CHARTS_URL}" \ + OUT_DIR="${WORK}/dist" \ + GH="${WORK}/bin/gh" \ + GH_TOKEN=stub-token \ + HELM="${HELM}" \ + "${PUBLISH}" "$@" +} + +echo +echo "==> A dry run publishes nothing" +run_publish --version 9.9.9 >"${WORK}/dry-run.log" +[ -f "${WORK}/dist/memgraph-operator-9.9.9.tgz" ] || + fail "the dry run did not package the chart" +[ ! -s "${GH_LOG}" ] || + fail "the dry run called gh: $(cat "${GH_LOG}")" +if grep -q "9.9.9" "$(published_index)"; then + fail "the dry run pushed to the charts repository" +fi +grep -q "Dry run; nothing was published" "${WORK}/dry-run.log" || + fail "the dry run did not say so" + +echo +echo "==> Publishing a release cuts it and indexes it" +run_publish --version 9.9.9 --publish >/dev/null +grep -qF "release create memgraph-operator-9.9.9 " "${GH_LOG}" || + fail "no release was created: $(cat "${GH_LOG}")" +if grep -F "release create memgraph-operator-9.9.9 " "${GH_LOG}" | grep -q -- "--prerelease"; then + fail "a stable version was released as a prerelease" +fi + +index=$(published_index) +assert_indexed 9.9.9 "${index}" +grep -qF "memgraph-1.0.5.tgz" "${index}" || + fail "the merge dropped the pre-existing memgraph entry" +grep -q "^ memgraph-operator:" "${index}" || + fail "the index has no memgraph-operator entry" + +echo +echo "==> Republishing the same version changes nothing" +run_publish --version 9.9.9 --publish >/dev/null +assert_indexed 9.9.9 "$(published_index)" + +echo +echo "==> A prerelease version is marked as one" +run_publish --version 9.9.9-rc.1 --publish >/dev/null +grep -F "release create memgraph-operator-9.9.9-rc.1" "${GH_LOG}" | grep -q -- "--prerelease" || + fail "the release candidate was not marked as a prerelease" + +index=$(published_index) +assert_indexed 9.9.9-rc.1 "${index}" +assert_indexed 9.9.9 "${index}" + +echo +echo "==> Publishing without a token is refused" +if env CHARTS_REPO_URL="${WORK}/helm-charts.git" OUT_DIR="${WORK}/dist" \ + GH="${WORK}/bin/gh" GH_TOKEN="" HELM="${HELM}" \ + "${PUBLISH}" --version 9.9.8 --publish >/dev/null 2>&1; then + fail "publishing without GH_TOKEN succeeded" +fi + +echo +echo "==> Cross-publish test passed" diff --git a/hack/chart-publish.sh b/hack/chart-publish.sh new file mode 100755 index 0000000..c5480b8 --- /dev/null +++ b/hack/chart-publish.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# +# Package the operator install chart and cross-publish it into the existing +# memgraph.github.io/helm-charts index. +# +# Chart source and CRDs stay in this repository, but users install from the +# helm repository they already have configured. That index is a plain Helm +# index.yaml served from the helm-charts repository's gh-pages branch, whose +# entries point at .tgz assets attached to GitHub releases in that same +# repository -- the layout chart-releaser produces for the charts that live +# there. Publishing from here means writing into both halves: +# +# 1. a GitHub release - in the charts repository, with the +# packaged chart attached, and +# 2. an index.yaml entry on gh-pages pointing at that asset. +# +# The release comes first: an index entry whose download URL 404s is worse than +# an asset nobody can find yet. +# +# Every step is skipped when its result already exists, so re-running after a +# half-finished release finishes the job instead of duplicating it. +# +# Nothing leaves the machine without --publish. Without it this packages the +# chart, works out the merged index, and prints what would change. +# +# Publishing needs GH_TOKEN to carry a token with contents write access to the +# charts repository, and git to be able to push there (the release workflow +# runs `gh auth setup-git` for that). See docs/releasing.md. + +set -euo pipefail + +CHART_DIR=${CHART_DIR:-charts/memgraph-operator} +CHART_NAME=${CHART_NAME:-memgraph-operator} +CHARTS_REPO=${CHARTS_REPO:-memgraph/helm-charts} +CHARTS_REPO_URL=${CHARTS_REPO_URL:-https://github.com/${CHARTS_REPO}.git} +PAGES_BRANCH=${PAGES_BRANCH:-gh-pages} +OUT_DIR=${OUT_DIR:-dist/chart} + +HELM=${HELM:-helm} +GIT=${GIT:-git} +GH=${GH:-gh} + +GIT_USER_NAME=${GIT_USER_NAME:-memgraph-operator release} +GIT_USER_EMAIL=${GIT_USER_EMAIL:-tech@memgraph.com} + +PUBLISH=false +VERSION="" + +usage() { + cat >&2 <] [--publish] + + --version chart version to publish; defaults to the version in ${CHART_DIR}/Chart.yaml + --publish actually create the release and push the index (default: dry run) +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --publish) PUBLISH=true ;; + --version) + [ $# -ge 2 ] || { usage; exit 2; } + VERSION=$2 + shift + ;; + --version=*) VERSION=${1#--version=} ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage + exit 2 + ;; + esac + shift +done + +fail() { + echo "$*" >&2 + exit 1 +} + +if [ -z "${VERSION}" ]; then + VERSION=$(sed -n 's/^version:[[:space:]]*//p' "${CHART_DIR}/Chart.yaml" | tr -d '"' | head -1) +fi +VERSION=${VERSION#v} +[ -n "${VERSION}" ] || fail "No chart version given and none found in ${CHART_DIR}/Chart.yaml." + +RELEASE_TAG="${CHART_NAME}-${VERSION}" +PACKAGE="${CHART_NAME}-${VERSION}.tgz" +# Where the index entry will point. chart-releaser builds the same URL for the +# charts already in this index, so the operator's entries look like the rest. +DOWNLOAD_URL="https://github.com/${CHARTS_REPO}/releases/download/${RELEASE_TAG}" + +# A SemVer prerelease is hidden from `helm install` unless --devel is passed, +# which is what makes a release candidate a safe rehearsal against the real +# index: everything is exercised, nobody installs it by accident. +case "${VERSION}" in + *-*) PRERELEASE=true ;; + *) PRERELEASE=false ;; +esac + +if [ "${PUBLISH}" = true ]; then + [ -n "${GH_TOKEN:-}" ] || fail "--publish needs GH_TOKEN set to a token with contents write access to ${CHARTS_REPO}." +fi + +WORK=$(mktemp -d) +trap 'rm -rf "${WORK}"' EXIT + +echo "==> Packaging ${CHART_NAME} ${VERSION}" +mkdir -p "${OUT_DIR}" +rm -f "${OUT_DIR}/${CHART_NAME}"-*.tgz +"${HELM}" package "${CHART_DIR}" --version "${VERSION}" --destination "${OUT_DIR}" +[ -f "${OUT_DIR}/${PACKAGE}" ] || fail "helm package did not produce ${OUT_DIR}/${PACKAGE}." + +# helm repo index reads a whole directory, so the package to be indexed gets a +# directory of its own -- OUT_DIR is the caller's and may hold anything. +mkdir -p "${WORK}/packages" +cp "${OUT_DIR}/${PACKAGE}" "${WORK}/packages/" + +echo "==> Fetching the ${PAGES_BRANCH} branch of ${CHARTS_REPO}" +"${GIT}" clone --quiet --depth 1 --branch "${PAGES_BRANCH}" "${CHARTS_REPO_URL}" "${WORK}/pages" +INDEX="${WORK}/pages/index.yaml" +[ -f "${INDEX}" ] || fail "${CHARTS_REPO} ${PAGES_BRANCH} has no index.yaml." + +# The download URL contains the version twice over, so this matches the exact +# version and never a prefix of it: 0.2.0 does not match 0.2.0-rc.1. +if grep -qF "/${RELEASE_TAG}/${PACKAGE}" "${INDEX}"; then + INDEX_CURRENT=true + echo " index already lists ${CHART_NAME} ${VERSION}" +else + INDEX_CURRENT=false +fi + +echo "==> Merging the index entry" +"${HELM}" repo index "${WORK}/packages" --url "${DOWNLOAD_URL}" --merge "${INDEX}" + +if [ "${PUBLISH}" != true ]; then + echo + echo "==> Dry run; nothing was published." + echo " package: ${OUT_DIR}/${PACKAGE}" + echo " would release ${RELEASE_TAG} in ${CHARTS_REPO} (prerelease: ${PRERELEASE})" + echo " would serve ${DOWNLOAD_URL}/${PACKAGE}" + echo + echo "==> Index diff" + diff -u "${INDEX}" "${WORK}/packages/index.yaml" || true + exit 0 +fi + +echo "==> Publishing ${RELEASE_TAG} to ${CHARTS_REPO}" +if "${GH}" release view "${RELEASE_TAG}" --repo "${CHARTS_REPO}" >/dev/null 2>&1; then + echo " release exists; making sure the package is attached" + "${GH}" release upload "${RELEASE_TAG}" "${OUT_DIR}/${PACKAGE}" --repo "${CHARTS_REPO}" --clobber +else + notes="Memgraph Kubernetes operator install chart ${VERSION}. + +Chart source, CRDs and RBAC live in https://github.com/memgraph/kubernetes-operator; +this release exists so the packaged chart is served from the Memgraph helm repository. + + helm repo add memgraph https://memgraph.github.io/helm-charts + helm repo update + helm install memgraph-operator memgraph/memgraph-operator --version ${VERSION}" + + prerelease_flag=() + [ "${PRERELEASE}" = true ] && prerelease_flag=(--prerelease) + + "${GH}" release create "${RELEASE_TAG}" "${OUT_DIR}/${PACKAGE}" \ + --repo "${CHARTS_REPO}" \ + --title "${RELEASE_TAG}" \ + --notes "${notes}" \ + "${prerelease_flag[@]}" +fi + +if [ "${INDEX_CURRENT}" = true ]; then + echo "==> Index already current; leaving ${PAGES_BRANCH} alone" + exit 0 +fi + +echo "==> Pushing the index to ${CHARTS_REPO} ${PAGES_BRANCH}" +cp "${WORK}/packages/index.yaml" "${INDEX}" +"${GIT}" -C "${WORK}/pages" add index.yaml +"${GIT}" -C "${WORK}/pages" \ + -c "user.name=${GIT_USER_NAME}" \ + -c "user.email=${GIT_USER_EMAIL}" \ + commit --quiet --message "Add ${CHART_NAME} ${VERSION} to the index + +Published from memgraph/kubernetes-operator." +"${GIT}" -C "${WORK}/pages" push --quiet origin "${PAGES_BRANCH}" + +echo "==> Published ${CHART_NAME} ${VERSION}" +echo " GitHub Pages needs a moment to serve the new index." diff --git a/hack/chart-version-check.sh b/hack/chart-version-check.sh new file mode 100755 index 0000000..1ba9057 --- /dev/null +++ b/hack/chart-version-check.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# Chart versioning check, run on every pull request. +# +# The chart's version and its appVersion move independently: appVersion names +# the operator image the chart installs by default, version names the chart +# itself. Neither is derived from the other, so what has to hold is narrower -- +# a pull request that changes the chart has to change the chart's version too. +# Without that, the published index keeps serving the previous package under a +# version number that no longer describes its contents, and `helm upgrade` has +# nothing to act on. +# +# Compares against the base branch, so it stays quiet on pull requests that do +# not touch the chart. + +set -euo pipefail + +CHART_DIR=${CHART_DIR:-charts/memgraph-operator} +CHART_FILE="${CHART_DIR}/Chart.yaml" +BASE_REF=${BASE_REF:-origin/main} + +# Helm requires SemVer for the chart version, and the appVersion is the image +# tag the Deployment template falls back to, so both are checked the same way. +SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' + +fail() { + echo "$*" >&2 + exit 1 +} + +# field prints a top-level scalar from a Chart.yaml on stdin, unquoted. +# Comments never match: the field name has to start the line. +field() { + sed -n "s/^$1:[[:space:]]*//p" | tr -d '"' | head -1 +} + +[ -f "${CHART_FILE}" ] || fail "No chart at ${CHART_FILE}; set CHART_DIR." + +version=$(field version <"${CHART_FILE}") +app_version=$(field appVersion <"${CHART_FILE}") + +[[ ${version} =~ ${SEMVER_RE} ]] || + fail "${CHART_FILE}: version '${version}' is not a SemVer version (e.g. 0.2.0, 0.2.0-rc.1)." +[[ ${app_version} =~ ${SEMVER_RE} ]] || + fail "${CHART_FILE}: appVersion '${app_version}' is not a SemVer version; it is an operator image tag." + +if ! git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null; then + echo "No ${BASE_REF} to compare against; checked the version fields only." + exit 0 +fi + +if git diff --quiet "${BASE_REF}" -- "${CHART_DIR}"; then + echo "Chart unchanged against ${BASE_REF} (version ${version}, appVersion ${app_version})." + exit 0 +fi + +if ! git cat-file -e "${BASE_REF}:${CHART_FILE}" 2>/dev/null; then + echo "${CHART_FILE} is new against ${BASE_REF}; nothing to compare (version ${version})." + exit 0 +fi + +base_version=$(git show "${BASE_REF}:${CHART_FILE}" | field version) + +if [ "${version}" = "${base_version}" ]; then + fail "${CHART_DIR} changed but its version is still ${version}. +A published chart version is immutable, so every change to the chart needs a new +one. Bump 'version' in ${CHART_FILE}; leave 'appVersion' alone unless this +release also ships a new operator image." +fi + +echo "Chart ${base_version} -> ${version} (appVersion ${app_version})." diff --git a/helm-charts b/helm-charts deleted file mode 160000 index a2f3d48..0000000 --- a/helm-charts +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a2f3d480cfa4efd3cd32935cd58391c914fb8296 diff --git a/internal/controller/fake_memgraph_test.go b/internal/controller/fake_memgraph_test.go new file mode 100644 index 0000000..93dc337 --- /dev/null +++ b/internal/controller/fake_memgraph_test.go @@ -0,0 +1,430 @@ +/* +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" + "slices" + "strconv" + "strings" + "sync" + + "github.com/memgraph/kubernetes-operator/internal/memgraph" +) + +// fakeMemgraph is an in-memory Memgraph HA cluster behind the +// memgraph.Connector seam. It keeps one shared SHOW INSTANCES view, applies +// registration commands to it, and — like the real thing — rejects duplicate +// registrations and second MAIN promotions, so any controller behavior that +// is not read-before-write fails the suite loudly. +type fakeMemgraph struct { + mu sync.Mutex + + // instances is the cluster view every coordinator serves. + instances []memgraph.Instance + // staleViews replaces the shared view for individual coordinator addresses: + // a coordinator that lost the leader answers from its own state machine, so + // what it reports need not match the cluster at all. Commands still land on + // the shared view — a stale coordinator is never written to. + staleViews map[string][]memgraph.Instance + // behind is how many transactions a data instance trails the MAIN by, for the + // instances a test puts behind. A registered instance absent from the map is + // caught up, which is what a healthy cluster looks like — falling behind is the + // exceptional state a test opts into. + behind map[string]int64 + connectAttempts int + // connectErr, when set, makes every Connect fail — the operator's view of a + // cluster whose coordinators do not yet answer Bolt. + connectErr error + // rejected are commands the cluster refuses whatever its state, keyed by + // command prefix. It stands in for the rejections the operator cannot reason + // about — a coordinator refusing a registration a healthy one would accept — + // which is the only way a permanently failing plan can be provoked here: every + // other rejection this fake models is one the planner is careful never to plan. + rejected map[string]error + // executed records every mutating command as ": ". + executed []string +} + +func newFakeMemgraph() *fakeMemgraph { + return &fakeMemgraph{} +} + +func (f *fakeMemgraph) Connect(_ context.Context, address string) (memgraph.Client, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.connectAttempts++ + if f.connectErr != nil { + return nil, f.connectErr + } + return &fakeClient{cluster: f, address: address}, nil +} + +func (f *fakeMemgraph) setConnectErr(err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.connectErr = err +} + +// rejectCommand makes every command starting with the given prefix fail with the +// given error, leaving the cluster view untouched. +func (f *fakeMemgraph) rejectCommand(prefix string, err error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.rejected == nil { + f.rejected = map[string]error{} + } + f.rejected[prefix] = err +} + +func (f *fakeMemgraph) connects() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.connectAttempts +} + +func (f *fakeMemgraph) executedCommands() []string { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.executed) +} + +func (f *fakeMemgraph) setInstances(instances []memgraph.Instance) { + f.mu.Lock() + defer f.mu.Unlock() + f.instances = slices.Clone(instances) +} + +// setLeader moves Raft leadership onto the named coordinator, which is how a spec +// parks it where a scale-down cannot remove it: on an ordinal the shrink retires. +func (f *fakeMemgraph) setLeader(name string) { + f.mu.Lock() + defer f.mu.Unlock() + for i, instance := range f.instances { + if !strings.HasPrefix(instance.Name, "coordinator_") { + continue + } + role := memgraph.RoleFollower + if instance.Name == name { + role = memgraph.RoleLeader + } + f.instances[i].Role = role + } +} + +// setBehind puts the named data instance the given number of transactions behind +// the MAIN, which is how a spec keeps it from being promoted. +func (f *fakeMemgraph) setBehind(name string, txns int64) { + f.mu.Lock() + defer f.mu.Unlock() + if f.behind == nil { + f.behind = map[string]int64{} + } + f.behind[name] = txns +} + +// setStaleView makes the coordinator at the given Bolt address answer +// SHOW INSTANCES with its own view instead of the cluster's. +func (f *fakeMemgraph) setStaleView(address string, instances []memgraph.Instance) { + f.mu.Lock() + defer f.mu.Unlock() + if f.staleViews == nil { + f.staleViews = map[string][]memgraph.Instance{} + } + f.staleViews[address] = slices.Clone(instances) +} + +type fakeClient struct { + cluster *fakeMemgraph + address string + closed bool +} + +// ShowInstances serves the shared cluster view, plus the connected +// coordinator's own row when the cluster does not know it yet. That row is how +// a real coordinator answers before it is added: its initial Raft configuration +// holds itself alone and it is started as the leader of that one-member +// cluster, so it names itself leader and reports an empty bolt_server until +// ADD COORDINATOR fills the address in. The row is not written into the shared +// view — a coordinator the cluster has lost track of speaks only for itself. +func (c *fakeClient) ShowInstances(context.Context) ([]memgraph.Instance, error) { + c.cluster.mu.Lock() + defer c.cluster.mu.Unlock() + if c.closed { + return nil, fmt.Errorf("fake memgraph: connection to %s already closed", c.address) + } + if stale, ok := c.cluster.staleViews[c.address]; ok { + return slices.Clone(stale), nil + } + self, err := c.selfName() + if err != nil { + return nil, err + } + view := slices.Clone(c.cluster.instances) + if !c.cluster.hasInstance(self) { + view = append(view, memgraph.Instance{Name: self, Health: "up", Role: memgraph.RoleLeader}) + } + return view, nil +} + +// ShowReplicationLag answers as the real query does: the counts come from the +// MAIN, so a cluster with no MAIN reports nothing at all rather than failing, and +// the MAIN reports itself at zero behind. Every other registered data instance is +// caught up unless a test put it behind. +func (c *fakeClient) ShowReplicationLag(context.Context) ([]memgraph.ReplicationLag, error) { + c.cluster.mu.Lock() + defer c.cluster.mu.Unlock() + if c.closed { + return nil, fmt.Errorf("fake memgraph: connection to %s already closed", c.address) + } + if !slices.ContainsFunc(c.cluster.instances, func(instance memgraph.Instance) bool { return instance.IsMain() }) { + return nil, nil + } + + var lag []memgraph.ReplicationLag + for _, instance := range c.cluster.instances { + if strings.HasPrefix(instance.Name, "coordinator_") { + continue + } + // Lag is measured against the MAIN, so the MAIN is zero behind itself + // whatever a test set for it — what it set describes the instance as a + // replica, which is what it becomes once demoted. + behind := c.cluster.behind[instance.Name] + if instance.IsMain() { + behind = 0 + } + lag = append(lag, memgraph.ReplicationLag{ + Instance: instance.Name, + Databases: []memgraph.DatabaseLag{{ + Database: memgraphDbName, + CommittedTxns: 100 - behind, + TxnsBehindMain: behind, + }}, + }) + } + return lag, nil +} + +func (c *fakeClient) AddCoordinator(_ context.Context, coordinator memgraph.CoordinatorSpec) error { + return c.execute(fmt.Sprintf("ADD COORDINATOR %d", coordinator.ID), func() error { + for i, instance := range c.cluster.instances { + if instance.Name != coordinator.Name() { + continue + } + if instance.BoltServer != "" { + return fmt.Errorf("fake memgraph: coordinator %s already exists", coordinator.Name()) + } + c.cluster.instances[i].BoltServer = coordinator.BoltServer + c.cluster.instances[i].CoordinatorServer = coordinator.CoordinatorServer + c.cluster.instances[i].ManagementServer = coordinator.ManagementServer + return nil + } + // Adding the coordinator that is serving this connection materializes + // the row it has been reporting for itself, so it keeps its leadership; + // any other coordinator joins the formed cluster as a follower. + self, err := c.selfName() + if err != nil { + return err + } + role := memgraph.RoleFollower + if coordinator.Name() == self { + role = memgraph.RoleLeader + } + c.cluster.instances = append(c.cluster.instances, memgraph.Instance{ + Name: coordinator.Name(), + BoltServer: coordinator.BoltServer, + CoordinatorServer: coordinator.CoordinatorServer, + ManagementServer: coordinator.ManagementServer, + Health: "up", + Role: role, + }) + return nil + }) +} + +func (c *fakeClient) RegisterInstance(_ context.Context, instance memgraph.DataInstanceSpec) error { + return c.execute("REGISTER INSTANCE "+instance.Name, func() error { + if c.cluster.hasInstance(instance.Name) { + return fmt.Errorf("fake memgraph: instance %s already registered", instance.Name) + } + c.cluster.instances = append(c.cluster.instances, memgraph.Instance{ + Name: instance.Name, + BoltServer: instance.BoltServer, + ManagementServer: instance.ManagementServer, + Health: "up", + Role: memgraph.RoleReplica, + }) + return nil + }) +} + +func (c *fakeClient) SetInstanceToMain(_ context.Context, name string) error { + return c.execute(fmt.Sprintf("SET INSTANCE %s TO MAIN", name), func() error { + for _, instance := range c.cluster.instances { + if instance.IsMain() { + return fmt.Errorf("fake memgraph: %s is already MAIN", instance.Name) + } + } + for i, instance := range c.cluster.instances { + if instance.Name == name { + c.cluster.instances[i].Role = memgraph.RoleMain + return nil + } + } + return fmt.Errorf("fake memgraph: instance %s is not registered", name) + }) +} + +// DemoteInstance turns the named MAIN back into a replica, and — like the real +// thing — refuses an instance that is not MAIN, so the operator's read-before-write +// is what has to keep this call meaningful. +func (c *fakeClient) DemoteInstance(_ context.Context, name string) error { + return c.execute("DEMOTE INSTANCE "+name, func() error { + for i, instance := range c.cluster.instances { + if instance.Name != name { + continue + } + if !instance.IsMain() { + return fmt.Errorf("fake memgraph: instance %s is not MAIN", name) + } + c.cluster.instances[i].Role = memgraph.RoleReplica + return nil + } + return fmt.Errorf("fake memgraph: instance %s is not registered", name) + }) +} + +// UnregisterInstance removes the named data instance from the cluster view. It +// rejects an unregistered name and, as Memgraph does, the MAIN — so a plan that +// aims an unregistration at a MAIN fails the suite loudly. +func (c *fakeClient) UnregisterInstance(_ context.Context, name string) error { + return c.execute("UNREGISTER INSTANCE "+name, func() error { + for i, instance := range c.cluster.instances { + if instance.Name != name { + continue + } + if instance.IsMain() { + return fmt.Errorf("fake memgraph: instance %s is MAIN", name) + } + c.cluster.instances = slices.Delete(c.cluster.instances, i, i+1) + return nil + } + return fmt.Errorf("fake memgraph: instance %s is not registered", name) + }) +} + +// RemoveCoordinator drops the coordinator with the given Raft ID from the cluster +// view and — as Raft does — refuses the current leader, so a plan that aims a +// removal at the leader fails the suite loudly instead of quietly working. +func (c *fakeClient) RemoveCoordinator(_ context.Context, id int32) error { + name := fmt.Sprintf("coordinator_%d", id) + return c.execute(fmt.Sprintf("REMOVE COORDINATOR %d", id), func() error { + for i, instance := range c.cluster.instances { + if instance.Name != name { + continue + } + if instance.IsLeader() { + return fmt.Errorf("fake memgraph: %s is the leader", name) + } + c.cluster.instances = slices.Delete(c.cluster.instances, i, i+1) + return nil + } + return fmt.Errorf("fake memgraph: coordinator %s is not a member", name) + }) +} + +// YieldLeadership moves leadership off the coordinator serving this connection to +// the lowest-numbered remaining member, standing in for the election NuRaft runs. +// A test cannot rely on which coordinator wins — that is the point of the command +// — only on leadership having moved, which is what the operator has to converge +// around. +func (c *fakeClient) YieldLeadership(context.Context) error { + self, err := c.selfName() + if err != nil { + return err + } + return c.execute("YIELD LEADERSHIP", func() error { + successor := -1 + for i, instance := range c.cluster.instances { + if strings.HasPrefix(instance.Name, "coordinator_") && instance.Name != self { + successor = i + break + } + } + if successor < 0 { + return fmt.Errorf("fake memgraph: %s is the only coordinator, so leadership cannot be yielded", self) + } + for i, instance := range c.cluster.instances { + if instance.Name == self { + c.cluster.instances[i].Role = memgraph.RoleFollower + } + } + c.cluster.instances[successor].Role = memgraph.RoleLeader + return nil + }) +} + +func (c *fakeClient) Close(context.Context) error { + c.cluster.mu.Lock() + defer c.cluster.mu.Unlock() + c.closed = true + return nil +} + +// execute records the command and applies it to the shared cluster view. +func (c *fakeClient) execute(command string, apply func() error) error { + c.cluster.mu.Lock() + defer c.cluster.mu.Unlock() + if c.closed { + return fmt.Errorf("fake memgraph: connection to %s already closed", c.address) + } + for prefix, err := range c.cluster.rejected { + if strings.HasPrefix(command, prefix) { + return err + } + } + if err := apply(); err != nil { + return err + } + c.cluster.executed = append(c.cluster.executed, c.address+": "+command) + return nil +} + +// selfName is the instance name of the coordinator this connection is served +// by. Addresses are the resource builders' pod FQDNs +// ("-...svc.:") and the +// coordinator on pod ordinal N runs with Raft ID N+1. +func (c *fakeClient) selfName() (string, error) { + pod, _, _ := strings.Cut(c.address, ".") + dash := strings.LastIndex(pod, "-") + if dash < 0 { + return "", fmt.Errorf("fake memgraph: %s is not a pod address", c.address) + } + ordinal, err := strconv.Atoi(pod[dash+1:]) + if err != nil { + return "", fmt.Errorf("fake memgraph: %s carries no pod ordinal: %w", c.address, err) + } + return fmt.Sprintf("coordinator_%d", ordinal+1), nil +} + +// hasInstance must be called with the cluster lock held. +func (f *fakeMemgraph) hasInstance(name string) bool { + return slices.ContainsFunc(f.instances, func(instance memgraph.Instance) bool { + return instance.Name == name + }) +} diff --git a/internal/controller/memgraphcluster_controller.go b/internal/controller/memgraphcluster_controller.go new file mode 100644 index 0000000..58dd405 --- /dev/null +++ b/internal/controller/memgraphcluster_controller.go @@ -0,0 +1,1013 @@ +/* +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" + "errors" + "fmt" + "strings" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" + "github.com/memgraph/kubernetes-operator/internal/memgraph" + "github.com/memgraph/kubernetes-operator/internal/planner" + "github.com/memgraph/kubernetes-operator/internal/resources" + "github.com/memgraph/kubernetes-operator/internal/rollout" +) + +// fieldOwner identifies this controller as the server-side-apply field +// manager of the workload objects it provisions. +const fieldOwner = "memgraph-operator" + +const ( + // requeueWhilePending is how long to wait before retrying when the + // cluster cannot be registered yet — pods not ready, or coordinators not + // answering Bolt queries. Both are expected while the cluster starts up. + requeueWhilePending = 10 * time.Second + + // requeueAfterRegistration schedules the follow-up reconcile that + // verifies issued registration commands actually converged the cluster. + requeueAfterRegistration = 10 * time.Second + + // resyncInterval is how often a converged cluster is re-observed to catch + // registration drift. A pod that loses its registration (rescheduled onto + // a fresh node, wiped storage) while still running produces no watch event + // — its StatefulSet is unchanged — so a lost registration would otherwise + // go undetected until an unrelated reconcile. This periodic resync is what + // makes re-registration continuous rather than one-shot. + resyncInterval = 30 * time.Second +) + +// MemgraphClusterReconciler reconciles a MemgraphCluster object +type MemgraphClusterReconciler struct { + client.Client + Scheme *runtime.Scheme + + // Memgraph opens Bolt connections to coordinators. Tests substitute a + // fake; everything above the memgraph.Client interface never touches the + // Bolt driver. + Memgraph memgraph.Connector +} + +// The install chart's ClusterRole is generated from these markers, so they are +// the operator's permission surface: nothing broader is ever granted. The +// verbs are only the ones this reconciler issues — it reads MemgraphClusters +// and patches their status, and server-side-applies (create plus patch) the +// workloads without ever updating or deleting them, because deletion belongs +// to garbage collection via the owner references. The finalizers subresource +// is needed to set those owner references: they block owner deletion, which +// clusters running the OwnerReferencesPermissionEnforcement admission plugin +// only allow with update access to the owner's finalizers. +// +// Pods are the one thing the operator deletes. Both StatefulSets use +// updateStrategy OnDelete, so replacing a pod whose template changed is the +// operator's job and nobody else's; get/list/watch reads their revision and +// readiness, and delete is the restart itself. +// +kubebuilder:rbac:groups=memgraph.com,resources=memgraphclusters,verbs=get;list;watch +// +kubebuilder:rbac:groups=memgraph.com,resources=memgraphclusters/status,verbs=get;patch +// +kubebuilder:rbac:groups=memgraph.com,resources=memgraphclusters/finalizers,verbs=update +// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;patch +// +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;patch +// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;delete + +// Reconcile drives the cluster toward the declared MemgraphCluster spec in +// two stages. First it server-side-applies the builders' desired objects: one +// StatefulSet per role (coordinators, data instances), each backed by a +// headless Service, at the replica counts replicaCounts derives. Then, once +// every pod is ready, it reconciles cluster registration: observe SHOW +// INSTANCES on the coordinator leader, diff against the declared topology, and +// issue only the missing commands — which is all growing a live cluster takes, +// because a raised count declares members the observed cluster does not have +// registered yet. A lowered dataInstances count runs the same loop in reverse: +// the members beyond the declared count are demoted if one of them holds MAIN, +// unregistered, and only then are their pods shed. +// +// All interaction is read-before-write and idempotent, so an operator restart +// mid-bootstrap is harmless. Registration reconciliation is continuous, not +// one-shot: a converged cluster is re-observed on a periodic resync, so a +// registration a pod loses (rescheduled, wiped storage) is re-issued without +// human action. A rejection is reported on the resource rather than only in the +// log, because no amount of retrying will clear it: an apply the API server +// refuses — an edit to a field Kubernetes treats as immutable, a quota denial — +// as ApplyFailed, and a registration command the coordinator leader refuses as +// RegistrationFailed. Deletion needs no handling here — every object +// carries a controller owner reference, so garbage collection removes the +// workloads with the CR. +func (r *MemgraphClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + var cluster memgraphcomv1alpha1.MemgraphCluster + if err := r.Get(ctx, req.NamespacedName, &cluster); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + replicas, err := r.replicaCounts(ctx, &cluster) + if err != nil { + return ctrl.Result{}, err + } + + if err := r.applyDesired(ctx, &cluster, + resources.CoordinatorHeadlessService(&cluster), + resources.DataHeadlessService(&cluster), + resources.CoordinatorStatefulSet(&cluster, replicas.coordinators.applied), + resources.DataStatefulSet(&cluster, replicas.data.applied), + ); err != nil { + return ctrl.Result{}, err + } + + log.Info("Applied desired workload objects for MemgraphCluster", "memgraphcluster", req.NamespacedName) + + return r.reconcileRegistration(ctx, &cluster, replicas) +} + +// applyDesired server-side-applies the desired workload objects, each owned by +// the cluster so garbage collection removes it with the CR. +// +// A rejected apply is retried forever behind the scenes, so it is reported on +// the resource before the error is returned: without that the conditions keep +// describing the cluster that is still running while the declared spec never +// lands, and the rejection is only visible in the operator's log. Both +// conditions go False — the workloads are not the declared ones, so neither +// serving nor convergence can be claimed for the spec the user asked for. +func (r *MemgraphClusterReconciler) applyDesired( + ctx context.Context, + cluster *memgraphcomv1alpha1.MemgraphCluster, + desired ...client.Object, +) error { + for _, obj := range desired { + if err := controllerutil.SetControllerReference(cluster, obj, r.Scheme); err != nil { + return fmt.Errorf("setting owner reference on %T %s: %w", obj, obj.GetName(), err) + } + if err := r.apply(ctx, obj); err != nil { + applyErr := fmt.Errorf("applying %T %s: %w", obj, obj.GetName(), err) + msg := truncateMessage(applyErr.Error()) + if statusErr := r.writeStatus(ctx, cluster, lastObserved(cluster), + notReadyCondition(memgraphcomv1alpha1.ReasonApplyFailed, msg), + notConvergedCondition(memgraphcomv1alpha1.ReasonApplyFailed, msg), + ); statusErr != nil { + return errors.Join(applyErr, statusErr) + } + return applyErr + } + } + return nil +} + +// roleReplicas is one role's replica arithmetic for a reconcile pass: how many +// replicas the spec declares, and how many the operator applies to the role's +// StatefulSet. +type roleReplicas struct { + // name is the StatefulSet's name, so a condition message names the object + // the user can look at. + name string + declared int32 + applied int32 +} + +// replicaCounts is both roles' replica arithmetic. +type replicaCounts struct { + coordinators roleReplicas + data roleReplicas +} + +// retirementMessage names the members a lowered count is shedding, and is empty +// when none are. It is non-empty for exactly as long as the retirement is +// unfinished: the retiring sets are derived from the replica counts the +// operator's own StatefulSets still run, so they empty only once the shrink that +// removes those pods has been applied. +func retirementMessage(topology planner.Topology) string { + var retiring []string + if names := coordinatorNames(topology.RetiringCoordinators); len(names) > 0 { + retiring = append(retiring, "coordinator(s) "+strings.Join(names, ", ")) + } + if names := instanceNames(topology.RetiringDataInstances); len(names) > 0 { + retiring = append(retiring, "data instance(s) "+strings.Join(names, ", ")) + } + if len(retiring) == 0 { + return "" + } + return "Retiring " + strings.Join(retiring, " and ") + " before their pods are shed" +} + +func coordinatorNames(coordinators []memgraph.CoordinatorSpec) []string { + names := make([]string, 0, len(coordinators)) + for _, coordinator := range coordinators { + names = append(names, coordinator.Name()) + } + return names +} + +func instanceNames(instances []memgraph.DataInstanceSpec) []string { + names := make([]string, 0, len(instances)) + for _, instance := range instances { + names = append(names, instance.Name) + } + return names +} + +// yieldedLeader is the retiring coordinator a plan ends by moving Raft leadership +// off, or the empty string when the plan does not do that. A yield is always the +// plan's last command, because nothing after it could be planned: the election +// picks the successor, so the pass stops there and the next one observes the +// cluster under whoever won. +func yieldedLeader(commands []planner.Command) string { + if len(commands) == 0 { + return "" + } + yield, ok := commands[len(commands)-1].(planner.YieldLeadership) + if !ok { + return "" + } + return yield.Leader +} + +// replicaCounts resolves the replica count to apply per role: the declared count +// while the cluster grows or holds its size, and deliberately the current count +// while a lowered count would shrink it. Shedding pods means removing members +// from the Memgraph cluster first — the coordinators otherwise keep expecting +// instances whose pods are gone, and a removed coordinator's vote must be given +// up before its pod is — so this rule never shrinks anything, which keeps it free +// of any knowledge about the cluster's state. +// +// A lowered count of either role is carried out at the end of the registration +// phase instead, once the retiring members have actually left the cluster (see +// reconcileRegistration). +func (r *MemgraphClusterReconciler) replicaCounts( + ctx context.Context, + cluster *memgraphcomv1alpha1.MemgraphCluster, +) (replicaCounts, error) { + var counts replicaCounts + for _, role := range []struct { + name string + declared int32 + resolved *roleReplicas + }{ + {resources.CoordinatorName(cluster), resources.DeclaredCoordinators(cluster), &counts.coordinators}, + {resources.DataName(cluster), resources.DeclaredDataInstances(cluster), &counts.data}, + } { + current, err := r.currentReplicas(ctx, cluster.Namespace, role.name) + if err != nil { + return replicaCounts{}, err + } + // The larger of the two, so growing applies the declared count while + // shrinking holds the current one. + *role.resolved = roleReplicas{ + name: role.name, declared: role.declared, applied: max(role.declared, current), + } + } + return counts, nil +} + +// rolloutRoles is both roles' pods as the rolling restart sees them. +type rolloutRoles struct { + coordinators rollout.Role + data rollout.Role +} + +// observeRollout reads both roles' pods and the revision their StatefulSet +// currently hashes its pod template to, which is everything the rolling restart +// needs about Kubernetes. It is pure observation: nothing is decided here. +func (r *MemgraphClusterReconciler) observeRollout( + ctx context.Context, + cluster *memgraphcomv1alpha1.MemgraphCluster, + replicas replicaCounts, +) (rolloutRoles, error) { + var roles rolloutRoles + coordinators, err := r.observeRolloutRole(ctx, cluster, replicas.coordinators, + resources.CoordinatorPodSelector(cluster), resources.CoordinatorInstanceName) + if err != nil { + return rolloutRoles{}, err + } + data, err := r.observeRolloutRole(ctx, cluster, replicas.data, + resources.DataPodSelector(cluster), resources.DataInstanceName) + if err != nil { + return rolloutRoles{}, err + } + roles.coordinators, roles.data = coordinators, data + return roles, nil +} + +// observeRolloutRole reads one role's pods, in ordinal order, each tagged with +// the Memgraph instance that runs on it. +// +// Pods are looked up by the name their ordinal gives them rather than by +// iterating whatever the list returned, so a pod that has been deleted and not +// yet recreated is simply absent from the result — which is how the rolling +// restart learns to wait for it, and what keeps a pod belonging to some other +// generation of the StatefulSet from being counted. +// +// A pod on its way out is not ready no matter what its conditions still say. Its +// containers keep passing their probes for as long as they take to shut down, and +// a restart that trusted that would delete the next pod while this one is still +// running. +func (r *MemgraphClusterReconciler) observeRolloutRole( + ctx context.Context, + cluster *memgraphcomv1alpha1.MemgraphCluster, + role roleReplicas, + selector map[string]string, + instanceName func(ordinal int32) string, +) (rollout.Role, error) { + observed := rollout.Role{Replicas: role.applied} + + var sts appsv1.StatefulSet + if err := r.Get(ctx, types.NamespacedName{Name: role.name, Namespace: cluster.Namespace}, &sts); err != nil { + if apierrors.IsNotFound(err) { + // Nothing has been provisioned yet, so there is no revision to measure + // pods against and nothing to restart. + return observed, nil + } + return rollout.Role{}, fmt.Errorf("getting StatefulSet %s: %w", role.name, err) + } + observed.UpdateRevision = sts.Status.UpdateRevision + + var pods corev1.PodList + if err := r.List(ctx, &pods, + client.InNamespace(cluster.Namespace), client.MatchingLabels(selector)); err != nil { + return rollout.Role{}, fmt.Errorf("listing pods of StatefulSet %s: %w", role.name, err) + } + byName := make(map[string]*corev1.Pod, len(pods.Items)) + for i := range pods.Items { + byName[pods.Items[i].Name] = &pods.Items[i] + } + + for ordinal := int32(0); ordinal < role.applied; ordinal++ { + pod, ok := byName[fmt.Sprintf("%s-%d", role.name, ordinal)] + if !ok { + continue + } + observed.Pods = append(observed.Pods, rollout.Pod{ + Name: pod.Name, + UID: string(pod.UID), + Instance: instanceName(ordinal), + Ordinal: ordinal, + RevisionHash: pod.Labels[appsv1.StatefulSetRevisionLabel], + Ready: pod.DeletionTimestamp == nil && podReady(pod), + }) + } + return observed, nil +} + +// podReady reports the pod's Ready condition. +func podReady(pod *corev1.Pod) bool { + for _, condition := range pod.Status.Conditions { + if condition.Type == corev1.PodReady { + return condition.Status == corev1.ConditionTrue + } + } + return false +} + +// restartPod deletes the pod a rolling restart picked, so its StatefulSet +// recreates it on the current pod template. The delete is conditioned on the UID +// that was observed: a pod already replaced between the observation and here is +// left alone rather than restarted twice, and a pod that is simply gone is not an +// error — the next pass re-observes and decides again. +func (r *MemgraphClusterReconciler) restartPod( + ctx context.Context, + cluster *memgraphcomv1alpha1.MemgraphCluster, + decision rollout.Decision, +) error { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: decision.Pod.Name, Namespace: cluster.Namespace}, + } + uid := types.UID(decision.Pod.UID) + err := r.Delete(ctx, pod, client.Preconditions{UID: &uid}) + switch { + case err == nil, apierrors.IsNotFound(err), apierrors.IsConflict(err): + return nil + default: + return fmt.Errorf("deleting pod %s to restart it: %w", decision.Pod.Name, err) + } +} + +// currentReplicas is the replica count the operator's own previous apply left on +// a role's StatefulSet, or zero when the cluster has not been provisioned yet. +func (r *MemgraphClusterReconciler) currentReplicas( + ctx context.Context, + namespace, name string, +) (int32, error) { + var sts appsv1.StatefulSet + if err := r.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, &sts); err != nil { + if apierrors.IsNotFound(err) { + return 0, nil + } + return 0, fmt.Errorf("getting StatefulSet %s: %w", name, err) + } + if sts.Spec.Replicas == nil { + return 0, nil + } + return *sts.Spec.Replicas, nil +} + +// reconcileRegistration converges cluster registration once the workloads are +// ready: find the coordinator leader, plan against its SHOW INSTANCES view, +// and execute the planned commands against that one connection. Unreachable +// coordinators are retried on a delay rather than surfaced as errors — Bolt +// endpoints lagging pod readiness is a normal startup phase, not a failure. +// +// The readiness gate is deliberately strict about the pods a lowered count is +// retiring too: they belong to the StatefulSet the operator is still holding at +// its current size, so a retiring pod that cannot become ready blocks its own +// removal, and the resource reports WorkloadsNotReady rather than the operator +// acting on a half-known cluster. +// +// This is also where a scale-down finishes. Once the plan comes back empty — +// meaning the retiring instances have been unregistered and the retiring +// coordinators have left the Raft cluster — the shrinking role's StatefulSet is +// applied at the declared count, shedding their pods. That is the one place the +// operator ever lowers a replica count, so the coordinators never see a registered +// instance's pod disappear, and no removed member's pod outlives its vote. +func (r *MemgraphClusterReconciler) reconcileRegistration( + ctx context.Context, + cluster *memgraphcomv1alpha1.MemgraphCluster, + replicas replicaCounts, +) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + // Both roles' pods are read once per pass: the readiness gate needs to know + // whether a restart is under way to tolerate the pod it took down, and the + // restart itself needs the same view further down. + roles, err := r.observeRollout(ctx, cluster, replicas) + if err != nil { + return ctrl.Result{}, err + } + + ready, err := r.workloadsReady(ctx, cluster, replicas, roles) + if err != nil { + return ctrl.Result{}, err + } + if !ready { + log.Info("Waited for workload pods to become ready before registration") + msg := "Waiting for all workload pods to become ready" + if statusErr := r.writeStatus(ctx, cluster, lastObserved(cluster), + notReadyCondition(memgraphcomv1alpha1.ReasonWorkloadsNotReady, msg), + notConvergedCondition(memgraphcomv1alpha1.ReasonWorkloadsNotReady, msg), + ); statusErr != nil { + return ctrl.Result{}, statusErr + } + return ctrl.Result{RequeueAfter: requeueWhilePending}, nil + } + + topology := resources.DeclaredTopology(cluster) + // The members a lowered count is shedding are the ordinals the operator's own + // previous apply still runs beyond the declared count, so the range is bounded + // by what the operator itself created. + topology.RetiringCoordinators = resources.RetiringCoordinators(cluster, replicas.coordinators.applied) + topology.RetiringDataInstances = resources.RetiringDataInstances(cluster, replicas.data.applied) + // Whether a retirement is in flight is decided once per pass, from the + // topology alone: it is what both the condition and the shrink below key off. + retiring := retirementMessage(topology) + + leader, observed, err := r.observeCluster(ctx, topology) + if err != nil { + log.Info("Deferred registration because no coordinator leader was usable", "reason", err.Error()) + reason, msg := memgraphcomv1alpha1.ReasonCoordinatorUnreachable, "No coordinator answered SHOW INSTANCES" + if errors.Is(err, errNoCoordinatorLeader) { + // The coordinators are up but have no leader between them, so their + // views are stale and no registration command would be accepted. + reason, msg = memgraphcomv1alpha1.ReasonNoCoordinatorLeader, + "No coordinator reported a leader, so the cluster has no Raft quorum" + } + if statusErr := r.writeStatus(ctx, cluster, lastObserved(cluster), + notReadyCondition(reason, msg), + notConvergedCondition(reason, msg), + ); statusErr != nil { + return ctrl.Result{}, statusErr + } + return ctrl.Result{RequeueAfter: requeueWhilePending}, nil + } + defer func() { + if err := leader.Close(ctx); err != nil { + log.Error(err, "Failed to close coordinator connection") + } + }() + + // How far behind the MAIN each data instance is, which is what decides whether + // a retiring MAIN can hand over. The read is not allowed to fail the pass: it + // is consulted for that one decision, every other one has to keep working + // without it, and an empty view already means "no survivor is known to be + // caught up" — the same conclusion, reached by the planner. + lag, err := leader.ShowReplicationLag(ctx) + if err != nil { + log.Info("Could not read replication lag, so no MAIN handover will be planned", "reason", err.Error()) + lag = nil + } + + latest := observe(topology, observed) + commands := planner.Plan(topology, observed, lag) + if len(commands) == 0 { + if retiring != "" { + // An empty plan is not on its own proof that the retirement finished: a + // handover waiting for a caught-up survivor plans nothing either, because + // no command would make a lagging replica ready. Shedding pods on that + // would delete a registered MAIN, so the membership is what gates it. + if !planner.Retired(topology, observed) { + log.Info("Deferred retirement because no surviving data instance is caught up with MAIN") + msg := "Waiting for a surviving data instance that is reachable and caught up with MAIN " + + "before moving MAIN off the instance being retired" + if statusErr := r.writeStatus(ctx, cluster, latest, readyOrNot(latest.main), + notConvergedCondition(memgraphcomv1alpha1.ReasonNoCaughtUpSurvivor, msg), + ); statusErr != nil { + return ctrl.Result{}, statusErr + } + return ctrl.Result{RequeueAfter: requeueWhilePending}, nil + } + if err := r.shedRetiredPods(ctx, cluster, topology, replicas); err != nil { + return ctrl.Result{}, err + } + if statusErr := r.writeStatus(ctx, cluster, latest, readyOrNot(latest.main), + notConvergedCondition(memgraphcomv1alpha1.ReasonRetirementInProgress, retiring), + ); statusErr != nil { + return ctrl.Result{}, statusErr + } + // The shrink was applied, not yet observed back: the next pass sees the + // lowered count, finds nothing retiring, and reports convergence. + return ctrl.Result{RequeueAfter: requeueAfterRegistration}, nil + } + + // Registration is converged, so this is where a changed pod template gets + // rolled through the cluster. It is deliberately the only place: a + // retirement is still moving MAIN around and a pending registration means + // the cluster is not the one the spec describes, so neither is a moment to + // start deleting pods. + converged := trueCondition(memgraphcomv1alpha1.ConditionConverged, + memgraphcomv1alpha1.ReasonAllInstancesRegistered, + fmt.Sprintf("All %d declared instances are registered", len(topology.Coordinators)+len(topology.DataInstances))) + + switch decision := rollout.Next(roles.data, roles.coordinators, observed, lag); decision.Action { + case rollout.Delete: + // Reported before the pod goes, for the reason a rejected apply is: the + // next pass has to explain an absence it caused, and a restart nobody + // announced looks like the cluster losing a pod on its own. + if statusErr := r.writeStatus(ctx, cluster, latest, readyOrNot(latest.main), converged, + notUpdatedCondition(decision.Reason, decision.Message)); statusErr != nil { + return ctrl.Result{}, statusErr + } + if err := r.restartPod(ctx, cluster, decision); err != nil { + return ctrl.Result{}, err + } + 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 + + case rollout.Wait: + log.Info("Deferred the next pod restart", "reason", decision.Message) + if statusErr := r.writeStatus(ctx, cluster, latest, readyOrNot(latest.main), converged, + notUpdatedCondition(decision.Reason, decision.Message)); statusErr != nil { + return ctrl.Result{}, statusErr + } + return ctrl.Result{RequeueAfter: requeueWhilePending}, nil + } + + // Converged, but keep re-observing: a registration a pod loses later + // produces no watch event, so drift is only caught by resyncing. + log.Info("Confirmed cluster registration is converged") + updated := trueCondition(memgraphcomv1alpha1.ConditionUpdated, + memgraphcomv1alpha1.ReasonAllPodsUpdated, + "All workload pods run the pod template the spec describes") + if statusErr := r.writeStatus(ctx, cluster, latest, + readyOrNot(latest.main), converged, updated); statusErr != nil { + return ctrl.Result{}, statusErr + } + return ctrl.Result{RequeueAfter: resyncInterval}, nil + } + + // Report the in-progress state before mutating the cluster: a MAIN already + // serving stays Ready while a lost registration is restored; a fresh + // bootstrap has no MAIN yet, so Ready is False until one is elected. A + // retirement in flight is named as such — it is the more specific operation, + // and the one whose pending members a user wants to see. A pending leadership + // yield is more specific still: it is the one step whose outcome nobody can + // predict, so a scale-down circling it says so rather than looking stuck on + // the removal it cannot reach yet. + reason := memgraphcomv1alpha1.ReasonRegistrationInProgress + message := fmt.Sprintf("Issuing %d registration command(s) to converge the cluster", len(commands)) + if retiring != "" { + reason, message = memgraphcomv1alpha1.ReasonRetirementInProgress, retiring + } + if yielded := yieldedLeader(commands); yielded != "" { + reason = memgraphcomv1alpha1.ReasonLeadershipTransferInProgress + message = fmt.Sprintf( + "Retiring coordinator %s holds Raft leadership, which cannot be removed: yielding it to another member", + yielded) + } + inProgress := notConvergedCondition(reason, message) + if statusErr := r.writeStatus(ctx, cluster, latest, readyOrNot(latest.main), inProgress); statusErr != nil { + return ctrl.Result{}, statusErr + } + + // A command the leader rejects is reported on the resource before the error is + // returned, for the reason a rejected apply is: the command is retried forever, + // so without it the conditions keep saying registration is in progress while + // the rejection only ever reaches the operator's log. Ready is left describing + // what the cluster was last observed doing — a MAIN that is serving keeps + // serving through a registration the coordinator refuses. + for _, command := range commands { + if err := command.Run(ctx, leader); err != nil { + commandErr := fmt.Errorf("executing registration command %q: %w", command, err) + msg := truncateMessage(commandErr.Error()) + if statusErr := r.writeStatus(ctx, cluster, latest, readyOrNot(latest.main), + notConvergedCondition(memgraphcomv1alpha1.ReasonRegistrationFailed, msg), + ); statusErr != nil { + return ctrl.Result{}, errors.Join(commandErr, statusErr) + } + return ctrl.Result{}, commandErr + } + log.Info("Executed registration command", "command", command.String()) + } + + // Registration was issued, not yet observed back; verify convergence on a + // follow-up reconcile instead of assuming success. + return ctrl.Result{RequeueAfter: requeueAfterRegistration}, nil +} + +// shedRetiredPods applies the shrinking roles' StatefulSets at their declared +// replica counts — the one place the operator ever lowers a replica count. It is +// reached only after the plan came back empty, so every pod it sheds belongs to a +// member that has already left the Memgraph cluster: an unregistered data +// instance, or a coordinator whose Raft vote is gone. +func (r *MemgraphClusterReconciler) shedRetiredPods( + ctx context.Context, + cluster *memgraphcomv1alpha1.MemgraphCluster, + topology planner.Topology, + replicas replicaCounts, +) error { + log := logf.FromContext(ctx) + for _, role := range []struct { + retiring int + replicas roleReplicas + build func(*memgraphcomv1alpha1.MemgraphCluster, int32) *appsv1.StatefulSet + }{ + {len(topology.RetiringCoordinators), replicas.coordinators, resources.CoordinatorStatefulSet}, + {len(topology.RetiringDataInstances), replicas.data, resources.DataStatefulSet}, + } { + if role.retiring == 0 { + continue + } + if err := r.applyDesired(ctx, cluster, role.build(cluster, role.replicas.declared)); err != nil { + return err + } + log.Info("Shrank a StatefulSet to the declared replica count", + "statefulset", role.replicas.name, "replicas", role.replicas.declared) + } + return nil +} + +// observation is everything a reconcile pass observed about the cluster that +// reaches the resource's status: which data instance is MAIN, and how many of +// each role's declared members are registered. It is observation only — no +// reconcile decision reads it back off the status. +type observation struct { + main string + registeredCoordinators int32 + registeredDataInstances int32 +} + +// observe reads the coordinator leader's cluster view into the status fields. +func observe(topology planner.Topology, observed []memgraph.Instance) observation { + coordinators, dataInstances := planner.Registered(topology, observed) + return observation{ + main: observedMain(observed), + registeredCoordinators: coordinators, + registeredDataInstances: dataInstances, + } +} + +// lastObserved is the observation already published on the resource. The paths +// that could not observe the cluster this pass republish it: an unready pod or +// an unreachable coordinator says nothing about what the last reachable leader +// reported. +func lastObserved(cluster *memgraphcomv1alpha1.MemgraphCluster) observation { + return observation{ + main: cluster.Status.Main, + registeredCoordinators: cluster.Status.Coordinators, + registeredDataInstances: cluster.Status.DataInstances, + } +} + +// observedMain returns the name of the data instance reported as MAIN *and* +// reachable, or the empty string when the cluster has none it can serve writes +// from. +// +// Reachability is part of the question, not a refinement of it. A MAIN whose pod +// is gone keeps its role in the coordinators' Raft state and keeps being reported +// as MAIN, so a check on the role alone would claim the cluster serves writes for +// the whole failover window — including every window the rolling restart opens on +// purpose by deleting the MAIN's pod. +func observedMain(observed []memgraph.Instance) string { + for _, instance := range observed { + if instance.IsMain() && instance.IsUp() { + return instance.Name + } + } + return "" +} + +// readyOrNot builds the Ready condition from whether a MAIN is elected: the +// cluster serves writes exactly when a data instance is MAIN. +func readyOrNot(main string) metav1.Condition { + if main == "" { + return notReadyCondition(memgraphcomv1alpha1.ReasonNoMainElected, + "No data instance has been promoted to MAIN yet") + } + return trueCondition(memgraphcomv1alpha1.ConditionReady, + memgraphcomv1alpha1.ReasonMainElected, "Data instance "+main+" is MAIN") +} + +// maxConditionMessage bounds a condition message well under the API's own +// 32Ki limit: an apply rejection can carry a long field list, and the useful +// part — what the API server refused — comes first. +const maxConditionMessage = 1024 + +func truncateMessage(message string) string { + if len(message) <= maxConditionMessage { + return message + } + return message[:maxConditionMessage-3] + "..." +} + +func trueCondition(condType, reason, message string) metav1.Condition { + return metav1.Condition{Type: condType, Status: metav1.ConditionTrue, Reason: reason, Message: message} +} + +func notReadyCondition(reason, message string) metav1.Condition { + return metav1.Condition{ + Type: memgraphcomv1alpha1.ConditionReady, Status: metav1.ConditionFalse, Reason: reason, Message: message, + } +} + +func notConvergedCondition(reason, message string) metav1.Condition { + return metav1.Condition{ + Type: memgraphcomv1alpha1.ConditionConverged, Status: metav1.ConditionFalse, Reason: reason, Message: message, + } +} + +func notUpdatedCondition(reason, message string) metav1.Condition { + return metav1.Condition{ + Type: memgraphcomv1alpha1.ConditionUpdated, Status: metav1.ConditionFalse, Reason: reason, Message: message, + } +} + +// writeStatus patches the status subresource with the pass's observation and the +// given conditions. It uses the status subresource exclusively — spec is never +// touched — and skips the patch when nothing changed, so a converged cluster +// re-observed on every resync does not churn the resource version. +func (r *MemgraphClusterReconciler) writeStatus( + ctx context.Context, + cluster *memgraphcomv1alpha1.MemgraphCluster, + observed observation, + conditions ...metav1.Condition, +) error { + base := cluster.DeepCopy() + cluster.Status.Main = observed.main + cluster.Status.Coordinators = observed.registeredCoordinators + cluster.Status.DataInstances = observed.registeredDataInstances + for _, condition := range conditions { + condition.ObservedGeneration = cluster.Generation + apimeta.SetStatusCondition(&cluster.Status.Conditions, condition) + } + if equality.Semantic.DeepEqual(base.Status, cluster.Status) { + return nil + } + if err := r.Status().Patch(ctx, cluster, client.MergeFrom(base)); err != nil { + return fmt.Errorf("patching MemgraphCluster status: %w", err) + } + return nil +} + +// workloadsReady reports whether both role StatefulSets have all their pods +// ready. Registration waits for the full topology: coordinators cannot form a +// Raft cluster and data instances cannot be registered until every advertised +// address resolves to a running pod. +// +// The count each role must reach is the one this pass applied, never the +// spec.replicas read back off the StatefulSet. Reads go through the informer +// cache, which lags the apply, so the object read back a few lines after a +// scale-up is still the pre-apply snapshot — and in that snapshot the old +// spec.replicas and the old status.readyReplicas agree, because the cluster +// genuinely was converged at the old size. Comparing those two stale numbers +// against each other reports a grown topology as ready and lets registration run +// against a pod Kubernetes has not been asked to create yet. Comparing a stale +// readyReplicas against the count this pass intends cannot fail that way: a lagging +// status only ever reads as not-yet-ready. +// +// A StatefulSet the apply just created is not ready, not an error: the same lag +// makes an absent StatefulSet the same waiting state as one whose pods have not +// come up yet. +// +// One absence is tolerated: the pod a rolling restart itself took down. Without +// that, the first pod the restart deletes would make this gate false, the pass +// would return before ever connecting to a coordinator, and the restart could +// never learn whether that pod came back — a roll that deletes one pod and then +// waits forever. The exception is deliberately narrow, and both halves of the +// condition matter. It applies only to a role that has outdated pods, so a +// healthy cluster is still held to every pod being ready; and only when all of +// the role's pods exist, because a role short of its replicas is exactly the +// stale-informer case above — during a 3-to-4 scale-up a readyReplicas of 3 +// against an applied 4 would otherwise read as "one pod down, mid-roll, +// tolerated" and let registration run against a pod that does not exist yet. +func (r *MemgraphClusterReconciler) workloadsReady( + ctx context.Context, + cluster *memgraphcomv1alpha1.MemgraphCluster, + replicas replicaCounts, + roles rolloutRoles, +) (bool, error) { + for _, role := range []struct { + replicas roleReplicas + rollout rollout.Role + }{ + {replicas.coordinators, roles.coordinators}, + {replicas.data, roles.data}, + } { + var sts appsv1.StatefulSet + name := role.replicas.name + if err := r.Get(ctx, types.NamespacedName{Name: name, Namespace: cluster.Namespace}, &sts); err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("getting StatefulSet %s: %w", name, err) + } + required := role.replicas.applied + if rollout.InProgress(role.rollout) && sts.Status.Replicas == required { + required-- + } + if sts.Status.ReadyReplicas < required { + return false, nil + } + } + return true, nil +} + +// errNoCoordinatorLeader reports that coordinators answered SHOW INSTANCES but +// none of them named a leader. It is distinguished from unreachable +// coordinators because the two need different remedies, and it is reported as +// such on the resource. +var errNoCoordinatorLeader = errors.New("no coordinator reported a leader") + +// observeCluster connects to the coordinator leader and returns its client +// together with the SHOW INSTANCES view the planner diffs against. +// Coordinators are tried in ordinal order: one reporting itself leader is used +// directly, and one reporting another coordinator as leader hands over the +// address to connect to. The view is read once either way — a follower forwards +// SHOW INSTANCES to the leader, so what it answers with is already the leader's +// view, and only the planner's mutating commands need the leader connection +// itself. +// +// A coordinator that names no leader is skipped, never used as planning input. +// Its view is not the fresh-cluster case: a coordinator starts with itself as +// the only member of its Raft configuration and as the leader of that +// one-member cluster, so a fresh coordinator always names itself. An absent +// leader means the coordinator lost track of one — quorum gone, or it stepped +// down or was removed from the Raft cluster — and it then answers from its own +// state machine, which can be arbitrarily stale. Every mutating query the +// planner could issue needs a leader anyway, so such a view describes a cluster +// state that is neither current nor writable. +func (r *MemgraphClusterReconciler) observeCluster( + ctx context.Context, + topology planner.Topology, +) (memgraph.Client, []memgraph.Instance, error) { + var errs []error + leaderless := false + for _, coordinator := range topology.Coordinators { + conn, observed, err := r.showInstances(ctx, coordinator.BoltServer) + if err != nil { + errs = append(errs, err) + continue + } + + leaderName := "" + for _, instance := range observed { + if instance.IsLeader() { + leaderName = instance.Name + break + } + } + if leaderName == coordinator.Name() { + return conn, observed, nil + } + if err := conn.Close(ctx); err != nil { + errs = append(errs, err) + } + if leaderName == "" { + leaderless = true + errs = append(errs, fmt.Errorf("%s reported no leader", coordinator.Name())) + continue + } + + // This coordinator is a follower, so it answered with the leader's + // forwarded view: keep that view and open the connection the mutating + // commands need on the leader itself. + address, found := leaderAddress(topology, observed, leaderName) + if !found { + errs = append(errs, fmt.Errorf("%s reported leader %s without a Bolt address", + coordinator.Name(), leaderName)) + continue + } + leader, err := r.Memgraph.Connect(ctx, address) + if err != nil { + errs = append(errs, err) + continue + } + return leader, observed, nil + } + if leaderless { + return nil, nil, fmt.Errorf("%w: %w", errNoCoordinatorLeader, errors.Join(errs...)) + } + return nil, nil, fmt.Errorf("no coordinator answered SHOW INSTANCES: %w", errors.Join(errs...)) +} + +// leaderAddress resolves the Bolt address of the coordinator reported as +// leader. The declared topology is preferred — it is the address the operator +// itself registered — but the reported view is a valid fallback, because the +// leader need not be one of the declared coordinators: a coordinator on its way +// out of the cluster can hold leadership while it is still being removed. +func leaderAddress(topology planner.Topology, observed []memgraph.Instance, name string) (string, bool) { + for _, coordinator := range topology.Coordinators { + if coordinator.Name() == name { + return coordinator.BoltServer, true + } + } + for _, instance := range observed { + if instance.Name == name && instance.BoltServer != "" { + return instance.BoltServer, true + } + } + return "", false +} + +// showInstances connects to one coordinator's Bolt address and fetches its +// cluster view, closing the connection again on query failure. +func (r *MemgraphClusterReconciler) showInstances( + ctx context.Context, + address string, +) (memgraph.Client, []memgraph.Instance, error) { + c, err := r.Memgraph.Connect(ctx, address) + if err != nil { + return nil, nil, err + } + observed, err := c.ShowInstances(ctx) + if err != nil { + if closeErr := c.Close(ctx); closeErr != nil { + return nil, nil, errors.Join(err, closeErr) + } + return nil, nil, err + } + return c, observed, nil +} + +// apply server-side-applies a desired object built by the resource builders. +// Builders set only the fields the operator owns, so the converted apply +// configuration claims exactly those fields for this controller. +func (r *MemgraphClusterReconciler) apply(ctx context.Context, obj client.Object) error { + content, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return fmt.Errorf("converting to unstructured: %w", err) + } + u := &unstructured.Unstructured{Object: content} + // Zero-valued struct fields survive the conversion; drop them so the + // applied configuration only claims fields the builders actually set. + unstructured.RemoveNestedField(u.Object, "status") + unstructured.RemoveNestedField(u.Object, "metadata", "creationTimestamp") + unstructured.RemoveNestedField(u.Object, "spec", "template", "metadata", "creationTimestamp") + + return r.Apply(ctx, client.ApplyConfigurationFromUnstructured(u), client.FieldOwner(fieldOwner), client.ForceOwnership) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *MemgraphClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&memgraphcomv1alpha1.MemgraphCluster{}). + Owns(&appsv1.StatefulSet{}). + Owns(&corev1.Service{}). + Named("memgraphcluster"). + Complete(r) +} diff --git a/internal/controller/memgraphcluster_controller_test.go b/internal/controller/memgraphcluster_controller_test.go new file mode 100644 index 0000000..154bbd9 --- /dev/null +++ b/internal/controller/memgraphcluster_controller_test.go @@ -0,0 +1,1728 @@ +/* +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" + "errors" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" + "github.com/memgraph/kubernetes-operator/internal/memgraph" +) + +// Name suffixes of the per-role workload objects a reconcile creates. +const ( + coordinatorSuffix = "-coordinator" + dataSuffix = "-data" + + // memgraphDbName is the value of the app.kubernetes.io/name label the operator + // stamps on everything, and the container name inside its pods. + memgraphDbName = "memgraph" +) + +// Non-default spec values the specs in this package override with, chosen so +// that they cannot be confused with the CRD schema defaults. +const ( + customImageTag = "3.13.0" + customSecretName = "my-license" + customStorageClassName = "fast-ssd" + uploaderImage = "amazon/aws-cli:2.33.28" +) + +// libClaim returns the lib storage claim template of a provisioned +// StatefulSet, failing the spec if the builder stopped emitting it. +func libClaim(sts *appsv1.StatefulSet) corev1.PersistentVolumeClaimSpec { + GinkgoHelper() + for _, claim := range sts.Spec.VolumeClaimTemplates { + if claim.Name == "lib-storage" { + return claim.Spec + } + } + Fail("StatefulSet " + sts.Name + " has no lib-storage claim template") + return corev1.PersistentVolumeClaimSpec{} +} + +var _ = Describe("MemgraphCluster Controller", func() { + const resourceNamespace = "default" + + ctx := context.Background() + + var ( + reconciler *MemgraphClusterReconciler + fake *fakeMemgraph + ) + + BeforeEach(func() { + fake = newFakeMemgraph() + reconciler = &MemgraphClusterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Memgraph: fake, + } + }) + + reconcileCluster := func(name string) reconcile.Result { + GinkgoHelper() + result, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: name, Namespace: resourceNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + return result + } + + get := func(name string, obj client.Object) { + GinkgoHelper() + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: resourceNamespace}, obj)).To(Succeed()) + } + + // deleteOwned removes the workload objects a reconcile created for the + // given cluster: envtest runs no garbage collector, so owner-reference + // cascade deletion never fires and each spec must clean up explicitly. + deleteOwned := func(clusterName string) { + GinkgoHelper() + for _, suffix := range []string{coordinatorSuffix, dataSuffix} { + sts := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{ + Name: clusterName + suffix, Namespace: resourceNamespace, + }} + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, sts))).To(Succeed()) + svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{ + Name: clusterName + suffix, Namespace: resourceNamespace, + }} + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, svc))).To(Succeed()) + } + } + + // markWorkloadsReady simulates the kubelet envtest does not run: it + // reports every replica of both role StatefulSets as ready, which is what + // gates the registration flow. + markWorkloadsReady := func(clusterName string) { + GinkgoHelper() + for _, suffix := range []string{coordinatorSuffix, dataSuffix} { + sts := &appsv1.StatefulSet{} + get(clusterName+suffix, sts) + sts.Status.Replicas = *sts.Spec.Replicas + sts.Status.ReadyReplicas = *sts.Spec.Replicas + sts.Status.AvailableReplicas = *sts.Spec.Replicas + sts.Status.ObservedGeneration = sts.Generation + Expect(k8sClient.Status().Update(ctx, sts)).To(Succeed()) + } + } + + expectControlledBy := func(obj client.Object, cluster *memgraphcomv1alpha1.MemgraphCluster) { + GinkgoHelper() + ref := metav1.GetControllerOf(obj) + Expect(ref).NotTo(BeNil(), "expected %s to carry a controller owner reference", obj.GetName()) + Expect(ref.Kind).To(Equal("MemgraphCluster")) + Expect(ref.Name).To(Equal(cluster.Name)) + Expect(ref.UID).To(Equal(cluster.UID)) + Expect(ref.Controller).To(HaveValue(BeTrue())) + } + + Context("when reconciling a minimal MemgraphCluster", func() { + const resourceName = "mgc-minimal" + + cluster := &memgraphcomv1alpha1.MemgraphCluster{} + + BeforeEach(func() { + resource := &memgraphcomv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{Name: resourceName, Namespace: resourceNamespace}, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + get(resourceName, cluster) + }) + + AfterEach(func() { + Expect(k8sClient.Delete(ctx, cluster)).To(Succeed()) + deleteOwned(resourceName) + }) + + It("should apply CRD schema defaults on admission", func() { + Expect(cluster.Spec.Coordinators).To(HaveValue(Equal(int32(3)))) + Expect(cluster.Spec.DataInstances).To(HaveValue(Equal(int32(2)))) + Expect(cluster.Spec.Image.Repository).To(Equal(memgraphcomv1alpha1.DefaultImageRepository)) + Expect(cluster.Spec.Image.Tag).To(Equal(memgraphcomv1alpha1.DefaultImageTag)) + Expect(cluster.Spec.Image.PullPolicy).To(Equal(memgraphcomv1alpha1.DefaultImagePullPolicy)) + Expect(cluster.Spec.Secrets.Name).To(Equal(memgraphcomv1alpha1.DefaultSecretName)) + Expect(cluster.Spec.Secrets.LicenseKey).To(Equal(memgraphcomv1alpha1.DefaultLicenseSecretKey)) + Expect(cluster.Spec.Secrets.OrganizationKey).To(Equal(memgraphcomv1alpha1.DefaultOrganizationSecretKey)) + }) + + It("should provision one StatefulSet and one headless Service per role", func() { + reconcileCluster(resourceName) + + coordinatorSts := &appsv1.StatefulSet{} + get(resourceName+coordinatorSuffix, coordinatorSts) + Expect(coordinatorSts.Spec.Replicas).To(HaveValue(Equal(int32(3)))) + Expect(coordinatorSts.Spec.ServiceName).To(Equal(resourceName + coordinatorSuffix)) + expectControlledBy(coordinatorSts, cluster) + + dataSts := &appsv1.StatefulSet{} + get(resourceName+dataSuffix, dataSts) + Expect(dataSts.Spec.Replicas).To(HaveValue(Equal(int32(2)))) + Expect(dataSts.Spec.ServiceName).To(Equal(resourceName + dataSuffix)) + expectControlledBy(dataSts, cluster) + + for _, sts := range []*appsv1.StatefulSet{coordinatorSts, dataSts} { + podSpec := sts.Spec.Template.Spec + Expect(podSpec.Containers).To(HaveLen(1)) + container := podSpec.Containers[0] + Expect(container.Image).To(Equal("docker.io/memgraph/memgraph:3.12.0-relwithdebinfo")) + Expect(podSpec.SecurityContext.RunAsUser).To(HaveValue(Equal(int64(101)))) + Expect(podSpec.SecurityContext.RunAsGroup).To(HaveValue(Equal(int64(103)))) + + licenseRef := container.Env[len(container.Env)-2].ValueFrom.SecretKeyRef + Expect(licenseRef.Name).To(Equal("memgraph-secrets")) + Expect(licenseRef.Key).To(Equal("MEMGRAPH_ENTERPRISE_LICENSE")) + } + + for _, suffix := range []string{coordinatorSuffix, dataSuffix} { + svc := &corev1.Service{} + get(resourceName+suffix, svc) + Expect(svc.Spec.ClusterIP).To(Equal(corev1.ClusterIPNone)) + Expect(svc.Spec.PublishNotReadyAddresses).To(BeTrue()) + expectControlledBy(svc, cluster) + } + }) + + It("should back both roles with retained lib and log claims", func() { + reconcileCluster(resourceName) + + for _, suffix := range []string{coordinatorSuffix, dataSuffix} { + sts := &appsv1.StatefulSet{} + get(resourceName+suffix, sts) + + claims := map[string]corev1.PersistentVolumeClaimSpec{} + for _, claim := range sts.Spec.VolumeClaimTemplates { + claims[claim.Name] = claim.Spec + } + Expect(claims).To(HaveKey("lib-storage")) + Expect(claims).To(HaveKey("log-storage")) + for name, claim := range claims { + Expect(claim.AccessModes).To(ConsistOf(corev1.ReadWriteOnce), "claim %s", name) + Expect(claim.Resources.Requests.Storage()).To(HaveValue(Equal(resource.MustParse("1Gi"))), + "claim %s", name) + Expect(claim.StorageClassName).To(BeNil(), + "claim %s must fall back to the cluster's default StorageClass", name) + } + + // The default keeps data safe from an accidental CR delete and + // from a scale-down alike: one retention knob, both halves of + // the policy. + Expect(sts.Spec.PersistentVolumeClaimRetentionPolicy).To(HaveValue(Equal( + appsv1.StatefulSetPersistentVolumeClaimRetentionPolicy{ + WhenDeleted: appsv1.RetainPersistentVolumeClaimRetentionPolicyType, + WhenScaled: appsv1.RetainPersistentVolumeClaimRetentionPolicyType, + }))) + } + }) + + // Deleting storage is the StatefulSet machinery's job alone. An + // operator-owned finalizer would be a second, undeclared deleter — and + // one that can wedge a deletion when the operator is down. + It("should claim no finalizer on the MemgraphCluster", func() { + reconcileCluster(resourceName) + + stored := &memgraphcomv1alpha1.MemgraphCluster{} + get(resourceName, stored) + Expect(stored.Finalizers).To(BeEmpty()) + }) + + It("should be idempotent when reconciling an unchanged resource", func() { + reconcileCluster(resourceName) + + versions := map[string]string{} + for _, suffix := range []string{coordinatorSuffix, dataSuffix} { + sts := &appsv1.StatefulSet{} + get(resourceName+suffix, sts) + versions["sts"+suffix] = sts.ResourceVersion + svc := &corev1.Service{} + get(resourceName+suffix, svc) + versions["svc"+suffix] = svc.ResourceVersion + } + + reconcileCluster(resourceName) + + for _, suffix := range []string{coordinatorSuffix, dataSuffix} { + sts := &appsv1.StatefulSet{} + get(resourceName+suffix, sts) + Expect(sts.ResourceVersion).To(Equal(versions["sts"+suffix]), + fmt.Sprintf("StatefulSet %s%s changed on a no-op reconcile", resourceName, suffix)) + svc := &corev1.Service{} + get(resourceName+suffix, svc) + Expect(svc.ResourceVersion).To(Equal(versions["svc"+suffix]), + fmt.Sprintf("Service %s%s changed on a no-op reconcile", resourceName, suffix)) + } + }) + }) + + Context("when reconciling a fully specified MemgraphCluster", func() { + const resourceName = "mgc-custom" + + cluster := &memgraphcomv1alpha1.MemgraphCluster{} + + BeforeEach(func() { + cr := &memgraphcomv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{Name: resourceName, Namespace: resourceNamespace}, + Spec: memgraphcomv1alpha1.MemgraphClusterSpec{ + Coordinators: ptr.To(int32(3)), + DataInstances: ptr.To(int32(1)), + Image: memgraphcomv1alpha1.ImageSpec{ + Repository: "registry.example.com/memgraph", + Tag: customImageTag, + PullPolicy: corev1.PullAlways, + }, + Secrets: memgraphcomv1alpha1.SecretsSpec{ + Name: customSecretName, + LicenseKey: "license", + OrganizationKey: "organization", + }, + Storage: memgraphcomv1alpha1.StorageSpec{ + RetentionPolicy: memgraphcomv1alpha1.RetentionPolicyDelete, + Coordinators: memgraphcomv1alpha1.RoleStorageSpec{ + LibPVCSize: ptr.To(resource.MustParse("2Gi")), + }, + Data: memgraphcomv1alpha1.RoleStorageSpec{ + LibPVCSize: ptr.To(resource.MustParse("100Gi")), + LibStorageClassName: ptr.To(customStorageClassName), + }, + }, + }, + } + Expect(k8sClient.Create(ctx, cr)).To(Succeed()) + get(resourceName, cluster) + }) + + AfterEach(func() { + Expect(k8sClient.Delete(ctx, cluster)).To(Succeed()) + deleteOwned(resourceName) + }) + + It("should propagate spec values into the workload objects", func() { + reconcileCluster(resourceName) + + for suffix, replicas := range map[string]int32{coordinatorSuffix: 3, dataSuffix: 1} { + sts := &appsv1.StatefulSet{} + get(resourceName+suffix, sts) + Expect(sts.Spec.Replicas).To(HaveValue(Equal(replicas)), + "each role's StatefulSet runs the count its own spec field declares") + + container := sts.Spec.Template.Spec.Containers[0] + Expect(container.Image).To(Equal("registry.example.com/memgraph:3.13.0")) + Expect(container.ImagePullPolicy).To(Equal(corev1.PullAlways)) + + licenseRef := container.Env[len(container.Env)-2].ValueFrom.SecretKeyRef + Expect(licenseRef.Name).To(Equal(customSecretName)) + Expect(licenseRef.Key).To(Equal("license")) + organizationRef := container.Env[len(container.Env)-1].ValueFrom.SecretKeyRef + Expect(organizationRef.Name).To(Equal(customSecretName)) + Expect(organizationRef.Key).To(Equal("organization")) + + Expect(sts.Spec.PersistentVolumeClaimRetentionPolicy).To(HaveValue(Equal( + appsv1.StatefulSetPersistentVolumeClaimRetentionPolicy{ + WhenDeleted: appsv1.DeletePersistentVolumeClaimRetentionPolicyType, + WhenScaled: appsv1.DeletePersistentVolumeClaimRetentionPolicyType, + })), "the Delete policy covers the claims a scale-down orphans too") + } + }) + + It("should size each role's claims from that role's storage block", func() { + reconcileCluster(resourceName) + + coordinatorSts := &appsv1.StatefulSet{} + get(resourceName+coordinatorSuffix, coordinatorSts) + coordinatorLib := libClaim(coordinatorSts) + Expect(coordinatorLib.Resources.Requests.Storage()).To(HaveValue(Equal(resource.MustParse("2Gi")))) + Expect(coordinatorLib.StorageClassName).To(BeNil()) + + dataSts := &appsv1.StatefulSet{} + get(resourceName+dataSuffix, dataSts) + dataLib := libClaim(dataSts) + Expect(dataLib.Resources.Requests.Storage()).To(HaveValue(Equal(resource.MustParse("100Gi")))) + Expect(dataLib.StorageClassName).To(HaveValue(Equal(customStorageClassName))) + }) + }) + + Context("when bootstrapping cluster registration", func() { + const resourceName = "mgc-bootstrap" + + coordinatorAddress := func(ordinal int) string { + return fmt.Sprintf("%s-coordinator-%d.%s-coordinator.%s.svc.cluster.local:7687", + resourceName, ordinal, resourceName, resourceNamespace) + } + + // observedCoordinator reports the coordinator with the given 1-based + // Raft ID, which runs on the pod with ordinal ID-1. + observedCoordinator := func(id int, role string) memgraph.Instance { + return memgraph.Instance{ + Name: fmt.Sprintf("coordinator_%d", id), + BoltServer: coordinatorAddress(id - 1), + Health: "up", + Role: role, + } + } + + observedDataInstance := func(i int, role string) memgraph.Instance { + return memgraph.Instance{ + Name: fmt.Sprintf("instance_%d", i), + Health: "up", + Role: role, + } + } + + BeforeEach(func() { + resource := &memgraphcomv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{Name: resourceName, Namespace: resourceNamespace}, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }) + + AfterEach(func() { + cluster := &memgraphcomv1alpha1.MemgraphCluster{} + get(resourceName, cluster) + Expect(k8sClient.Delete(ctx, cluster)).To(Succeed()) + deleteOwned(resourceName) + }) + + It("should not touch Memgraph before every pod is ready", func() { + result := reconcileCluster(resourceName) + + Expect(result.RequeueAfter).To(BeNumerically(">", 0)) + Expect(fake.connects()).To(BeZero()) + }) + + It("should bootstrap a fresh cluster to fully registered with one MAIN", func() { + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + + result := reconcileCluster(resourceName) + + leader := coordinatorAddress(0) + Expect(fake.executedCommands()).To(Equal([]string{ + leader + ": ADD COORDINATOR 1", + leader + ": ADD COORDINATOR 2", + leader + ": ADD COORDINATOR 3", + leader + ": REGISTER INSTANCE instance_0", + leader + ": REGISTER INSTANCE instance_1", + leader + ": SET INSTANCE instance_0 TO MAIN", + })) + Expect(result.RequeueAfter).To(BeNumerically(">", 0), + "registration was issued, so a follow-up reconcile must verify convergence") + + result = reconcileCluster(resourceName) + Expect(fake.executedCommands()).To(HaveLen(6), + "a converged cluster must not receive further commands") + Expect(result.RequeueAfter).To(Equal(resyncInterval), + "a converged cluster must still reschedule a resync to catch registration drift") + }) + + It("should resume a partial bootstrap without duplicate registrations or a second MAIN", func() { + // The state a crash mid-bootstrap leaves behind: two coordinators + // formed, the first data instance registered and promoted. The + // fake rejects duplicate registrations and second promotions, so + // re-issuing anything fails this test loudly. + fake.setInstances([]memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + }) + + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + + leader := coordinatorAddress(0) + Expect(fake.executedCommands()).To(Equal([]string{ + leader + ": ADD COORDINATOR 3", + leader + ": REGISTER INSTANCE instance_1", + })) + }) + + It("should execute registration on the leader a follower reports", func() { + fake.setInstances([]memgraph.Instance{ + observedCoordinator(1, memgraph.RoleFollower), + observedCoordinator(2, memgraph.RoleLeader), + observedCoordinator(3, memgraph.RoleFollower), + }) + + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + + leader := coordinatorAddress(1) + Expect(fake.executedCommands()).To(Equal([]string{ + leader + ": REGISTER INSTANCE instance_0", + leader + ": REGISTER INSTANCE instance_1", + leader + ": SET INSTANCE instance_0 TO MAIN", + })) + }) + + // A coordinator that reports no leader answers from its own state + // machine: quorum is gone, or it stepped down or was removed from the + // Raft cluster. Its view can be arbitrarily stale and no management + // query it forwards would be accepted, so it is skipped rather than + // planned against. + It("should skip a coordinator reporting no leader and plan on the next one's view", func() { + fake.setInstances([]memgraph.Instance{ + observedCoordinator(1, memgraph.RoleFollower), + observedCoordinator(2, memgraph.RoleLeader), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + }) + // coordinator_1 lost the leader and still remembers a cluster that + // has both data instances registered. + fake.setStaleView(coordinatorAddress(0), []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleFollower), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + }) + + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + + leader := coordinatorAddress(1) + Expect(fake.executedCommands()).To(Equal([]string{ + leader + ": REGISTER INSTANCE instance_1", + }), "the leader's view is planned against, not the stale one that reports instance_1 registered") + }) + + It("should issue nothing while no coordinator reports a leader", func() { + // Quorum lost: every coordinator answers, none names a leader. + fake.setInstances([]memgraph.Instance{ + observedCoordinator(1, memgraph.RoleFollower), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + }) + + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + result := reconcileCluster(resourceName) + + Expect(fake.executedCommands()).To(BeEmpty(), + "a cluster with no coordinator leader cannot be observed or written to") + Expect(fake.connects()).To(Equal(3), "every declared coordinator is tried before giving up") + Expect(result.RequeueAfter).To(BeNumerically(">", 0), + "a cluster that lost its quorum is retried, not abandoned") + }) + + // The leader is whoever the coordinators elected, which need not be a + // coordinator the CR declares — a coordinator on its way out of the + // cluster can still hold leadership. + It("should register on a leader outside the declared coordinator set", func() { + fake.setInstances([]memgraph.Instance{ + observedCoordinator(1, memgraph.RoleFollower), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedCoordinator(4, memgraph.RoleLeader), + observedDataInstance(0, memgraph.RoleMain), + }) + + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + + Expect(fake.executedCommands()).To(Equal([]string{ + coordinatorAddress(3) + ": REGISTER INSTANCE instance_1", + }), "the undeclared leader is redirected to, and left registered as it is") + }) + + // convergedCluster is the fully registered view of the default + // 3-coordinator, 2-data topology with instance_0 elected MAIN — the + // steady state drift is introduced against below. + convergedCluster := func() []memgraph.Instance { + return []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + } + } + + It("should re-register a data instance whose registration was lost, leaving MAIN untouched", func() { + fake.setInstances(convergedCluster()) + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + Expect(fake.executedCommands()).To(BeEmpty(), "the cluster started converged") + + // instance_1 loses its registration (pod rescheduled onto a fresh + // node): drop it from the observed view and reconcile again. + fake.setInstances([]memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + }) + + result := reconcileCluster(resourceName) + + leader := coordinatorAddress(0) + Expect(fake.executedCommands()).To(Equal([]string{ + leader + ": REGISTER INSTANCE instance_1", + }), "only the lost registration is re-issued; the existing MAIN is not re-promoted") + Expect(result.RequeueAfter).To(BeNumerically(">", 0), + "re-registration was issued, so a follow-up reconcile must verify convergence") + }) + + It("should re-add a coordinator whose registration was lost", func() { + fake.setInstances(convergedCluster()) + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + Expect(fake.executedCommands()).To(BeEmpty(), "the cluster started converged") + + // coordinator_3 disappears from the Raft cluster view. + fake.setInstances([]memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + }) + + reconcileCluster(resourceName) + + leader := coordinatorAddress(0) + Expect(fake.executedCommands()).To(Equal([]string{ + leader + ": ADD COORDINATOR 3", + }), "only the missing coordinator is re-added") + }) + + It("should stay a no-op on a converged cluster across repeated resyncs", func() { + fake.setInstances(convergedCluster()) + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + + for range 3 { + result := reconcileCluster(resourceName) + Expect(fake.executedCommands()).To(BeEmpty(), + "a converged cluster must never receive commands, however often it is resynced") + Expect(result.RequeueAfter).To(Equal(resyncInterval), + "each converged reconcile reschedules the drift-detection resync") + } + }) + }) + + Context("when scaling the topology of a live cluster", func() { + const resourceName = "mgc-scale" + + coordinatorAddress := func(ordinal int) string { + return fmt.Sprintf("%s-coordinator-%d.%s-coordinator.%s.svc.cluster.local:7687", + resourceName, ordinal, resourceName, resourceNamespace) + } + + // convergedWith is the fully registered 3-coordinator topology with the given + // number of data instances, the one on mainOrdinal elected MAIN — so a spec + // can put MAIN where it needs it before lowering a count. + convergedWith := func(dataInstances, mainOrdinal int) []memgraph.Instance { + instances := make([]memgraph.Instance, 0, 3+dataInstances) + for id := 1; id <= 3; id++ { + role := memgraph.RoleFollower + if id == 1 { + role = memgraph.RoleLeader + } + instances = append(instances, memgraph.Instance{ + Name: fmt.Sprintf("coordinator_%d", id), BoltServer: coordinatorAddress(id - 1), + Health: memgraph.HealthUp, Role: role, + }) + } + for ordinal := range dataInstances { + role := memgraph.RoleReplica + if ordinal == mainOrdinal { + role = memgraph.RoleMain + } + instances = append(instances, memgraph.Instance{ + Name: fmt.Sprintf("instance_%d", ordinal), Health: memgraph.HealthUp, Role: role, + }) + } + return instances + } + + // convergedWithMainOn is that view at the default 2 data instances. + convergedWithMainOn := func(mainOrdinal int) []memgraph.Instance { + return convergedWith(2, mainOrdinal) + } + + status := func() memgraphcomv1alpha1.MemgraphClusterStatus { + GinkgoHelper() + cluster := &memgraphcomv1alpha1.MemgraphCluster{} + get(resourceName, cluster) + return cluster.Status + } + + convergedCondition := func() *metav1.Condition { + GinkgoHelper() + return apimeta.FindStatusCondition(status().Conditions, memgraphcomv1alpha1.ConditionConverged) + } + + // setCounts edits the declared topology of the live cluster. + setCounts := func(coordinators, dataInstances int32) { + GinkgoHelper() + cluster := &memgraphcomv1alpha1.MemgraphCluster{} + get(resourceName, cluster) + cluster.Spec.Coordinators = ptr.To(coordinators) + cluster.Spec.DataInstances = ptr.To(dataInstances) + Expect(k8sClient.Update(ctx, cluster)).To(Succeed()) + } + + replicas := func(suffix string) int32 { + GinkgoHelper() + sts := &appsv1.StatefulSet{} + get(resourceName+suffix, sts) + Expect(sts.Spec.Replicas).NotTo(BeNil()) + return *sts.Spec.Replicas + } + + // bootstrapped drives the default 3/2 cluster to converged, so the specs + // below start from a live, fully registered cluster. It returns how many + // commands that took, which is the baseline sinceBootstrap counts from. + bootstrapped := func() int { + GinkgoHelper() + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + reconcileCluster(resourceName) + cluster := &memgraphcomv1alpha1.MemgraphCluster{} + get(resourceName, cluster) + Expect(apimeta.IsStatusConditionTrue(cluster.Status.Conditions, + memgraphcomv1alpha1.ConditionConverged)).To(BeTrue()) + return len(fake.executedCommands()) + } + + // sinceBootstrap is the commands the spec's own topology edit caused, so + // the assertions are not about the bootstrap that set the scene. + sinceBootstrap := func(baseline int) []string { + GinkgoHelper() + executed := fake.executedCommands() + Expect(len(executed)).To(BeNumerically(">=", baseline)) + return executed[baseline:] + } + + BeforeEach(func() { + resource := &memgraphcomv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{Name: resourceName, Namespace: resourceNamespace}, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }) + + AfterEach(func() { + cluster := &memgraphcomv1alpha1.MemgraphCluster{} + get(resourceName, cluster) + Expect(k8sClient.Delete(ctx, cluster)).To(Succeed()) + deleteOwned(resourceName) + }) + + It("should grow both roles and register only the added members", func() { + baseline := bootstrapped() + + setCounts(5, 3) + // The added pods are not ready yet, so this pass only widens the + // StatefulSets. + reconcileCluster(resourceName) + Expect(replicas(coordinatorSuffix)).To(Equal(int32(5))) + Expect(replicas(dataSuffix)).To(Equal(int32(3))) + Expect(sinceBootstrap(baseline)).To(BeEmpty(), + "registration waits until every pod of the grown topology is ready") + + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + + leader := coordinatorAddress(0) + Expect(sinceBootstrap(baseline)).To(Equal([]string{ + leader + ": ADD COORDINATOR 4", + leader + ": ADD COORDINATOR 5", + leader + ": REGISTER INSTANCE instance_2", + }), "the members the cluster already has are left alone, and no MAIN is re-promoted") + + reconcileCluster(resourceName) + s := status() + Expect(s.Coordinators).To(Equal(int32(5))) + Expect(s.DataInstances).To(Equal(int32(3))) + Expect(s.Main).To(Equal("instance_0"), "growing the cluster does not move MAIN") + converged := apimeta.FindStatusCondition(s.Conditions, memgraphcomv1alpha1.ConditionConverged) + Expect(converged.Status).To(Equal(metav1.ConditionTrue)) + Expect(converged.Reason).To(Equal(memgraphcomv1alpha1.ReasonAllInstancesRegistered)) + }) + + // grownToFive drives the cluster to a converged five-coordinator topology, + // which is the only shape a coordinator shrink can start from: the count must + // stay odd and at or above three, so five is the smallest cluster with members + // to drop. It returns the command count the shrink assertions start from. + grownToFive := func() int { + GinkgoHelper() + bootstrapped() + setCounts(5, 2) + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + reconcileCluster(resourceName) + Expect(apimeta.IsStatusConditionTrue(status().Conditions, + memgraphcomv1alpha1.ConditionConverged)).To(BeTrue()) + return len(fake.executedCommands()) + } + + // Raft membership is given up before the pods are, so no removed member's pod + // outlives its vote. With the leader on a survivor that is the whole shrink: + // removing a follower needs no leadership dance. + It("should remove retiring coordinators from Raft and only then shed their pods", func() { + baseline := grownToFive() + + setCounts(3, 2) + reconcileCluster(resourceName) + + leader := coordinatorAddress(0) + Expect(sinceBootstrap(baseline)).To(Equal([]string{ + leader + ": REMOVE COORDINATOR 4", + leader + ": REMOVE COORDINATOR 5", + }), "both retiring members leave the Raft cluster in one pass under a surviving leader") + Expect(replicas(coordinatorSuffix)).To(Equal(int32(5)), + "a pass with pending commands must never lower the replica count") + converged := convergedCondition() + Expect(converged.Status).To(Equal(metav1.ConditionFalse)) + Expect(converged.Reason).To(Equal(memgraphcomv1alpha1.ReasonRetirementInProgress)) + Expect(converged.Message).To(ContainSubstring("coordinator_4"), + "the condition must name the coordinators being retired") + + By("shedding the pods once the members have left the Raft cluster") + reconcileCluster(resourceName) + Expect(replicas(coordinatorSuffix)).To(Equal(int32(3))) + Expect(sinceBootstrap(baseline)).To(HaveLen(2), "the removals are not re-issued") + Expect(convergedCondition().Reason).To(Equal(memgraphcomv1alpha1.ReasonRetirementInProgress)) + + By("reporting the shrink as finished once the StatefulSet runs the declared count") + reconcileCluster(resourceName) + s := status() + Expect(s.Coordinators).To(Equal(int32(3))) + Expect(s.Main).To(Equal("instance_0"), "shrinking the coordinators does not move MAIN") + Expect(apimeta.IsStatusConditionTrue(s.Conditions, + memgraphcomv1alpha1.ConditionConverged)).To(BeTrue()) + }) + + // A StatefulSet sheds its highest ordinals, so the Raft leader may well sit on + // one of them — and Raft refuses to remove its own leader. The plan then ends + // with YIELD LEADERSHIP and nothing after it, because the election picks the + // successor: the pass stops there and the next one removes under whoever won. + // The fake refuses a removal aimed at its leader, so a plan that skipped the + // yield would fail this spec rather than quietly working. + It("should yield leadership off a retiring coordinator before removing it", func() { + baseline := grownToFive() + + By("parking Raft leadership on the coordinator the shrink retires") + fake.setLeader("coordinator_4") + + setCounts(3, 2) + reconcileCluster(resourceName) + + retiringLeader := coordinatorAddress(3) + Expect(sinceBootstrap(baseline)).To(Equal([]string{ + retiringLeader + ": REMOVE COORDINATOR 5", + retiringLeader + ": YIELD LEADERSHIP", + }), "the yield comes last, after the removal the planner could still order safely") + converged := convergedCondition() + Expect(converged.Status).To(Equal(metav1.ConditionFalse)) + Expect(converged.Reason).To(Equal(memgraphcomv1alpha1.ReasonLeadershipTransferInProgress)) + Expect(converged.Message).To(ContainSubstring("coordinator_4"), + "the condition must name the coordinator being moved off leadership") + Expect(replicas(coordinatorSuffix)).To(Equal(int32(5)), + "a pass with a pending yield must never lower the replica count") + + By("removing the former leader on the next pass, under whichever coordinator won") + reconcileCluster(resourceName) + Expect(sinceBootstrap(baseline)).To(Equal([]string{ + retiringLeader + ": REMOVE COORDINATOR 5", + retiringLeader + ": YIELD LEADERSHIP", + coordinatorAddress(0) + ": REMOVE COORDINATOR 4", + })) + Expect(convergedCondition().Reason).To(Equal(memgraphcomv1alpha1.ReasonRetirementInProgress)) + + By("shedding the pods and converging once the Raft cluster is down to three") + reconcileCluster(resourceName) + Expect(replicas(coordinatorSuffix)).To(Equal(int32(3))) + reconcileCluster(resourceName) + s := status() + Expect(s.Coordinators).To(Equal(int32(3))) + Expect(apimeta.IsStatusConditionTrue(s.Conditions, + memgraphcomv1alpha1.ConditionConverged)).To(BeTrue()) + }) + + // Raising the count back to what is already running is a no-op scale: the + // members are still registered, so nothing is planned and nothing is applied. + It("should converge again when a lowered coordinator count is raised back", func() { + baseline := grownToFive() + + setCounts(3, 2) + setCounts(5, 2) + reconcileCluster(resourceName) + + Expect(sinceBootstrap(baseline)).To(BeEmpty(), + "a shrink that was undone before it was acted on touches the cluster not at all") + Expect(replicas(coordinatorSuffix)).To(Equal(int32(5))) + Expect(apimeta.IsStatusConditionTrue(status().Conditions, + memgraphcomv1alpha1.ConditionConverged)).To(BeTrue()) + }) + + // Both counts lowered in one edit: each role's retirement is planned + // independently, and both StatefulSets shrink once the plan is empty. + It("should retire members of both roles in one edit", func() { + baseline := grownToFive() + + setCounts(3, 1) + reconcileCluster(resourceName) + + leader := coordinatorAddress(0) + Expect(sinceBootstrap(baseline)).To(Equal([]string{ + leader + ": UNREGISTER INSTANCE instance_1", + leader + ": REMOVE COORDINATOR 4", + leader + ": REMOVE COORDINATOR 5", + }), "the surviving MAIN is left alone, and each role's removals are planned on their own") + Expect(replicas(coordinatorSuffix)).To(Equal(int32(5))) + Expect(replicas(dataSuffix)).To(Equal(int32(2))) + Expect(convergedCondition().Message).To(SatisfyAll( + ContainSubstring("coordinator_4"), ContainSubstring("instance_1")), + "the condition must name the retiring members of both roles") + + reconcileCluster(resourceName) + Expect(replicas(coordinatorSuffix)).To(Equal(int32(3))) + Expect(replicas(dataSuffix)).To(Equal(int32(1))) + + reconcileCluster(resourceName) + s := status() + Expect(s.Coordinators).To(Equal(int32(3))) + Expect(s.DataInstances).To(Equal(int32(1))) + Expect(apimeta.IsStatusConditionTrue(s.Conditions, + memgraphcomv1alpha1.ConditionConverged)).To(BeTrue()) + }) + + // The whole point of the shrink: the member beyond the declared count leaves + // the cluster before its pod does, so the coordinators never expect an + // instance whose pod is gone. + It("should unregister a retiring data instance and only then shed its pod", func() { + baseline := bootstrapped() + Expect(status().Main).To(Equal("instance_0")) + + setCounts(3, 1) + reconcileCluster(resourceName) + + leader := coordinatorAddress(0) + Expect(sinceBootstrap(baseline)).To(Equal([]string{ + leader + ": UNREGISTER INSTANCE instance_1", + }), "the MAIN survives the shrink, so nothing but the removal is issued") + Expect(replicas(dataSuffix)).To(Equal(int32(2)), + "a pass with pending commands must never lower the replica count") + converged := convergedCondition() + Expect(converged.Status).To(Equal(metav1.ConditionFalse)) + Expect(converged.Reason).To(Equal(memgraphcomv1alpha1.ReasonRetirementInProgress)) + Expect(converged.Message).To(ContainSubstring("instance_1"), + "the condition must name the instance being retired") + + By("shedding the pod once the instance has left the cluster") + reconcileCluster(resourceName) + Expect(replicas(dataSuffix)).To(Equal(int32(1))) + Expect(sinceBootstrap(baseline)).To(HaveLen(1), "the removal is not re-issued") + Expect(convergedCondition().Reason).To(Equal(memgraphcomv1alpha1.ReasonRetirementInProgress)) + + By("reporting the shrink as finished once the StatefulSet runs the declared count") + reconcileCluster(resourceName) + s := status() + Expect(s.DataInstances).To(Equal(int32(1))) + Expect(s.Main).To(Equal("instance_0"), "the surviving MAIN was never moved") + Expect(apimeta.IsStatusConditionTrue(s.Conditions, + memgraphcomv1alpha1.ConditionConverged)).To(BeTrue()) + }) + + // Memgraph refuses to unregister the MAIN, so a retiring instance holding it + // is demoted and a survivor promoted in its place — within the same pass, on + // the same leader connection, so the cluster is MAIN-less for the time + // between two queries. + It("should move MAIN off a retiring data instance before unregistering it", func() { + fake.setInstances(convergedWithMainOn(1)) + baseline := bootstrapped() + Expect(status().Main).To(Equal("instance_1")) + + setCounts(3, 1) + reconcileCluster(resourceName) + + leader := coordinatorAddress(0) + Expect(sinceBootstrap(baseline)).To(Equal([]string{ + leader + ": DEMOTE INSTANCE instance_1", + leader + ": SET INSTANCE instance_0 TO MAIN", + leader + ": UNREGISTER INSTANCE instance_1", + }), "demote, promote and unregister issue in one pass against one leader") + Expect(replicas(dataSuffix)).To(Equal(int32(2)), + "a pass with pending commands must never lower the replica count") + + reconcileCluster(resourceName) + Expect(replicas(dataSuffix)).To(Equal(int32(1))) + reconcileCluster(resourceName) + + s := status() + Expect(s.Main).To(Equal("instance_0"), "MAIN moved to the surviving instance") + Expect(s.DataInstances).To(Equal(int32(1))) + Expect(apimeta.IsStatusConditionTrue(s.Conditions, + memgraphcomv1alpha1.ConditionConverged)).To(BeTrue()) + }) + + // Moving MAIN is the one step of a retirement that can lose data, so it waits + // for a survivor that actually holds the writes. Everything else about the + // scale-down waits with it: the demotion would leave the cluster serving from + // an instance missing transactions, and the unregistration cannot precede the + // demotion at all. + It("should not move MAIN off a retiring instance while every survivor is behind", func() { + fake.setInstances(convergedWithMainOn(1)) + fake.setBehind("instance_0", 12) + baseline := bootstrapped() + Expect(status().Main).To(Equal("instance_1")) + + setCounts(3, 1) + reconcileCluster(resourceName) + + Expect(sinceBootstrap(baseline)).To(BeEmpty(), + "no demotion, no promotion, and no unregistration of a MAIN that cannot be demoted") + Expect(replicas(dataSuffix)).To(Equal(int32(2)), + "the retiring pod must outlive its registration, so the count is held") + converged := convergedCondition() + Expect(converged.Status).To(Equal(metav1.ConditionFalse)) + Expect(converged.Reason).To(Equal(memgraphcomv1alpha1.ReasonNoCaughtUpSurvivor)) + // The retiring MAIN is still MAIN, and still serving: a paused scale-down + // costs availability nothing, which is what makes waiting the better trade. + Expect(apimeta.IsStatusConditionTrue(status().Conditions, + memgraphcomv1alpha1.ConditionReady)).To(BeTrue()) + Expect(status().Main).To(Equal("instance_1")) + + By("holding there for as long as the survivor stays behind") + reconcileCluster(resourceName) + Expect(sinceBootstrap(baseline)).To(BeEmpty()) + Expect(replicas(dataSuffix)).To(Equal(int32(2))) + Expect(convergedCondition().Reason).To(Equal(memgraphcomv1alpha1.ReasonNoCaughtUpSurvivor)) + + By("handing MAIN over once the survivor has caught up") + fake.setBehind("instance_0", 0) + reconcileCluster(resourceName) + + leader := coordinatorAddress(0) + Expect(sinceBootstrap(baseline)).To(Equal([]string{ + leader + ": DEMOTE INSTANCE instance_1", + leader + ": SET INSTANCE instance_0 TO MAIN", + leader + ": UNREGISTER INSTANCE instance_1", + }), "the retirement resumes from where it stalled, in one pass") + + reconcileCluster(resourceName) + Expect(replicas(dataSuffix)).To(Equal(int32(1))) + reconcileCluster(resourceName) + + s := status() + Expect(s.Main).To(Equal("instance_0")) + Expect(s.DataInstances).To(Equal(int32(1))) + Expect(apimeta.IsStatusConditionTrue(s.Conditions, + memgraphcomv1alpha1.ConditionConverged)).To(BeTrue()) + }) + + // The promotion is the one command of a retirement with no second chance: the + // demotion has already landed, and a later pass cannot recompute which + // survivor is safe because lag is served by the MAIN. So it carries every + // survivor the lag view proved caught up, and a refused one moves to the next. + It("should promote the next caught-up survivor when the first one is refused", func() { + setCounts(3, 3) + fake.setInstances(convergedWith(3, 2)) + baseline := bootstrapped() + Expect(status().Main).To(Equal("instance_2")) + + fake.rejectCommand("SET INSTANCE instance_0 TO MAIN", errors.New("instance is not registered")) + setCounts(3, 2) + reconcileCluster(resourceName) + + leader := coordinatorAddress(0) + Expect(sinceBootstrap(baseline)).To(Equal([]string{ + leader + ": DEMOTE INSTANCE instance_2", + 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") + + reconcileCluster(resourceName) + Expect(replicas(dataSuffix)).To(Equal(int32(2))) + reconcileCluster(resourceName) + + s := status() + Expect(s.Main).To(Equal("instance_1")) + Expect(apimeta.IsStatusConditionTrue(s.Conditions, + memgraphcomv1alpha1.ConditionConverged)).To(BeTrue()) + }) + + // When no survivor can be promoted the cluster would be left MAIN-less by the + // demotion that already landed, so MAIN goes back to the instance being + // retired: it was MAIN a moment ago and a MAIN-less cluster accepts no writes, + // so nothing has advanced past it. The pass still fails — the cluster serves + // again, but the retirement made no progress and has to be retried. + It("should restore MAIN to the retiring instance when every survivor is refused", func() { + fake.setInstances(convergedWithMainOn(1)) + baseline := bootstrapped() + Expect(status().Main).To(Equal("instance_1")) + + fake.rejectCommand("SET INSTANCE instance_0 TO MAIN", errors.New("instance is down")) + setCounts(3, 1) + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: resourceName, Namespace: resourceNamespace}, + }) + Expect(err).To(HaveOccurred(), "a rolled-back handover is reported, not read as progress") + + leader := coordinatorAddress(0) + Expect(sinceBootstrap(baseline)).To(Equal([]string{ + 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(replicas(dataSuffix)).To(Equal(int32(2)), + "the retiring pod outlives a retirement that did not finish") + + s := status() + Expect(s.Main).To(Equal("instance_1")) + Expect(apimeta.IsStatusConditionTrue(s.Conditions, memgraphcomv1alpha1.ConditionReady)).To(BeTrue(), + "the cluster serves from the restored MAIN") + converged := convergedCondition() + Expect(converged.Status).To(Equal(metav1.ConditionFalse)) + Expect(converged.Reason).To(Equal(memgraphcomv1alpha1.ReasonRegistrationFailed)) + Expect(converged.Message).To(ContainSubstring("MAIN was restored to the retiring instance instance_1")) + Expect(converged.Message).To(ContainSubstring("instance is down"), + "the condition carries why the survivor was refused") + }) + + // The readiness gate covers the pods on their way out too: they belong to + // the StatefulSet the operator is still holding at its current size. A + // retiring pod that cannot become ready therefore blocks its own removal, + // which is a deliberate trade — the alternative is acting on a cluster whose + // state is only half known. + It("should not retire anything while a pod of the held StatefulSet is unready", func() { + baseline := bootstrapped() + + setCounts(3, 1) + sts := &appsv1.StatefulSet{} + get(resourceName+dataSuffix, sts) + sts.Status.ReadyReplicas = *sts.Spec.Replicas - 1 + sts.Status.AvailableReplicas = sts.Status.ReadyReplicas + Expect(k8sClient.Status().Update(ctx, sts)).To(Succeed()) + + reconcileCluster(resourceName) + + Expect(sinceBootstrap(baseline)).To(BeEmpty(), + "a half-known cluster is not written to, retirement included") + Expect(replicas(dataSuffix)).To(Equal(int32(2))) + converged := convergedCondition() + Expect(converged.Status).To(Equal(metav1.ConditionFalse)) + Expect(converged.Reason).To(Equal(memgraphcomv1alpha1.ReasonWorkloadsNotReady)) + }) + + // The gate has to key off the count the pass applied, not the spec.replicas it + // can read back. Production reads through the informer cache, so a few lines + // after a scale-up that field is still the pre-apply value — and it agrees + // with a status.readyReplicas from the same old snapshot, because the cluster + // really was converged at the old size. Two stale numbers that agree report a + // grown topology as ready, and registration then names a pod Kubernetes has + // not been asked to create. + // + // The gate is called directly here: the envtest client is uncached, so the + // staleness itself cannot be reproduced, only the comparison it would defeat. + // A StatefulSet left at 2 ready out of 2 is exactly what that stale read looks + // like, and the pass that intends 3 must not accept it. + It("should gate readiness on the applied count, not the StatefulSet's own spec", func() { + bootstrapped() + cluster := &memgraphcomv1alpha1.MemgraphCluster{} + get(resourceName, cluster) + Expect(replicas(dataSuffix)).To(Equal(int32(2))) + + held := replicaCounts{ + coordinators: roleReplicas{name: resourceName + coordinatorSuffix, declared: 3, applied: 3}, + data: roleReplicas{name: resourceName + dataSuffix, declared: 2, applied: 2}, + } + // A zero rolloutRoles is a cluster with no restart under way, which is what + // keeps this about the count comparison alone: the gate only ever tolerates + // an unready pod while a role actually has pods left to restart. + ready, err := reconciler.workloadsReady(ctx, cluster, held, rolloutRoles{}) + Expect(err).NotTo(HaveOccurred()) + Expect(ready).To(BeTrue(), "the cluster is ready at the size this pass applies") + + grown := held + grown.data.declared, grown.data.applied = 3, 3 + ready, err = reconciler.workloadsReady(ctx, cluster, grown, rolloutRoles{}) + Expect(err).NotTo(HaveOccurred()) + Expect(ready).To(BeFalse(), + "a pass applying 3 must not read 2-ready-of-2 as ready, whatever spec.replicas still says") + }) + + It("should report the registered counts as observed, not as declared", func() { + bootstrapped() + Expect(status().DataInstances).To(Equal(int32(2))) + + // instance_1 loses its registration: the count reports what the + // cluster has, which is what makes it worth watching during a scale. + fake.setInstances([]memgraph.Instance{ + { + Name: "coordinator_1", BoltServer: coordinatorAddress(0), + Health: memgraph.HealthUp, Role: memgraph.RoleLeader, + }, + { + Name: "coordinator_2", BoltServer: coordinatorAddress(1), + Health: memgraph.HealthUp, Role: memgraph.RoleFollower, + }, + {Name: "instance_0", Health: memgraph.HealthUp, Role: memgraph.RoleMain}, + }) + reconcileCluster(resourceName) + + s := status() + Expect(s.Coordinators).To(Equal(int32(2)), "coordinator_3 is no longer a member") + Expect(s.DataInstances).To(Equal(int32(1))) + }) + }) + + Context("when reporting status and conditions", func() { + const resourceName = "mgc-status" + + observedCoordinator := func(id int, role string) memgraph.Instance { + return memgraph.Instance{ + Name: fmt.Sprintf("coordinator_%d", id), + BoltServer: fmt.Sprintf("%s-coordinator-%d.%s-coordinator.%s.svc.cluster.local:7687", + resourceName, id-1, resourceName, resourceNamespace), + Health: "up", + Role: role, + } + } + observedDataInstance := func(i int, role string) memgraph.Instance { + return memgraph.Instance{Name: fmt.Sprintf("instance_%d", i), Health: "up", Role: role} + } + convergedCluster := func() []memgraph.Instance { + return []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + } + } + + status := func() memgraphcomv1alpha1.MemgraphClusterStatus { + GinkgoHelper() + cluster := &memgraphcomv1alpha1.MemgraphCluster{} + get(resourceName, cluster) + return cluster.Status + } + condition := func(condType string) *metav1.Condition { + GinkgoHelper() + s := status() + return apimeta.FindStatusCondition(s.Conditions, condType) + } + + BeforeEach(func() { + resource := &memgraphcomv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{Name: resourceName, Namespace: resourceNamespace}, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }) + + AfterEach(func() { + cluster := &memgraphcomv1alpha1.MemgraphCluster{} + get(resourceName, cluster) + Expect(k8sClient.Delete(ctx, cluster)).To(Succeed()) + deleteOwned(resourceName) + }) + + It("should report bootstrapping while workload pods are not ready", func() { + reconcileCluster(resourceName) + + s := status() + Expect(s.Main).To(BeEmpty(), "no MAIN is known before the cluster is observed") + ready := condition(memgraphcomv1alpha1.ConditionReady) + Expect(ready).NotTo(BeNil()) + Expect(ready.Status).To(Equal(metav1.ConditionFalse)) + Expect(ready.Reason).To(Equal(memgraphcomv1alpha1.ReasonWorkloadsNotReady)) + converged := condition(memgraphcomv1alpha1.ConditionConverged) + Expect(converged).NotTo(BeNil()) + Expect(converged.Status).To(Equal(metav1.ConditionFalse)) + Expect(converged.Reason).To(Equal(memgraphcomv1alpha1.ReasonWorkloadsNotReady)) + }) + + It("should report degraded when no coordinator is reachable", func() { + fake.setConnectErr(errors.New("connection refused")) + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + + ready := condition(memgraphcomv1alpha1.ConditionReady) + Expect(ready.Status).To(Equal(metav1.ConditionFalse)) + Expect(ready.Reason).To(Equal(memgraphcomv1alpha1.ReasonCoordinatorUnreachable)) + converged := condition(memgraphcomv1alpha1.ConditionConverged) + Expect(converged.Status).To(Equal(metav1.ConditionFalse)) + Expect(converged.Reason).To(Equal(memgraphcomv1alpha1.ReasonCoordinatorUnreachable)) + }) + + // Coordinators that answer but have no leader between them are a + // different problem from coordinators that do not answer at all — the + // pods are up and serving Bolt, what is missing is the Raft quorum — so + // they get their own reason. + It("should report a missing quorum apart from unreachable coordinators", func() { + fake.setInstances([]memgraph.Instance{ + observedCoordinator(1, memgraph.RoleFollower), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + }) + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + + for _, condType := range []string{ + memgraphcomv1alpha1.ConditionReady, + memgraphcomv1alpha1.ConditionConverged, + } { + cond := condition(condType) + Expect(cond.Status).To(Equal(metav1.ConditionFalse), "condition %s", condType) + Expect(cond.Reason).To(Equal(memgraphcomv1alpha1.ReasonNoCoordinatorLeader), "condition %s", condType) + } + }) + + // A rejected apply is retried behind the scenes forever, so the resource + // itself has to say what the API server refused — otherwise the + // conditions keep describing the cluster that is still running while the + // declared spec never lands. Removing a role's log storage claim is the + // realistic trigger: Kubernetes forbids changing a StatefulSet's + // volumeClaimTemplates, so the flip needs the StatefulSet recreated. + It("should report the API server's rejection when applying a workload fails", func() { + fake.setInstances(convergedCluster()) + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + Expect(condition(memgraphcomv1alpha1.ConditionReady).Status).To(Equal(metav1.ConditionTrue)) + Expect(condition(memgraphcomv1alpha1.ConditionConverged).Status).To(Equal(metav1.ConditionTrue)) + + cluster := &memgraphcomv1alpha1.MemgraphCluster{} + get(resourceName, cluster) + cluster.Spec.Storage.Data.CreateLogStorageClaim = ptr.To(false) + Expect(k8sClient.Update(ctx, cluster)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: resourceName, Namespace: resourceNamespace}, + }) + Expect(err).To(HaveOccurred(), "the API server refuses to drop a volume claim template") + + s := status() + Expect(s.Main).To(Equal("instance_0"), "the last observed MAIN survives an apply failure") + for _, condType := range []string{ + memgraphcomv1alpha1.ConditionReady, + memgraphcomv1alpha1.ConditionConverged, + } { + cond := condition(condType) + Expect(cond.Status).To(Equal(metav1.ConditionFalse), "condition %s", condType) + Expect(cond.Reason).To(Equal(memgraphcomv1alpha1.ReasonApplyFailed), "condition %s", condType) + Expect(cond.Message).To(ContainSubstring(resourceName+dataSuffix), + "condition %s must name the object that was refused", condType) + Expect(cond.Message).To(ContainSubstring("Forbidden"), + "condition %s must carry the API server's own words", condType) + } + }) + + // The same argument as the rejected apply, one layer down: a registration + // command the leader refuses is reissued on every pass forever, so a + // resource that only ever says "registration in progress" hides a cluster + // that will never converge. The MAIN keeps serving throughout, so Ready is + // the one condition that stays True. + It("should report a registration command the coordinator leader rejected", func() { + fake.setInstances(convergedCluster()) + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + Expect(condition(memgraphcomv1alpha1.ConditionConverged).Status).To(Equal(metav1.ConditionTrue)) + + // A replica loses its registration and the leader refuses to take it + // back, which is the shape of a plan no retry can converge. + fake.setInstances([]memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + }) + fake.rejectCommand("REGISTER INSTANCE instance_1", errors.New("replication port already in use")) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: resourceName, Namespace: resourceNamespace}, + }) + Expect(err).To(HaveOccurred(), "a rejected command fails the pass so it is retried with backoff") + + s := status() + Expect(s.Main).To(Equal("instance_0"), "the last observed MAIN survives a rejected command") + ready := condition(memgraphcomv1alpha1.ConditionReady) + Expect(ready.Status).To(Equal(metav1.ConditionTrue), + "a cluster with a MAIN keeps serving while a registration is refused") + converged := condition(memgraphcomv1alpha1.ConditionConverged) + Expect(converged.Status).To(Equal(metav1.ConditionFalse)) + Expect(converged.Reason).To(Equal(memgraphcomv1alpha1.ReasonRegistrationFailed)) + Expect(converged.Message).To(ContainSubstring("instance_1"), + "the condition must name the command that was refused") + Expect(converged.Message).To(ContainSubstring("replication port already in use"), + "the condition must carry the coordinator's own words") + }) + + It("should report ready and converged once the cluster is bootstrapped", func() { + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + // Second reconcile observes the registrations issued by the first. + reconcileCluster(resourceName) + + s := status() + Expect(s.Main).To(Equal("instance_0")) + Expect(s.Coordinators).To(Equal(int32(3)), "every declared coordinator is registered") + Expect(s.DataInstances).To(Equal(int32(2))) + ready := condition(memgraphcomv1alpha1.ConditionReady) + Expect(ready.Status).To(Equal(metav1.ConditionTrue)) + Expect(ready.Reason).To(Equal(memgraphcomv1alpha1.ReasonMainElected)) + converged := condition(memgraphcomv1alpha1.ConditionConverged) + Expect(converged.Status).To(Equal(metav1.ConditionTrue)) + Expect(converged.Reason).To(Equal(memgraphcomv1alpha1.ReasonAllInstancesRegistered)) + }) + + It("should stay ready but drop convergence while a lost registration is restored", func() { + fake.setInstances(convergedCluster()) + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + Expect(condition(memgraphcomv1alpha1.ConditionConverged).Status).To(Equal(metav1.ConditionTrue)) + + // A replica loses its registration; the MAIN keeps serving. + fake.setInstances([]memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + }) + reconcileCluster(resourceName) + + s := status() + Expect(s.Main).To(Equal("instance_0"), "the serving MAIN is unchanged") + ready := condition(memgraphcomv1alpha1.ConditionReady) + Expect(ready.Status).To(Equal(metav1.ConditionTrue), + "a cluster with a MAIN still serves while a replica is re-registered") + converged := condition(memgraphcomv1alpha1.ConditionConverged) + Expect(converged.Status).To(Equal(metav1.ConditionFalse)) + Expect(converged.Reason).To(Equal(memgraphcomv1alpha1.ReasonRegistrationInProgress)) + }) + + It("should track MAIN across a coordinator-driven failover", func() { + fake.setInstances(convergedCluster()) + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + Expect(status().Main).To(Equal("instance_0")) + + // The Raft coordinators fail over to instance_1; the operator only + // observes the new MAIN, it never promotes one. + fake.setInstances([]memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleMain), + }) + reconcileCluster(resourceName) + + Expect(status().Main).To(Equal("instance_1")) + Expect(fake.executedCommands()).To(BeEmpty(), + "failover belongs to the coordinators; the operator issues no promotion") + }) + + It("should update status through the subresource without modifying spec", func() { + cluster := &memgraphcomv1alpha1.MemgraphCluster{} + get(resourceName, cluster) + specBefore := cluster.Spec.DeepCopy() + + fake.setInstances(convergedCluster()) + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + + get(resourceName, cluster) + Expect(&cluster.Spec).To(Equal(specBefore), "status updates must never mutate spec") + Expect(cluster.Status.Conditions).NotTo(BeEmpty()) + }) + + // A MAIN whose pod is gone keeps its role in the coordinators' Raft state, so + // the role alone would claim the cluster serves writes for the whole failover + // window — including every window a rolling restart opens on purpose. + It("should report NotReady while the MAIN is unreachable", func() { + fake.setInstances(convergedCluster()) + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + Expect(status().Main).To(Equal("instance_0")) + + fake.setInstances([]memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + func() memgraph.Instance { + main := observedDataInstance(0, memgraph.RoleMain) + main.Health = "down" + return main + }(), + observedDataInstance(1, memgraph.RoleReplica), + }) + reconcileCluster(resourceName) + + Expect(status().Main).To(BeEmpty(), "an unreachable MAIN is not a MAIN the cluster can serve from") + ready := condition(memgraphcomv1alpha1.ConditionReady) + Expect(ready.Status).To(Equal(metav1.ConditionFalse)) + Expect(ready.Reason).To(Equal(memgraphcomv1alpha1.ReasonNoMainElected)) + Expect(fake.executedCommands()).To(BeEmpty(), + "the coordinators own the failover; the operator issues no promotion") + }) + }) + + Context("when a changed pod template has to be rolled through the cluster", func() { + const ( + resourceName = "mgc-rollout" + oldRevision = "mgc-rollout-6c9f8b7d5" + newRevision = "mgc-rollout-77b4c8f9d" + ) + + observedCoordinator := func(id int, role string) memgraph.Instance { + host := fmt.Sprintf("%s-coordinator-%d.%s-coordinator.%s.svc.cluster.local", + resourceName, id-1, resourceName, resourceNamespace) + return memgraph.Instance{ + Name: fmt.Sprintf("coordinator_%d", id), BoltServer: host + ":7687", + CoordinatorServer: host + ":12000", ManagementServer: host + ":10000", + Health: "up", Role: role, + } + } + observedDataInstance := func(i int, role string) memgraph.Instance { + return memgraph.Instance{Name: fmt.Sprintf("instance_%d", i), Health: "up", Role: role} + } + convergedCluster := func() []memgraph.Instance { + return []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + } + } + condition := func(condType string) *metav1.Condition { + GinkgoHelper() + cluster := &memgraphcomv1alpha1.MemgraphCluster{} + get(resourceName, cluster) + return apimeta.FindStatusCondition(cluster.Status.Conditions, condType) + } + + // putPod stands in for the StatefulSet controller envtest does not run: it + // creates or replaces one role pod at the given revision, ready. + putPod := func(suffix, component string, ordinal int, revision string) { + GinkgoHelper() + name := fmt.Sprintf("%s%s-%d", resourceName, suffix, ordinal) + existing := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: resourceNamespace}} + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, existing))).To(Succeed()) + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: resourceNamespace, + Labels: map[string]string{ + "app.kubernetes.io/name": memgraphDbName, + "app.kubernetes.io/instance": resourceName, + "app.kubernetes.io/component": component, + "app.kubernetes.io/managed-by": "memgraph-operator", + appsv1.StatefulSetRevisionLabel: revision, + }, + }, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: memgraphDbName, Image: memgraphDbName}}}, + } + Expect(k8sClient.Create(ctx, pod)).To(Succeed()) + pod.Status.Conditions = []corev1.PodCondition{{ + Type: corev1.PodReady, Status: corev1.ConditionTrue, + LastTransitionTime: metav1.Now(), + }} + Expect(k8sClient.Status().Update(ctx, pod)).To(Succeed()) + } + + // putPods places every pod of both roles at one revision. + putPods := func(revision string) { + GinkgoHelper() + for ordinal := range 3 { + putPod(coordinatorSuffix, "coordinator", ordinal, revision) + } + for ordinal := range 2 { + putPod(dataSuffix, "data", ordinal, revision) + } + } + + // declareRevision publishes the revision both StatefulSets' current pod + // template hashes to, which is what makes the pods above outdated. + declareRevision := func(revision string) { + GinkgoHelper() + for _, suffix := range []string{coordinatorSuffix, dataSuffix} { + sts := &appsv1.StatefulSet{} + get(resourceName+suffix, sts) + sts.Status.UpdateRevision = revision + Expect(k8sClient.Status().Update(ctx, sts)).To(Succeed()) + } + } + + podExists := func(suffix string, ordinal int) bool { + GinkgoHelper() + pod := &corev1.Pod{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: fmt.Sprintf("%s%s-%d", resourceName, suffix, ordinal), + Namespace: resourceNamespace, + }, pod) + if apierrors.IsNotFound(err) { + return false + } + Expect(err).NotTo(HaveOccurred()) + return pod.DeletionTimestamp == nil + } + + BeforeEach(func() { + resource := &memgraphcomv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{Name: resourceName, Namespace: resourceNamespace}, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + + fake.setInstances(convergedCluster()) + reconcileCluster(resourceName) + markWorkloadsReady(resourceName) + reconcileCluster(resourceName) + }) + + AfterEach(func() { + cluster := &memgraphcomv1alpha1.MemgraphCluster{} + get(resourceName, cluster) + Expect(k8sClient.Delete(ctx, cluster)).To(Succeed()) + deleteOwned(resourceName) + Expect(k8sClient.DeleteAllOf(ctx, &corev1.Pod{}, + client.InNamespace(resourceNamespace), + client.MatchingLabels{"app.kubernetes.io/instance": resourceName}, + client.GracePeriodSeconds(0), + )).To(Succeed()) + }) + + It("should report Updated once every pod runs the declared template", func() { + putPods(newRevision) + declareRevision(newRevision) + reconcileCluster(resourceName) + + updated := condition(memgraphcomv1alpha1.ConditionUpdated) + Expect(updated).NotTo(BeNil()) + Expect(updated.Status).To(Equal(metav1.ConditionTrue)) + Expect(updated.Reason).To(Equal(memgraphcomv1alpha1.ReasonAllPodsUpdated)) + }) + + // The whole order in one spec: replicas before MAIN, data plane before + // coordinators, Raft leader last, one pod at a time throughout. + It("should restart data pods before coordinators, MAIN and the leader last", func() { + putPods(oldRevision) + declareRevision(newRevision) + + // 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(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) + Expect(updated.Status).To(Equal(metav1.ConditionFalse)) + Expect(updated.Reason).To(Equal(memgraphcomv1alpha1.ReasonRollingRestartInProgress)) + + // It comes back on the new revision, reachable and caught up. + putPod(dataSuffix, "data", 1, newRevision) + reconcileCluster(resourceName) + Expect(podExists(dataSuffix, 0)).To(BeFalse(), "the MAIN's pod is restarted last of its role") + Expect(podExists(coordinatorSuffix, 2)).To(BeTrue()) + + // The coordinators fail over to instance_1, and the old MAIN returns as a + // replica — which is what the operator observes rather than arranges. + fake.setInstances([]memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleMain), + }) + putPod(dataSuffix, "data", 0, newRevision) + + // Data done: coordinator_1 leads on ordinal 0, so ordinal 2 goes first. + reconcileCluster(resourceName) + Expect(podExists(coordinatorSuffix, 2)).To(BeFalse()) + Expect(podExists(coordinatorSuffix, 0)).To(BeTrue(), "the Raft leader's pod is last") + + putPod(coordinatorSuffix, "coordinator", 2, newRevision) + reconcileCluster(resourceName) + Expect(podExists(coordinatorSuffix, 1)).To(BeFalse()) + Expect(podExists(coordinatorSuffix, 0)).To(BeTrue()) + + putPod(coordinatorSuffix, "coordinator", 1, newRevision) + reconcileCluster(resourceName) + Expect(podExists(coordinatorSuffix, 0)).To(BeFalse(), "the leader goes once nothing else is left") + + putPod(coordinatorSuffix, "coordinator", 0, newRevision) + reconcileCluster(resourceName) + Expect(condition(memgraphcomv1alpha1.ConditionUpdated).Status).To(Equal(metav1.ConditionTrue)) + Expect(fake.executedCommands()).To(BeEmpty(), + "a rolling restart issues no registration commands at all") + }) + + It("should not restart the MAIN while no replica is caught up", func() { + putPods(oldRevision) + putPod(dataSuffix, "data", 1, newRevision) + declareRevision(newRevision) + fake.setBehind("instance_1", 7) + + reconcileCluster(resourceName) + + Expect(podExists(dataSuffix, 0)).To(BeTrue(), "the MAIN keeps serving; the roll waits") + updated := condition(memgraphcomv1alpha1.ConditionUpdated) + Expect(updated.Status).To(Equal(metav1.ConditionFalse)) + Expect(updated.Reason).To(Equal(memgraphcomv1alpha1.ReasonNoCaughtUpSurvivor)) + }) + + // Registration convergence comes first: a cluster missing a registration is + // not the one the spec describes, so it is no moment to start deleting pods. + It("should not restart any pod while a registration is pending", func() { + putPods(oldRevision) + declareRevision(newRevision) + fake.setInstances([]memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + }) + + reconcileCluster(resourceName) + + Expect(podExists(dataSuffix, 1)).To(BeTrue()) + Expect(podExists(coordinatorSuffix, 2)).To(BeTrue()) + Expect(fake.executedCommands()).To(ContainElement(ContainSubstring("REGISTER INSTANCE instance_1"))) + }) + }) +}) diff --git a/internal/controller/memgraphcluster_validation_test.go b/internal/controller/memgraphcluster_validation_test.go new file mode 100644 index 0000000..266863f --- /dev/null +++ b/internal/controller/memgraphcluster_validation_test.go @@ -0,0 +1,723 @@ +/* +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" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" +) + +// These specs exercise the CRD schema itself — defaults, creation-time +// validation and the CEL transition rules that pin the topology counts. They +// never reconcile: the API server is the unit under test, which is exactly the +// v1 contract that no admission webhook is involved. +// defaultRoleStorage is one role's storage block as the CRD schema defaults +// materialize it. +func defaultRoleStorage() memgraphcomv1alpha1.RoleStorageSpec { + return memgraphcomv1alpha1.RoleStorageSpec{ + LibPVCSize: ptr.To(resource.MustParse(memgraphcomv1alpha1.DefaultLibPVCSize)), + LibStorageAccessMode: memgraphcomv1alpha1.DefaultStorageAccessMode, + CreateLogStorageClaim: ptr.To(memgraphcomv1alpha1.DefaultCreateLogStorageClaim), + LogPVCSize: ptr.To(resource.MustParse(memgraphcomv1alpha1.DefaultLogPVCSize)), + LogStorageAccessMode: memgraphcomv1alpha1.DefaultStorageAccessMode, + } +} + +// defaultRoleCoreDumps is one role's core dumps block as the CRD schema +// defaults materialize it: off, but with the size it would ask for. +func defaultRoleCoreDumps() memgraphcomv1alpha1.RoleCoreDumpsSpec { + return memgraphcomv1alpha1.RoleCoreDumpsSpec{ + Size: ptr.To(resource.MustParse(memgraphcomv1alpha1.DefaultCoreDumpsSize)), + } +} + +// defaultCoreDumps is the whole core dumps block as the CRD schema defaults +// materialize it. +func defaultCoreDumps() memgraphcomv1alpha1.CoreDumpsSpec { + return memgraphcomv1alpha1.CoreDumpsSpec{ + Coordinators: defaultRoleCoreDumps(), + Data: defaultRoleCoreDumps(), + ConfigureCorePattern: ptr.To(memgraphcomv1alpha1.DefaultConfigureCorePattern), + } +} + +// defaultPorts are the internal ports as the CRD schema defaults materialize +// them. +func defaultPorts() memgraphcomv1alpha1.PortsSpec { + return memgraphcomv1alpha1.PortsSpec{ + BoltPort: ptr.To(memgraphcomv1alpha1.DefaultBoltPort), + ManagementPort: ptr.To(memgraphcomv1alpha1.DefaultManagementPort), + ReplicationPort: ptr.To(memgraphcomv1alpha1.DefaultReplicationPort), + CoordinatorPort: ptr.To(memgraphcomv1alpha1.DefaultCoordinatorPort), + } +} + +var _ = Describe("MemgraphCluster CRD validation", func() { + const resourceNamespace = "default" + + ctx := context.Background() + + // create posts a cluster and returns the admission error, if any. + create := func(name string, spec memgraphcomv1alpha1.MemgraphClusterSpec) error { + return k8sClient.Create(ctx, &memgraphcomv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: resourceNamespace}, + Spec: spec, + }) + } + + // createAccepted posts a cluster, asserts admission accepted it, registers + // its cleanup and returns the stored object with defaults applied. + createAccepted := func(name string, spec memgraphcomv1alpha1.MemgraphClusterSpec) *memgraphcomv1alpha1.MemgraphCluster { + GinkgoHelper() + Expect(create(name, spec)).To(Succeed()) + + stored := &memgraphcomv1alpha1.MemgraphCluster{} + Expect(k8sClient.Get(ctx, + types.NamespacedName{Name: name, Namespace: resourceNamespace}, stored)).To(Succeed()) + DeferCleanup(func() { + Expect(k8sClient.Delete(ctx, stored)).To(Succeed()) + }) + return stored + } + + // expectRejected asserts that admission rejected the spec as invalid and + // that the message actually tells the user what to change. + expectRejected := func(name string, spec memgraphcomv1alpha1.MemgraphClusterSpec, wantMessage string) { + GinkgoHelper() + err := create(name, spec) + Expect(err).To(HaveOccurred(), "expected admission to reject the spec") + Expect(apierrors.IsInvalid(err)).To(BeTrue(), "expected an Invalid error, got %v", err) + Expect(err.Error()).To(ContainSubstring(wantMessage)) + } + + Context("when creating a cluster", func() { + It("should accept the minimal spec of counts, image and secrets", func() { + stored := createAccepted("valid-minimal-explicit", memgraphcomv1alpha1.MemgraphClusterSpec{ + Coordinators: ptr.To(int32(3)), + DataInstances: ptr.To(int32(2)), + Image: memgraphcomv1alpha1.ImageSpec{ + Repository: "docker.io/memgraph/memgraph", + Tag: "3.12.0-relwithdebinfo", + }, + Secrets: memgraphcomv1alpha1.SecretsSpec{Name: customSecretName}, + }) + + Expect(stored.Spec.Secrets.Name).To(Equal(customSecretName)) + }) + + It("should accept an empty spec and materialize every documented default", func() { + stored := createAccepted("valid-empty-spec", memgraphcomv1alpha1.MemgraphClusterSpec{}) + + Expect(stored.Spec).To(Equal(memgraphcomv1alpha1.MemgraphClusterSpec{ + Coordinators: ptr.To(memgraphcomv1alpha1.DefaultCoordinatorCount), + DataInstances: ptr.To(memgraphcomv1alpha1.DefaultDataInstanceCount), + Image: memgraphcomv1alpha1.ImageSpec{ + Repository: memgraphcomv1alpha1.DefaultImageRepository, + Tag: memgraphcomv1alpha1.DefaultImageTag, + PullPolicy: memgraphcomv1alpha1.DefaultImagePullPolicy, + }, + Secrets: memgraphcomv1alpha1.SecretsSpec{ + Name: memgraphcomv1alpha1.DefaultSecretName, + LicenseKey: memgraphcomv1alpha1.DefaultLicenseSecretKey, + OrganizationKey: memgraphcomv1alpha1.DefaultOrganizationSecretKey, + }, + Storage: memgraphcomv1alpha1.StorageSpec{ + RetentionPolicy: memgraphcomv1alpha1.DefaultStorageRetention, + Coordinators: defaultRoleStorage(), + Data: defaultRoleStorage(), + }, + CoreDumps: defaultCoreDumps(), + ClusterDomain: memgraphcomv1alpha1.DefaultClusterDomain, + Ports: defaultPorts(), + // Probes, resources, labels and the env/args passthrough have no + // schema defaults: the probe timings' defaults depend on the role + // and the rest default to "nothing added". + }), "the CRD schema defaults must match the Go constants the builders fall back to") + }) + + It("should default the fields a partially specified block leaves out", func() { + stored := createAccepted("valid-partial-blocks", memgraphcomv1alpha1.MemgraphClusterSpec{ + Image: memgraphcomv1alpha1.ImageSpec{Tag: customImageTag}, + Secrets: memgraphcomv1alpha1.SecretsSpec{Name: customSecretName}, + Storage: memgraphcomv1alpha1.StorageSpec{ + Data: memgraphcomv1alpha1.RoleStorageSpec{LibPVCSize: ptr.To(resource.MustParse("100Gi"))}, + }, + }) + + Expect(stored.Spec.Image.Tag).To(Equal(customImageTag)) + Expect(stored.Spec.Image.Repository).To(Equal(memgraphcomv1alpha1.DefaultImageRepository)) + Expect(stored.Spec.Secrets.LicenseKey).To(Equal(memgraphcomv1alpha1.DefaultLicenseSecretKey)) + Expect(stored.Spec.Secrets.OrganizationKey).To(Equal(memgraphcomv1alpha1.DefaultOrganizationSecretKey)) + Expect(stored.Spec.Storage.Data.LibPVCSize).To(Equal(ptr.To(resource.MustParse("100Gi")))) + Expect(stored.Spec.Storage.Data.LogPVCSize).To( + Equal(ptr.To(resource.MustParse(memgraphcomv1alpha1.DefaultLogPVCSize)))) + Expect(stored.Spec.Storage.RetentionPolicy).To(Equal(memgraphcomv1alpha1.DefaultStorageRetention)) + Expect(stored.Spec.Storage.Coordinators).To(Equal(defaultRoleStorage())) + }) + + It("should accept an explicit Delete retention policy", func() { + stored := createAccepted("valid-retention-delete", memgraphcomv1alpha1.MemgraphClusterSpec{ + Storage: memgraphcomv1alpha1.StorageSpec{ + RetentionPolicy: memgraphcomv1alpha1.RetentionPolicyDelete, + }, + }) + + Expect(stored.Spec.Storage.RetentionPolicy).To(Equal(memgraphcomv1alpha1.RetentionPolicyDelete)) + }) + + // An unset storage class means "cluster default" and an empty one means + // "no dynamic provisioning"; both must survive a round trip through the + // API server as distinct values. + It("should preserve the difference between an unset and an empty storage class", func() { + raw := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": memgraphcomv1alpha1.SchemeGroupVersion.String(), + "kind": "MemgraphCluster", + "metadata": map[string]any{"name": "valid-empty-storage-class", "namespace": resourceNamespace}, + "spec": map[string]any{ + "storage": map[string]any{ + "data": map[string]any{"libStorageClassName": ""}, + }, + }, + }} + Expect(k8sClient.Create(ctx, raw)).To(Succeed()) + DeferCleanup(func() { Expect(k8sClient.Delete(ctx, raw)).To(Succeed()) }) + + stored := &memgraphcomv1alpha1.MemgraphCluster{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "valid-empty-storage-class", Namespace: resourceNamespace}, stored)).To(Succeed()) + + Expect(stored.Spec.Storage.Data.LibStorageClassName).To(Equal(ptr.To(""))) + Expect(stored.Spec.Storage.Data.LogStorageClassName).To(BeNil()) + }) + + DescribeTable("should accept any odd coordinator count from three up and any positive data instance count", + func(name string, coordinators, dataInstances int32) { + createAccepted(name, memgraphcomv1alpha1.MemgraphClusterSpec{ + Coordinators: ptr.To(coordinators), + DataInstances: ptr.To(dataInstances), + }) + }, + Entry("the smallest quorum and a single data instance", "valid-topology-min", int32(3), int32(1)), + Entry("a quorum and replica count beyond the former upper bounds", "valid-topology-large", + int32(9), int32(16)), + ) + + It("should accept a fully tuned pod configuration", func() { + stored := createAccepted("valid-pod-tuning", memgraphcomv1alpha1.MemgraphClusterSpec{ + ClusterDomain: "k8s.example.com", + Ports: memgraphcomv1alpha1.PortsSpec{ + BoltPort: ptr.To(int32(7777)), + ManagementPort: ptr.To(int32(10001)), + ReplicationPort: ptr.To(int32(20001)), + CoordinatorPort: ptr.To(int32(12001)), + }, + Probes: memgraphcomv1alpha1.ProbesSpec{ + Data: memgraphcomv1alpha1.RoleProbesSpec{ + StartupProbe: memgraphcomv1alpha1.ProbeSpec{FailureThreshold: ptr.To(int32(4320))}, + }, + }, + Resources: memgraphcomv1alpha1.ResourcesSpec{ + Data: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("4Gi")}, + }, + }, + Labels: memgraphcomv1alpha1.LabelsSpec{ + Data: memgraphcomv1alpha1.RoleLabelsSpec{PodLabels: map[string]string{"team": "data"}}, + }, + ExtraEnv: memgraphcomv1alpha1.ExtraEnvSpec{ + Data: []memgraphcomv1alpha1.EnvVar{{Name: "DATA_LABEL_ONE", Value: "one"}}, + }, + ExtraArgs: memgraphcomv1alpha1.ExtraArgsSpec{ + // The second one shares a prefix with the reserved --bolt-port + // without being it: the guard matches whole flag names, so a + // legitimate neighbour is not caught by it. + Data: []string{"--storage-snapshot-on-exit=true", "--bolt-num-workers=8"}, + }, + }) + + Expect(stored.Spec.ClusterDomain).To(Equal("k8s.example.com")) + Expect(stored.Spec.Ports.BoltPort).To(HaveValue(Equal(int32(7777)))) + Expect(stored.Spec.Probes.Data.StartupProbe.FailureThreshold).To(HaveValue(Equal(int32(4320)))) + Expect(stored.Spec.Probes.Data.ReadinessProbe).To(Equal(memgraphcomv1alpha1.ProbeSpec{}), + "an unset probe stays unset; its defaults are resolved by the builders, not the schema") + Expect(stored.Spec.ExtraEnv.Data).To(HaveLen(1)) + Expect(stored.Spec.ExtraArgs.Data).To(ConsistOf( + "--storage-snapshot-on-exit=true", "--bolt-num-workers=8")) + }) + + It("should default the ports a partially specified block leaves out", func() { + stored := createAccepted("valid-partial-ports", memgraphcomv1alpha1.MemgraphClusterSpec{ + Ports: memgraphcomv1alpha1.PortsSpec{BoltPort: ptr.To(int32(7777))}, + }) + + Expect(stored.Spec.Ports.BoltPort).To(HaveValue(Equal(int32(7777)))) + Expect(stored.Spec.Ports.ManagementPort).To(HaveValue(Equal(memgraphcomv1alpha1.DefaultManagementPort))) + Expect(stored.Spec.Ports.ReplicationPort).To(HaveValue(Equal(memgraphcomv1alpha1.DefaultReplicationPort))) + Expect(stored.Spec.Ports.CoordinatorPort).To(HaveValue(Equal(memgraphcomv1alpha1.DefaultCoordinatorPort))) + }) + + It("should accept core dumps with an uploader and default what it leaves out", func() { + stored := createAccepted("valid-core-dumps-uploader", memgraphcomv1alpha1.MemgraphClusterSpec{ + CoreDumps: memgraphcomv1alpha1.CoreDumpsSpec{ + Data: memgraphcomv1alpha1.RoleCoreDumpsSpec{ + Enabled: true, + Size: ptr.To(resource.MustParse("200Gi")), + }, + Uploader: &memgraphcomv1alpha1.CoreDumpsUploaderSpec{ + Image: uploaderImage, + Env: []memgraphcomv1alpha1.EnvVar{{Name: "S3_BUCKET", Value: "dumps"}}, + EnvFromSecrets: []string{"aws-s3-credentials"}, + }, + }, + }) + + dumps := stored.Spec.CoreDumps + Expect(dumps.Data.Enabled).To(BeTrue()) + Expect(dumps.Data.Size).To(HaveValue(Equal(resource.MustParse("200Gi")))) + Expect(dumps.ConfigureCorePattern).To(HaveValue(BeTrue())) + Expect(dumps.Uploader.PullPolicy).To(Equal(memgraphcomv1alpha1.DefaultImagePullPolicy)) + // Whether a role collects at all, and how much room it needs, stays + // its own decision: the coordinators asked for neither. + Expect(dumps.Coordinators).To(Equal(defaultRoleCoreDumps())) + }) + + // The extraVolumes entries are schemaless, so nothing but this spec + // proves the API server keeps an arbitrary volume source intact instead + // of pruning the fields it has no schema for. + It("should preserve a schemaless extra volume through a round trip", func() { + stored := createAccepted("valid-extra-volumes", memgraphcomv1alpha1.MemgraphClusterSpec{ + ExtraVolumes: memgraphcomv1alpha1.ExtraVolumesSpec{ + Data: []corev1.Volume{{ + Name: "bolt-certs", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "bolt-tls", + DefaultMode: ptr.To(int32(0o400)), + Items: []corev1.KeyToPath{{Key: "tls.crt", Path: "cert.pem"}}, + }, + }, + }}, + Coordinators: []corev1.Volume{{ + Name: "vault", + VolumeSource: corev1.VolumeSource{ + CSI: &corev1.CSIVolumeSource{ + Driver: "secrets-store.csi.k8s.io", + ReadOnly: ptr.To(true), + VolumeAttributes: map[string]string{"secretProviderClass": memgraphDbName}, + }, + }, + }}, + }, + ExtraVolumeMounts: memgraphcomv1alpha1.ExtraVolumeMountsSpec{ + Data: []corev1.VolumeMount{{ + Name: "bolt-certs", MountPath: "/etc/memgraph/ssl", ReadOnly: true, + }}, + }, + }) + + volume := stored.Spec.ExtraVolumes.Data[0] + Expect(volume.Name).To(Equal("bolt-certs")) + Expect(volume.Secret).NotTo(BeNil(), "the secret source must survive a schemaless round trip") + Expect(volume.Secret.SecretName).To(Equal("bolt-tls")) + Expect(volume.Secret.DefaultMode).To(HaveValue(Equal(int32(0o400)))) + Expect(volume.Secret.Items).To(ConsistOf(corev1.KeyToPath{Key: "tls.crt", Path: "cert.pem"})) + + csi := stored.Spec.ExtraVolumes.Coordinators[0].CSI + Expect(csi).NotTo(BeNil()) + Expect(csi.Driver).To(Equal("secrets-store.csi.k8s.io")) + Expect(csi.VolumeAttributes).To(HaveKeyWithValue("secretProviderClass", memgraphDbName)) + + Expect(stored.Spec.ExtraVolumeMounts.Data[0].MountPath).To(Equal("/etc/memgraph/ssl")) + Expect(stored.Spec.ExtraVolumeMounts.Coordinators).To(BeEmpty()) + }) + + It("should accept a registry host carrying a port", func() { + stored := createAccepted("valid-registry-port", memgraphcomv1alpha1.MemgraphClusterSpec{ + Image: memgraphcomv1alpha1.ImageSpec{Repository: "registry.example.com:5000/memgraph"}, + }) + + Expect(stored.Spec.Image.Repository).To(Equal("registry.example.com:5000/memgraph")) + }) + + DescribeTable("should reject an invalid spec with an actionable message", + func(name string, spec memgraphcomv1alpha1.MemgraphClusterSpec, wantMessage string) { + expectRejected(name, spec, wantMessage) + }, + Entry("zero coordinators", "invalid-coordinators-zero", + memgraphcomv1alpha1.MemgraphClusterSpec{Coordinators: ptr.To(int32(0))}, + "should be greater than or equal to 3"), + // A single coordinator is a quorum of one: it cannot survive losing + // itself, which is the whole point of running HA. + Entry("a coordinator count below the HA floor", "invalid-coordinators-below-floor", + memgraphcomv1alpha1.MemgraphClusterSpec{Coordinators: ptr.To(int32(1))}, + "should be greater than or equal to 3"), + Entry("an even coordinator count", "invalid-coordinators-even", + memgraphcomv1alpha1.MemgraphClusterSpec{Coordinators: ptr.To(int32(4))}, + "coordinators must be an odd number"), + Entry("zero data instances", "invalid-data-zero", + memgraphcomv1alpha1.MemgraphClusterSpec{DataInstances: ptr.To(int32(0))}, + "should be greater than or equal to 1"), + Entry("a tag smuggled into the repository", "invalid-image-repository-tagged", + memgraphcomv1alpha1.MemgraphClusterSpec{ + Image: memgraphcomv1alpha1.ImageSpec{Repository: "memgraph/memgraph:3.12.0"}, + }, + "repository must not contain a tag; set image.tag instead"), + Entry("a digest smuggled into the repository", "invalid-image-repository-digest", + memgraphcomv1alpha1.MemgraphClusterSpec{ + Image: memgraphcomv1alpha1.ImageSpec{ + Repository: "memgraph/memgraph@sha256:0000000000000000000000000000000000000000000000000000000000000000", + }, + }, + "repository must not contain a digest"), + Entry("an image tag that is not a valid OCI tag", "invalid-image-tag-chars", + memgraphcomv1alpha1.MemgraphClusterSpec{ + Image: memgraphcomv1alpha1.ImageSpec{Tag: "3.12.0 relwithdebinfo"}, + }, + "in body should match"), + Entry("a secret name that is not a DNS subdomain", "invalid-secret-name", + memgraphcomv1alpha1.MemgraphClusterSpec{ + Secrets: memgraphcomv1alpha1.SecretsSpec{Name: "My_Secret"}, + }, + "in body should match"), + Entry("one secret key serving both values", "invalid-secret-keys-collide", + memgraphcomv1alpha1.MemgraphClusterSpec{ + Secrets: memgraphcomv1alpha1.SecretsSpec{ + LicenseKey: "MEMGRAPH_LICENSE", + OrganizationKey: "MEMGRAPH_LICENSE", + }, + }, + "licenseKey and organizationKey must name different keys of the Secret"), + Entry("a retention policy outside the enum", "invalid-retention-policy", + memgraphcomv1alpha1.MemgraphClusterSpec{ + Storage: memgraphcomv1alpha1.StorageSpec{RetentionPolicy: "Purge"}, + }, + `Unsupported value: "Purge"`), + Entry("an access mode outside the enum", "invalid-access-mode", + memgraphcomv1alpha1.MemgraphClusterSpec{ + Storage: memgraphcomv1alpha1.StorageSpec{ + Data: memgraphcomv1alpha1.RoleStorageSpec{LibStorageAccessMode: "ReadWriteSometimes"}, + }, + }, + `Unsupported value: "ReadWriteSometimes"`), + Entry("a port outside the valid range", "invalid-port-range", + memgraphcomv1alpha1.MemgraphClusterSpec{ + Ports: memgraphcomv1alpha1.PortsSpec{BoltPort: ptr.To(int32(70000))}, + }, + "should be less than or equal to 65535"), + Entry("a port of zero", "invalid-port-zero", + memgraphcomv1alpha1.MemgraphClusterSpec{ + Ports: memgraphcomv1alpha1.PortsSpec{ManagementPort: ptr.To(int32(0))}, + }, + "should be greater than or equal to 1"), + // Two roles sharing a port number would make the advertised + // addresses ambiguous, so it is rejected instead of half-working. + Entry("two ports colliding", "invalid-ports-collide", + memgraphcomv1alpha1.MemgraphClusterSpec{ + Ports: memgraphcomv1alpha1.PortsSpec{ManagementPort: ptr.To(memgraphcomv1alpha1.DefaultBoltPort)}, + }, + "must all be different ports"), + // Two mounts cannot share a path, and mounting over the data or log + // directory would hide Memgraph's own storage behind another volume. + Entry("an extra mount over the data directory", "invalid-extra-mount-lib", + memgraphcomv1alpha1.MemgraphClusterSpec{ + ExtraVolumeMounts: memgraphcomv1alpha1.ExtraVolumeMountsSpec{ + Data: []corev1.VolumeMount{{Name: "shadow", MountPath: "/var/lib/memgraph"}}, + }, + }, + "extraVolumeMounts must not mount over a path the operator already mounts"), + Entry("an extra mount over the scratch directory", "invalid-extra-mount-tmp", + memgraphcomv1alpha1.MemgraphClusterSpec{ + ExtraVolumeMounts: memgraphcomv1alpha1.ExtraVolumeMountsSpec{ + Coordinators: []corev1.VolumeMount{{Name: "shadow", MountPath: "/tmp"}}, + }, + }, + "extraVolumeMounts must not mount over a path the operator already mounts"), + Entry("a cluster domain that is not a DNS name", "invalid-cluster-domain", + memgraphcomv1alpha1.MemgraphClusterSpec{ClusterDomain: "Cluster_Local"}, + "in body should match"), + // An uploader with no volume to read would poll an empty directory + // forever, so the dependency the Helm chart leaves implicit between + // its two blocks is enforced here. + Entry("an uploader with no role collecting dumps", "invalid-uploader-without-dumps", + memgraphcomv1alpha1.MemgraphClusterSpec{ + CoreDumps: memgraphcomv1alpha1.CoreDumpsSpec{ + Uploader: &memgraphcomv1alpha1.CoreDumpsUploaderSpec{Image: uploaderImage}, + }, + }, + "uploader requires core dumps enabled for at least one role"), + Entry("an uploader without an image", "invalid-uploader-no-image", + memgraphcomv1alpha1.MemgraphClusterSpec{ + CoreDumps: memgraphcomv1alpha1.CoreDumpsSpec{ + Data: memgraphcomv1alpha1.RoleCoreDumpsSpec{Enabled: true}, + Uploader: &memgraphcomv1alpha1.CoreDumpsUploaderSpec{}, + }, + }, + "should be at least 1 chars long"), + Entry("an uploader shadowing the core dumps path variable", "invalid-uploader-env", + memgraphcomv1alpha1.MemgraphClusterSpec{ + CoreDumps: memgraphcomv1alpha1.CoreDumpsSpec{ + Coordinators: memgraphcomv1alpha1.RoleCoreDumpsSpec{Enabled: true}, + Uploader: &memgraphcomv1alpha1.CoreDumpsUploaderSpec{ + Image: uploaderImage, + Env: []memgraphcomv1alpha1.EnvVar{{ + Name: memgraphcomv1alpha1.EnvCoreDumpsDir, Value: "/elsewhere", + }}, + }, + }, + }, + "env must not set CORE_DUMPS_DIR"), + Entry("an env var name that is not a shell identifier", "invalid-env-name", + memgraphcomv1alpha1.MemgraphClusterSpec{ + ExtraEnv: memgraphcomv1alpha1.ExtraEnvSpec{ + Data: []memgraphcomv1alpha1.EnvVar{{Name: "not-an-identifier", Value: "x"}}, + }, + }, + "in body should match"), + Entry("an env var shadowing the license the secrets block owns", "invalid-env-license", + memgraphcomv1alpha1.MemgraphClusterSpec{ + ExtraEnv: memgraphcomv1alpha1.ExtraEnvSpec{ + Data: []memgraphcomv1alpha1.EnvVar{{ + Name: memgraphcomv1alpha1.EnvLicense, + Value: "smuggled-license", + }}, + }, + }, + "they come from the secrets block"), + Entry("an env var shadowing the pod's own identity", "invalid-env-pod-name", + memgraphcomv1alpha1.MemgraphClusterSpec{ + ExtraEnv: memgraphcomv1alpha1.ExtraEnvSpec{ + Coordinators: []memgraphcomv1alpha1.EnvVar{{ + Name: memgraphcomv1alpha1.EnvPodName, + Value: "not-my-name", + }}, + }, + }, + "it carries the pod's own identity"), + // A port set through extraArgs would leave the pods listening + // somewhere the registered addresses do not point. + Entry("an extra arg overriding a port", "invalid-args-port", + memgraphcomv1alpha1.MemgraphClusterSpec{ + ExtraArgs: memgraphcomv1alpha1.ExtraArgsSpec{Data: []string{"--bolt-port=7777"}}, + }, + "configure ports through spec.ports"), + Entry("an extra arg overriding the coordinator identity", "invalid-args-coordinator-id", + memgraphcomv1alpha1.MemgraphClusterSpec{ + ExtraArgs: memgraphcomv1alpha1.ExtraArgsSpec{Coordinators: []string{"--coordinator-id=9"}}, + }, + "the coordinator identity the operator derives"), + // Memgraph's flags are gflags, which treats one dash as two and a hyphen as + // an underscore — the flags are declared bolt_port, coordinator_id and so + // on, and the operator's own --bolt-port only works because of that. Every + // spelling reaches the same flag, so the guard has to reject all of them or + // it rejects none. + Entry("a reserved flag spelled with one dash", "invalid-args-single-dash", + memgraphcomv1alpha1.MemgraphClusterSpec{ + ExtraArgs: memgraphcomv1alpha1.ExtraArgsSpec{Data: []string{"-bolt-port=7777"}}, + }, + "configure ports through spec.ports"), + Entry("a reserved flag spelled with underscores", "invalid-args-underscores", + memgraphcomv1alpha1.MemgraphClusterSpec{ + ExtraArgs: memgraphcomv1alpha1.ExtraArgsSpec{Data: []string{"--bolt_port=7777"}}, + }, + "configure ports through spec.ports"), + Entry("a reserved flag spelled with one dash and underscores", "invalid-args-single-underscore", + memgraphcomv1alpha1.MemgraphClusterSpec{ + ExtraArgs: memgraphcomv1alpha1.ExtraArgsSpec{Coordinators: []string{"-coordinator_id=9"}}, + }, + "the coordinator identity the operator derives"), + // gflags takes a non-boolean flag's value as the next argument too, so the + // flag can arrive as an element of its own. + Entry("a reserved flag with its value in the next element", "invalid-args-separate-value", + memgraphcomv1alpha1.MemgraphClusterSpec{ + ExtraArgs: memgraphcomv1alpha1.ExtraArgsSpec{Data: []string{"--bolt-port", "7777"}}, + }, + "configure ports through spec.ports"), + Entry("a probe timing below one", "invalid-probe-period", + memgraphcomv1alpha1.MemgraphClusterSpec{ + Probes: memgraphcomv1alpha1.ProbesSpec{ + Coordinators: memgraphcomv1alpha1.RoleProbesSpec{ + ReadinessProbe: memgraphcomv1alpha1.ProbeSpec{PeriodSeconds: ptr.To(int32(0))}, + }, + }, + }, + "should be greater than or equal to 1"), + ) + + // Two extra env vars of the same name would be an ambiguous + // configuration, so the list is keyed by name. + It("should reject a repeated env var name", func() { + err := create("invalid-env-duplicate", memgraphcomv1alpha1.MemgraphClusterSpec{ + ExtraEnv: memgraphcomv1alpha1.ExtraEnvSpec{ + Data: []memgraphcomv1alpha1.EnvVar{ + {Name: "DATA_LABEL", Value: "one"}, + {Name: "DATA_LABEL", Value: "two"}, + }, + }, + }) + + Expect(err).To(HaveOccurred(), "expected admission to reject the duplicate") + Expect(err.Error()).To(ContainSubstring("Duplicate value")) + }) + + // The typed client drops empty strings before they reach the API + // server (omitempty), so the fields a user can only blank out from + // YAML are submitted as a raw manifest instead. + DescribeTable("should reject a blanked-out field of a hand-written manifest", + func(name, block, field string) { + raw := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": memgraphcomv1alpha1.SchemeGroupVersion.String(), + "kind": "MemgraphCluster", + "metadata": map[string]any{"name": name, "namespace": resourceNamespace}, + "spec": map[string]any{block: map[string]any{field: ""}}, + }} + + err := k8sClient.Create(ctx, raw) + Expect(err).To(HaveOccurred(), "expected admission to reject the manifest") + Expect(apierrors.IsInvalid(err)).To(BeTrue(), "expected an Invalid error, got %v", err) + Expect(err.Error()).To(ContainSubstring("should be at least 1 chars long")) + }, + Entry("an empty image repository", "invalid-raw-repository", "image", "repository"), + Entry("an empty image tag", "invalid-raw-tag", "image", "tag"), + Entry("an empty secret name", "invalid-raw-secret-name", "secrets", "name"), + Entry("an empty license key", "invalid-raw-license-key", "secrets", "licenseKey"), + Entry("an empty organization key", "invalid-raw-organization-key", "secrets", "organizationKey"), + ) + }) + + Context("when updating a live cluster", func() { + // live creates a cluster with the default topology and returns a + // mutate-and-update helper over the freshest stored copy. + update := func(name string, mutate func(*memgraphcomv1alpha1.MemgraphCluster)) error { + GinkgoHelper() + stored := &memgraphcomv1alpha1.MemgraphCluster{} + Expect(k8sClient.Get(ctx, + types.NamespacedName{Name: name, Namespace: resourceNamespace}, stored)).To(Succeed()) + mutate(stored) + return k8sClient.Update(ctx, stored) + } + + // expectRejectedUpdate asserts that admission refused a topology change + // and said what is wrong with the new value. + expectRejectedUpdate := func( + name string, + mutate func(*memgraphcomv1alpha1.MemgraphCluster), + wantMessage string, + ) { + GinkgoHelper() + err := update(name, mutate) + Expect(err).To(HaveOccurred(), "expected admission to reject the topology change") + Expect(apierrors.IsInvalid(err)).To(BeTrue(), "expected an Invalid error, got %v", err) + Expect(err.Error()).To(ContainSubstring(wantMessage)) + } + + It("should accept growing both counts in one edit", func() { + createAccepted("scale-up-both", memgraphcomv1alpha1.MemgraphClusterSpec{ + Coordinators: ptr.To(int32(3)), + DataInstances: ptr.To(int32(2)), + }) + + Expect(update("scale-up-both", func(c *memgraphcomv1alpha1.MemgraphCluster) { + c.Spec.Coordinators = ptr.To(int32(5)) + c.Spec.DataInstances = ptr.To(int32(3)) + })).To(Succeed(), "both counts are mutable, in any step size, in one edit") + + stored := &memgraphcomv1alpha1.MemgraphCluster{} + Expect(k8sClient.Get(ctx, + types.NamespacedName{Name: "scale-up-both", Namespace: resourceNamespace}, stored)).To(Succeed()) + Expect(stored.Spec.Coordinators).To(HaveValue(Equal(int32(5)))) + Expect(stored.Spec.DataInstances).To(HaveValue(Equal(int32(3)))) + }) + + It("should accept lowering either count", func() { + createAccepted("scale-down-both", memgraphcomv1alpha1.MemgraphClusterSpec{ + Coordinators: ptr.To(int32(5)), + DataInstances: ptr.To(int32(3)), + }) + + Expect(update("scale-down-both", func(c *memgraphcomv1alpha1.MemgraphCluster) { + c.Spec.Coordinators = ptr.To(int32(3)) + c.Spec.DataInstances = ptr.To(int32(1)) + })).To(Succeed(), "admission constrains the target counts, nothing about the direction") + }) + + It("should accept a count change that arrives as a field removal", func() { + // Dropping a non-default count from the manifest re-defaults it, + // which is a real topology change and no longer refused. + createAccepted("scale-omitted", memgraphcomv1alpha1.MemgraphClusterSpec{ + Coordinators: ptr.To(int32(5)), + }) + + Expect(update("scale-omitted", func(c *memgraphcomv1alpha1.MemgraphCluster) { + c.Spec.Coordinators = nil + })).To(Succeed()) + + stored := &memgraphcomv1alpha1.MemgraphCluster{} + Expect(k8sClient.Get(ctx, + types.NamespacedName{Name: "scale-omitted", Namespace: resourceNamespace}, stored)).To(Succeed()) + Expect(stored.Spec.Coordinators).To(HaveValue(Equal(memgraphcomv1alpha1.DefaultCoordinatorCount))) + }) + + It("should accept an update that leaves the counts alone", func() { + createAccepted("scale-unchanged", memgraphcomv1alpha1.MemgraphClusterSpec{ + Coordinators: ptr.To(int32(3)), + DataInstances: ptr.To(int32(2)), + }) + + Expect(update("scale-unchanged", func(c *memgraphcomv1alpha1.MemgraphCluster) { + c.Spec.Image.Tag = customImageTag + c.Spec.Secrets.Name = "another-license" + })).To(Succeed()) + }) + + // The floors and the odd rule are creation-time validation that keeps + // applying on every update: a live cluster cannot be edited into a + // topology it could not have been created with. + DescribeTable("should reject a topology change that breaks a floor", + func(name string, mutate func(*memgraphcomv1alpha1.MemgraphCluster), wantMessage string) { + createAccepted(name, memgraphcomv1alpha1.MemgraphClusterSpec{ + Coordinators: ptr.To(int32(5)), + DataInstances: ptr.To(int32(2)), + }) + + expectRejectedUpdate(name, mutate, wantMessage) + }, + Entry("coordinators below the HA floor", "scale-floor-coordinators", + func(c *memgraphcomv1alpha1.MemgraphCluster) { + c.Spec.Coordinators = ptr.To(int32(1)) + }, "should be greater than or equal to 3"), + Entry("an even coordinator count", "scale-floor-coordinators-even", + func(c *memgraphcomv1alpha1.MemgraphCluster) { + c.Spec.Coordinators = ptr.To(int32(4)) + }, "coordinators must be an odd number"), + Entry("no data instances left", "scale-floor-data", + func(c *memgraphcomv1alpha1.MemgraphCluster) { + c.Spec.DataInstances = ptr.To(int32(0)) + }, "should be greater than or equal to 1"), + ) + }) +}) diff --git a/internal/controller/memgraphha_constants.go b/internal/controller/memgraphha_constants.go deleted file mode 100644 index 5c3160a..0000000 --- a/internal/controller/memgraphha_constants.go +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024 Memgraph Ltd. - -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 - -var boltPort int = 7687 -var coordinatorPort int = 12000 -var mgmtPort int = 10000 -var replicationPort int = 20000 -var image string = "memgraph/memgraph:2.18.1" diff --git a/internal/controller/memgraphha_controller.go b/internal/controller/memgraphha_controller.go deleted file mode 100644 index 041f2aa..0000000 --- a/internal/controller/memgraphha_controller.go +++ /dev/null @@ -1,152 +0,0 @@ -/* -Copyright 2024 Memgraph Ltd. - -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" - - "k8s.io/apimachinery/pkg/api/errors" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/log" - - memgraphv1 "github.com/memgraph/kubernetes-operator/api/v1" -) - -//+kubebuilder:rbac:groups=memgraph.com,resources=memgraphhas,verbs=get;list;watch;create;update;patch;delete -//+kubebuilder:rbac:groups=memgraph.com,resources=memgraphhas/status,verbs=get;update;patch -//+kubebuilder:rbac:groups=memgraph.com,resources=memgraphhas/finalizers,verbs=update - -// For more details, check Reconcile and its Result here: -// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.16.3/pkg/reconcile -func (r *MemgraphHAReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - memgraphha := &memgraphv1.MemgraphHA{} - err := r.Get(ctx, req.NamespacedName, memgraphha) - if err != nil { - if errors.IsNotFound(err) { - logger.Info("MemgraphHA resource not found. Ignoring since object must be deleted.") - return ctrl.Result{}, nil - } - logger.Error(err, "Failed to get MemgraphHA") - return ctrl.Result{}, err - } - - logger.Info("Started reconciliation MemgrahHA") - - for coordId := 1; coordId <= 3; coordId++ { - // ClusterIP - coordClusterIPStatus, coordClusterIPErr := r.reconcileCoordClusterIPService(ctx, memgraphha, &logger, coordId) - if coordClusterIPErr != nil { - logger.Info("Error returned when reconciling ClusterIP Returning empty Result with error.", "coordId", coordId) - return ctrl.Result{}, coordClusterIPErr - } - - if coordClusterIPStatus == true { - logger.Info("ClusterIP has been created. Returning Result with the request for requeing with error set to nil.", "coordId", coordId) - return ctrl.Result{Requeue: true}, nil - } - - // NodePort - coordNodePortStatus, coordNodePortErr := r.reconcileCoordNodePortService(ctx, memgraphha, &logger, coordId) - if coordNodePortErr != nil { - logger.Info("Error returned when reconciling NodePort. Returning empty Result with error.", "coordId", coordId) - return ctrl.Result{}, coordNodePortErr - } - - if coordNodePortStatus == true { - logger.Info("NodePort has been created. Returning Result with the request for requeing with error set to nil.", "coordId", coordId) - return ctrl.Result{Requeue: true}, nil - } - - // Coordinator - coordStatus, coordErr := r.reconcileCoordinator(ctx, memgraphha, &logger, coordId) - if coordErr != nil { - logger.Info("Error returned when reconciling coordinator. Returning empty Result with error.", "coordId", coordId) - return ctrl.Result{}, coordErr - } - - if coordStatus == true { - logger.Info("Coordinator has been created. Returning Result with the request for requeing with error set to nil.", "coordId", coordId) - return ctrl.Result{Requeue: true}, nil - } - } - - logger.Info("Reconciliation of coordinators finished without actions needed.") - - for dataInstanceId := 0; dataInstanceId <= 1; dataInstanceId++ { - // ClusterIP - dataInstanceClusterIPStatus, dataInstanceClusterIPErr := r.reconcileDataInstanceClusterIPService(ctx, memgraphha, &logger, dataInstanceId) - if dataInstanceClusterIPErr != nil { - logger.Info("Error returned when reconciling ClusterIP. Returning empty Result with error.", "dataInstanceId", dataInstanceId) - return ctrl.Result{}, dataInstanceClusterIPErr - } - - if dataInstanceClusterIPStatus == true { - logger.Info("ClusterIP has been created. Returning Result with the request for requeing with error set to nil.", "dataInstanceId", dataInstanceId) - return ctrl.Result{Requeue: true}, nil - } - - // NodePort - dataInstanceNodePortStatus, dataInstanceNodePortErr := r.reconcileDataInstanceNodePortService(ctx, memgraphha, &logger, dataInstanceId) - if dataInstanceNodePortErr != nil { - logger.Info("Error returned when reconciling NodePort. Returning empty Result with error.", "dataInstanceId", dataInstanceId) - return ctrl.Result{}, dataInstanceNodePortErr - } - - if dataInstanceNodePortStatus == true { - logger.Info("NodePort has been created. Returning Result with the request for requeing with error set to nil.", "dataInstanceId", dataInstanceId) - return ctrl.Result{Requeue: true}, nil - } - - // Data instance - dataInstancesStatus, dataInstancesErr := r.reconcileDataInstance(ctx, memgraphha, &logger, dataInstanceId) - if dataInstancesErr != nil { - logger.Info("Error returned when reconciling data instance. Returning empty Result with error.", "dataInstanceId", dataInstanceId) - return ctrl.Result{}, dataInstancesErr - } - - if dataInstancesStatus == true { - logger.Info("Data instance has been created. Returning Result with the request for requeing with error=nil.", "dataInstanceId", dataInstanceId) - return ctrl.Result{Requeue: true}, nil - } - } - - logger.Info("Reconciliation of data instances finished without actions needed.") - - setupJobStatus, setupJobErr := r.reconcileSetupJob(ctx, memgraphha, &logger) - if setupJobErr != nil { - logger.Info("Error returned when reconciling coordinator. Returning empty Result with error.") - return ctrl.Result{}, setupJobErr - } - - // Since it is currently the last step, we don't need to requeue - if setupJobStatus == true { - logger.Info("SetupJob has been created.") - } - - logger.Info("Reconciliation of MemgraphHA finished.") - // The resource doesn't need to be reconciled anymore - return ctrl.Result{}, nil -} - -// SetupWithManager sets up the controller with the Manager. -func (r *MemgraphHAReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&memgraphv1.MemgraphHA{}). - Complete(r) -} diff --git a/internal/controller/memgraphha_coord.go b/internal/controller/memgraphha_coord.go deleted file mode 100644 index cba91b8..0000000 --- a/internal/controller/memgraphha_coord.go +++ /dev/null @@ -1,236 +0,0 @@ -/* -copyright 2024 memgraph ltd. - -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" - - "github.com/go-logr/logr" - memgraphv1 "github.com/memgraph/kubernetes-operator/api/v1" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" -) - -/* -Returns bool, error tuple. If error exists, the caller should return with error and status will always be set to true. -If there is no error, we must look at bool status which when true will say that the coordinator was createdand we need to requeue -or that nothing was done and we can continue with the next step of reconciliation. -*/ -func (r *MemgraphHAReconciler) reconcileCoordinator(ctx context.Context, memgraphha *memgraphv1.MemgraphHA, logger *logr.Logger, coordId int) (bool, error) { - name := fmt.Sprintf("memgraph-coordinator-%d", coordId) - logger.Info("Started reconciling", "StatefulSet", name) - coordStatefulSet := &appsv1.StatefulSet{} - err := r.Get(ctx, types.NamespacedName{Name: name, Namespace: memgraphha.Namespace}, coordStatefulSet) - - if err == nil { - logger.Info("StatefulSet already exists.", "StatefulSet", name) - return false, nil - } - - if errors.IsNotFound(err) { - coord := r.createStatefulSetForCoord(memgraphha, coordId) - logger.Info("Creating a new StatefulSet", "StatefulSet.Namespace", coord.Namespace, "StatefulSet.Name", coord.Name) - err := r.Create(ctx, coord) - if err != nil { - logger.Error(err, "Failed to create new StatefulSet", "StatefulSet.Namespace", coord.Namespace, "StatefulSet.Name", coord.Name) - return true, err - } - logger.Info("StatefulSet is created.", "StatefulSet", name) - return true, nil - } - - logger.Error(err, "Failed to fetch StatefulSet", "StatefulSet", name) - return true, err -} - -func (r *MemgraphHAReconciler) createStatefulSetForCoord(memgraphha *memgraphv1.MemgraphHA, coordId int) *appsv1.StatefulSet { - coordName := fmt.Sprintf("memgraph-coordinator-%d", coordId) - serviceName := coordName // service has the same name as the coordinator - labels := createCoordLabels(coordName) - replicas := int32(1) - containerName := "memgraph-coordinator" - args := []string{ - fmt.Sprintf("--coordinator-id=%d", coordId), - fmt.Sprintf("--coordinator-port=%d", coordinatorPort), - fmt.Sprintf("--management-port=%d", mgmtPort), - fmt.Sprintf("--bolt-port=%d", boltPort), - fmt.Sprintf("--coordinator-hostname=%s.default.svc.cluster.local", coordName), - "--experimental-enabled=high-availability", - "--also-log-to-stderr", - "--log-level=TRACE", - "--log-file=/var/log/memgraph/memgraph.log", - } - volumeLibName := fmt.Sprintf("%s-lib-storage", coordName) - volumeLibSize := "1Gi" - volumeLogName := fmt.Sprintf("%s-log-storage", coordName) - volumeLogSize := "256Mi" - initContainerName := "init" - initContainerCommand := []string{ - "/bin/sh", - "-c", - } - initContainerArgs := []string{"chown -R memgraph:memgraph /var/log; chown -R memgraph:memgraph /var/lib"} - initContainerPrivileged := true - initContainerReadOnlyRootFilesystem := false - initContainerRunAsNonRoot := false - initContainerRunAsUser := int64(0) - - coord := &appsv1.StatefulSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: coordName, - Namespace: memgraphha.Namespace, - }, - Spec: appsv1.StatefulSetSpec{ - ServiceName: serviceName, - Replicas: &replicas, - Selector: &metav1.LabelSelector{ - MatchLabels: labels, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: labels, - }, - Spec: corev1.PodSpec{ - InitContainers: []corev1.Container{ - { - Name: initContainerName, - Image: image, - VolumeMounts: []corev1.VolumeMount{ - { - Name: volumeLibName, - MountPath: "/var/lib/memgraph", - }, - { - Name: volumeLogName, - MountPath: "/var/log/memgraph", - }, - }, - Command: initContainerCommand, - Args: initContainerArgs, - SecurityContext: &corev1.SecurityContext{ - Privileged: &initContainerPrivileged, - ReadOnlyRootFilesystem: &initContainerReadOnlyRootFilesystem, - RunAsNonRoot: &initContainerRunAsNonRoot, - RunAsUser: &initContainerRunAsUser, - Capabilities: &corev1.Capabilities{ - Drop: []corev1.Capability{"all"}, - Add: []corev1.Capability{"CHOWN"}, - }, - }, - }, - }, - - Containers: []corev1.Container{{ - Name: containerName, - Image: image, - ImagePullPolicy: corev1.PullAlways, // set to PullIfNotPresent when testing with local image - Ports: []corev1.ContainerPort{ - { - ContainerPort: int32(boltPort), - Name: "bolt", - }, - { - ContainerPort: int32(mgmtPort), - Name: "management", - }, - { - ContainerPort: int32(coordinatorPort), - Name: "coordinator", - }, - }, - Args: args, - Env: []corev1.EnvVar{ - { - Name: "MEMGRAPH_ENTERPRISE_LICENSE", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "memgraph-secrets", - }, - Key: "MEMGRAPH_ENTERPRISE_LICENSE", - }, - }, - }, - { - Name: "MEMGRAPH_ORGANIZATION_NAME", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "memgraph-secrets", - }, - Key: "MEMGRAPH_ORGANIZATION_NAME", - }, - }, - }, - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: volumeLibName, - MountPath: "/var/lib/memgraph", - }, - { - Name: volumeLogName, - MountPath: "/var/log/memgraph", - }, - }, - }}, - }, - }, - VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: volumeLibName, - }, - Spec: corev1.PersistentVolumeClaimSpec{ - AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse(volumeLibSize), - }, - }, - }, - }, - { - ObjectMeta: metav1.ObjectMeta{ - Name: volumeLogName, - }, - Spec: corev1.PersistentVolumeClaimSpec{ - AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse(volumeLogSize), - }, - }, - }, - }, - }, - }, - } - - ctrl.SetControllerReference(memgraphha, coord, r.Scheme) - return coord -} - -func createCoordLabels(coordName string) map[string]string { - return map[string]string{"app": coordName} -} diff --git a/internal/controller/memgraphha_coord_services.go b/internal/controller/memgraphha_coord_services.go deleted file mode 100644 index 659a43f..0000000 --- a/internal/controller/memgraphha_coord_services.go +++ /dev/null @@ -1,156 +0,0 @@ -/* -Copyright 2024 Memgraph Ltd. - -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" - ctrl "sigs.k8s.io/controller-runtime" - - "github.com/go-logr/logr" - memgraphv1 "github.com/memgraph/kubernetes-operator/api/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/intstr" -) - -func (r *MemgraphHAReconciler) reconcileCoordNodePortService(ctx context.Context, memgraphha *memgraphv1.MemgraphHA, logger *logr.Logger, coordId int) (bool, error) { - serviceName := fmt.Sprintf("memgraph-coordinator-%d-external", coordId) - logger.Info("Started reconciling NodePort service", "NodePort", serviceName) - - coordNodePortService := &corev1.Service{} - err := r.Get(ctx, types.NamespacedName{Name: serviceName, Namespace: memgraphha.Namespace}, coordNodePortService) - - if err == nil { - logger.Info("NodePort already exists.", "NodePort", serviceName) - return false, nil - } - - if errors.IsNotFound(err) { - nodePort := r.createCoordNodePort(memgraphha, coordId) - logger.Info("Creating a new NodePort", "NodePort.Namespace", nodePort.Namespace, "NodePort.Name", nodePort.Name) - err := r.Create(ctx, nodePort) - if err != nil { - logger.Error(err, "Failed to create new NodePort", "NodePort.Namespace", nodePort.Namespace, "NodePort.Name", nodePort.Name) - return true, err - } - logger.Info("NodePort is created.", "NodePort", serviceName) - return true, nil - } - - logger.Error(err, "Failed to fetch NodePort", "NodePort", serviceName) - return true, err - -} - -func (r *MemgraphHAReconciler) createCoordNodePort(memgraphha *memgraphv1.MemgraphHA, coordId int) *corev1.Service { - serviceName := fmt.Sprintf("memgraph-coordinator-%d-external", coordId) - coordName := fmt.Sprintf("memgraph-coordinator-%d", coordId) - - coordNodePort := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: serviceName, - Namespace: memgraphha.Namespace, - }, - Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeNodePort, - Selector: createCoordLabels(coordName), - Ports: []corev1.ServicePort{ - { - Name: "bolt", - Protocol: corev1.ProtocolTCP, - Port: int32(boltPort), - TargetPort: intstr.FromInt(boltPort), - }, - }, - }, - } - - ctrl.SetControllerReference(memgraphha, coordNodePort, r.Scheme) - return coordNodePort -} - -func (r *MemgraphHAReconciler) reconcileCoordClusterIPService(ctx context.Context, memgraphha *memgraphv1.MemgraphHA, logger *logr.Logger, coordId int) (bool, error) { - serviceName := fmt.Sprintf("memgraph-coordinator-%d", coordId) - logger.Info("Started reconciling ClusterIP service", "ClusterIP", serviceName) - - coordClusterIPService := &corev1.Service{} - err := r.Get(ctx, types.NamespacedName{Name: serviceName, Namespace: memgraphha.Namespace}, coordClusterIPService) - - if err == nil { - logger.Info("ClusterIP already exists.", "ClusterIP", serviceName) - return false, nil - } - - if errors.IsNotFound(err) { - clusterIP := r.createCoordClusterIP(memgraphha, coordId) - logger.Info("Creating a new ClusterIP", "ClusterIP.Namespace", clusterIP.Namespace, "ClusterIP.Name", clusterIP.Name) - err := r.Create(ctx, clusterIP) - if err != nil { - logger.Error(err, "Failed to create new ClusterIP", "ClusterIP.Namespace", clusterIP.Namespace, "ClusterIP.Name", clusterIP.Name) - return true, err - } - logger.Info("ClusterIP is created.", "ClusterIP", serviceName) - return true, nil - } - - logger.Error(err, "Failed to fetch ClusterIP", "ClusterIP", serviceName) - return true, err - -} - -func (r *MemgraphHAReconciler) createCoordClusterIP(memgraphha *memgraphv1.MemgraphHA, coordId int) *corev1.Service { - serviceName := fmt.Sprintf("memgraph-coordinator-%d", coordId) - coordName := serviceName - - coordClusterIP := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: serviceName, - Namespace: memgraphha.Namespace, - }, - Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeClusterIP, - Selector: createCoordLabels(coordName), - Ports: []corev1.ServicePort{ - { - Name: "bolt", - Protocol: corev1.ProtocolTCP, - Port: int32(boltPort), - TargetPort: intstr.FromInt(boltPort), - }, - { - Name: "coordinator", - Protocol: corev1.ProtocolTCP, - Port: int32(coordinatorPort), - TargetPort: intstr.FromInt(coordinatorPort), - }, - { - Name: "management", - Protocol: corev1.ProtocolTCP, - Port: int32(mgmtPort), - TargetPort: intstr.FromInt(mgmtPort), - }, - }, - }, - } - - ctrl.SetControllerReference(memgraphha, coordClusterIP, r.Scheme) - return coordClusterIP -} diff --git a/internal/controller/memgraphha_data_instance.go b/internal/controller/memgraphha_data_instance.go deleted file mode 100644 index fd2a5ee..0000000 --- a/internal/controller/memgraphha_data_instance.go +++ /dev/null @@ -1,229 +0,0 @@ -/* -Copyright 2024 Memgraph Ltd. - -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" - - "github.com/go-logr/logr" - memgraphv1 "github.com/memgraph/kubernetes-operator/api/v1" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" -) - -func (r *MemgraphHAReconciler) reconcileDataInstance(ctx context.Context, memgraphha *memgraphv1.MemgraphHA, logger *logr.Logger, dataInstanceId int) (bool, error) { - name := fmt.Sprintf("memgraph-data-%d", dataInstanceId) - logger.Info("Started reconciling", "StatefulSet", name) - dataInstanceStatefulSet := &appsv1.StatefulSet{} - err := r.Get(ctx, types.NamespacedName{Name: name, Namespace: memgraphha.Namespace}, dataInstanceStatefulSet) - - if err == nil { - logger.Info("StatefulSet already exists.", "StatefulSet", name) - return false, nil - } - - if errors.IsNotFound(err) { - dataInstance := r.createStatefulSetForDataInstance(memgraphha, dataInstanceId) - logger.Info("Creating a new StatefulSet", "StatefulSet.Namespace", dataInstance.Namespace, "StatefulSet.Name", dataInstance.Name) - err := r.Create(ctx, dataInstance) - if err != nil { - logger.Error(err, "Failed to create new StatefulSet", "StatefulSet.Namespace", dataInstance.Namespace, "StatefulSet.Name", dataInstance.Name) - return true, err - } - logger.Info("StatefulSet is created.", "StatefulSet", name) - return true, nil - } - - logger.Error(err, "Failed to fetch StatefulSet", "StatefulSet", name) - return true, err - -} - -func (r *MemgraphHAReconciler) createStatefulSetForDataInstance(memgraphha *memgraphv1.MemgraphHA, dataInstanceId int) *appsv1.StatefulSet { - dataInstanceName := fmt.Sprintf("memgraph-data-%d", dataInstanceId) - serviceName := dataInstanceName - labels := createDataInstanceLabels(dataInstanceName) - replicas := int32(1) - containerName := "memgraph-data" - args := []string{ - fmt.Sprintf("--management-port=%d", mgmtPort), - fmt.Sprintf("--bolt-port=%d", boltPort), - "--experimental-enabled=high-availability", - "--also-log-to-stderr", - "--log-level=TRACE", - "--log-file=/var/log/memgraph/memgraph.log", - } - volumeLibName := fmt.Sprintf("%s-lib-storage", dataInstanceName) - volumeLibSize := "1Gi" - volumeLogName := fmt.Sprintf("%s-log-storage", dataInstanceName) - volumeLogSize := "256Mi" - initContainerName := "init" - initContainerCommand := []string{ - "/bin/sh", - "-c", - } - initContainerArgs := []string{"chown -R memgraph:memgraph /var/log; chown -R memgraph:memgraph /var/lib"} - initContainerPrivileged := true - initContainerReadOnlyRootFilesystem := false - initContainerRunAsNonRoot := false - initContainerRunAsUser := int64(0) - - data := &appsv1.StatefulSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: dataInstanceName, - Namespace: memgraphha.Namespace, - }, - Spec: appsv1.StatefulSetSpec{ - ServiceName: serviceName, - Replicas: &replicas, - Selector: &metav1.LabelSelector{ - MatchLabels: labels, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: labels, - }, - Spec: corev1.PodSpec{ - InitContainers: []corev1.Container{ - { - Name: initContainerName, - Image: image, - VolumeMounts: []corev1.VolumeMount{ - { - Name: volumeLibName, - MountPath: "/var/lib/memgraph", - }, - { - Name: volumeLogName, - MountPath: "/var/log/memgraph", - }, - }, - Command: initContainerCommand, - Args: initContainerArgs, - SecurityContext: &corev1.SecurityContext{ - Privileged: &initContainerPrivileged, - ReadOnlyRootFilesystem: &initContainerReadOnlyRootFilesystem, - RunAsNonRoot: &initContainerRunAsNonRoot, - RunAsUser: &initContainerRunAsUser, - Capabilities: &corev1.Capabilities{ - Drop: []corev1.Capability{"all"}, - Add: []corev1.Capability{"CHOWN"}, - }, - }, - }, - }, - - Containers: []corev1.Container{{ - Name: containerName, - Image: image, - ImagePullPolicy: corev1.PullAlways, // set to PullIfNotPresent when testing with local image - Ports: []corev1.ContainerPort{ - { - ContainerPort: int32(boltPort), - Name: "bolt", - }, - { - ContainerPort: int32(mgmtPort), - Name: "management", - }, - { - ContainerPort: int32(replicationPort), - Name: "replication", - }, - }, - Args: args, - Env: []corev1.EnvVar{ - { - Name: "MEMGRAPH_ENTERPRISE_LICENSE", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "memgraph-secrets", - }, - Key: "MEMGRAPH_ENTERPRISE_LICENSE", - }, - }, - }, - { - Name: "MEMGRAPH_ORGANIZATION_NAME", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "memgraph-secrets", - }, - Key: "MEMGRAPH_ORGANIZATION_NAME", - }, - }, - }, - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: volumeLibName, - MountPath: "/var/lib/memgraph", - }, - { - Name: volumeLogName, - MountPath: "/var/log/memgraph", - }, - }, - }}, - }, - }, - VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: volumeLibName, - }, - Spec: corev1.PersistentVolumeClaimSpec{ - AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse(volumeLibSize), - }, - }, - }, - }, - { - ObjectMeta: metav1.ObjectMeta{ - Name: volumeLogName, - }, - Spec: corev1.PersistentVolumeClaimSpec{ - AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse(volumeLogSize), - }, - }, - }, - }, - }, - }, - } - - ctrl.SetControllerReference(memgraphha, data, r.Scheme) - return data -} - -func createDataInstanceLabels(dataInstanceName string) map[string]string { - return map[string]string{"app": dataInstanceName} -} diff --git a/internal/controller/memgraphha_data_services.go b/internal/controller/memgraphha_data_services.go deleted file mode 100644 index 253228a..0000000 --- a/internal/controller/memgraphha_data_services.go +++ /dev/null @@ -1,155 +0,0 @@ -/* -Copyright 2024 Memgraph Ltd. - -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" - - "github.com/go-logr/logr" - memgraphv1 "github.com/memgraph/kubernetes-operator/api/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/intstr" - ctrl "sigs.k8s.io/controller-runtime" -) - -func (r *MemgraphHAReconciler) reconcileDataInstanceNodePortService(ctx context.Context, memgraphha *memgraphv1.MemgraphHA, logger *logr.Logger, dataInstanceId int) (bool, error) { - serviceName := fmt.Sprintf("memgraph-data-%d-external", dataInstanceId) - logger.Info("Started reconciling NodePort service", "NodePort", serviceName) - - dataInstanceNodePortService := &corev1.Service{} - err := r.Get(ctx, types.NamespacedName{Name: serviceName, Namespace: memgraphha.Namespace}, dataInstanceNodePortService) - - if err == nil { - logger.Info("NodePort already exists.", "NodePort", serviceName) - return false, nil - } - - if errors.IsNotFound(err) { - nodePort := r.createDataInstanceNodePort(memgraphha, dataInstanceId) - logger.Info("Creating a new NodePort", "NodePort.Namespace", nodePort.Namespace, "NodePort.Name", nodePort.Name) - err := r.Create(ctx, nodePort) - if err != nil { - logger.Error(err, "Failed to create new NodePort", "NodePort.Namespace", nodePort.Namespace, "NodePort.Name", nodePort.Name) - return true, err - } - logger.Info("NodePort is created.", "NodePort", serviceName) - return true, nil - } - - logger.Error(err, "Failed to fetch NodePort", "NodePort", serviceName) - return true, err - -} - -func (r *MemgraphHAReconciler) createDataInstanceNodePort(memgraphha *memgraphv1.MemgraphHA, dataInstanceId int) *corev1.Service { - serviceName := fmt.Sprintf("memgraph-data-%d-external", dataInstanceId) - dataInstanceName := fmt.Sprintf("memgraph-data-%d", dataInstanceId) - - dataInstanceNodePort := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: serviceName, - Namespace: memgraphha.Namespace, - }, - Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeNodePort, - Selector: createDataInstanceLabels(dataInstanceName), - Ports: []corev1.ServicePort{ - { - Name: "bolt", - Protocol: corev1.ProtocolTCP, - Port: int32(boltPort), - TargetPort: intstr.FromInt(boltPort), - }, - }, - }, - } - - ctrl.SetControllerReference(memgraphha, dataInstanceNodePort, r.Scheme) - return dataInstanceNodePort -} - -func (r *MemgraphHAReconciler) reconcileDataInstanceClusterIPService(ctx context.Context, memgraphha *memgraphv1.MemgraphHA, logger *logr.Logger, dataInstanceId int) (bool, error) { - serviceName := fmt.Sprintf("memgraph-data-%d", dataInstanceId) - logger.Info("Started reconciling ClusterIP service", "ClusterIP", serviceName) - - dataInstanceClusterIPService := &corev1.Service{} - err := r.Get(ctx, types.NamespacedName{Name: serviceName, Namespace: memgraphha.Namespace}, dataInstanceClusterIPService) - - if err == nil { - logger.Info("ClusterIP already exists.", "ClusterIP", serviceName) - return false, nil - } - - if errors.IsNotFound(err) { - clusterIP := r.createDataInstanceClusterIP(memgraphha, dataInstanceId) - logger.Info("Creating a new ClusterIP", "ClusterIP.Namespace", clusterIP.Namespace, "ClusterIP.Name", clusterIP.Name) - err := r.Create(ctx, clusterIP) - if err != nil { - logger.Error(err, "Failed to create new ClusterIP", "ClusterIP.Namespace", clusterIP.Namespace, "ClusterIP.Name", clusterIP.Name) - return true, err - } - logger.Info("ClusterIP is created.", "ClusterIP", serviceName) - return true, nil - } - - logger.Error(err, "Failed to fetch ClusterIP", "ClusterIP", serviceName) - return true, err - -} - -func (r *MemgraphHAReconciler) createDataInstanceClusterIP(memgraphha *memgraphv1.MemgraphHA, dataInstanceId int) *corev1.Service { - serviceName := fmt.Sprintf("memgraph-data-%d", dataInstanceId) - dataInstanceName := serviceName - - dataInstanceClusterIP := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: serviceName, - Namespace: memgraphha.Namespace, - }, - Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeClusterIP, - Selector: createDataInstanceLabels(dataInstanceName), - Ports: []corev1.ServicePort{ - { - Name: "bolt", - Protocol: corev1.ProtocolTCP, - Port: int32(boltPort), - TargetPort: intstr.FromInt(boltPort), - }, - { - Name: "replication", - Protocol: corev1.ProtocolTCP, - Port: int32(replicationPort), - TargetPort: intstr.FromInt(replicationPort), - }, - { - Name: "management", - Protocol: corev1.ProtocolTCP, - Port: int32(mgmtPort), - TargetPort: intstr.FromInt(mgmtPort), - }, - }, - }, - } - - ctrl.SetControllerReference(memgraphha, dataInstanceClusterIP, r.Scheme) - return dataInstanceClusterIP -} diff --git a/internal/controller/memgraphha_reconciler.go b/internal/controller/memgraphha_reconciler.go deleted file mode 100644 index 49c3067..0000000 --- a/internal/controller/memgraphha_reconciler.go +++ /dev/null @@ -1,28 +0,0 @@ -/* -Copyright 2024 Memgraph Ltd. - -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 ( - "k8s.io/apimachinery/pkg/runtime" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -// MemgraphHAReconciler reconciles a MemgraphHA object -type MemgraphHAReconciler struct { - client.Client - Scheme *runtime.Scheme -} diff --git a/internal/controller/memgraphha_setup_job.go b/internal/controller/memgraphha_setup_job.go deleted file mode 100644 index 6607335..0000000 --- a/internal/controller/memgraphha_setup_job.go +++ /dev/null @@ -1,115 +0,0 @@ -/* -copyright 2024 memgraph ltd. - -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" - - "github.com/go-logr/logr" - memgraphv1 "github.com/memgraph/kubernetes-operator/api/v1" - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" -) - -func (r *MemgraphHAReconciler) reconcileSetupJob(ctx context.Context, memgraphha *memgraphv1.MemgraphHA, logger *logr.Logger) (bool, error) { - name := fmt.Sprintf("memgraph-setup") - logger.Info("Started reconciling", "Job", name) - setupJob := &batchv1.Job{} - err := r.Get(ctx, types.NamespacedName{Name: name, Namespace: memgraphha.Namespace}, setupJob) - - if err == nil { - logger.Info("SetupJob already exists.", "Job", name) - return false, nil - } - - if errors.IsNotFound(err) { - job := r.createSetupJob(memgraphha) - logger.Info("Creating a new SetupJob", "SetupJob.Namespace", job.Namespace, "SetupJob.Name", job.Name) - err := r.Create(ctx, job) - if err != nil { - logger.Error(err, "Failed to create new SetupJob", "SetupJob.Namespace", job.Namespace, "SetupJob.Name", job.Name) - return true, err - } - logger.Info("SetupJob is created.", "Job", name) - return true, nil - } - - logger.Error(err, "Failed to fetch SetupJob", "Job", name) - return true, err - -} - -func (r *MemgraphHAReconciler) createSetupJob(memgraphha *memgraphv1.MemgraphHA) *batchv1.Job { - containerName := "memgraph-setup" - runAsUser := int64(0) - backoffLimit := int32(4) - - job := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: containerName, - Namespace: memgraphha.Namespace, - }, - Spec: batchv1.JobSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: containerName, - Image: image, - Command: []string{"/bin/bash", "-c"}, - Args: []string{` - echo "Installing netcat..." - apt-get update && apt-get install -y netcat-openbsd - echo "Waiting for pods to become available for Bolt connection. Time: $(date +'%H:%M:%S')" - until nc -z memgraph-coordinator-1.default.svc.cluster.local 7687; do sleep 1; done - until nc -z memgraph-coordinator-2.default.svc.cluster.local 7687; do sleep 1; done - until nc -z memgraph-coordinator-3.default.svc.cluster.local 7687; do sleep 1; done - until nc -z memgraph-data-0.default.svc.cluster.local 7687; do sleep 1; done - until nc -z memgraph-data-1.default.svc.cluster.local 7687; do sleep 1; done - echo "Pods are available for Bolt connection. Running registration queries! Time: $(date +'%H:%M:%S')" - echo 'ADD COORDINATOR 2 WITH CONFIG {"bolt_server": "memgraph-coordinator-2.default.svc.cluster.local:7687", "management_server": "memgraph-coordinator-2.default.svc.cluster.local:10000", "coordinator_server": "memgraph-coordinator-2.default.svc.cluster.local:12000"};' | mgconsole --host memgraph-coordinator-1.default.svc.cluster.local --port 7687 - echo "Coordinator 2 added. Time: $(date +'%H:%M:%S')" - echo 'ADD COORDINATOR 3 WITH CONFIG {"bolt_server": "memgraph-coordinator-3.default.svc.cluster.local:7687", "management_server": "memgraph-coordinator-3.default.svc.cluster.local:10000", "coordinator_server": "memgraph-coordinator-3.default.svc.cluster.local:12000"};' | mgconsole --host memgraph-coordinator-1.default.svc.cluster.local --port 7687 - echo "Coordinator 3 added. Time: $(date +'%H:%M:%S')" - echo 'REGISTER INSTANCE instance_1 WITH CONFIG {"bolt_server": "memgraph-data-0.default.svc.cluster.local:7687", "management_server": "memgraph-data-0.default.svc.cluster.local:10000", "replication_server": "memgraph-data-0.default.svc.cluster.local:20000"};' | mgconsole --host memgraph-coordinator-1.default.svc.cluster.local --port 7687 - echo "Instance 1 added. Time: $(date +'%H:%M:%S')" - echo 'REGISTER INSTANCE instance_2 WITH CONFIG {"bolt_server": "memgraph-data-1.default.svc.cluster.local:7687", "management_server": "memgraph-data-1.default.svc.cluster.local:10000", "replication_server": "memgraph-data-1.default.svc.cluster.local:20000"};' | mgconsole --host memgraph-coordinator-1.default.svc.cluster.local --port 7687 - echo "Instance 2 added. Time: $(date +'%H:%M:%S')" - echo 'SET INSTANCE instance_1 TO MAIN;' | mgconsole --host memgraph-coordinator-1.default.svc.cluster.local --port 7687 - echo "Instance 1 set to main. Time: $(date +'%H:%M:%S')" - echo "Setup finished!" - `}, - SecurityContext: &corev1.SecurityContext{ - RunAsUser: &runAsUser, - }, - }, - }, - RestartPolicy: corev1.RestartPolicyNever, - }, - }, - BackoffLimit: &backoffLimit, - }, - } - - ctrl.SetControllerReference(memgraphha, job, r.Scheme) - return job -} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go new file mode 100644 index 0000000..2ed9dea --- /dev/null +++ b/internal/controller/suite_test.go @@ -0,0 +1,118 @@ +/* +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" + "os" + "path/filepath" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" + // +kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var ( + ctx context.Context + cancel context.CancelFunc + testEnv *envtest.Environment + cfg *rest.Config + k8sClient client.Client +) + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + var err error + err = memgraphcomv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if getFirstFoundEnvTestBinaryDir() != "" { + testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() + } + + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + Eventually(func() error { + return testEnv.Stop() + }, time.Minute, time.Second).Should(Succeed()) +}) + +// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. +// ENVTEST-based tests depend on specific binaries, usually located in paths set by +// controller-runtime. When running tests directly (e.g., via an IDE) without using +// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. +// +// This function streamlines the process by finding the required binaries, similar to +// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are +// properly set up, run 'make setup-envtest' beforehand. +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + logf.Log.Error(err, "Failed to read directory", "path", basePath) + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} diff --git a/internal/memgraph/bolt.go b/internal/memgraph/bolt.go new file mode 100644 index 0000000..3d168dc --- /dev/null +++ b/internal/memgraph/bolt.go @@ -0,0 +1,230 @@ +/* +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 memgraph + +import ( + "context" + "fmt" + "maps" + "slices" + + "github.com/neo4j/neo4j-go-driver/v5/neo4j" + "github.com/neo4j/neo4j-go-driver/v5/neo4j/db" +) + +// NewBoltConnector returns the production Connector dialing coordinators over +// unauthenticated Bolt (Bolt auth is out of scope for v1alpha1). +func NewBoltConnector() Connector { + return boltConnector{} +} + +type boltConnector struct{} + +func (boltConnector) Connect(ctx context.Context, address string) (Client, error) { + driver, err := neo4j.NewDriverWithContext("bolt://"+address, neo4j.NoAuth()) + if err != nil { + return nil, fmt.Errorf("creating bolt driver for %s: %w", address, err) + } + if err := driver.VerifyConnectivity(ctx); err != nil { + _ = driver.Close(ctx) + return nil, fmt.Errorf("connecting to %s: %w", address, err) + } + return &boltClient{driver: driver}, nil +} + +type boltClient struct { + driver neo4j.DriverWithContext +} + +func (c *boltClient) ShowInstances(ctx context.Context) ([]Instance, error) { + records, err := c.run(ctx, showInstancesQuery) + if err != nil { + return nil, err + } + instances := make([]Instance, 0, len(records)) + for _, record := range records { + instances = append(instances, instanceFromRecord(record)) + } + return instances, nil +} + +func (c *boltClient) ShowReplicationLag(ctx context.Context) ([]ReplicationLag, error) { + records, err := c.run(ctx, showReplicationLagQuery) + if err != nil { + return nil, err + } + lag := make([]ReplicationLag, 0, len(records)) + for _, record := range records { + instance, err := replicationLagFromRecord(record) + if err != nil { + return nil, err + } + lag = append(lag, instance) + } + return lag, nil +} + +func (c *boltClient) AddCoordinator(ctx context.Context, coordinator CoordinatorSpec) error { + _, err := c.run(ctx, addCoordinatorQuery(coordinator)) + return err +} + +func (c *boltClient) RegisterInstance(ctx context.Context, instance DataInstanceSpec) error { + _, err := c.run(ctx, registerInstanceQuery(instance)) + return err +} + +func (c *boltClient) SetInstanceToMain(ctx context.Context, name string) error { + _, err := c.run(ctx, setInstanceToMainQuery(name)) + return err +} + +func (c *boltClient) DemoteInstance(ctx context.Context, name string) error { + _, err := c.run(ctx, demoteInstanceQuery(name)) + return err +} + +func (c *boltClient) UnregisterInstance(ctx context.Context, name string) error { + _, err := c.run(ctx, unregisterInstanceQuery(name)) + return err +} + +func (c *boltClient) RemoveCoordinator(ctx context.Context, id int32) error { + _, err := c.run(ctx, removeCoordinatorQuery(id)) + return err +} + +func (c *boltClient) YieldLeadership(ctx context.Context) error { + _, err := c.run(ctx, yieldLeadershipQuery) + return err +} + +func (c *boltClient) Close(ctx context.Context) error { + return c.driver.Close(ctx) +} + +// run executes one query in an autocommit session; Memgraph's coordinator +// queries cannot run inside explicit transactions. +func (c *boltClient) run(ctx context.Context, query string) ([]*db.Record, error) { + session := c.driver.NewSession(ctx, neo4j.SessionConfig{}) + defer func() { _ = session.Close(ctx) }() + + result, err := session.Run(ctx, query, nil) + if err != nil { + return nil, fmt.Errorf("running %q: %w", query, err) + } + records, err := result.Collect(ctx) + if err != nil { + return nil, fmt.Errorf("collecting results of %q: %w", query, err) + } + return records, nil +} + +// instanceFromRecord maps one SHOW INSTANCES row to an Instance. Columns are +// looked up by name so the parsing survives added or reordered columns +// (last_succ_resp_ms is deliberately ignored). +func instanceFromRecord(record *db.Record) Instance { + return Instance{ + Name: stringColumn(record, "name"), + BoltServer: stringColumn(record, "bolt_server"), + CoordinatorServer: stringColumn(record, "coordinator_server"), + ManagementServer: stringColumn(record, "management_server"), + Health: stringColumn(record, "health"), + Role: stringColumn(record, "role"), + } +} + +// replicationLagFromRecord maps one SHOW REPLICATION LAG row to a +// ReplicationLag: an instance_name and a data_info map keyed by database name, +// each entry carrying that database's counters. +// +// Unlike instanceFromRecord this refuses a row it cannot read rather than +// filling in zero values. The leniency there is safe because a missing column +// leaves an empty string, which reads as neither up nor MAIN; here a missing +// counter would read as zero transactions behind, which is precisely the answer +// that makes an instance promotable. This view must never invent that. +func replicationLagFromRecord(record *db.Record) (ReplicationLag, error) { + name := stringColumn(record, "instance_name") + if name == "" { + return ReplicationLag{}, fmt.Errorf("%s row carries no instance_name", showReplicationLagQuery) + } + databases, ok := mapColumn(record, "data_info") + if !ok { + return ReplicationLag{}, fmt.Errorf("%s row for %s carries no data_info map", showReplicationLagQuery, name) + } + + lag := ReplicationLag{Instance: name} + // Databases come out ordered by name so the view a caller compares is stable + // across reconciles; the driver hands back an unordered map. + for _, database := range slices.Sorted(maps.Keys(databases)) { + counters, ok := asMap(databases[database]) + if !ok { + return ReplicationLag{}, fmt.Errorf("%s row for %s carries no counters for database %s", + showReplicationLagQuery, name, database) + } + committed, ok := intEntry(counters, "num_committed_txns") + if !ok { + return ReplicationLag{}, fmt.Errorf("%s row for %s is missing num_committed_txns for database %s", + showReplicationLagQuery, name, database) + } + behind, ok := intEntry(counters, "num_txns_behind_main") + if !ok { + return ReplicationLag{}, fmt.Errorf("%s row for %s is missing num_txns_behind_main for database %s", + showReplicationLagQuery, name, database) + } + lag.Databases = append(lag.Databases, DatabaseLag{ + Database: database, + CommittedTxns: committed, + TxnsBehindMain: behind, + }) + } + return lag, nil +} + +func stringColumn(record *db.Record, key string) string { + value, ok := record.Get(key) + if !ok { + return "" + } + s, ok := value.(string) + if !ok { + return "" + } + return s +} + +func mapColumn(record *db.Record, key string) (map[string]any, bool) { + value, ok := record.Get(key) + if !ok { + return nil, false + } + return asMap(value) +} + +func asMap(value any) (map[string]any, bool) { + m, ok := value.(map[string]any) + return m, ok +} + +func intEntry(m map[string]any, key string) (int64, bool) { + value, ok := m[key] + if !ok { + return 0, false + } + i, ok := value.(int64) + return i, ok +} diff --git a/internal/memgraph/client.go b/internal/memgraph/client.go new file mode 100644 index 0000000..f23f9c9 --- /dev/null +++ b/internal/memgraph/client.go @@ -0,0 +1,205 @@ +/* +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 memgraph provides the narrow client surface the operator uses to +// drive a Memgraph high-availability cluster over Bolt: show instances, show +// replication lag, add coordinator, register instance, set main, and — for the +// members a lowered replica count is retiring — demote and unregister a data +// instance, yield coordinator leadership and remove a coordinator. All higher +// layers depend on the Client and Connector interfaces, never on the Bolt driver +// — this package is the mock seam for testing and the only place the driver is +// referenced. +package memgraph + +import ( + "context" + "fmt" + "strings" +) + +// Roles reported in the SHOW INSTANCES role column: coordinators are +// leader/follower, data instances are main/replica. +const ( + RoleLeader = "leader" + RoleFollower = "follower" + RoleMain = "main" + RoleReplica = "replica" +) + +// HealthUp is the SHOW INSTANCES health of an instance the coordinator leader +// currently reaches. Anything else — "down", or "unknown" from a coordinator +// that does not health-check the data plane — means it does not. +const HealthUp = "up" + +// Instance is one row of SHOW INSTANCES: a coordinator or data instance the +// cluster currently knows about. +type Instance struct { + Name string + BoltServer string + CoordinatorServer string + ManagementServer string + Health string + Role string +} + +// IsLeader reports whether the instance is the current coordinator leader. +func (i Instance) IsLeader() bool { + return strings.EqualFold(i.Role, RoleLeader) +} + +// IsMain reports whether the instance is the current MAIN data instance. +func (i Instance) IsMain() bool { + return strings.EqualFold(i.Role, RoleMain) +} + +// IsUp reports whether the coordinator leader currently reaches the instance. +func (i Instance) IsUp() bool { + return strings.EqualFold(i.Health, HealthUp) +} + +// CoordinatorSpec declares one coordinator to add to the cluster. Servers are +// "host:port" addresses the rest of the cluster reaches the coordinator at. +type CoordinatorSpec struct { + // ID is the Raft coordinator ID (1-based; Memgraph treats ID 0 as unset). + ID int32 + BoltServer string + CoordinatorServer string + ManagementServer string +} + +// Name returns the instance name Memgraph derives from the coordinator ID and +// reports in SHOW INSTANCES. +func (c CoordinatorSpec) Name() string { + return fmt.Sprintf(coordinatorNameFormat, c.ID) +} + +// CoordinatorIDFromName is the inverse of Name: the Raft ID of the coordinator a +// view names. It sits here rather than with its callers so that the format and its +// parser cannot drift — a changed name would otherwise leave the parser silently +// matching nothing. +func CoordinatorIDFromName(name string) (int32, error) { + var id int32 + if _, err := fmt.Sscanf(name, coordinatorNameFormat, &id); err != nil { + return 0, fmt.Errorf("parsing coordinator name %q: %w", name, err) + } + return id, nil +} + +// coordinatorNameFormat is how Memgraph derives a coordinator's instance name +// from its Raft ID, stated once for both directions. +const coordinatorNameFormat = "coordinator_%d" + +// DataInstanceSpec declares one data instance to register with the cluster. +type DataInstanceSpec struct { + Name string + BoltServer string + ManagementServer string + ReplicationServer string +} + +// DatabaseLag is one database's replication progress on one data instance: how +// many transactions it has committed, and how many that leaves it behind the +// MAIN. The count behind can be negative for a moment after a failover — a SYNC +// replica can hold transactions the new MAIN never saw — so "not behind" is the +// condition worth testing, never "exactly equal". +type DatabaseLag struct { + Database string + CommittedTxns int64 + TxnsBehindMain int64 +} + +// ReplicationLag is one row of SHOW REPLICATION LAG: one data instance's +// replication progress across every database it holds. The MAIN reports itself +// too, at zero behind, because the lag of every other instance is measured +// against it. +type ReplicationLag struct { + Instance string + Databases []DatabaseLag +} + +// IsCaughtUp reports whether the instance holds every transaction the MAIN has +// committed, in every one of its databases — which is what makes it promotable +// without losing writes. +// +// An instance with no databases reported is not caught up. That is the answer +// for anything the view does not cover: an instance the MAIN does not list, or a +// whole view that came back empty because there is no MAIN to measure against. +// Unknown has to read as "not safe to promote", because the alternative is +// promoting on an assumption and discarding whatever the survivor never received. +func (l ReplicationLag) IsCaughtUp() bool { + if len(l.Databases) == 0 { + return false + } + for _, database := range l.Databases { + if database.TxnsBehindMain > 0 { + return false + } + } + return true +} + +// Client is the narrow surface of a single coordinator's Bolt endpoint. Every +// method issues exactly one HA management query. +type Client interface { + ShowInstances(ctx context.Context) ([]Instance, error) + + // ShowReplicationLag reports how far behind the MAIN every data instance the + // cluster knows is, counted in committed transactions. Only a coordinator + // answers it, and the answer is relayed from the MAIN itself — so a cluster + // with no MAIN, or one whose MAIN the coordinator leader cannot reach, reports + // no rows rather than failing. An empty view therefore means "cannot tell", + // which is why nothing is promoted on the strength of it. + ShowReplicationLag(ctx context.Context) ([]ReplicationLag, error) + + AddCoordinator(ctx context.Context, coordinator CoordinatorSpec) error + RegisterInstance(ctx context.Context, instance DataInstanceSpec) error + SetInstanceToMain(ctx context.Context, name string) error + + // DemoteInstance turns the named MAIN back into a replica, which is what + // makes a MAIN on its way out of the cluster unregisterable: Memgraph + // refuses to unregister the MAIN. It deliberately leaves the cluster + // MAIN-less — the coordinators fail over only on a leadership change or a + // failed ping, so the caller promotes a survivor itself. + DemoteInstance(ctx context.Context, name string) error + + // UnregisterInstance removes the named data instance from the cluster, so + // the coordinators stop expecting it before its pod goes away. + UnregisterInstance(ctx context.Context, name string) error + + // RemoveCoordinator drops the coordinator with the given Raft ID from the + // Raft cluster, so its vote is gone before its pod is. Raft refuses to + // remove its own leader, so the caller must never aim this at the leader — + // YieldLeadership moves leadership away first. + // + // The removed coordinator keeps running and keeps its state: NuRaft only + // stops it from campaigning, which is what makes a later ADD COORDINATOR on + // the retained volume safe. + RemoveCoordinator(ctx context.Context, id int32) error + + // YieldLeadership makes the coordinator this client is connected to give up + // Raft leadership. It has to be issued on the leader itself and cannot name + // a successor — NuRaft's election picks one — so its outcome is not + // predictable and the caller must re-observe the cluster afterwards. + YieldLeadership(ctx context.Context) error + + Close(ctx context.Context) error +} + +// Connector opens a Client to a coordinator's "host:port" Bolt address. The +// controller depends on this interface so tests can substitute a fake cluster. +type Connector interface { + Connect(ctx context.Context, address string) (Client, error) +} diff --git a/internal/memgraph/queries.go b/internal/memgraph/queries.go new file mode 100644 index 0000000..c5841e0 --- /dev/null +++ b/internal/memgraph/queries.go @@ -0,0 +1,72 @@ +/* +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 memgraph + +import "fmt" + +// The HA management query grammar, mirroring what the memgraph-high-availability +// Helm chart's registration job issues. Config values are rendered inline +// because Memgraph's coordinator queries do not accept Bolt parameters; all +// inputs are operator-derived names and "host:port" addresses, never user text. + +const showInstancesQuery = "SHOW INSTANCES" + +// showReplicationLagQuery is answered only by a coordinator, which relays the +// counts from the MAIN. Like YIELD LEADERSHIP it takes no argument: the answer +// covers every instance the cluster knows at once. +const showReplicationLagQuery = "SHOW REPLICATION LAG" + +func addCoordinatorQuery(coordinator CoordinatorSpec) string { + return fmt.Sprintf( + `ADD COORDINATOR %d WITH CONFIG {"bolt_server": %q, "coordinator_server": %q, "management_server": %q}`, + coordinator.ID, + coordinator.BoltServer, + coordinator.CoordinatorServer, + coordinator.ManagementServer, + ) +} + +func registerInstanceQuery(instance DataInstanceSpec) string { + return fmt.Sprintf( + `REGISTER INSTANCE %s WITH CONFIG {"bolt_server": %q, "management_server": %q, "replication_server": %q}`, + instance.Name, + instance.BoltServer, + instance.ManagementServer, + instance.ReplicationServer, + ) +} + +func setInstanceToMainQuery(name string) string { + return fmt.Sprintf("SET INSTANCE %s TO MAIN", name) +} + +func demoteInstanceQuery(name string) string { + return fmt.Sprintf("DEMOTE INSTANCE %s", name) +} + +func unregisterInstanceQuery(name string) string { + return fmt.Sprintf("UNREGISTER INSTANCE %s", name) +} + +func removeCoordinatorQuery(id int32) string { + return fmt.Sprintf("REMOVE COORDINATOR %d", id) +} + +// yieldLeadershipQuery takes no argument on purpose: Memgraph's grammar has no +// successor to name, so the coordinator it runs on hands leadership to whichever +// member NuRaft's election picks. +const yieldLeadershipQuery = "YIELD LEADERSHIP" diff --git a/internal/memgraph/queries_test.go b/internal/memgraph/queries_test.go new file mode 100644 index 0000000..e14b6d2 --- /dev/null +++ b/internal/memgraph/queries_test.go @@ -0,0 +1,298 @@ +/* +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 memgraph + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/neo4j/neo4j-go-driver/v5/neo4j/db" +) + +const testInstanceName = "instance_1" + +// The SHOW REPLICATION LAG column and map keys, plus the two databases the cases +// below report on. They are spelled out here rather than shared with the parser +// on purpose: pinning the names the parser reads off the wire is what these tests +// are for, and a constant shared with it would let a rename pass unnoticed. +const ( + instanceNameColumn = "instance_name" + dataInfoColumn = "data_info" + committedTxnsKey = "num_committed_txns" + behindMainKey = "num_txns_behind_main" + defaultDatabase = "memgraph" + otherDatabase = "analytics" +) + +func TestAddCoordinatorQuery(t *testing.T) { + got := addCoordinatorQuery(CoordinatorSpec{ + ID: 2, + BoltServer: "example-coordinator-1.example-coordinator.default.svc.cluster.local:7687", + CoordinatorServer: "example-coordinator-1.example-coordinator.default.svc.cluster.local:12000", + ManagementServer: "example-coordinator-1.example-coordinator.default.svc.cluster.local:10000", + }) + want := `ADD COORDINATOR 2 WITH CONFIG {` + + `"bolt_server": "example-coordinator-1.example-coordinator.default.svc.cluster.local:7687", ` + + `"coordinator_server": "example-coordinator-1.example-coordinator.default.svc.cluster.local:12000", ` + + `"management_server": "example-coordinator-1.example-coordinator.default.svc.cluster.local:10000"}` + if got != want { + t.Errorf("addCoordinatorQuery() = %q, want %q", got, want) + } +} + +func TestRegisterInstanceQuery(t *testing.T) { + got := registerInstanceQuery(DataInstanceSpec{ + Name: testInstanceName, + BoltServer: "example-data-0.example-data.default.svc.cluster.local:7687", + ManagementServer: "example-data-0.example-data.default.svc.cluster.local:10000", + ReplicationServer: "example-data-0.example-data.default.svc.cluster.local:20000", + }) + want := `REGISTER INSTANCE instance_1 WITH CONFIG {` + + `"bolt_server": "example-data-0.example-data.default.svc.cluster.local:7687", ` + + `"management_server": "example-data-0.example-data.default.svc.cluster.local:10000", ` + + `"replication_server": "example-data-0.example-data.default.svc.cluster.local:20000"}` + if got != want { + t.Errorf("registerInstanceQuery() = %q, want %q", got, want) + } +} + +func TestSetInstanceToMainQuery(t *testing.T) { + got := setInstanceToMainQuery(testInstanceName) + if want := "SET INSTANCE instance_1 TO MAIN"; got != want { + t.Errorf("setInstanceToMainQuery() = %q, want %q", got, want) + } +} + +func TestDemoteInstanceQuery(t *testing.T) { + got := demoteInstanceQuery(testInstanceName) + if want := "DEMOTE INSTANCE instance_1"; got != want { + t.Errorf("demoteInstanceQuery() = %q, want %q", got, want) + } +} + +func TestUnregisterInstanceQuery(t *testing.T) { + got := unregisterInstanceQuery(testInstanceName) + if want := "UNREGISTER INSTANCE instance_1"; got != want { + t.Errorf("unregisterInstanceQuery() = %q, want %q", got, want) + } +} + +func TestRemoveCoordinatorQuery(t *testing.T) { + got := removeCoordinatorQuery(4) + if want := "REMOVE COORDINATOR 4"; got != want { + t.Errorf("removeCoordinatorQuery() = %q, want %q", got, want) + } +} + +// YIELD LEADERSHIP names no successor: the coordinator it runs on is the subject, +// and NuRaft picks who takes over. A query that grew an argument would mean the +// planner could suddenly predict the outcome, so the shape is pinned. +func TestYieldLeadershipQuery(t *testing.T) { + if want := "YIELD LEADERSHIP"; yieldLeadershipQuery != want { + t.Errorf("yieldLeadershipQuery = %q, want %q", yieldLeadershipQuery, want) + } +} + +// SHOW REPLICATION LAG takes no argument: one call covers every instance the +// cluster knows, so a query that grew one would mean the caller had to ask per +// instance and could no longer compare them from a single view. +func TestShowReplicationLagQuery(t *testing.T) { + if want := "SHOW REPLICATION LAG"; showReplicationLagQuery != want { + t.Errorf("showReplicationLagQuery = %q, want %q", showReplicationLagQuery, want) + } +} + +func TestReplicationLagFromRecord(t *testing.T) { + record := &db.Record{ + Keys: []string{instanceNameColumn, dataInfoColumn}, + Values: []any{testInstanceName, map[string]any{ + defaultDatabase: map[string]any{ + committedTxnsKey: int64(42), + behindMainKey: int64(0), + }, + // Sorted by database name, so this one comes out first. + otherDatabase: map[string]any{ + committedTxnsKey: int64(40), + behindMainKey: int64(2), + }, + }}, + } + want := ReplicationLag{ + Instance: testInstanceName, + Databases: []DatabaseLag{ + {Database: otherDatabase, CommittedTxns: 40, TxnsBehindMain: 2}, + {Database: defaultDatabase, CommittedTxns: 42, TxnsBehindMain: 0}, + }, + } + + got, err := replicationLagFromRecord(record) + if err != nil { + t.Fatalf("replicationLagFromRecord() error = %v", err) + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("replicationLagFromRecord() mismatch (-want +got):\n%s", diff) + } +} + +// A row the parsing cannot read has to fail rather than default. Every field it +// could default is zero, and zero transactions behind is exactly the answer that +// makes an instance promotable — so a malformed row must never be quietly read as +// a caught-up one. +func TestReplicationLagFromRecordRejectsUnreadableRows(t *testing.T) { + cases := []struct { + name string + record *db.Record + }{ + { + name: "no instance_name", + record: &db.Record{Keys: []string{dataInfoColumn}, Values: []any{map[string]any{}}}, + }, + { + name: "no data_info", + record: &db.Record{Keys: []string{instanceNameColumn}, Values: []any{testInstanceName}}, + }, + { + name: "data_info is not a map of maps", + record: &db.Record{ + Keys: []string{instanceNameColumn, dataInfoColumn}, + Values: []any{testInstanceName, map[string]any{defaultDatabase: int64(3)}}, + }, + }, + { + name: "no num_txns_behind_main", + record: &db.Record{ + Keys: []string{instanceNameColumn, dataInfoColumn}, + Values: []any{testInstanceName, map[string]any{ + defaultDatabase: map[string]any{committedTxnsKey: int64(42)}, + }}, + }, + }, + { + name: "no num_committed_txns", + record: &db.Record{ + Keys: []string{instanceNameColumn, dataInfoColumn}, + Values: []any{testInstanceName, map[string]any{ + defaultDatabase: map[string]any{behindMainKey: int64(0)}, + }}, + }, + }, + { + name: "counters are not integers", + record: &db.Record{ + Keys: []string{instanceNameColumn, dataInfoColumn}, + Values: []any{testInstanceName, map[string]any{ + defaultDatabase: map[string]any{committedTxnsKey: "42", behindMainKey: "0"}, + }}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := replicationLagFromRecord(tc.record); err == nil { + t.Error("replicationLagFromRecord() succeeded on an unreadable row, want error") + } + }) + } +} + +func TestReplicationLagIsCaughtUp(t *testing.T) { + cases := []struct { + name string + lag ReplicationLag + want bool + }{ + { + name: "every database at the MAIN's offset", + lag: ReplicationLag{Instance: testInstanceName, Databases: []DatabaseLag{ + {Database: otherDatabase, TxnsBehindMain: 0}, + {Database: defaultDatabase, TxnsBehindMain: 0}, + }}, + want: true, + }, + { + name: "behind in one database of several", + lag: ReplicationLag{Instance: testInstanceName, Databases: []DatabaseLag{ + {Database: otherDatabase, TxnsBehindMain: 0}, + {Database: defaultDatabase, TxnsBehindMain: 1}, + }}, + want: false, + }, + // SYNC replication can leave a replica holding transactions a newly promoted + // MAIN never saw. Ahead is not behind, so it does not disqualify. + { + name: "ahead of the MAIN", + lag: ReplicationLag{Instance: testInstanceName, Databases: []DatabaseLag{ + {Database: defaultDatabase, TxnsBehindMain: -2}, + }}, + want: true, + }, + // Nothing reported is not the same as nothing behind: an instance the MAIN + // does not list is one whose progress is unknown. + { + name: "no databases reported", + lag: ReplicationLag{Instance: testInstanceName}, + want: false, + }, + { + name: "the zero value, as a lookup miss reads back", + lag: ReplicationLag{}, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.lag.IsCaughtUp(); got != tc.want { + t.Errorf("IsCaughtUp() = %t, want %t", got, tc.want) + } + }) + } +} + +func TestInstanceFromRecord(t *testing.T) { + record := &db.Record{ + Keys: []string{ + "name", "bolt_server", "coordinator_server", "management_server", "health", "role", "last_succ_resp_ms", + }, + Values: []any{ + "coordinator_1", "localhost:7687", "localhost:12000", "localhost:10000", "up", "leader", int64(12), + }, + } + want := Instance{ + Name: "coordinator_1", + BoltServer: "localhost:7687", + CoordinatorServer: "localhost:12000", + ManagementServer: "localhost:10000", + Health: "up", + Role: "leader", + } + if diff := cmp.Diff(want, instanceFromRecord(record)); diff != "" { + t.Errorf("instanceFromRecord() mismatch (-want +got):\n%s", diff) + } +} + +func TestInstanceFromRecordToleratesMissingColumns(t *testing.T) { + record := &db.Record{ + Keys: []string{"name", "role"}, + Values: []any{testInstanceName, "main"}, + } + want := Instance{Name: testInstanceName, Role: "main"} + if diff := cmp.Diff(want, instanceFromRecord(record)); diff != "" { + t.Errorf("instanceFromRecord() mismatch (-want +got):\n%s", diff) + } +} diff --git a/internal/planner/planner.go b/internal/planner/planner.go new file mode 100644 index 0000000..4bd6851 --- /dev/null +++ b/internal/planner/planner.go @@ -0,0 +1,548 @@ +/* +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 planner computes the ordered registration commands that drive an +// observed Memgraph HA cluster toward its declared topology. The logic is a +// pure diff — declared topology plus observed SHOW INSTANCES output in, +// commands out (empty when converged) — so reconciliation stays idempotent +// and read-before-write: only missing registrations are re-issued, and a MAIN is +// promoted only when the cluster has none. After bootstrap, failover belongs to +// the Raft coordinators; the planner never overrides a MAIN that is staying. +// +// Members a lowered replica count is retiring are the one thing the planner +// removes, and it orders each removal so that Memgraph never has to refuse it: a +// retiring MAIN is demoted and a survivor promoted in its place before any +// UNREGISTER INSTANCE, and a retiring coordinator is only ever removed from Raft +// while it is not the leader. +// +// Moving MAIN off a retiring instance is the one thing here that can lose data, +// so it has a precondition the rest do not: a survivor that is both reachable and +// fully caught up with the MAIN, per SHOW REPLICATION LAG. Without one the +// retirement is planned as nothing at all and the retiring MAIN keeps serving — +// waiting is always better than promoting onto an instance that never received +// the writes. +// +// Every survivor that qualifies is carried into the promotion, and the demoted +// MAIN behind them, because the demotion lands before the promotion runs and a +// later pass cannot repeat the reasoning: lag is served by the MAIN, so a +// MAIN-less cluster has nothing left to measure. The alternatives are what keep a +// refused promotion from stranding the cluster without a MAIN. +// +// One command breaks the pure-diff mould: YIELD LEADERSHIP, which moves +// coordinator leadership off a retiring coordinator so it can be removed at all. +// It cannot name a successor, so its outcome is the one thing the planner cannot +// predict — which is why it is always the last command of a plan. Everything the +// planner can still order safely goes out ahead of it in the same pass, and the +// caller re-observes the cluster under whichever coordinator won the election. +package planner + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + + logf "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/memgraph/kubernetes-operator/internal/memgraph" +) + +// Topology is the declared cluster registration state: every coordinator and +// data instance the CR says must exist, with the addresses each advertises, +// plus the members on their way out when a count was lowered. +type Topology struct { + Coordinators []memgraph.CoordinatorSpec + DataInstances []memgraph.DataInstanceSpec + + // RetiringCoordinators and RetiringDataInstances are the members a lowered + // replica count is shedding: still registered and still running, but no + // longer declared. They are empty while a cluster grows or holds its size. + // + // A retiring data instance is demoted if it holds MAIN and then + // unregistered, so the cluster stops expecting it before its pod goes. A + // retiring coordinator is removed from the Raft cluster, which Raft only + // allows for a member that is not the leader — so leadership is yielded away + // from a retiring leader first. + RetiringCoordinators []memgraph.CoordinatorSpec + RetiringDataInstances []memgraph.DataInstanceSpec +} + +// Command is one registration step to execute against the coordinator leader. +type Command interface { + Run(ctx context.Context, client memgraph.Client) error + fmt.Stringer +} + +// AddCoordinator adds one declared coordinator to the Raft cluster. +type AddCoordinator struct { + Coordinator memgraph.CoordinatorSpec +} + +// Run implements Command. +func (c AddCoordinator) Run(ctx context.Context, client memgraph.Client) error { + return client.AddCoordinator(ctx, c.Coordinator) +} + +func (c AddCoordinator) String() string { + return fmt.Sprintf("ADD COORDINATOR %d", c.Coordinator.ID) +} + +// RegisterInstance registers one declared data instance with the cluster. +type RegisterInstance struct { + Instance memgraph.DataInstanceSpec +} + +// Run implements Command. +func (c RegisterInstance) Run(ctx context.Context, client memgraph.Client) error { + return client.RegisterInstance(ctx, c.Instance) +} + +func (c RegisterInstance) String() string { + return "REGISTER INSTANCE " + c.Instance.Name +} + +// SetInstanceToMain promotes a data instance to MAIN: at bootstrap, when the +// cluster has no MAIN yet, and after a retiring MAIN was demoted. +// +// It carries every instance the planner considers safe rather than one name, +// because this is the one command with no second chance. The demotion that +// precedes it in a handover has already landed by the time it runs, so the +// cluster is MAIN-less until something is promoted, and a later pass cannot +// recompute the choice: SHOW REPLICATION LAG is served by the MAIN, so with none +// there is nothing left to measure. Candidates are tried in order and the first +// the cluster accepts wins. +// +// Restore is the MAIN the same plan demoted, and it is the last resort when no +// candidate can be promoted. It is safe by construction: a MAIN-less cluster +// accepts no writes, so nothing has advanced past the instance that was MAIN a +// moment ago, which makes it the most up-to-date member of the cluster by +// definition. Falling back to it still fails the pass on purpose — the cluster is +// serving again, but the retirement made no progress, and the unregistration that +// follows in the plan would be aimed at a MAIN. A rolled-back handover has to be +// reported and retried, not read as a step forward. +type SetInstanceToMain struct { + // Candidates are the instances to try, in order. + Candidates []string + + // Restore is the demoted MAIN to promote back when every candidate fails, or + // empty at bootstrap, where no MAIN was demoted and none can be restored. + Restore string +} + +// Run implements Command. +func (c SetInstanceToMain) Run(ctx context.Context, client memgraph.Client) error { + log := logf.FromContext(ctx) + + var errs []error + for _, name := range c.Candidates { + if err := client.SetInstanceToMain(ctx, name); err != nil { + log.Info("Promotion of a data instance was refused", "instance", name, "reason", err.Error()) + errs = append(errs, fmt.Errorf("promoting %s: %w", name, err)) + continue + } + if len(errs) > 0 { + log.Info("Promoted a fallback data instance to MAIN after earlier candidates were refused", + "instance", name, "refused", len(errs)) + } + return nil + } + + if c.Restore == "" { + // Bootstrap: there is no demoted MAIN to fall back to, and a cluster that + // never had one has no writes to lose by being retried. + return errors.Join(errs...) + } + if err := client.SetInstanceToMain(ctx, c.Restore); err != nil { + log.Error(err, "Left the cluster without a MAIN: no promotion candidate was accepted "+ + "and the demoted instance could not be promoted back", "instance", c.Restore) + return errors.Join(append(errs, fmt.Errorf("restoring MAIN to %s: %w", c.Restore, err))...) + } + log.Info("Restored MAIN to the instance being retired because no promotion candidate was accepted, "+ + "so the retirement made no progress", "instance", c.Restore, "refused", len(errs)) + return fmt.Errorf("no promotion candidate was accepted, so MAIN was restored to the retiring instance %s: %w", + c.Restore, errors.Join(errs...)) +} + +// String names the first instance the command promotes, plus the ones it falls +// back to: which of them ends up MAIN depends on what the cluster accepts, and a +// condition or log line reporting the command has to name all of them. +func (c SetInstanceToMain) String() string { + targets := c.Candidates + if c.Restore != "" { + targets = append(slices.Clone(c.Candidates), c.Restore) + } + if len(targets) == 0 { + return "SET INSTANCE TO MAIN (no candidate)" + } + if len(targets) == 1 { + return fmt.Sprintf("SET INSTANCE %s TO MAIN", targets[0]) + } + return fmt.Sprintf("SET INSTANCE %s TO MAIN (or, if refused: %s)", + targets[0], strings.Join(targets[1:], ", ")) +} + +// DemoteInstance turns a retiring MAIN back into a replica, which is what makes +// it unregisterable. It is only ever aimed at an instance on its way out of the +// cluster: demoting one that is staying would be the operator overriding a +// failover decision that belongs to the coordinators. +type DemoteInstance struct { + Name string +} + +// Run implements Command. +func (c DemoteInstance) Run(ctx context.Context, client memgraph.Client) error { + return client.DemoteInstance(ctx, c.Name) +} + +func (c DemoteInstance) String() string { + return "DEMOTE INSTANCE " + c.Name +} + +// UnregisterInstance removes a retiring data instance from the cluster, so the +// coordinators stop expecting it before its pod is shed. +type UnregisterInstance struct { + Name string +} + +// Run implements Command. +func (c UnregisterInstance) Run(ctx context.Context, client memgraph.Client) error { + return client.UnregisterInstance(ctx, c.Name) +} + +func (c UnregisterInstance) String() string { + return "UNREGISTER INSTANCE " + c.Name +} + +// RemoveCoordinator drops a retiring coordinator from the Raft cluster, so its +// vote is gone before its pod is. It is never aimed at the observed leader: Raft +// refuses to remove its own leader, which is what YieldLeadership is for. +// +// The removed coordinator keeps running and keeps its state — NuRaft only stops +// it campaigning — so nothing here has to be undone before a raised count adds +// it back on its retained volume. +type RemoveCoordinator struct { + Coordinator memgraph.CoordinatorSpec +} + +// Run implements Command. +func (c RemoveCoordinator) Run(ctx context.Context, client memgraph.Client) error { + return client.RemoveCoordinator(ctx, c.Coordinator.ID) +} + +func (c RemoveCoordinator) String() string { + return fmt.Sprintf("REMOVE COORDINATOR %d", c.Coordinator.ID) +} + +// YieldLeadership hands Raft leadership away from the retiring coordinator that +// currently holds it, which is the only way it can then be removed. It runs on +// the leader — the connection the caller already holds — and cannot name a +// successor, so the plan it ends says nothing about who takes over: the caller +// re-observes the cluster and plans again under the new leader. +type YieldLeadership struct { + // Leader is the retiring coordinator giving leadership up. It is carried for + // the sake of whoever is watching the scale-down; the query itself has no + // argument, and no successor can be named. + Leader string +} + +// Run implements Command. +func (c YieldLeadership) Run(ctx context.Context, client memgraph.Client) error { + return client.YieldLeadership(ctx) +} + +func (c YieldLeadership) String() string { + return "YIELD LEADERSHIP" +} + +// Plan diffs the declared topology against the observed instances and returns +// the commands still needed, in execution order: coordinators before data +// instances (registration requires a formed Raft cluster), then the retirement +// of the members a lowered count sheds — demote a retiring MAIN, promote a +// survivor in its place, unregister every retiring data instance, remove every +// retiring coordinator from Raft. The promotion sits between the demotion and the +// unregistrations so that no UNREGISTER INSTANCE is ever aimed at an observed +// MAIN, and so the cluster is MAIN-less only for the few milliseconds between two +// queries of the same pass. +// +// The handover off a retiring MAIN is conditional on a survivor being able to +// take it: reachable, and holding every transaction the MAIN has committed. When +// none is, the demotion, the promotion and that instance's unregistration are all +// left out of the plan — the retiring MAIN stays MAIN and stays registered, and a +// later pass tries again once a survivor has caught up. The rest of the +// retirement still goes out: retiring instances that are not MAIN are +// unregistered, and retiring coordinators are removed, because neither depends on +// where MAIN sits. +// +// The promotion carries every qualifying survivor plus the demoted MAIN as its +// last resort, so a refused promotion is retried within the same pass rather than +// leaving a demoted cluster with no MAIN at all. Falling back to the demoted +// instance fails the pass on purpose: the cluster serves again, but the retirement +// has to start over, and the unregistration planned after the promotion would +// otherwise be aimed at a MAIN. +// +// A retiring coordinator that holds Raft leadership cannot be removed at all, so +// the plan ends with YIELD LEADERSHIP instead and stops there — the retiring +// coordinators that are not the leader still go out ahead of it in that same +// pass. Nothing follows a yield, because nothing after it could be planned: the +// election picks the next leader, and the caller has to observe the cluster again +// to learn who won. +// +// Instances the cluster knows but the topology neither declares nor retires are +// left untouched: the retiring set is bounded by the operator's own prior apply, +// so an instance a human registered is never removed. +func Plan(declared Topology, observed []memgraph.Instance, lag []memgraph.ReplicationLag) []Command { + registered := index(observed) + retiring := retiringNames(declared) + + // Which instance holds MAIN, and whether it is one on its way out. A retiring + // MAIN is the cluster's MAIN for as long as it stays: it stops counting as one + // only once this pass commits to demoting it, which is what the handover below + // decides. + retiringMain, hasMain := "", false + for _, instance := range observed { + switch { + case !instance.IsMain(): + case retiring[instance.Name]: + retiringMain = instance.Name + default: + hasMain = true + } + } + // The survivors a retiring MAIN can hand over to, in the order to try them, and + // empty when none qualifies — which is what defers the whole retirement to a + // later pass. + var successors []string + if retiringMain != "" { + successors = handoverCandidates(declared, registered, indexLag(lag)) + } + handover := retiringMain != "" && len(successors) > 0 + + var commands []Command + for _, coordinator := range declared.Coordinators { + if !coordinatorRegistered(registered, coordinator) { + commands = append(commands, AddCoordinator{Coordinator: coordinator}) + } + } + for _, instance := range declared.DataInstances { + if _, ok := registered[instance.Name]; !ok { + commands = append(commands, RegisterInstance{Instance: instance}) + } + } + if handover { + commands = append(commands, DemoteInstance{Name: retiringMain}) + } + switch { + case handover && !hasMain: + // The demotion above left the cluster MAIN-less on purpose; the survivors + // picked for the handover take over in the next command, and the demoted + // instance is promoted back if none of them can. + commands = append(commands, SetInstanceToMain{Candidates: successors, Restore: retiringMain}) + case retiringMain == "" && !hasMain && len(declared.DataInstances) > 0: + // No MAIN and none retiring: a fresh bootstrap, a MAIN whose promotion never + // landed, or a pass that died between a retiring MAIN's demotion and the + // promotion meant to follow it. Lag is measured against a MAIN, so with none + // there is nothing to measure and this promotion goes by reachability alone. + // That is also why the handover is gated before the demotion rather than + // after: it is the last moment at which the choice is still free. + commands = append(commands, SetInstanceToMain{Candidates: []string{promotionTarget(declared, registered)}}) + } + for _, instance := range declared.RetiringDataInstances { + if _, ok := registered[instance.Name]; !ok { + continue + } + if instance.Name == retiringMain && !handover { + // Memgraph refuses to unregister the MAIN, and the demotion that would + // make this one unregisterable is waiting for a survivor to take over. + continue + } + commands = append(commands, UnregisterInstance{Name: instance.Name}) + } + + leader := leaderName(observed) + yieldFrom := "" + for _, coordinator := range declared.RetiringCoordinators { + if coordinator.Name() == leader { + // Raft refuses to remove its own leader, so this one waits for the + // yield below to move leadership to another member. + yieldFrom = leader + continue + } + if coordinatorRegistered(registered, coordinator) { + commands = append(commands, RemoveCoordinator{Coordinator: coordinator}) + } + } + if yieldFrom != "" { + commands = append(commands, YieldLeadership{Leader: yieldFrom}) + } + return commands +} + +// leaderName is the coordinator the observed view reports as Raft leader, or the +// empty string when it reports none. +func leaderName(observed []memgraph.Instance) string { + for _, instance := range observed { + if instance.IsLeader() { + return instance.Name + } + } + return "" +} + +// Retired reports whether every member a lowered replica count is shedding has +// left the cluster: no retiring data instance is still registered, and no +// retiring coordinator is still a Raft member. It is the precondition for +// shedding their pods. +// +// Plan coming back empty does not establish that on its own. A retirement whose +// handover is waiting for a caught-up survivor also plans nothing — there is no +// command that would make a lagging replica ready — so a caller that read +// emptiness as "done" would delete the pod of a registered MAIN. This is the +// question it has to ask instead. +func Retired(declared Topology, observed []memgraph.Instance) bool { + registered := index(observed) + for _, instance := range declared.RetiringDataInstances { + if _, ok := registered[instance.Name]; ok { + return false + } + } + for _, coordinator := range declared.RetiringCoordinators { + if coordinatorRegistered(registered, coordinator) { + return false + } + } + return true +} + +// Registered reports how many of the declared coordinators and data instances +// the observed cluster has registered. It is pure observation for the CR's +// status, and it shares Plan's definition of "registered" — so a role's count +// reaches its declared count exactly when Plan stops issuing registrations for +// it. +func Registered(declared Topology, observed []memgraph.Instance) (coordinators, dataInstances int32) { + registered := index(observed) + for _, coordinator := range declared.Coordinators { + if coordinatorRegistered(registered, coordinator) { + coordinators++ + } + } + for _, instance := range declared.DataInstances { + if _, ok := registered[instance.Name]; ok { + dataInstances++ + } + } + return coordinators, dataInstances +} + +// retiringNames is the set of data instances the topology is shedding, keyed by +// the name they are registered under. +func retiringNames(declared Topology) map[string]bool { + retiring := make(map[string]bool, len(declared.RetiringDataInstances)) + for _, instance := range declared.RetiringDataInstances { + retiring[instance.Name] = true + } + return retiring +} + +// index keys the observed cluster view by instance name. +func index(observed []memgraph.Instance) map[string]memgraph.Instance { + registered := make(map[string]memgraph.Instance, len(observed)) + for _, instance := range observed { + registered[instance.Name] = instance + } + return registered +} + +// indexLag keys the observed replication lag by instance name. A name the view +// does not cover reads back as the zero ReplicationLag, which reports itself as +// not caught up — the safe answer for an instance nothing is known about. +func indexLag(lag []memgraph.ReplicationLag) map[string]memgraph.ReplicationLag { + byInstance := make(map[string]memgraph.ReplicationLag, len(lag)) + for _, instance := range lag { + byInstance[instance.Instance] = instance + } + return byInstance +} + +// coordinatorRegistered reports whether the declared coordinator is a member of +// the Raft cluster. A coordinator reports itself in SHOW INSTANCES with an +// empty bolt_server until ADD COORDINATOR is issued for its ID, so presence +// alone does not prove registration. +func coordinatorRegistered(registered map[string]memgraph.Instance, coordinator memgraph.CoordinatorSpec) bool { + observed, ok := registered[coordinator.Name()] + return ok && observed.BoltServer != "" +} + +// handoverCandidates are the survivors a retiring MAIN may hand MAIN over to, in +// ordinal order: every declared instance the coordinator leader observes as up and +// that SHOW REPLICATION LAG reports as holding every transaction the MAIN has +// committed, in every database. It returns none when no survivor qualifies. +// +// Both conditions are needed and neither implies the other. Health says the +// leader can reach the instance, which a caught-up replica can still fail — +// registration outlives reachability, and the lag view is relayed from the MAIN's +// own cached progress for each replica, so an instance that went down a moment +// ago still appears there at the offset it last reached. Lag says the instance +// holds the writes, which a reachable one need not. +// +// Every qualifying survivor is returned, not just the first, because the choice +// cannot be made again later: once the demotion lands there is no MAIN, and lag is +// served by the MAIN. Carrying the alternatives is what lets a refused promotion be +// retried against another instance that was proven caught up by the same view. +// +// There is deliberately no fallback onto an instance that fails the conditions, +// which is what separates this from promotionTarget. A cluster with no MAIN is +// worse off than one whose promotion has to be retried, so that one guesses rather +// than stall. A retiring MAIN is still serving: waiting costs nothing but the +// scale-down's completion, while promoting a lagging survivor discards every +// transaction it never received. +func handoverCandidates( + declared Topology, + registered map[string]memgraph.Instance, + lag map[string]memgraph.ReplicationLag, +) []string { + var candidates []string + for _, instance := range declared.DataInstances { + if observed, ok := registered[instance.Name]; !ok || !observed.IsUp() { + continue + } + if lag[instance.Name].IsCaughtUp() { + candidates = append(candidates, instance.Name) + } + } + return candidates +} + +// promotionTarget picks the data instance to promote when the cluster has no +// MAIN: the lowest-ordinal declared instance the cluster observes as up. +// Promoting a down instance would only write the intent to Raft and leave the +// cluster MAIN-less until the coordinators retried it, so an instance that is +// known to be reachable is preferred over a lower-ordinal one that is not. +// +// Only declared instances are candidates, which is what makes the rule serve a +// retirement too: promoting a survivor is the same choice as promoting at +// bootstrap, and a member on its way out can never be the target. +// +// The first declared instance is the fallback, which is what a fresh bootstrap +// uses: nothing is observed yet at the point its registrations are planned. +func promotionTarget(declared Topology, registered map[string]memgraph.Instance) string { + for _, instance := range declared.DataInstances { + if observed, ok := registered[instance.Name]; ok && observed.IsUp() { + return instance.Name + } + } + return declared.DataInstances[0].Name +} diff --git a/internal/planner/planner_test.go b/internal/planner/planner_test.go new file mode 100644 index 0000000..13e2672 --- /dev/null +++ b/internal/planner/planner_test.go @@ -0,0 +1,1191 @@ +/* +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 planner_test + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" + "github.com/memgraph/kubernetes-operator/internal/memgraph" + "github.com/memgraph/kubernetes-operator/internal/planner" + "github.com/memgraph/kubernetes-operator/internal/resources" +) + +// The data instances the cases below name. firstInstance is the one a bootstrap +// promotes — the first the topology declares — and the one a shrink keeps; the +// higher ordinals are what a lowered count retires. +const ( + firstInstance = "instance_0" + secondInstance = "instance_1" + thirdInstance = "instance_2" +) + +// fourthCoordinator is the lowest-numbered coordinator a shrink from five to +// three retires, and the one the cases below park Raft leadership on: a +// StatefulSet sheds its highest ordinals, so the leader may well sit on one. +const fourthCoordinator = "coordinator_4" + +// declaredTopology is the canonical 3-coordinator, 2-data-instance fixture +// the cases below diff observed cluster states against. +func declaredTopology() planner.Topology { + return topologyOf(3, 2) +} + +// grownTopology is the same cluster after both counts were raised: 5 +// coordinators and 3 data instances. +func grownTopology() planner.Topology { + return topologyOf(5, 3) +} + +// shrunkTopology is a cluster whose dataInstances count was lowered to the given +// number while its StatefulSet still runs `running` data pods: the ordinals in +// between are retiring. +func shrunkTopology(declared, running int) planner.Topology { + topology := topologyOf(3, declared) + for i := declared; i < running; i++ { + topology.RetiringDataInstances = append(topology.RetiringDataInstances, dataInstanceSpec(i)) + } + return topology +} + +// mixedTopology is one edit moving both counts in opposite directions: the +// coordinators grow from 3 to 5 while the data instances shrink from 3 to 2. +func mixedTopology() planner.Topology { + topology := topologyOf(5, 2) + topology.RetiringDataInstances = append(topology.RetiringDataInstances, dataInstanceSpec(2)) + return topology +} + +// shrunkCoordinators is a cluster whose coordinators count was lowered from five +// to three — the smallest coordinator shrink the schema floors allow, and an even +// number of members either way — while its StatefulSet still runs all five pods: +// coordinator_4 and coordinator_5 are Raft members on their way out. +func shrunkCoordinators() planner.Topology { + topology := topologyOf(3, 2) + for id := int32(4); id <= 5; id++ { + topology.RetiringCoordinators = append(topology.RetiringCoordinators, coordinatorSpec(id)) + } + return topology +} + +// retiringBothRoles is one edit lowering both counts: the coordinators shrink from +// 5 to 3 while the data instances shrink from 3 to 2. +func retiringBothRoles() planner.Topology { + topology := shrunkCoordinators() + topology.RetiringDataInstances = append(topology.RetiringDataInstances, dataInstanceSpec(2)) + return topology +} + +func topologyOf(coordinators int32, dataInstances int) planner.Topology { + topology := planner.Topology{} + for id := int32(1); id <= coordinators; id++ { + topology.Coordinators = append(topology.Coordinators, coordinatorSpec(id)) + } + for i := range dataInstances { + topology.DataInstances = append(topology.DataInstances, dataInstanceSpec(i)) + } + return topology +} + +// coordinatorSpec builds the declared coordinator with the given 1-based Raft +// ID (Memgraph treats ID 0 as unset), hosted on the pod with ordinal ID-1. +func coordinatorSpec(id int32) memgraph.CoordinatorSpec { + host := fmt.Sprintf("example-coordinator-%d.example-coordinator.default.svc.cluster.local", id-1) + return memgraph.CoordinatorSpec{ + ID: id, + BoltServer: host + ":7687", + CoordinatorServer: host + ":12000", + ManagementServer: host + ":10000", + } +} + +func dataInstanceSpec(i int) memgraph.DataInstanceSpec { + host := fmt.Sprintf("example-data-%d.example-data.default.svc.cluster.local", i) + return memgraph.DataInstanceSpec{ + Name: fmt.Sprintf("instance_%d", i), + BoltServer: host + ":7687", + ManagementServer: host + ":10000", + ReplicationServer: host + ":20000", + } +} + +func observedCoordinator(id int32, role string) memgraph.Instance { + spec := coordinatorSpec(id) + return memgraph.Instance{ + Name: spec.Name(), + BoltServer: spec.BoltServer, + CoordinatorServer: spec.CoordinatorServer, + ManagementServer: spec.ManagementServer, + Health: "up", + Role: role, + } +} + +// observedCoordinators is the Raft membership a leader reports: one row per given +// coordinator ID, the one named by leaderID reporting itself leader. Which +// coordinator holds leadership is what decides whether a shrink can remove +// members at all, so every retirement case below states it outright. +func observedCoordinators(leaderID int32, ids ...int32) []memgraph.Instance { + view := make([]memgraph.Instance, 0, len(ids)) + for _, id := range ids { + role := memgraph.RoleFollower + if id == leaderID { + role = memgraph.RoleLeader + } + view = append(view, observedCoordinator(id, role)) + } + return view +} + +func observedDataInstance(i int, role string) memgraph.Instance { + spec := dataInstanceSpec(i) + return memgraph.Instance{ + Name: spec.Name, + BoltServer: spec.BoltServer, + ManagementServer: spec.ManagementServer, + Health: memgraph.HealthUp, + Role: role, + } +} + +// downDataInstance is a registered data instance the coordinator leader cannot +// reach — the state a promotion must route around. +func downDataInstance(i int) memgraph.Instance { + instance := observedDataInstance(i, memgraph.RoleReplica) + instance.Health = "down" + return instance +} + +// mainDownDataInstance is the MAIN with its pod gone: Raft still records it as the +// current MAIN, and the coordinator leader cannot reach it. This is what a +// coordinator-driven failover looks like from the moment the MAIN dies until a +// successor is promoted, and what the rolling restart deliberately creates when it +// deletes the MAIN's pod. +func mainDownDataInstance(i int) memgraph.Instance { + instance := observedDataInstance(i, memgraph.RoleMain) + instance.Health = "down" + return instance +} + +// caughtUp is the SHOW REPLICATION LAG view of the given data instances with every +// one of them holding all of the MAIN's transactions: the state that lets a +// retiring MAIN hand over. The MAIN reports itself in the view too, at zero +// behind, which is what every other row is measured against. +func caughtUp(indices ...int) []memgraph.ReplicationLag { + return behindBy(0, indices...) +} + +// behindBy is the same view with every named instance trailing the MAIN by the +// given number of transactions. A negative count is a replica briefly ahead of a +// new MAIN, which SYNC replication can produce and which is not "behind". +func behindBy(txns int64, indices ...int) []memgraph.ReplicationLag { + lag := make([]memgraph.ReplicationLag, 0, len(indices)) + for _, i := range indices { + lag = append(lag, memgraph.ReplicationLag{ + Instance: dataInstanceSpec(i).Name, + Databases: []memgraph.DatabaseLag{{ + Database: "memgraph", + CommittedTxns: 100 - txns, + TxnsBehindMain: txns, + }}, + }) + } + return lag +} + +// multiDatabaseLag is one instance's view across two databases, which is how an +// enterprise cluster reports: an instance is only promotable when it is caught up +// in every one of them. +func multiDatabaseLag(i int, behind ...int64) []memgraph.ReplicationLag { + lag := memgraph.ReplicationLag{Instance: dataInstanceSpec(i).Name} + for db, txns := range behind { + lag.Databases = append(lag.Databases, memgraph.DatabaseLag{ + Database: fmt.Sprintf("db_%d", db), + CommittedTxns: 100 - txns, + TxnsBehindMain: txns, + }) + } + return []memgraph.ReplicationLag{lag} +} + +// promote is the promotion a cluster with no MAIN and nothing retiring plans: +// candidates in order, and no demoted instance to fall back to. +func promote(candidates ...string) planner.SetInstanceToMain { + return planner.SetInstanceToMain{Candidates: candidates} +} + +// handover is the promotion a retirement plans: every survivor proven caught up, +// in ordinal order, with the MAIN the same plan demoted behind them as the last +// resort. +func handover(demoted string, candidates ...string) planner.SetInstanceToMain { + return planner.SetInstanceToMain{Candidates: candidates, Restore: demoted} +} + +func TestPlan(t *testing.T) { + cases := []struct { + name string + observed []memgraph.Instance + // declared overrides the canonical fixture for the cases about a + // topology whose counts changed. + declared *planner.Topology + // lag is the SHOW REPLICATION LAG view. It is nil for every case with no + // retiring MAIN to move, because that is what the real query answers with + // when there is no MAIN to measure against — and the only decision that + // consults it is the handover off a retiring MAIN. + lag []memgraph.ReplicationLag + want []planner.Command + }{ + { + name: "fresh cluster bootstraps everything and promotes one MAIN", + observed: nil, + want: []planner.Command{ + planner.AddCoordinator{Coordinator: coordinatorSpec(1)}, + planner.AddCoordinator{Coordinator: coordinatorSpec(2)}, + planner.AddCoordinator{Coordinator: coordinatorSpec(3)}, + planner.RegisterInstance{Instance: dataInstanceSpec(0)}, + planner.RegisterInstance{Instance: dataInstanceSpec(1)}, + promote(firstInstance), + }, + }, + { + name: "partially registered cluster gets only the missing registrations", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + }, + want: []planner.Command{ + planner.AddCoordinator{Coordinator: coordinatorSpec(2)}, + planner.RegisterInstance{Instance: dataInstanceSpec(1)}, + }, + }, + { + name: "self-reporting coordinator with empty bolt server is still added", + observed: []memgraph.Instance{ + // The coordinator the client is connected to lists itself in + // SHOW INSTANCES with an empty bolt_server until explicitly + // added. + func() memgraph.Instance { + instance := observedCoordinator(2, memgraph.RoleLeader) + instance.BoltServer = "" + return instance + }(), + observedCoordinator(1, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + }, + want: []planner.Command{ + planner.AddCoordinator{Coordinator: coordinatorSpec(2)}, + }, + }, + { + name: "fully converged cluster is a no-op", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + }, + want: nil, + }, + { + name: "an existing MAIN is never overridden, even on another instance", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleMain), + }, + want: nil, + }, + { + name: "registered but leaderless data plane still gets the one MAIN promotion", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleReplica), + }, + want: []planner.Command{ + promote(firstInstance), + }, + }, + { + name: "missing instance registers without MAIN promotion when a MAIN exists", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(1, memgraph.RoleMain), + }, + want: []planner.Command{ + planner.RegisterInstance{Instance: dataInstanceSpec(0)}, + }, + }, + { + name: "multiple lost registrations are all re-issued without a second MAIN", + // A coordinator and a data instance both lost their registration + // while instance_1 remained MAIN: every missing registration is + // re-issued, and no promotion is planned because a MAIN exists. + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(1, memgraph.RoleMain), + }, + want: []planner.Command{ + planner.AddCoordinator{Coordinator: coordinatorSpec(2)}, + planner.RegisterInstance{Instance: dataInstanceSpec(0)}, + }, + }, + // Promoting a down instance only writes the intent to Raft: the cluster + // stays MAIN-less until the coordinators retry it. A registered instance + // the leader can reach is the better target even at a higher ordinal. + { + name: "a down instance_0 is skipped in favor of the lowest reachable instance", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + downDataInstance(0), + observedDataInstance(1, memgraph.RoleReplica), + }, + want: []planner.Command{ + promote(secondInstance), + }, + }, + { + name: "a reachable instance_0 is promoted ahead of its higher-ordinal peers", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleReplica), + }, + want: []planner.Command{ + promote(firstInstance), + }, + }, + // With every declared instance down there is no reachable target, so the + // first one is promoted anyway: the coordinators act on the intent once + // the instance comes back, which beats never promoting at all. + { + name: "the first instance is promoted when none is reachable", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + downDataInstance(0), + downDataInstance(1), + }, + want: []planner.Command{ + promote(firstInstance), + }, + }, + // A grown topology declares members the cluster has never heard of: the + // diff that restores a lost registration is the same one that registers a + // new pod, so growth needs no separate plan. + { + name: "a grown topology registers only the added members", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + }, + declared: ptr.To(grownTopology()), + want: []planner.Command{ + planner.AddCoordinator{Coordinator: coordinatorSpec(4)}, + planner.AddCoordinator{Coordinator: coordinatorSpec(5)}, + planner.RegisterInstance{Instance: dataInstanceSpec(2)}, + }, + }, + { + name: "instances the topology does not declare are left untouched", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedCoordinator(4, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleReplica), + }, + want: nil, + }, + // A lowered count with MAIN on a survivor needs nothing but the removal: + // the cluster keeps serving from the instance it is already serving from. + { + name: "a retiring instance is unregistered with the surviving MAIN untouched", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleReplica), + }, + declared: ptr.To(shrunkTopology(2, 3)), + want: []planner.Command{ + planner.UnregisterInstance{Name: thirdInstance}, + }, + }, + // MAIN on the ordinal that is going away: Memgraph refuses to unregister + // the MAIN, so it is demoted, a survivor is promoted in its place, and only + // then is it removed — all in this one plan. + { + name: "a retiring MAIN is demoted, a survivor promoted, and only then unregistered", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleMain), + }, + declared: ptr.To(shrunkTopology(2, 3)), + lag: caughtUp(0, 1, 2), + want: []planner.Command{ + planner.DemoteInstance{Name: thirdInstance}, + handover(thirdInstance, firstInstance, secondInstance), + planner.UnregisterInstance{Name: thirdInstance}, + }, + }, + // A survivor the leader cannot reach does not get MAIN even when the lag view + // still reports it caught up: that view is the MAIN's cached record of how far + // each replica got, so it outlives the replica's reachability. + { + name: "a retiring MAIN hands MAIN to the lowest reachable survivor", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + downDataInstance(0), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleMain), + }, + declared: ptr.To(shrunkTopology(2, 3)), + lag: caughtUp(0, 1, 2), + want: []planner.Command{ + planner.DemoteInstance{Name: thirdInstance}, + handover(thirdInstance, secondInstance), + planner.UnregisterInstance{Name: thirdInstance}, + }, + }, + // The handover skips a survivor that is behind, exactly as it skips one that + // is down: promoting it would drop the transactions it never received. + { + name: "a retiring MAIN hands MAIN to the lowest caught-up survivor", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleMain), + }, + declared: ptr.To(shrunkTopology(2, 3)), + lag: append(behindBy(7, 0), caughtUp(1, 2)...), + want: []planner.Command{ + planner.DemoteInstance{Name: thirdInstance}, + handover(thirdInstance, secondInstance), + planner.UnregisterInstance{Name: thirdInstance}, + }, + }, + // The whole retirement waits: no demotion, no promotion, and no + // unregistration of the MAIN that cannot be demoted. The cluster keeps + // serving from the instance on its way out until a survivor catches up, + // which is a scale-down that pauses rather than one that loses writes. + { + name: "a retiring MAIN is left alone while every survivor is behind", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleMain), + }, + declared: ptr.To(shrunkTopology(2, 3)), + lag: append(behindBy(7, 0, 1), caughtUp(2)...), + want: nil, + }, + // Down and behind are separate reasons to refuse, and either one alone is + // enough: here the one caught-up survivor is unreachable and the one + // reachable survivor is behind. + { + name: "a retiring MAIN is left alone when no survivor is both up and caught up", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + downDataInstance(0), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleMain), + }, + declared: ptr.To(shrunkTopology(2, 3)), + lag: append(caughtUp(0, 2), behindBy(3, 1)...), + want: nil, + }, + // No lag view at all — the MAIN unreachable from the coordinator leader, or + // the read having failed — is not evidence that anything is caught up, so the + // handover waits on it exactly as it waits on a lagging survivor. + { + name: "a retiring MAIN is left alone when the lag view is empty", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleMain), + }, + declared: ptr.To(shrunkTopology(2, 3)), + lag: nil, + want: nil, + }, + // A survivor the lag view does not mention is not caught up either: an + // instance the MAIN does not list is one it is not replicating to. + { + name: "a survivor missing from the lag view does not get MAIN", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleMain), + }, + declared: ptr.To(shrunkTopology(2, 3)), + lag: append(caughtUp(1), caughtUp(2)...), + want: []planner.Command{ + planner.DemoteInstance{Name: thirdInstance}, + handover(thirdInstance, secondInstance), + planner.UnregisterInstance{Name: thirdInstance}, + }, + }, + // A replica ahead of the MAIN is not behind it. SYNC replication can leave one + // holding transactions a newly promoted MAIN never saw, which is a negative + // count and no reason to refuse the handover. + { + name: "a survivor ahead of the MAIN is still promotable", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleMain), + }, + declared: ptr.To(shrunkTopology(2, 3)), + lag: append(behindBy(-2, 0), caughtUp(1, 2)...), + want: []planner.Command{ + planner.DemoteInstance{Name: thirdInstance}, + handover(thirdInstance, firstInstance, secondInstance), + planner.UnregisterInstance{Name: thirdInstance}, + }, + }, + // Every database has to be caught up, not just one: a survivor holding all of + // one database's writes and none of another's would lose the second on + // promotion. + { + name: "a survivor behind in one of several databases does not get MAIN", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleMain), + }, + declared: ptr.To(shrunkTopology(2, 3)), + lag: append(multiDatabaseLag(0, 0, 4), + append(multiDatabaseLag(1, 0, 0), multiDatabaseLag(2, 0, 0)...)...), + want: []planner.Command{ + planner.DemoteInstance{Name: thirdInstance}, + handover(thirdInstance, secondInstance), + planner.UnregisterInstance{Name: thirdInstance}, + }, + }, + { + name: "several retiring instances are removed down to a single survivor", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleMain), + observedDataInstance(2, memgraph.RoleReplica), + }, + declared: ptr.To(shrunkTopology(1, 3)), + lag: caughtUp(0, 1, 2), + want: []planner.Command{ + planner.DemoteInstance{Name: secondInstance}, + handover(secondInstance, firstInstance), + planner.UnregisterInstance{Name: secondInstance}, + planner.UnregisterInstance{Name: thirdInstance}, + }, + }, + // A blocked handover holds up only the MAIN's own retirement. The other + // retiring instance is not MAIN, so unregistering it needs nothing from the + // survivors and goes out in this pass. + { + name: "a blocked handover still unregisters the retiring instances that are not MAIN", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleMain), + observedDataInstance(2, memgraph.RoleReplica), + }, + declared: ptr.To(shrunkTopology(1, 3)), + lag: append(behindBy(9, 0), caughtUp(1, 2)...), + want: []planner.Command{ + planner.UnregisterInstance{Name: thirdInstance}, + }, + }, + // A pass that died between the demotion and the promotion meant to follow it + // leaves the cluster with no MAIN at all, which is the one state the caught-up + // rule cannot be applied in: lag is measured against a MAIN, and there is + // none. The plan falls back to the MAIN-less rule and promotes by + // reachability, because a cluster serving nothing is worse off than one whose + // promotion has to be retried — and gating the handover ahead of the demotion + // is what keeps this window as narrow as a single pair of queries. + { + name: "an already-demoted retiring MAIN leaves a promotion by reachability", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleReplica), + }, + declared: ptr.To(shrunkTopology(2, 3)), + lag: nil, + want: []planner.Command{ + promote(firstInstance), + planner.UnregisterInstance{Name: thirdInstance}, + }, + }, + // Read-before-write: a retiring member the cluster no longer knows about + // gets no command, so a reconcile that crashed between the unregistration + // and the shrink re-plans to just the rest of the work. + { + name: "an already-unregistered retiring instance is not unregistered again", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleReplica), + }, + declared: ptr.To(shrunkTopology(2, 4)), + want: []planner.Command{ + planner.UnregisterInstance{Name: thirdInstance}, + }, + }, + // Both counts change in one edit, in opposite directions: the coordinators + // grow while the data instances shrink, and each role's work is planned + // independently of the other's. + { + name: "a mixed grow-and-shrink adds coordinators and retires a data instance", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleMain), + }, + declared: ptr.To(mixedTopology()), + lag: caughtUp(0, 1, 2), + want: []planner.Command{ + planner.AddCoordinator{Coordinator: coordinatorSpec(4)}, + planner.AddCoordinator{Coordinator: coordinatorSpec(5)}, + planner.DemoteInstance{Name: thirdInstance}, + handover(thirdInstance, firstInstance, secondInstance), + planner.UnregisterInstance{Name: thirdInstance}, + }, + }, + // Coordinators the count drops are removed from Raft outright when the + // leader is a survivor: their votes are gone before their pods are, and + // removing a follower needs no leadership dance. + { + name: "retiring coordinators are removed from Raft under a surviving leader", + observed: append(observedCoordinators(1, 1, 2, 3, 4, 5), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + ), + declared: ptr.To(shrunkCoordinators()), + want: []planner.Command{ + planner.RemoveCoordinator{Coordinator: coordinatorSpec(4)}, + planner.RemoveCoordinator{Coordinator: coordinatorSpec(5)}, + }, + }, + // Raft refuses to remove its own leader, so a leader on a retiring ordinal + // is asked to yield — last in the plan, with nothing after it, because the + // election picks the successor and only a fresh observation can say who won. + // The other retiring member still goes in this same pass: removing a + // follower is safe and predictable. + { + name: "a retiring leader yields last, after every removal it can still order", + observed: append(observedCoordinators(4, 1, 2, 3, 4, 5), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + ), + declared: ptr.To(shrunkCoordinators()), + want: []planner.Command{ + planner.RemoveCoordinator{Coordinator: coordinatorSpec(5)}, + planner.YieldLeadership{Leader: fourthCoordinator}, + }, + }, + // Read-before-write: a coordinator already gone from the Raft membership gets + // no removal, so a pass that crashed between two removals re-plans to just + // the rest of the work. + { + name: "an already-removed retiring coordinator is not removed again", + observed: append(observedCoordinators(1, 1, 2, 3, 4), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + ), + declared: ptr.To(shrunkCoordinators()), + want: []planner.Command{ + planner.RemoveCoordinator{Coordinator: coordinatorSpec(4)}, + }, + }, + // Nothing is left to order ahead of the yield: the plan is the yield alone, + // and the removal of the leader itself waits for the next pass. + { + name: "a retiring leader with nothing else to remove plans only the yield", + observed: append(observedCoordinators(4, 1, 2, 3, 4), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + ), + declared: ptr.To(shrunkCoordinators()), + want: []planner.Command{ + planner.YieldLeadership{Leader: fourthCoordinator}, + }, + }, + // Leadership on a coordinator the topology neither declares nor retires — one + // a human added — is left where it is: it is not in the way of any removal. + { + name: "a leader outside the retiring set is not asked to yield", + observed: append(observedCoordinators(6, 1, 2, 3, 4, 5, 6), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + ), + declared: ptr.To(shrunkCoordinators()), + want: []planner.Command{ + planner.RemoveCoordinator{Coordinator: coordinatorSpec(4)}, + planner.RemoveCoordinator{Coordinator: coordinatorSpec(5)}, + }, + }, + // Both roles shrinking in one edit: the data instances are retired first + // (MAIN moved off the one going away), then the Raft members are removed. + { + name: "both roles retire in one pass under a surviving leader", + observed: append(observedCoordinators(1, 1, 2, 3, 4, 5), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleMain), + ), + declared: ptr.To(retiringBothRoles()), + lag: caughtUp(0, 1, 2), + want: []planner.Command{ + planner.DemoteInstance{Name: thirdInstance}, + handover(thirdInstance, firstInstance, secondInstance), + planner.UnregisterInstance{Name: thirdInstance}, + planner.RemoveCoordinator{Coordinator: coordinatorSpec(4)}, + planner.RemoveCoordinator{Coordinator: coordinatorSpec(5)}, + }, + }, + // The coordinator side of the same edit is independent of where MAIN sits, so + // a handover the survivors cannot take does not hold up the Raft removals. + { + name: "a blocked handover does not hold up the coordinator removals", + observed: append(observedCoordinators(1, 1, 2, 3, 4, 5), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleMain), + ), + declared: ptr.To(retiringBothRoles()), + lag: append(behindBy(5, 0, 1), caughtUp(2)...), + want: []planner.Command{ + planner.RemoveCoordinator{Coordinator: coordinatorSpec(4)}, + planner.RemoveCoordinator{Coordinator: coordinatorSpec(5)}, + }, + }, + // The same edit with leadership in the way: the data-instance retirement is + // fully ordered and issues in this pass regardless — only the removal of the + // leader itself has to wait behind the yield. + { + name: "a retiring leader does not hold up the data-instance retirement", + observed: append(observedCoordinators(4, 1, 2, 3, 4, 5), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleMain), + ), + declared: ptr.To(retiringBothRoles()), + lag: caughtUp(0, 1, 2), + want: []planner.Command{ + planner.DemoteInstance{Name: thirdInstance}, + handover(thirdInstance, firstInstance, secondInstance), + planner.UnregisterInstance{Name: thirdInstance}, + planner.RemoveCoordinator{Coordinator: coordinatorSpec(5)}, + planner.YieldLeadership{Leader: fourthCoordinator}, + }, + }, + // The retiring range is bounded by the operator's own prior apply, which is + // what keeps an instance a human registered out of it — even one at a + // higher ordinal than everything the operator ever ran. + { + name: "an undeclared instance outside the retiring range is left registered", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleReplica), + observedDataInstance(3, memgraph.RoleReplica), + }, + declared: ptr.To(shrunkTopology(2, 3)), + want: []planner.Command{ + planner.UnregisterInstance{Name: thirdInstance}, + }, + }, + // The property the sequenced rolling restart rests on. It deletes the MAIN's + // pod on purpose and leaves the promotion to the coordinators, so the state + // below happens on every upgrade: MAIN still holds its role in Raft, but the + // leader cannot reach it. The planner must plan nothing at all — a promotion + // here would be the operator racing the failover it just triggered, which is + // two control systems choosing a MAIN at once. + // + // It only holds on a Memgraph that keeps reporting role=main for an + // unreachable MAIN. A release that reports role=unknown instead vacates the + // main row, and this same view would take the bootstrap promotion branch. + { + name: "an unreachable MAIN is left to the coordinators, not promoted around", + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + mainDownDataInstance(0), + observedDataInstance(1, memgraph.RoleReplica), + }, + want: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + declared := declaredTopology() + if tc.declared != nil { + declared = *tc.declared + } + + got := planner.Plan(declared, tc.observed, tc.lag) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("Plan() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestRetired covers the question the controller has to ask before it sheds a +// retiring member's pod. Plan falling silent is not the same question — a +// handover waiting for a caught-up survivor is also silent — so the two disagree +// on purpose in the blocked case below. +func TestRetired(t *testing.T) { + cases := []struct { + name string + declared planner.Topology + observed []memgraph.Instance + want bool + }{ + { + name: "nothing retiring is retired", + declared: declaredTopology(), + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedDataInstance(0, memgraph.RoleMain), + }, + want: true, + }, + { + name: "a retiring instance the cluster has forgotten is retired", + declared: shrunkTopology(2, 3), + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + }, + want: true, + }, + { + name: "a retiring instance still registered is not retired", + declared: shrunkTopology(2, 3), + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleReplica), + }, + want: false, + }, + // The state a blocked handover leaves behind: Plan has nothing to issue, and + // the pod still belongs to a registered MAIN. + { + name: "a retiring MAIN whose handover is blocked is not retired", + declared: shrunkTopology(2, 3), + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedDataInstance(0, memgraph.RoleReplica), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleMain), + }, + want: false, + }, + { + name: "a retiring coordinator still in Raft is not retired", + declared: shrunkCoordinators(), + observed: append(observedCoordinators(1, 1, 2, 3, 4), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + ), + want: false, + }, + { + name: "retiring coordinators all out of Raft are retired", + declared: shrunkCoordinators(), + observed: append(observedCoordinators(1, 1, 2, 3), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + ), + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := planner.Retired(tc.declared, tc.observed); got != tc.want { + t.Errorf("Retired() = %t, want %t", got, tc.want) + } + }) + } +} + +// TestRegistered covers what the CR's status publishes: how many of each role's +// declared members the cluster has registered. It shares Plan's definition of +// registered, so the counts reach the declared ones exactly when Plan falls +// silent. +func TestRegistered(t *testing.T) { + selfReporting := observedCoordinator(2, memgraph.RoleLeader) + // The coordinator the client is connected to lists itself with an empty + // bolt_server until ADD COORDINATOR is issued for its ID. + selfReporting.BoltServer = "" + + cases := []struct { + name string + declared planner.Topology + observed []memgraph.Instance + wantCoordinators int32 + wantDataInstances int32 + }{ + { + name: "a fresh cluster has nothing registered", + declared: declaredTopology(), + }, + { + name: "a converged cluster reports the declared counts", + declared: declaredTopology(), + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + }, + wantCoordinators: 3, + wantDataInstances: 2, + }, + { + name: "a coordinator that is present but not added does not count", + declared: declaredTopology(), + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleFollower), + selfReporting, + observedDataInstance(0, memgraph.RoleMain), + }, + wantCoordinators: 1, + wantDataInstances: 1, + }, + { + name: "a grown topology reports the members registered so far", + declared: grownTopology(), + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedCoordinator(4, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + }, + wantCoordinators: 4, + wantDataInstances: 2, + }, + // Counting only declared members keeps the status a report on the topology + // the user asked for, not on whatever else the cluster happens to know. + { + name: "members the topology does not declare are not counted", + declared: declaredTopology(), + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + observedCoordinator(4, memgraph.RoleFollower), + observedDataInstance(0, memgraph.RoleMain), + observedDataInstance(1, memgraph.RoleReplica), + observedDataInstance(2, memgraph.RoleReplica), + }, + wantCoordinators: 3, + wantDataInstances: 2, + }, + // Health is not registration: an instance the leader cannot reach is still + // a member of the cluster. + { + name: "a down instance still counts as registered", + declared: declaredTopology(), + observed: []memgraph.Instance{ + observedCoordinator(1, memgraph.RoleLeader), + observedCoordinator(2, memgraph.RoleFollower), + observedCoordinator(3, memgraph.RoleFollower), + downDataInstance(0), + observedDataInstance(1, memgraph.RoleMain), + }, + wantCoordinators: 3, + wantDataInstances: 2, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + coordinators, dataInstances := planner.Registered(tc.declared, tc.observed) + if coordinators != tc.wantCoordinators || dataInstances != tc.wantDataInstances { + t.Errorf("Registered() = (%d, %d), want (%d, %d)", + coordinators, dataInstances, tc.wantCoordinators, tc.wantDataInstances) + } + }) + } +} + +// TestPlanUsesConfiguredPortsAndClusterDomain plans a fresh bootstrap over a +// topology derived from a CR with non-default ports and cluster domain: the +// registration commands must carry exactly those addresses, because they are +// what the coordinators will use to reach every instance. +func TestPlanUsesConfiguredPortsAndClusterDomain(t *testing.T) { + cluster := &memgraphcomv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "example", Namespace: "memgraph-test"}, + Spec: memgraphcomv1alpha1.MemgraphClusterSpec{ + Coordinators: ptr.To(int32(1)), + DataInstances: ptr.To(int32(1)), + ClusterDomain: "k8s.example.com", + Ports: memgraphcomv1alpha1.PortsSpec{ + BoltPort: ptr.To(int32(7777)), + ManagementPort: ptr.To(int32(10001)), + ReplicationPort: ptr.To(int32(20001)), + CoordinatorPort: ptr.To(int32(12001)), + }, + }, + } + + coordinatorHost := "example-coordinator-0.example-coordinator.memgraph-test.svc.k8s.example.com" + dataHost := "example-data-0.example-data.memgraph-test.svc.k8s.example.com" + want := []planner.Command{ + planner.AddCoordinator{Coordinator: memgraph.CoordinatorSpec{ + ID: 1, + BoltServer: coordinatorHost + ":7777", + CoordinatorServer: coordinatorHost + ":12001", + ManagementServer: coordinatorHost + ":10001", + }}, + planner.RegisterInstance{Instance: memgraph.DataInstanceSpec{ + Name: firstInstance, + BoltServer: dataHost + ":7777", + ManagementServer: dataHost + ":10001", + ReplicationServer: dataHost + ":20001", + }}, + promote(firstInstance), + } + + got := planner.Plan(resources.DeclaredTopology(cluster), nil, nil) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("Plan() mismatch (-want +got):\n%s", diff) + } +} + +// A cluster already registered on the configured addresses is converged: the +// planner must not re-issue registrations just because the ports are not the +// defaults. +func TestPlanConvergedOnConfiguredPorts(t *testing.T) { + cluster := &memgraphcomv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "example", Namespace: "memgraph-test"}, + Spec: memgraphcomv1alpha1.MemgraphClusterSpec{ + Coordinators: ptr.To(int32(1)), + DataInstances: ptr.To(int32(1)), + ClusterDomain: "k8s.example.com", + Ports: memgraphcomv1alpha1.PortsSpec{BoltPort: ptr.To(int32(7777))}, + }, + } + declared := resources.DeclaredTopology(cluster) + + coordinator := declared.Coordinators[0] + instance := declared.DataInstances[0] + observed := []memgraph.Instance{ + { + Name: coordinator.Name(), + BoltServer: coordinator.BoltServer, + CoordinatorServer: coordinator.CoordinatorServer, + ManagementServer: coordinator.ManagementServer, + Health: "up", + Role: memgraph.RoleLeader, + }, + { + Name: instance.Name, + BoltServer: instance.BoltServer, + ManagementServer: instance.ManagementServer, + Health: "up", + Role: memgraph.RoleMain, + }, + } + + if got := planner.Plan(declared, observed, nil); got != nil { + t.Errorf("Plan() = %v, want no commands", got) + } +} diff --git a/internal/resources/resources.go b/internal/resources/resources.go new file mode 100644 index 0000000..9c52c12 --- /dev/null +++ b/internal/resources/resources.go @@ -0,0 +1,402 @@ +/* +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 resources contains pure builders from a MemgraphCluster spec to the +// desired Kubernetes objects. Builders make no API calls and have no side +// effects; the controller server-side-applies their output. +package resources + +import ( + "maps" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" +) + +const ( + // Memgraph workload pods run as the non-root memgraph user baked into the + // official images. + memgraphUserID int64 = 101 + memgraphGroupID int64 = 103 + + coordinatorComponent = "coordinator" + dataComponent = "data" + + // ManagedByLabel and ManagedByValue mark every object the operator builds. + // They are exported because the manager scopes its Pod cache to them: the + // rolling restart needs per-pod revisions, and caching every pod in the + // cluster to get them would be a rude surprise on a large one. + ManagedByLabel = "app.kubernetes.io/managed-by" + ManagedByValue = "memgraph-operator" +) + +// Named container and Service port names shared by both roles. +const ( + boltPortName = "bolt" + managementPortName = "management" + coordinatorPortName = "coordinator" + replicationPortName = "replication" +) + +// CoordinatorName is the name shared by the coordinator StatefulSet and its +// headless Service. +func CoordinatorName(cluster *memgraphcomv1alpha1.MemgraphCluster) string { + return cluster.Name + "-" + coordinatorComponent +} + +// DataName is the name shared by the data-instance StatefulSet and its +// headless Service. +func DataName(cluster *memgraphcomv1alpha1.MemgraphCluster) string { + return cluster.Name + "-" + dataComponent +} + +// labels returns the full label set stamped on all objects of a role, with the +// role's custom labels merged underneath: the operator's own identity labels +// always win a key collision, so a custom label can never detach an object +// from its cluster. +func labels( + cluster *memgraphcomv1alpha1.MemgraphCluster, + component string, + custom map[string]string, +) map[string]string { + l := make(map[string]string, len(custom)+4) + maps.Copy(l, custom) + maps.Copy(l, selectorLabels(cluster, component)) + l[ManagedByLabel] = ManagedByValue + return l +} + +// CoordinatorPodSelector matches the pods of this cluster's coordinator +// StatefulSet, and DataPodSelector those of its data StatefulSet. Both are the +// StatefulSets' own selectors, so they cannot drift from the pods they describe. +func CoordinatorPodSelector(cluster *memgraphcomv1alpha1.MemgraphCluster) map[string]string { + return selectorLabels(cluster, coordinatorComponent) +} + +// DataPodSelector matches the pods of this cluster's data StatefulSet. +func DataPodSelector(cluster *memgraphcomv1alpha1.MemgraphCluster) map[string]string { + return selectorLabels(cluster, dataComponent) +} + +// selectorLabels returns the immutable subset of labels used as StatefulSet +// and Service selectors. +func selectorLabels(cluster *memgraphcomv1alpha1.MemgraphCluster, component string) map[string]string { + return map[string]string{ + "app.kubernetes.io/name": "memgraph", + "app.kubernetes.io/instance": cluster.Name, + "app.kubernetes.io/component": component, + } +} + +// normalizedSpec is a MemgraphClusterSpec with every optional field resolved +// to its default, so builders behave correctly on specs that never passed +// admission. Most defaults are CRD schema defaults mirrored as Go constants; +// the probe failure thresholds are Go-only, because they depend on the role. +type normalizedSpec struct { + coordinators int32 + dataInstances int32 + image string + pullPolicy corev1.PullPolicy + secretName string + licenseKey string + organizationKey string + clusterDomain string + ports normalizedPorts + retentionPolicy memgraphcomv1alpha1.StorageRetentionPolicy + coordinatorRole normalizedRole + dataRole normalizedRole +} + +// normalizedPorts are the internal ports every advertised address, container +// port and Service port is built from. +type normalizedPorts struct { + bolt int32 + management int32 + replication int32 + coordinator int32 +} + +// normalizedRole is everything the builders need that is configured per role. +type normalizedRole struct { + storage normalizedStorage + coreDumps normalizedCoreDumps + startupProbe normalizedProbe + readinessProbe normalizedProbe + livenessProbe normalizedProbe + resources corev1.ResourceRequirements + podLabels map[string]string + statefulSetLabels map[string]string + serviceLabels map[string]string + env []corev1.EnvVar + extraArgs []string + extraVolumes []corev1.Volume + extraMounts []corev1.VolumeMount +} + +// normalizedCoreDumps is one role's core dump configuration with every optional +// field resolved to its CRD schema default. Everything hangs off enabled: with +// it false the rest is unused, and no claim, mount, init container or sidecar +// reaches the role's pods. +type normalizedCoreDumps struct { + enabled bool + size resource.Quantity + class *string + configurePattern bool + uploader *memgraphcomv1alpha1.CoreDumpsUploaderSpec +} + +// normalizedProbe is one probe's timings; the probe type is always a TCP-socket +// check against the role's own port. +type normalizedProbe struct { + failureThreshold int32 + timeoutSeconds int32 + periodSeconds int32 +} + +// normalizedStorage is one role's lib and log claim configuration with every +// optional field resolved to its CRD schema default. A nil storage class means +// "use the cluster default" and is passed through as nil, which is distinct +// from the empty string (no dynamic provisioning). +type normalizedStorage struct { + libSize resource.Quantity + libAccessMode corev1.PersistentVolumeAccessMode + libClass *string + // createLogClaim is false when the role opted out of log storage; the log + // fields below are then unused. + createLogClaim bool + logSize resource.Quantity + logAccessMode corev1.PersistentVolumeAccessMode + logClass *string +} + +func normalize(spec memgraphcomv1alpha1.MemgraphClusterSpec) normalizedSpec { + n := normalizedSpec{ + coordinators: memgraphcomv1alpha1.DefaultCoordinatorCount, + dataInstances: memgraphcomv1alpha1.DefaultDataInstanceCount, + image: imageRef(spec.Image), + pullPolicy: spec.Image.PullPolicy, + secretName: spec.Secrets.Name, + licenseKey: spec.Secrets.LicenseKey, + organizationKey: spec.Secrets.OrganizationKey, + clusterDomain: spec.ClusterDomain, + ports: normalizePorts(spec.Ports), + retentionPolicy: spec.Storage.RetentionPolicy, + coordinatorRole: normalizeRole(roleSpec{ + storage: spec.Storage.Coordinators, + coreDumps: normalizeCoreDumps(spec.CoreDumps, spec.CoreDumps.Coordinators), + probes: spec.Probes.Coordinators, + resources: spec.Resources.Coordinators, + labels: spec.Labels.Coordinators, + env: spec.ExtraEnv.Coordinators, + extraArgs: spec.ExtraArgs.Coordinators, + extraVolumes: spec.ExtraVolumes.Coordinators, + extraMounts: spec.ExtraVolumeMounts.Coordinators, + + startupFailureThreshold: memgraphcomv1alpha1.DefaultProbeFailureThreshold, + }), + dataRole: normalizeRole(roleSpec{ + storage: spec.Storage.Data, + coreDumps: normalizeCoreDumps(spec.CoreDumps, spec.CoreDumps.Data), + probes: spec.Probes.Data, + resources: spec.Resources.Data, + labels: spec.Labels.Data, + env: spec.ExtraEnv.Data, + extraArgs: spec.ExtraArgs.Data, + extraVolumes: spec.ExtraVolumes.Data, + extraMounts: spec.ExtraVolumeMounts.Data, + + // Data instances get the long startup budget: only they load + // snapshots, and a large restore must not be killed mid-load. + startupFailureThreshold: memgraphcomv1alpha1.DefaultDataStartupProbeFailureThreshold, + }), + } + if spec.Coordinators != nil { + n.coordinators = *spec.Coordinators + } + if spec.DataInstances != nil { + n.dataInstances = *spec.DataInstances + } + if n.clusterDomain == "" { + n.clusterDomain = memgraphcomv1alpha1.DefaultClusterDomain + } + if n.pullPolicy == "" { + n.pullPolicy = memgraphcomv1alpha1.DefaultImagePullPolicy + } + if n.secretName == "" { + n.secretName = memgraphcomv1alpha1.DefaultSecretName + } + if n.licenseKey == "" { + n.licenseKey = memgraphcomv1alpha1.DefaultLicenseSecretKey + } + if n.organizationKey == "" { + n.organizationKey = memgraphcomv1alpha1.DefaultOrganizationSecretKey + } + if n.retentionPolicy == "" { + n.retentionPolicy = memgraphcomv1alpha1.DefaultStorageRetention + } + return n +} + +// roleSpec gathers the per-role pieces the spec's concern-first blocks +// (storage, probes, resources, labels, extraEnv, extraArgs) scatter across the +// CR, so normalization is written once and both roles resolve their defaults +// the same way. +type roleSpec struct { + storage memgraphcomv1alpha1.RoleStorageSpec + // coreDumps arrives already normalized: unlike the other entries it is + // folded from two spec blocks (the cluster-wide settings and the role's + // own), which the caller does before handing it over. + coreDumps normalizedCoreDumps + probes memgraphcomv1alpha1.RoleProbesSpec + resources corev1.ResourceRequirements + labels memgraphcomv1alpha1.RoleLabelsSpec + env []memgraphcomv1alpha1.EnvVar + extraArgs []string + extraVolumes []corev1.Volume + extraMounts []corev1.VolumeMount + + // startupFailureThreshold is this role's default startup probe failure + // budget — the one default that differs between the roles. + startupFailureThreshold int32 +} + +func normalizeRole(role roleSpec) normalizedRole { + return normalizedRole{ + storage: normalizeStorage(role.storage), + coreDumps: role.coreDumps, + startupProbe: normalizeProbe(role.probes.StartupProbe, role.startupFailureThreshold), + readinessProbe: normalizeProbe(role.probes.ReadinessProbe, memgraphcomv1alpha1.DefaultProbeFailureThreshold), + livenessProbe: normalizeProbe(role.probes.LivenessProbe, memgraphcomv1alpha1.DefaultProbeFailureThreshold), + resources: role.resources, + podLabels: role.labels.PodLabels, + statefulSetLabels: role.labels.StatefulSetLabels, + serviceLabels: role.labels.ServiceLabels, + env: normalizeEnv(role.env), + extraArgs: role.extraArgs, + extraVolumes: role.extraVolumes, + extraMounts: role.extraMounts, + } +} + +// normalizePorts resolves the internal ports, whose defaults mirror the +// memgraph-high-availability Helm chart's. +func normalizePorts(spec memgraphcomv1alpha1.PortsSpec) normalizedPorts { + return normalizedPorts{ + bolt: intOrDefault(spec.BoltPort, memgraphcomv1alpha1.DefaultBoltPort), + management: intOrDefault(spec.ManagementPort, memgraphcomv1alpha1.DefaultManagementPort), + replication: intOrDefault(spec.ReplicationPort, memgraphcomv1alpha1.DefaultReplicationPort), + coordinator: intOrDefault(spec.CoordinatorPort, memgraphcomv1alpha1.DefaultCoordinatorPort), + } +} + +// intOrDefault resolves an optional numeric knob against its default. +func intOrDefault(configured *int32, fallback int32) int32 { + if configured == nil { + return fallback + } + return *configured +} + +// normalizeProbe resolves one probe's timings. Only the failure threshold's +// default depends on the role — the probe that guards a snapshot restore needs +// a far larger budget than the rest. +func normalizeProbe(spec memgraphcomv1alpha1.ProbeSpec, defaultFailureThreshold int32) normalizedProbe { + return normalizedProbe{ + failureThreshold: intOrDefault(spec.FailureThreshold, defaultFailureThreshold), + timeoutSeconds: intOrDefault(spec.TimeoutSeconds, memgraphcomv1alpha1.DefaultProbeTimeoutSeconds), + periodSeconds: intOrDefault(spec.PeriodSeconds, memgraphcomv1alpha1.DefaultProbePeriodSeconds), + } +} + +// normalizeEnv converts the spec's non-secret name/value pairs into container +// environment variables. Nothing is defaulted: an unset list means no extra +// environment. +func normalizeEnv(spec []memgraphcomv1alpha1.EnvVar) []corev1.EnvVar { + if len(spec) == 0 { + return nil + } + env := make([]corev1.EnvVar, 0, len(spec)) + for _, variable := range spec { + env = append(env, corev1.EnvVar{Name: variable.Name, Value: variable.Value}) + } + return env +} + +func normalizeStorage(spec memgraphcomv1alpha1.RoleStorageSpec) normalizedStorage { + n := normalizedStorage{ + libSize: resource.MustParse(memgraphcomv1alpha1.DefaultLibPVCSize), + libAccessMode: spec.LibStorageAccessMode, + libClass: spec.LibStorageClassName, + createLogClaim: memgraphcomv1alpha1.DefaultCreateLogStorageClaim, + logSize: resource.MustParse(memgraphcomv1alpha1.DefaultLogPVCSize), + logAccessMode: spec.LogStorageAccessMode, + logClass: spec.LogStorageClassName, + } + if spec.CreateLogStorageClaim != nil { + n.createLogClaim = *spec.CreateLogStorageClaim + } + if spec.LibPVCSize != nil { + n.libSize = *spec.LibPVCSize + } + if spec.LogPVCSize != nil { + n.logSize = *spec.LogPVCSize + } + if n.libAccessMode == "" { + n.libAccessMode = memgraphcomv1alpha1.DefaultStorageAccessMode + } + if n.logAccessMode == "" { + n.logAccessMode = memgraphcomv1alpha1.DefaultStorageAccessMode + } + return n +} + +// normalizeCoreDumps folds the cluster-wide core dump settings together with +// the role's own into the single view the builders work from. Nothing is +// resolved eagerly for a disabled role beyond its defaults: the builders check +// enabled before reading the rest. +func normalizeCoreDumps( + shared memgraphcomv1alpha1.CoreDumpsSpec, + role memgraphcomv1alpha1.RoleCoreDumpsSpec, +) normalizedCoreDumps { + n := normalizedCoreDumps{ + enabled: role.Enabled, + size: resource.MustParse(memgraphcomv1alpha1.DefaultCoreDumpsSize), + class: shared.StorageClassName, + configurePattern: memgraphcomv1alpha1.DefaultConfigureCorePattern, + uploader: shared.Uploader, + } + if role.Size != nil { + n.size = *role.Size + } + if shared.ConfigureCorePattern != nil { + n.configurePattern = *shared.ConfigureCorePattern + } + return n +} + +func imageRef(image memgraphcomv1alpha1.ImageSpec) string { + repository := image.Repository + if repository == "" { + repository = memgraphcomv1alpha1.DefaultImageRepository + } + tag := image.Tag + if tag == "" { + tag = memgraphcomv1alpha1.DefaultImageTag + } + return repository + ":" + tag +} diff --git a/internal/resources/service.go b/internal/resources/service.go new file mode 100644 index 0000000..4354ed5 --- /dev/null +++ b/internal/resources/service.go @@ -0,0 +1,76 @@ +/* +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 resources + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" +) + +// CoordinatorHeadlessService builds the headless Service backing the +// coordinator StatefulSet's per-pod DNS identities. +func CoordinatorHeadlessService(cluster *memgraphcomv1alpha1.MemgraphCluster) *corev1.Service { + spec := normalize(cluster.Spec) + + return headlessService(cluster, coordinatorComponent, CoordinatorName(cluster), + spec.coordinatorRole.serviceLabels, []corev1.ServicePort{ + {Name: boltPortName, Port: spec.ports.bolt}, + {Name: managementPortName, Port: spec.ports.management}, + {Name: coordinatorPortName, Port: spec.ports.coordinator}, + }) +} + +// DataHeadlessService builds the headless Service backing the data-instance +// StatefulSet's per-pod DNS identities. +func DataHeadlessService(cluster *memgraphcomv1alpha1.MemgraphCluster) *corev1.Service { + spec := normalize(cluster.Spec) + + return headlessService(cluster, dataComponent, DataName(cluster), + spec.dataRole.serviceLabels, []corev1.ServicePort{ + {Name: boltPortName, Port: spec.ports.bolt}, + {Name: managementPortName, Port: spec.ports.management}, + {Name: replicationPortName, Port: spec.ports.replication}, + }) +} + +func headlessService( + cluster *memgraphcomv1alpha1.MemgraphCluster, + component, name string, + customLabels map[string]string, + ports []corev1.ServicePort, +) *corev1.Service { + return &corev1.Service{ + // TypeMeta is set explicitly because the controller server-side + // applies builder output, and apply patches must carry the GVK. + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: cluster.Namespace, + Labels: labels(cluster, component, customLabels), + }, + Spec: corev1.ServiceSpec{ + ClusterIP: corev1.ClusterIPNone, + Selector: selectorLabels(cluster, component), + // Pods must resolve each other's DNS names before they are ready, + // otherwise coordinators could never form a cluster. + PublishNotReadyAddresses: true, + Ports: ports, + }, + } +} diff --git a/internal/resources/service_test.go b/internal/resources/service_test.go new file mode 100644 index 0000000..dc607c7 --- /dev/null +++ b/internal/resources/service_test.go @@ -0,0 +1,130 @@ +/* +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 resources_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/memgraph/kubernetes-operator/internal/resources" +) + +func TestCoordinatorHeadlessService(t *testing.T) { + want := &corev1.Service{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: serviceKind}, + ObjectMeta: metav1.ObjectMeta{ + Name: coordinatorName, + Namespace: testNamespace, + Labels: expectedLabels(coordinatorComponent), + }, + Spec: corev1.ServiceSpec{ + ClusterIP: corev1.ClusterIPNone, + Selector: expectedSelectorLabels(coordinatorComponent), + PublishNotReadyAddresses: true, + Ports: []corev1.ServicePort{ + {Name: boltPortName, Port: 7687}, + {Name: managementPortName, Port: 10000}, + {Name: coordinatorComponent, Port: 12000}, + }, + }, + } + + got := resources.CoordinatorHeadlessService(minimalCluster()) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("CoordinatorHeadlessService() mismatch (-want +got):\n%s", diff) + } +} + +func TestDataHeadlessService(t *testing.T) { + want := &corev1.Service{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: serviceKind}, + ObjectMeta: metav1.ObjectMeta{ + Name: dataName, + Namespace: testNamespace, + Labels: expectedLabels(dataComponent), + }, + Spec: corev1.ServiceSpec{ + ClusterIP: corev1.ClusterIPNone, + Selector: expectedSelectorLabels(dataComponent), + PublishNotReadyAddresses: true, + Ports: []corev1.ServicePort{ + {Name: boltPortName, Port: 7687}, + {Name: managementPortName, Port: 10000}, + {Name: replicationPortName, Port: 20000}, + }, + }, + } + + got := resources.DataHeadlessService(minimalCluster()) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("DataHeadlessService() mismatch (-want +got):\n%s", diff) + } +} + +// TestHeadlessServicePortsAndLabels asserts the Services publish the configured +// ports — the pods listen on nothing else — and carry the role's custom Service +// labels while keeping the operator-owned selector. +func TestHeadlessServicePortsAndLabels(t *testing.T) { + cluster := tunedCluster() + + tests := []struct { + name string + service *corev1.Service + component string + labels map[string]string + ports []corev1.ServicePort + }{ + { + name: coordinatorComponent, + service: resources.CoordinatorHeadlessService(cluster), + component: coordinatorComponent, + labels: map[string]string{exposeLabel: "internal"}, + ports: []corev1.ServicePort{ + {Name: boltPortName, Port: customBoltPort}, + {Name: managementPortName, Port: customManagementPort}, + {Name: coordinatorComponent, Port: customCoordinatorPort}, + }, + }, + { + name: dataComponent, + service: resources.DataHeadlessService(cluster), + component: dataComponent, + labels: map[string]string{exposeLabel: "bolt"}, + ports: []corev1.ServicePort{ + {Name: boltPortName, Port: customBoltPort}, + {Name: managementPortName, Port: customManagementPort}, + {Name: replicationPortName, Port: customReplicationPort}, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(tc.ports, tc.service.Spec.Ports); diff != "" { + t.Errorf("Service ports mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(expectedLabelsWith(tc.component, tc.labels), tc.service.Labels); diff != "" { + t.Errorf("Service labels mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(expectedSelectorLabels(tc.component), tc.service.Spec.Selector); diff != "" { + t.Errorf("Service selector mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/resources/statefulset.go b/internal/resources/statefulset.go new file mode 100644 index 0000000..114d124 --- /dev/null +++ b/internal/resources/statefulset.go @@ -0,0 +1,498 @@ +/* +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 resources + +import ( + "fmt" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" + + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" +) + +const ( + memgraphBinary = "/usr/lib/memgraph/memgraph" + dataDirectory = "/var/lib/memgraph/mg_data" + logFile = "/var/log/memgraph/memgraph.log" + + libMountPath = "/var/lib/memgraph" + logMountPath = "/var/log/memgraph" + tmpMountPath = "/tmp" + // coreDumpsMountPath is not configurable: it is only ever written to by the + // kernel and read by the uploader, both of which the operator points at it, + // so a knob would only create ways for the two to disagree. + coreDumpsMountPath = "/var/core/memgraph" + + // Volume names double as the StatefulSet volumeClaimTemplate names, so the + // provisioned claims are -, e.g. lib-storage-example-data-0. + libVolumeName = "lib-storage" + logVolumeName = "log-storage" + coreDumpsVolumeName = "core-dumps" + tmpVolumeName = "tmp" + + // containerName is the Memgraph container's name, and doubles as the $0 the + // coordinator's shell wrapper is given. + containerName = "memgraph" + + // Container names of the two optional containers core dumps bring along. + corePatternContainerName = "init-core-pattern" + uploaderContainerName = "core-dumps-uploader" + + // terminationGracePeriod is how long a pod gets to shut down cleanly before + // SIGKILL. Kubernetes' own default of 30 seconds was harmless while nothing + // routinely deleted these pods; it is wrong now that the operator deletes + // every one of them on every pod-template change, because an instance killed + // mid-shutdown recovers from its write-ahead log on startup and lengthens + // exactly the catch-up the rolling restart then waits on. This is a ceiling + // and not a delay — an instance that exits in two seconds costs two seconds — + // so it is a constant rather than a knob until someone needs a different one. + terminationGracePeriod int64 = 300 +) + +// CoordinatorStatefulSet builds the single StatefulSet running all +// coordinator instances. Per-pod identity (coordinator ID, advertised FQDN) +// is derived from the pod ordinal at startup, so the pod template stays +// uniform across replicas. +// +// The replica count is an argument rather than read off the spec because the +// count to apply is a decision about the live cluster, not about the spec: it +// is the declared count while the cluster grows, and the current count while a +// lowered one is still being retired. Keeping that decision in the controller +// keeps this builder pure. DeclaredCoordinators is the count for a cluster that +// is not shrinking. +func CoordinatorStatefulSet( + cluster *memgraphcomv1alpha1.MemgraphCluster, + replicas int32, +) *appsv1.StatefulSet { + spec := normalize(cluster.Spec) + role := spec.coordinatorRole + + container := memgraphContainer(spec, role) + // The coordinator ID and advertised FQDN depend on the pod ordinal, which + // only the pod itself knows; a shell wrapper derives them from the pod + // name so all replicas share one template. + container.Command = []string{"/bin/sh", "-ec", coordinatorStartScript(cluster, spec)} + // The flags are handed to the wrapper as arguments rather than interpolated + // into the script, so `exec ... "$@"` passes each one to Memgraph verbatim — + // a value carrying whitespace or shell metacharacters is never re-parsed by + // the shell. `sh -c` assigns the first operand to $0, so it is a placeholder + // name and not a flag. + container.Args = append([]string{containerName}, append(commonArgs(spec, role), role.extraArgs...)...) + container.Env = append([]corev1.EnvVar{{ + Name: memgraphcomv1alpha1.EnvPodName, + ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"}, + }, + }}, container.Env...) + container.Ports = []corev1.ContainerPort{ + {Name: boltPortName, ContainerPort: spec.ports.bolt}, + {Name: managementPortName, ContainerPort: spec.ports.management}, + {Name: coordinatorPortName, ContainerPort: spec.ports.coordinator}, + } + // Coordinators are probed on their Raft port: it is the one they serve + // even before the Raft cluster has been formed. + container.StartupProbe = tcpProbe(spec.ports.coordinator, role.startupProbe) + container.ReadinessProbe = tcpProbe(spec.ports.coordinator, role.readinessProbe) + container.LivenessProbe = tcpProbe(spec.ports.coordinator, role.livenessProbe) + + return statefulSet(cluster, coordinatorComponent, CoordinatorName(cluster), spec, role, replicas, container) +} + +// DataStatefulSet builds the single StatefulSet running all data instances. The +// replica count is an argument for the reason CoordinatorStatefulSet documents; +// DeclaredDataInstances is the count for a cluster that is not shrinking. +func DataStatefulSet(cluster *memgraphcomv1alpha1.MemgraphCluster, replicas int32) *appsv1.StatefulSet { + spec := normalize(cluster.Spec) + role := spec.dataRole + + container := memgraphContainer(spec, role) + container.Args = append(commonArgs(spec, role), role.extraArgs...) + container.Ports = []corev1.ContainerPort{ + {Name: boltPortName, ContainerPort: spec.ports.bolt}, + {Name: managementPortName, ContainerPort: spec.ports.management}, + {Name: replicationPortName, ContainerPort: spec.ports.replication}, + } + container.StartupProbe = tcpProbe(spec.ports.bolt, role.startupProbe) + container.ReadinessProbe = tcpProbe(spec.ports.bolt, role.readinessProbe) + container.LivenessProbe = tcpProbe(spec.ports.bolt, role.livenessProbe) + + return statefulSet(cluster, dataComponent, DataName(cluster), spec, role, replicas, container) +} + +// coordinatorStartScript derives the coordinator's identity from its pod +// ordinal (the numeric suffix of the pod name): ordinal N becomes coordinator +// ID N+1 (Memgraph treats coordinator ID 0 as unset and refuses to start, so +// IDs stay 1-based) advertised at the pod's stable DNS name within the +// headless Service. Every other flag arrives as a container argument and is +// forwarded by "$@" — only the derived ones are written into the script. +func coordinatorStartScript( + cluster *memgraphcomv1alpha1.MemgraphCluster, + spec normalizedSpec, +) string { + fqdnSuffix := podFQDNSuffix(cluster, CoordinatorName(cluster), spec) + return fmt.Sprintf(`ordinal="${POD_NAME##*-}" +exec %s \ + --coordinator-id="$((ordinal + 1))" \ + --coordinator-hostname="${POD_NAME}.%s" \ + --coordinator-port=%d \ + "$@"`, memgraphBinary, fqdnSuffix, spec.ports.coordinator) +} + +// commonArgs are the Memgraph flags shared by both roles, mirroring the HA +// chart's auto-appended and default logging arguments. A role's extra args are +// appended after these, and Memgraph takes the last occurrence of a repeated +// flag, so a user-supplied flag wins. +// +// A role that opted out of log storage gets --log-file with an empty value, +// which is what turns file logging off. Leaving the flag out would not: the +// image ships /etc/memgraph/memgraph.conf with log_file set to the path below, +// Memgraph parses that file before the command line, and failing to open the +// resulting path is fatal — so an unmounted log directory on a read-only root +// filesystem would crash-loop the pod. --also-log-to-stderr keeps the logs in +// `kubectl logs` either way. +func commonArgs(spec normalizedSpec, role normalizedRole) []string { + logDestination := logFile + if !role.storage.createLogClaim { + logDestination = "" + } + return []string{ + fmt.Sprintf("--bolt-port=%d", spec.ports.bolt), + fmt.Sprintf("--management-port=%d", spec.ports.management), + "--data-directory=" + dataDirectory, + "--log-level=TRACE", + "--also-log-to-stderr", + "--log-file=" + logDestination, + "--log-retention-days=35", + } +} + +// memgraphContainer builds the parts of the Memgraph container shared by both +// roles: image, license env wiring, the role's extra environment and resources, +// storage mounts, and the restricted security context. +func memgraphContainer(spec normalizedSpec, role normalizedRole) corev1.Container { + return corev1.Container{ + Name: containerName, + Image: spec.image, + ImagePullPolicy: spec.pullPolicy, + Resources: role.resources, + // The license variables come first and the role's extra environment + // last; admission rejects an extra variable that repeats one of the + // names the operator owns, so the two can never collide. + Env: append([]corev1.EnvVar{ + { + Name: memgraphcomv1alpha1.EnvLicense, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: spec.secretName}, + Key: spec.licenseKey, + }, + }, + }, + { + Name: memgraphcomv1alpha1.EnvOrganization, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: spec.secretName}, + Key: spec.organizationKey, + }, + }, + }, + }, role.env...), + VolumeMounts: volumeMounts(role), + SecurityContext: restrictedSecurityContext(), + } +} + +// restrictedSecurityContext is what every container the operator builds runs +// under: no privilege escalation, no capabilities, a read-only root filesystem +// and the default seccomp profile. The core pattern init container is the one +// exception — it cannot do its job under this. +func restrictedSecurityContext() *corev1.SecurityContext { + return &corev1.SecurityContext{ + AllowPrivilegeEscalation: ptr.To(false), + Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}, + ReadOnlyRootFilesystem: ptr.To(true), + RunAsNonRoot: ptr.To(true), + SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}, + } +} + +// corePatternInitContainer points the node's kernel at the role's core dumps +// directory. It runs the cluster's own Memgraph image — already pulled on the +// node, so core dumps need no second image to be configured or mirrored — and +// is the only container the operator builds that breaks the restricted security +// posture: /proc/sys is mounted read-only in an unprivileged container, so +// writing core_pattern needs privileged plus root. Nothing else about the pod +// is relaxed, and a namespace that forbids privileged pods can turn this off +// and have the platform manage core_pattern on the node instead. +func corePatternInitContainer(spec normalizedSpec) corev1.Container { + // %e.%p.%t.%s expand to the crashing executable, its pid, the time and the + // signal. + pattern := coreDumpsMountPath + "/core.%e.%p.%t.%s" + return corev1.Container{ + Name: corePatternContainerName, + Image: spec.image, + ImagePullPolicy: spec.pullPolicy, + // tee rather than a plain redirect so the pattern that was set is + // visible in the init container's logs. + Command: []string{"/bin/sh", "-ec", fmt.Sprintf("echo '%s' | tee /proc/sys/kernel/core_pattern", pattern)}, + SecurityContext: &corev1.SecurityContext{ + Privileged: ptr.To(true), + // Kubernetes rejects a privileged container that also forbids + // privilege escalation, so this one cannot be false. + AllowPrivilegeEscalation: ptr.To(true), + ReadOnlyRootFilesystem: ptr.To(true), + RunAsUser: ptr.To(int64(0)), + RunAsNonRoot: ptr.To(false), + SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}, + }, + } +} + +// uploaderSidecar builds the optional container that ships collected dumps off +// the volume. The operator owns the wiring a sidecar must not get wrong: the +// dumps are mounted read-only (an uploader has no business writing them), the +// mount path arrives as CORE_DUMPS_DIR so the path is stated once, and the +// pod's scratch volume is mounted at /tmp so the sidecar has somewhere to write +// without a writable root filesystem. +func uploaderSidecar(coreDumps normalizedCoreDumps) corev1.Container { + uploader := coreDumps.uploader + pullPolicy := uploader.PullPolicy + if pullPolicy == "" { + pullPolicy = memgraphcomv1alpha1.DefaultImagePullPolicy + } + env := append([]corev1.EnvVar{{ + Name: memgraphcomv1alpha1.EnvCoreDumpsDir, Value: coreDumpsMountPath, + }}, normalizeEnv(uploader.Env)...) + + envFrom := make([]corev1.EnvFromSource, 0, len(uploader.EnvFromSecrets)) + for _, secret := range uploader.EnvFromSecrets { + envFrom = append(envFrom, corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: secret}, + }, + }) + } + + return corev1.Container{ + Name: uploaderContainerName, + Image: uploader.Image, + ImagePullPolicy: pullPolicy, + Command: uploader.Command, + Args: uploader.Args, + Env: env, + EnvFrom: envFrom, + Resources: uploader.Resources, + VolumeMounts: []corev1.VolumeMount{ + {Name: coreDumpsVolumeName, MountPath: coreDumpsMountPath, ReadOnly: true}, + {Name: tmpVolumeName, MountPath: tmpMountPath}, + }, + SecurityContext: restrictedSecurityContext(), + } +} + +// volumeMounts are the Memgraph container's mounts: lib storage, the scratch +// directory the read-only root filesystem needs, log storage unless the role +// opted out of it, the core dumps directory when the role collects dumps, and +// last the role's own extra mounts. +func volumeMounts(role normalizedRole) []corev1.VolumeMount { + mounts := []corev1.VolumeMount{{Name: libVolumeName, MountPath: libMountPath}} + if role.storage.createLogClaim { + mounts = append(mounts, corev1.VolumeMount{Name: logVolumeName, MountPath: logMountPath}) + } + mounts = append(mounts, corev1.VolumeMount{Name: tmpVolumeName, MountPath: tmpMountPath}) + if role.coreDumps.enabled { + mounts = append(mounts, + corev1.VolumeMount{Name: coreDumpsVolumeName, MountPath: coreDumpsMountPath}) + } + return append(mounts, role.extraMounts...) +} + +// podVolumes is the scratch directory the read-only root filesystem needs plus +// the role's extra volumes. Everything persistent comes from +// volumeClaimTemplates instead. +func podVolumes(role normalizedRole) []corev1.Volume { + volumes := make([]corev1.Volume, 0, 1+len(role.extraVolumes)) + volumes = append(volumes, corev1.Volume{ + Name: tmpVolumeName, VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, + }) + return append(volumes, role.extraVolumes...) +} + +// volumeClaimTemplates are the per-pod claims of the role: lib storage always, +// log storage unless the role opted out of it, and core dumps when enabled. All +// three follow the cluster's single retention policy. +func volumeClaimTemplates(role normalizedRole) []corev1.PersistentVolumeClaim { + storage := role.storage + claims := []corev1.PersistentVolumeClaim{ + volumeClaimTemplate(libVolumeName, storage.libSize, storage.libAccessMode, storage.libClass), + } + if storage.createLogClaim { + claims = append(claims, + volumeClaimTemplate(logVolumeName, storage.logSize, storage.logAccessMode, storage.logClass)) + } + if role.coreDumps.enabled { + // Dumps are written by one node's kernel into one pod's directory, so + // the access mode is not a knob: ReadWriteOnce is the only one that + // describes it. + claims = append(claims, volumeClaimTemplate(coreDumpsVolumeName, + role.coreDumps.size, corev1.ReadWriteOnce, role.coreDumps.class)) + } + return claims +} + +// podContainers is the Memgraph container plus the uploader sidecar when the +// role has one. Memgraph stays first, so `kubectl logs` without -c keeps +// showing the database. +func podContainers(memgraph corev1.Container, role normalizedRole) []corev1.Container { + containers := []corev1.Container{memgraph} + if role.coreDumps.enabled && role.coreDumps.uploader != nil { + containers = append(containers, uploaderSidecar(role.coreDumps)) + } + return containers +} + +// podInitContainers is empty unless the role asked the operator to configure +// the node's core pattern. +func podInitContainers(spec normalizedSpec, role normalizedRole) []corev1.Container { + if !role.coreDumps.enabled || !role.coreDumps.configurePattern { + return nil + } + return []corev1.Container{corePatternInitContainer(spec)} +} + +func statefulSet( + cluster *memgraphcomv1alpha1.MemgraphCluster, + component, name string, + spec normalizedSpec, + role normalizedRole, + replicas int32, + container corev1.Container, +) *appsv1.StatefulSet { + return &appsv1.StatefulSet{ + // TypeMeta is set explicitly because the controller server-side + // applies builder output, and apply patches must carry the GVK. + TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "StatefulSet"}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: cluster.Namespace, + Labels: labels(cluster, component, role.statefulSetLabels), + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(replicas), + ServiceName: name, + PodManagementPolicy: appsv1.ParallelPodManagement, + // The operator replaces pods itself, one at a time and in an order + // Kubernetes cannot express: data instances before coordinators, the + // MAIN last, the Raft leader last. RollingUpdate sweeps highest ordinal + // to lowest and `partition` is a descending cutoff rather than a set, so + // a MAIN on any ordinal but 0 would be restarted mid-sweep and every + // such restart costs another coordinator-driven failover. + // + // The cost of this is real and permanent: nothing but the operator will + // ever restart one of these pods again, so a pod-template change no + // reconcile acts on takes effect never. That is what the Updated + // condition is for. + UpdateStrategy: appsv1.StatefulSetUpdateStrategy{ + Type: appsv1.OnDeleteStatefulSetStrategyType, + }, + Selector: &metav1.LabelSelector{MatchLabels: selectorLabels(cluster, component)}, + // The StatefulSet controller is the only thing that ever deletes + // this cluster's storage; the operator owns no finalizer and runs + // no cleanup of its own. Both halves of the policy follow the one + // retention knob: whether a claim is orphaned by deleting the + // cluster or by scaling a role down, the user asked the same + // question — keep this cluster's data, or do not. + PersistentVolumeClaimRetentionPolicy: &appsv1.StatefulSetPersistentVolumeClaimRetentionPolicy{ + WhenDeleted: retentionType(spec.retentionPolicy), + WhenScaled: retentionType(spec.retentionPolicy), + }, + VolumeClaimTemplates: volumeClaimTemplates(role), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels(cluster, component, role.podLabels), + }, + Spec: corev1.PodSpec{ + TerminationGracePeriodSeconds: ptr.To(terminationGracePeriod), + InitContainers: podInitContainers(spec, role), + Containers: podContainers(container, role), + SecurityContext: &corev1.PodSecurityContext{ + RunAsUser: ptr.To(memgraphUserID), + RunAsGroup: ptr.To(memgraphGroupID), + FSGroup: ptr.To(memgraphGroupID), + RunAsNonRoot: ptr.To(true), + SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}, + }, + Volumes: podVolumes(role), + }, + }, + }, + } +} + +// volumeClaimTemplate builds one StatefulSet volumeClaimTemplate. A nil class +// is left unset so the cluster's default StorageClass applies; the empty +// string is passed through as-is, which disables dynamic provisioning. +func volumeClaimTemplate( + name string, + size resource.Quantity, + accessMode corev1.PersistentVolumeAccessMode, + class *string, +) corev1.PersistentVolumeClaim { + return corev1.PersistentVolumeClaim{ + // TypeMeta is set explicitly for the same reason the StatefulSet sets + // it: the controller server-side applies the builder output. + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "PersistentVolumeClaim"}, + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{accessMode}, + StorageClassName: class, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceStorage: size}, + }, + }, + } +} + +// retentionType maps the spec's retention policy onto the StatefulSet's +// whenDeleted policy. The two vocabularies coincide, so the mapping is a +// rename rather than a decision. +func retentionType(policy memgraphcomv1alpha1.StorageRetentionPolicy) appsv1.PersistentVolumeClaimRetentionPolicyType { + if policy == memgraphcomv1alpha1.RetentionPolicyDelete { + return appsv1.DeletePersistentVolumeClaimRetentionPolicyType + } + return appsv1.RetainPersistentVolumeClaimRetentionPolicyType +} + +// tcpProbe builds one probe. The handler is always a TCP-socket check against +// the role's own port — the probe type is deliberately not configurable — so +// only the timings come from the spec. +func tcpProbe(port int32, timings normalizedProbe) *corev1.Probe { + return &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + TCPSocket: &corev1.TCPSocketAction{Port: intstr.FromInt32(port)}, + }, + FailureThreshold: timings.failureThreshold, + TimeoutSeconds: timings.timeoutSeconds, + PeriodSeconds: timings.periodSeconds, + } +} diff --git a/internal/resources/statefulset_test.go b/internal/resources/statefulset_test.go new file mode 100644 index 0000000..d313de9 --- /dev/null +++ b/internal/resources/statefulset_test.go @@ -0,0 +1,1311 @@ +/* +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 resources_test + +import ( + "fmt" + "maps" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" + + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" + "github.com/memgraph/kubernetes-operator/internal/resources" +) + +// Shared fixture names for the golden tests in this package. +const ( + testNamespace = "memgraph-test" + clusterName = "example" + + coordinatorComponent = "coordinator" + dataComponent = "data" + + coordinatorName = clusterName + "-" + coordinatorComponent + dataName = clusterName + "-" + dataComponent + + memgraphName = "memgraph" + + boltPortName = "bolt" + managementPortName = "management" + replicationPortName = "replication" + + statefulSetKind = "StatefulSet" + serviceKind = "Service" + + tmpVolume = "tmp" + shell = "/bin/sh" + defaultImageRef = "docker.io/memgraph/memgraph:3.12.0-relwithdebinfo" + coreDumpsVolume = "core-dumps" + coreDumpsPath = "/var/core/memgraph" + dataPath = "/var/lib/memgraph/mg_data" + logFilePath = "/var/log/memgraph/memgraph.log" + + // The operator's identity labels, which custom labels may never override. + nameLabel = "app.kubernetes.io/name" + instanceLabel = "app.kubernetes.io/instance" + componentLabel = "app.kubernetes.io/component" + managedByLabel = "app.kubernetes.io/managed-by" + + // Custom label keys and values used by the tuning tests. + teamLabel = "team" + tierLabel = "tier" + exposeLabel = "expose" + + platformTeam = "platform" +) + +// minimalCluster returns a MemgraphCluster as a client would minimally create +// it, deliberately without CRD schema defaults applied: builders must resolve +// defaults themselves on specs that never passed admission. +func minimalCluster() *memgraphcomv1alpha1.MemgraphCluster { + return &memgraphcomv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName, Namespace: testNamespace}, + } +} + +// coordinatorStatefulSet and dataStatefulSet build a role's StatefulSet at the +// replica count the spec declares — the count the controller derives for a +// cluster that is growing or holding its size. The count a shrinking cluster is +// held at is the controller's decision, so the cases that cover it pass it to +// the builder directly. +func coordinatorStatefulSet(cluster *memgraphcomv1alpha1.MemgraphCluster) *appsv1.StatefulSet { + return resources.CoordinatorStatefulSet(cluster, resources.DeclaredCoordinators(cluster)) +} + +func dataStatefulSet(cluster *memgraphcomv1alpha1.MemgraphCluster) *appsv1.StatefulSet { + return resources.DataStatefulSet(cluster, resources.DeclaredDataInstances(cluster)) +} + +func specifiedCluster() *memgraphcomv1alpha1.MemgraphCluster { + return &memgraphcomv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName, Namespace: testNamespace}, + Spec: memgraphcomv1alpha1.MemgraphClusterSpec{ + Coordinators: ptr.To(int32(5)), + DataInstances: ptr.To(int32(3)), + Image: memgraphcomv1alpha1.ImageSpec{ + Repository: "registry.example.com/memgraph", + Tag: "3.13.0", + PullPolicy: corev1.PullAlways, + }, + Secrets: memgraphcomv1alpha1.SecretsSpec{ + Name: "my-license", + LicenseKey: "license", + OrganizationKey: "organization", + }, + }, + } +} + +// Non-default ports and cluster domain shared by the tuning tests. Every one +// differs from its default, so a knob that fails to propagate cannot hide +// behind a value that happened to be right anyway. +const ( + customBoltPort int32 = 7777 + customManagementPort int32 = 10001 + customReplicationPort int32 = 20001 + customCoordinatorPort int32 = 12001 + + customClusterDomain = "k8s.example.com" +) + +// tunedCluster returns a MemgraphCluster with every pod-tuning knob set away +// from its default, so the golden tests can pin what each one lands on. +func tunedCluster() *memgraphcomv1alpha1.MemgraphCluster { + cluster := minimalCluster() + cluster.Spec.ClusterDomain = customClusterDomain + cluster.Spec.Ports = memgraphcomv1alpha1.PortsSpec{ + BoltPort: ptr.To(customBoltPort), + ManagementPort: ptr.To(customManagementPort), + ReplicationPort: ptr.To(customReplicationPort), + CoordinatorPort: ptr.To(customCoordinatorPort), + } + cluster.Spec.Probes = memgraphcomv1alpha1.ProbesSpec{ + Coordinators: memgraphcomv1alpha1.RoleProbesSpec{ + StartupProbe: memgraphcomv1alpha1.ProbeSpec{FailureThreshold: ptr.To(int32(30))}, + ReadinessProbe: memgraphcomv1alpha1.ProbeSpec{ + TimeoutSeconds: ptr.To(int32(3)), + PeriodSeconds: ptr.To(int32(2)), + }, + }, + Data: memgraphcomv1alpha1.RoleProbesSpec{ + StartupProbe: memgraphcomv1alpha1.ProbeSpec{ + FailureThreshold: ptr.To(int32(4320)), + TimeoutSeconds: ptr.To(int32(15)), + PeriodSeconds: ptr.To(int32(10)), + }, + LivenessProbe: memgraphcomv1alpha1.ProbeSpec{FailureThreshold: ptr.To(int32(6))}, + }, + } + cluster.Spec.Resources = memgraphcomv1alpha1.ResourcesSpec{ + Coordinators: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("100m")}, + Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("512Mi")}, + }, + Data: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("4Gi")}, + }, + } + cluster.Spec.Labels = memgraphcomv1alpha1.LabelsSpec{ + Coordinators: memgraphcomv1alpha1.RoleLabelsSpec{ + PodLabels: map[string]string{teamLabel: platformTeam}, + StatefulSetLabels: map[string]string{tierLabel: "control"}, + ServiceLabels: map[string]string{exposeLabel: "internal"}, + }, + Data: memgraphcomv1alpha1.RoleLabelsSpec{ + PodLabels: map[string]string{teamLabel: dataComponent}, + StatefulSetLabels: map[string]string{tierLabel: "storage"}, + ServiceLabels: map[string]string{exposeLabel: "bolt"}, + }, + } + cluster.Spec.ExtraEnv = memgraphcomv1alpha1.ExtraEnvSpec{ + Coordinators: []memgraphcomv1alpha1.EnvVar{{Name: "COORDINATOR_LABEL", Value: "coord"}}, + Data: []memgraphcomv1alpha1.EnvVar{ + {Name: "DATA_LABEL_ONE", Value: "one"}, + {Name: "DATA_LABEL_TWO", Value: "two"}, + }, + } + cluster.Spec.ExtraArgs = memgraphcomv1alpha1.ExtraArgsSpec{ + Coordinators: []string{"--log-level=WARNING"}, + Data: []string{"--storage-snapshot-on-exit=true", "--memory-limit=2048"}, + } + return cluster +} + +func licenseEnv(secretName, licenseKey, organizationKey string) []corev1.EnvVar { + return []corev1.EnvVar{ + { + Name: "MEMGRAPH_ENTERPRISE_LICENSE", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + Key: licenseKey, + }, + }, + }, + { + Name: "MEMGRAPH_ORGANIZATION_NAME", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + Key: organizationKey, + }, + }, + }, + } +} + +// tcpProbe is a probe with the default timings, of which only the failure +// threshold differs between probes. +func tcpProbe(port, failureThreshold int32) *corev1.Probe { + return tunedTCPProbe(port, failureThreshold, 10, 5) +} + +func tunedTCPProbe(port, failureThreshold, timeoutSeconds, periodSeconds int32) *corev1.Probe { + return &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + TCPSocket: &corev1.TCPSocketAction{Port: intstr.FromInt32(port)}, + }, + FailureThreshold: failureThreshold, + TimeoutSeconds: timeoutSeconds, + PeriodSeconds: periodSeconds, + } +} + +func expectedPodSecurityContext() *corev1.PodSecurityContext { + return &corev1.PodSecurityContext{ + RunAsUser: ptr.To(int64(101)), + RunAsGroup: ptr.To(int64(103)), + FSGroup: ptr.To(int64(103)), + RunAsNonRoot: ptr.To(true), + SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}, + } +} + +func expectedContainerSecurityContext() *corev1.SecurityContext { + return &corev1.SecurityContext{ + AllowPrivilegeEscalation: ptr.To(false), + Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}, + ReadOnlyRootFilesystem: ptr.To(true), + RunAsNonRoot: ptr.To(true), + SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}, + } +} + +func expectedVolumeMounts() []corev1.VolumeMount { + return []corev1.VolumeMount{ + {Name: "lib-storage", MountPath: "/var/lib/memgraph"}, + {Name: "log-storage", MountPath: "/var/log/memgraph"}, + {Name: tmpVolume, MountPath: "/tmp"}, + } +} + +// expectedVolumeMountsWithoutLog is the mount set of a role that opted out of +// log storage: everything except the log volume. +func expectedVolumeMountsWithoutLog() []corev1.VolumeMount { + return slices.DeleteFunc(expectedVolumeMounts(), func(mount corev1.VolumeMount) bool { + return mount.Name == "log-storage" + }) +} + +// expectedCommand wraps a coordinator start script the way the builder does. +func expectedCommand(script string) []string { + return []string{shell, "-ec", script} +} + +// expectedArgs are the flags a role is started with: the shared ones in the +// order the builder emits them, then the fixture's extra args. The ports vary +// per fixture and a role that opted out of log storage gets an empty +// --log-file, so both are parameters. +func expectedArgs(boltPort, managementPort int32, logDestination string, extra ...string) []string { + return append([]string{ + fmt.Sprintf("--bolt-port=%d", boltPort), + fmt.Sprintf("--management-port=%d", managementPort), + "--data-directory=" + dataPath, + "--log-level=TRACE", + "--also-log-to-stderr", + "--log-file=" + logDestination, + "--log-retention-days=35", + }, extra...) +} + +// expectedCoordinatorArgs are the same flags as arguments to the coordinator's +// shell wrapper, which forwards them with "$@" — so they are never parsed by +// the shell. The leading element is the wrapper's $0, not a flag. +func expectedCoordinatorArgs(boltPort, managementPort int32, logDestination string, extra ...string) []string { + return append([]string{memgraphName}, expectedArgs(boltPort, managementPort, logDestination, extra...)...) +} + +// expectedVolumes covers only the ephemeral scratch volume: lib and log +// storage are provisioned through volumeClaimTemplates. +func expectedVolumes() []corev1.Volume { + return []corev1.Volume{ + {Name: tmpVolume, VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, + } +} + +func expectedClaimTemplate(name, size string, accessMode corev1.PersistentVolumeAccessMode, + class *string) corev1.PersistentVolumeClaim { + return corev1.PersistentVolumeClaim{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "PersistentVolumeClaim"}, + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{accessMode}, + StorageClassName: class, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse(size)}, + }, + }, + } +} + +// expectedClaimTemplates are the claims a spec that never set storage gets: +// 1Gi ReadWriteOnce on the cluster's default StorageClass for both volumes. +func expectedClaimTemplates() []corev1.PersistentVolumeClaim { + return []corev1.PersistentVolumeClaim{ + expectedClaimTemplate("lib-storage", "1Gi", corev1.ReadWriteOnce, nil), + expectedClaimTemplate("log-storage", "1Gi", corev1.ReadWriteOnce, nil), + } +} + +// expectedRetentionPolicy is the claim retention policy both roles get: the one +// retention knob decides both halves, so a claim orphaned by deleting the +// cluster and one orphaned by scaling a role down are treated alike. +func expectedRetentionPolicy( + policy appsv1.PersistentVolumeClaimRetentionPolicyType, +) *appsv1.StatefulSetPersistentVolumeClaimRetentionPolicy { + return &appsv1.StatefulSetPersistentVolumeClaimRetentionPolicy{ + WhenDeleted: policy, + WhenScaled: policy, + } +} + +func expectedLabels(component string) map[string]string { + return map[string]string{ + nameLabel: memgraphName, + instanceLabel: clusterName, + componentLabel: component, + managedByLabel: "memgraph-operator", + } +} + +// expectedLabelsWith is the full label set of a role's object once the user's +// custom labels are merged in. +func expectedLabelsWith(component string, custom map[string]string) map[string]string { + l := expectedLabels(component) + maps.Copy(l, custom) + return l +} + +func expectedSelectorLabels(component string) map[string]string { + return map[string]string{ + nameLabel: memgraphName, + instanceLabel: clusterName, + componentLabel: component, + } +} + +const expectedCoordinatorScript = `ordinal="${POD_NAME##*-}" +exec /usr/lib/memgraph/memgraph \ + --coordinator-id="$((ordinal + 1))" \ + --coordinator-hostname="${POD_NAME}.example-coordinator.memgraph-test.svc.cluster.local" \ + --coordinator-port=12000 \ + "$@"` + +func TestCoordinatorStatefulSetDefaults(t *testing.T) { + want := &appsv1.StatefulSet{ + TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: statefulSetKind}, + ObjectMeta: metav1.ObjectMeta{ + Name: coordinatorName, + Namespace: testNamespace, + Labels: expectedLabels(coordinatorComponent), + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + ServiceName: coordinatorName, + PodManagementPolicy: appsv1.ParallelPodManagement, + // The operator replaces these pods itself, one at a time and MAIN or Raft + // leader last, which no RollingUpdate can express. + UpdateStrategy: appsv1.StatefulSetUpdateStrategy{ + Type: appsv1.OnDeleteStatefulSetStrategyType, + }, + Selector: &metav1.LabelSelector{MatchLabels: expectedSelectorLabels(coordinatorComponent)}, + PersistentVolumeClaimRetentionPolicy: expectedRetentionPolicy( + appsv1.RetainPersistentVolumeClaimRetentionPolicyType), + VolumeClaimTemplates: expectedClaimTemplates(), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: expectedLabels(coordinatorComponent)}, + Spec: corev1.PodSpec{ + // Kubernetes' 30-second default is too short for a database that is + // now restarted on every pod-template change: an instance killed + // mid-shutdown recovers from its WAL and lengthens the catch-up the + // rolling restart waits on. + TerminationGracePeriodSeconds: ptr.To(int64(300)), + Containers: []corev1.Container{{ + Name: memgraphName, + Image: defaultImageRef, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: expectedCommand(expectedCoordinatorScript), + Args: expectedCoordinatorArgs(7687, 10000, logFilePath), + Env: append([]corev1.EnvVar{{ + Name: "POD_NAME", + ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"}, + }, + }}, licenseEnv("memgraph-secrets", "MEMGRAPH_ENTERPRISE_LICENSE", "MEMGRAPH_ORGANIZATION_NAME")...), + Ports: []corev1.ContainerPort{ + {Name: boltPortName, ContainerPort: 7687}, + {Name: managementPortName, ContainerPort: 10000}, + {Name: coordinatorComponent, ContainerPort: 12000}, + }, + StartupProbe: tcpProbe(12000, 20), + ReadinessProbe: tcpProbe(12000, 20), + LivenessProbe: tcpProbe(12000, 20), + VolumeMounts: expectedVolumeMounts(), + SecurityContext: expectedContainerSecurityContext(), + }}, + SecurityContext: expectedPodSecurityContext(), + Volumes: expectedVolumes(), + }, + }, + }, + } + + got := coordinatorStatefulSet(minimalCluster()) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("CoordinatorStatefulSet() mismatch (-want +got):\n%s", diff) + } +} + +func TestDataStatefulSetDefaults(t *testing.T) { + want := &appsv1.StatefulSet{ + TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: statefulSetKind}, + ObjectMeta: metav1.ObjectMeta{ + Name: dataName, + Namespace: testNamespace, + Labels: expectedLabels(dataComponent), + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(2)), + ServiceName: dataName, + PodManagementPolicy: appsv1.ParallelPodManagement, + UpdateStrategy: appsv1.StatefulSetUpdateStrategy{ + Type: appsv1.OnDeleteStatefulSetStrategyType, + }, + Selector: &metav1.LabelSelector{MatchLabels: expectedSelectorLabels(dataComponent)}, + PersistentVolumeClaimRetentionPolicy: expectedRetentionPolicy( + appsv1.RetainPersistentVolumeClaimRetentionPolicyType), + VolumeClaimTemplates: expectedClaimTemplates(), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: expectedLabels(dataComponent)}, + Spec: corev1.PodSpec{ + // Kubernetes' 30-second default is too short for a database that is + // now restarted on every pod-template change: an instance killed + // mid-shutdown recovers from its WAL and lengthens the catch-up the + // rolling restart waits on. + TerminationGracePeriodSeconds: ptr.To(int64(300)), + Containers: []corev1.Container{{ + Name: memgraphName, + Image: defaultImageRef, + ImagePullPolicy: corev1.PullIfNotPresent, + Args: expectedArgs(7687, 10000, logFilePath), + Env: licenseEnv("memgraph-secrets", "MEMGRAPH_ENTERPRISE_LICENSE", "MEMGRAPH_ORGANIZATION_NAME"), + Ports: []corev1.ContainerPort{ + {Name: boltPortName, ContainerPort: 7687}, + {Name: managementPortName, ContainerPort: 10000}, + {Name: replicationPortName, ContainerPort: 20000}, + }, + StartupProbe: tcpProbe(7687, 1440), + ReadinessProbe: tcpProbe(7687, 20), + LivenessProbe: tcpProbe(7687, 20), + VolumeMounts: expectedVolumeMounts(), + SecurityContext: expectedContainerSecurityContext(), + }}, + SecurityContext: expectedPodSecurityContext(), + Volumes: expectedVolumes(), + }, + }, + }, + } + + got := dataStatefulSet(minimalCluster()) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("DataStatefulSet() mismatch (-want +got):\n%s", diff) + } +} + +// TestStatefulSetReplicasFollowTheArgument pins the replica count to the +// builder's argument rather than to the spec. That separation is what lets the +// controller hold a role at its current size while a lowered count is being +// retired, without the builders having to know anything about the live cluster. +func TestStatefulSetReplicasFollowTheArgument(t *testing.T) { + // The spec declares fewer replicas than the cluster currently runs, which is + // the count the controller passes so a shrink never sheds pods on its own. + cluster := minimalCluster() + cluster.Spec.Coordinators = ptr.To(int32(3)) + cluster.Spec.DataInstances = ptr.To(int32(2)) + + tests := []struct { + name string + sts *appsv1.StatefulSet + want int32 + }{ + {name: coordinatorComponent, sts: resources.CoordinatorStatefulSet(cluster, 5), want: 5}, + {name: dataComponent, sts: resources.DataStatefulSet(cluster, 3), want: 3}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := *tc.sts.Spec.Replicas; got != tc.want { + t.Errorf("replicas = %d, want the given %d, not the declared count", got, tc.want) + } + }) + } +} + +// The declared counts are what the controller passes for a cluster that is not +// shrinking, so they resolve the same schema defaults the builders do. +func TestDeclaredCounts(t *testing.T) { + if got := resources.DeclaredCoordinators(minimalCluster()); got != memgraphcomv1alpha1.DefaultCoordinatorCount { + t.Errorf("DeclaredCoordinators() = %d, want the schema default %d", + got, memgraphcomv1alpha1.DefaultCoordinatorCount) + } + if got := resources.DeclaredDataInstances(minimalCluster()); got != memgraphcomv1alpha1.DefaultDataInstanceCount { + t.Errorf("DeclaredDataInstances() = %d, want the schema default %d", + got, memgraphcomv1alpha1.DefaultDataInstanceCount) + } + + cluster := specifiedCluster() + if got := resources.DeclaredCoordinators(cluster); got != 5 { + t.Errorf("DeclaredCoordinators() = %d, want 5", got) + } + if got := resources.DeclaredDataInstances(cluster); got != 3 { + t.Errorf("DeclaredDataInstances() = %d, want 3", got) + } +} + +func TestStatefulSetSpecOverrides(t *testing.T) { + cluster := specifiedCluster() + + tests := []struct { + name string + sts *appsv1.StatefulSet + replicas int32 + }{ + {name: coordinatorComponent, sts: coordinatorStatefulSet(cluster), replicas: 5}, + {name: dataComponent, sts: dataStatefulSet(cluster), replicas: 3}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := *tc.sts.Spec.Replicas; got != tc.replicas { + t.Errorf("replicas = %d, want %d", got, tc.replicas) + } + container := tc.sts.Spec.Template.Spec.Containers[0] + if container.Image != "registry.example.com/memgraph:3.13.0" { + t.Errorf("image = %q, want %q", container.Image, "registry.example.com/memgraph:3.13.0") + } + if container.ImagePullPolicy != corev1.PullAlways { + t.Errorf("pull policy = %q, want %q", container.ImagePullPolicy, corev1.PullAlways) + } + wantEnv := licenseEnv("my-license", "license", "organization") + gotEnv := container.Env[len(container.Env)-2:] + if diff := cmp.Diff(wantEnv, gotEnv); diff != "" { + t.Errorf("license env mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestStatefulSetStorageOverrides asserts each role's claim templates follow +// that role's storage block only, so sizing coordinators and data instances +// differently really produces differently sized claims. +func TestStatefulSetStorageOverrides(t *testing.T) { + cluster := minimalCluster() + cluster.Spec.Storage = memgraphcomv1alpha1.StorageSpec{ + Coordinators: memgraphcomv1alpha1.RoleStorageSpec{ + LibPVCSize: ptr.To(resource.MustParse("4Gi")), + LibStorageAccessMode: corev1.ReadWriteOncePod, + LibStorageClassName: ptr.To("fast-ssd"), + LogPVCSize: ptr.To(resource.MustParse("512Mi")), + LogStorageClassName: ptr.To(""), + }, + Data: memgraphcomv1alpha1.RoleStorageSpec{ + LibPVCSize: ptr.To(resource.MustParse("100Gi")), + LibStorageClassName: ptr.To("gp3"), + }, + } + + tests := []struct { + name string + sts *appsv1.StatefulSet + want []corev1.PersistentVolumeClaim + }{ + { + name: coordinatorComponent, + sts: coordinatorStatefulSet(cluster), + want: []corev1.PersistentVolumeClaim{ + expectedClaimTemplate("lib-storage", "4Gi", corev1.ReadWriteOncePod, ptr.To("fast-ssd")), + // An empty storage class is passed through verbatim: it means + // "no dynamic provisioning", not "cluster default". + expectedClaimTemplate("log-storage", "512Mi", corev1.ReadWriteOnce, ptr.To("")), + }, + }, + { + name: dataComponent, + sts: dataStatefulSet(cluster), + want: []corev1.PersistentVolumeClaim{ + expectedClaimTemplate("lib-storage", "100Gi", corev1.ReadWriteOnce, ptr.To("gp3")), + // Untouched by the spec, so it keeps every schema default. + expectedClaimTemplate("log-storage", "1Gi", corev1.ReadWriteOnce, nil), + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(tc.want, tc.sts.Spec.VolumeClaimTemplates); diff != "" { + t.Errorf("volume claim templates mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestStatefulSetWithoutLogStorageClaim asserts that a role which opted out of +// log storage gets no log claim, no log mount, and an empty --log-file. The +// empty flag is load-bearing rather than cosmetic: the image's +// /etc/memgraph/memgraph.conf sets log_file, Memgraph reads it before the +// command line, and it fails startup when that path cannot be opened — so +// dropping the flag would crash-loop the pod instead of disabling file logging. +// Logs still reach `kubectl logs` through --also-log-to-stderr. +func TestStatefulSetWithoutLogStorageClaim(t *testing.T) { + cluster := minimalCluster() + cluster.Spec.Storage = memgraphcomv1alpha1.StorageSpec{ + Coordinators: memgraphcomv1alpha1.RoleStorageSpec{ + CreateLogStorageClaim: ptr.To(false), + }, + // The data instances keep their log claim: the knob is per role. + Data: memgraphcomv1alpha1.RoleStorageSpec{}, + } + + t.Run(coordinatorComponent, func(t *testing.T) { + sts := coordinatorStatefulSet(cluster) + + wantClaims := []corev1.PersistentVolumeClaim{ + expectedClaimTemplate("lib-storage", "1Gi", corev1.ReadWriteOnce, nil), + } + if diff := cmp.Diff(wantClaims, sts.Spec.VolumeClaimTemplates); diff != "" { + t.Errorf("volume claim templates mismatch (-want +got):\n%s", diff) + } + + container := sts.Spec.Template.Spec.Containers[0] + if diff := cmp.Diff(expectedVolumeMountsWithoutLog(), container.VolumeMounts); diff != "" { + t.Errorf("volume mounts mismatch (-want +got):\n%s", diff) + } + wantCommand := expectedCommand(expectedCoordinatorScript) + if diff := cmp.Diff(wantCommand, container.Command); diff != "" { + t.Errorf("start script mismatch (-want +got):\n%s", diff) + } + wantArgs := expectedCoordinatorArgs(7687, 10000, "") + if diff := cmp.Diff(wantArgs, container.Args); diff != "" { + t.Errorf("args mismatch (-want +got):\n%s", diff) + } + }) + + t.Run(dataComponent, func(t *testing.T) { + sts := dataStatefulSet(cluster) + + if diff := cmp.Diff(expectedClaimTemplates(), sts.Spec.VolumeClaimTemplates); diff != "" { + t.Errorf("volume claim templates mismatch (-want +got):\n%s", diff) + } + + container := sts.Spec.Template.Spec.Containers[0] + if diff := cmp.Diff(expectedVolumeMounts(), container.VolumeMounts); diff != "" { + t.Errorf("volume mounts mismatch (-want +got):\n%s", diff) + } + if !slices.Contains(container.Args, "--log-file=/var/log/memgraph/memgraph.log") { + t.Errorf("args = %v, want the log file the role still has storage for", container.Args) + } + }) +} + +// TestStatefulSetCoreDumpsDisabledByDefault pins that a spec which never +// mentions core dumps carries no trace of the feature: no claim, no mount, no +// init container, no sidecar. +func TestStatefulSetCoreDumpsDisabledByDefault(t *testing.T) { + for _, sts := range []*appsv1.StatefulSet{ + coordinatorStatefulSet(minimalCluster()), + dataStatefulSet(minimalCluster()), + } { + t.Run(sts.Name, func(t *testing.T) { + for _, claim := range sts.Spec.VolumeClaimTemplates { + if claim.Name == coreDumpsVolume { + t.Errorf("claim %s exists without core dumps being enabled", claim.Name) + } + } + podSpec := sts.Spec.Template.Spec + if len(podSpec.InitContainers) != 0 { + t.Errorf("init containers = %v, want none", podSpec.InitContainers) + } + if len(podSpec.Containers) != 1 { + t.Errorf("containers = %d, want only Memgraph's", len(podSpec.Containers)) + } + }) + } +} + +// TestStatefulSetCoreDumps covers the enabled path end to end for one role +// while the other stays untouched: the claim, the Memgraph container's mount, +// and the privileged init container that points the node's kernel at it. +func TestStatefulSetCoreDumps(t *testing.T) { + cluster := minimalCluster() + cluster.Spec.CoreDumps = memgraphcomv1alpha1.CoreDumpsSpec{ + Data: memgraphcomv1alpha1.RoleCoreDumpsSpec{ + Enabled: true, + Size: ptr.To(resource.MustParse("20Gi")), + }, + StorageClassName: ptr.To("cheap-hdd"), + } + + t.Run(dataComponent, func(t *testing.T) { + sts := dataStatefulSet(cluster) + + wantClaims := append(expectedClaimTemplates(), + expectedClaimTemplate(coreDumpsVolume, "20Gi", corev1.ReadWriteOnce, ptr.To("cheap-hdd"))) + if diff := cmp.Diff(wantClaims, sts.Spec.VolumeClaimTemplates); diff != "" { + t.Errorf("volume claim templates mismatch (-want +got):\n%s", diff) + } + + podSpec := sts.Spec.Template.Spec + wantMounts := append(expectedVolumeMounts(), + corev1.VolumeMount{Name: coreDumpsVolume, MountPath: coreDumpsPath}) + if diff := cmp.Diff(wantMounts, podSpec.Containers[0].VolumeMounts); diff != "" { + t.Errorf("volume mounts mismatch (-want +got):\n%s", diff) + } + + // The init container has to be privileged root to write a kernel sysctl, + // and it reuses the cluster's Memgraph image so nothing else is pulled. + wantInit := []corev1.Container{{ + Name: "init-core-pattern", + Image: defaultImageRef, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: expectedCommand( + "echo '/var/core/memgraph/core.%e.%p.%t.%s' | tee /proc/sys/kernel/core_pattern"), + SecurityContext: &corev1.SecurityContext{ + Privileged: ptr.To(true), + AllowPrivilegeEscalation: ptr.To(true), + ReadOnlyRootFilesystem: ptr.To(true), + RunAsUser: ptr.To(int64(0)), + RunAsNonRoot: ptr.To(false), + SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}, + }, + }} + if diff := cmp.Diff(wantInit, podSpec.InitContainers); diff != "" { + t.Errorf("init containers mismatch (-want +got):\n%s", diff) + } + if len(podSpec.Containers) != 1 { + t.Errorf("containers = %d, want only Memgraph's without an uploader", len(podSpec.Containers)) + } + }) + + // The knob is per role: coordinators asked for nothing and get nothing. + t.Run(coordinatorComponent, func(t *testing.T) { + sts := coordinatorStatefulSet(cluster) + + if diff := cmp.Diff(expectedClaimTemplates(), sts.Spec.VolumeClaimTemplates); diff != "" { + t.Errorf("volume claim templates mismatch (-want +got):\n%s", diff) + } + if got := sts.Spec.Template.Spec.InitContainers; len(got) != 0 { + t.Errorf("init containers = %v, want none", got) + } + }) +} + +// TestStatefulSetCoreDumpsWithoutCorePattern covers the restricted-namespace +// path: the volume is provisioned and mounted, but the operator runs no +// privileged container and trusts the node's own core pattern. +func TestStatefulSetCoreDumpsWithoutCorePattern(t *testing.T) { + cluster := minimalCluster() + cluster.Spec.CoreDumps = memgraphcomv1alpha1.CoreDumpsSpec{ + Data: memgraphcomv1alpha1.RoleCoreDumpsSpec{Enabled: true}, + ConfigureCorePattern: ptr.To(false), + } + + sts := dataStatefulSet(cluster) + podSpec := sts.Spec.Template.Spec + + if got := podSpec.InitContainers; len(got) != 0 { + t.Errorf("init containers = %v, want none when the node owns the core pattern", got) + } + wantMount := corev1.VolumeMount{Name: coreDumpsVolume, MountPath: coreDumpsPath} + if got := podSpec.Containers[0].VolumeMounts; !slices.Contains(got, wantMount) { + t.Errorf("volume mounts = %v, want the core dumps volume mounted anyway", got) + } + claims := sts.Spec.VolumeClaimTemplates + if diff := cmp.Diff( + expectedClaimTemplate(coreDumpsVolume, "10Gi", corev1.ReadWriteOnce, nil), + claims[len(claims)-1], + ); diff != "" { + t.Errorf("core dumps claim mismatch (-want +got):\n%s", diff) + } +} + +// TestStatefulSetCoreDumpsUploader pins the wiring the operator owns on behalf +// of the sidecar: a read-only view of the dumps, the path as CORE_DUMPS_DIR, a +// writable /tmp, credentials by Secret reference, and the same locked-down +// security context the Memgraph container runs under. +func TestStatefulSetCoreDumpsUploader(t *testing.T) { + cluster := minimalCluster() + // The uploader is declared once for the cluster; only the role that + // collects dumps gets it. + cluster.Spec.CoreDumps = memgraphcomv1alpha1.CoreDumpsSpec{ + Data: memgraphcomv1alpha1.RoleCoreDumpsSpec{Enabled: true}, + Uploader: &memgraphcomv1alpha1.CoreDumpsUploaderSpec{ + Image: "amazon/aws-cli:2.33.28", + Command: []string{shell, "-c"}, + Args: []string{"upload-loop"}, + Env: []memgraphcomv1alpha1.EnvVar{{Name: "S3_BUCKET", Value: "dumps"}}, + EnvFromSecrets: []string{"aws-s3-credentials"}, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("64Mi")}, + }, + }, + } + + containers := dataStatefulSet(cluster).Spec.Template.Spec.Containers + if len(containers) != 2 { + t.Fatalf("containers = %d, want Memgraph plus the uploader", len(containers)) + } + if containers[0].Name != memgraphName { + t.Errorf("first container = %q, want Memgraph to stay first", containers[0].Name) + } + + want := corev1.Container{ + Name: "core-dumps-uploader", + Image: "amazon/aws-cli:2.33.28", + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{shell, "-c"}, + Args: []string{"upload-loop"}, + Env: []corev1.EnvVar{ + {Name: "CORE_DUMPS_DIR", Value: coreDumpsPath}, + {Name: "S3_BUCKET", Value: "dumps"}, + }, + EnvFrom: []corev1.EnvFromSource{{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "aws-s3-credentials"}, + }, + }}, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("64Mi")}, + }, + VolumeMounts: []corev1.VolumeMount{ + {Name: coreDumpsVolume, MountPath: coreDumpsPath, ReadOnly: true}, + {Name: tmpVolume, MountPath: "/tmp"}, + }, + SecurityContext: expectedContainerSecurityContext(), + } + if diff := cmp.Diff(want, containers[1]); diff != "" { + t.Errorf("uploader sidecar mismatch (-want +got):\n%s", diff) + } + + // Coordinators collect no dumps, so the shared uploader has nothing to read + // in their pods and must not be injected there. + coordinators := coordinatorStatefulSet(cluster).Spec.Template.Spec.Containers + if len(coordinators) != 1 { + t.Errorf("coordinator containers = %d, want only Memgraph's: the role collects no dumps", + len(coordinators)) + } +} + +// TestStatefulSetExtraVolumes covers the passthrough: the role's volumes join +// the pod after the operator's scratch volume, its mounts join the Memgraph +// container after the operator's, and the other role is untouched. +func TestStatefulSetExtraVolumes(t *testing.T) { + certVolume := corev1.Volume{ + Name: "bolt-certs", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{SecretName: "bolt-tls"}, + }, + } + certMount := corev1.VolumeMount{Name: "bolt-certs", MountPath: "/etc/memgraph/ssl", ReadOnly: true} + + cluster := minimalCluster() + cluster.Spec.ExtraVolumes = memgraphcomv1alpha1.ExtraVolumesSpec{ + Data: []corev1.Volume{certVolume}, + } + cluster.Spec.ExtraVolumeMounts = memgraphcomv1alpha1.ExtraVolumeMountsSpec{ + Data: []corev1.VolumeMount{certMount}, + } + + t.Run(dataComponent, func(t *testing.T) { + podSpec := dataStatefulSet(cluster).Spec.Template.Spec + + wantVolumes := append(expectedVolumes(), certVolume) + if diff := cmp.Diff(wantVolumes, podSpec.Volumes); diff != "" { + t.Errorf("volumes mismatch (-want +got):\n%s", diff) + } + wantMounts := append(expectedVolumeMounts(), certMount) + if diff := cmp.Diff(wantMounts, podSpec.Containers[0].VolumeMounts); diff != "" { + t.Errorf("volume mounts mismatch (-want +got):\n%s", diff) + } + }) + + t.Run(coordinatorComponent, func(t *testing.T) { + podSpec := coordinatorStatefulSet(cluster).Spec.Template.Spec + + if diff := cmp.Diff(expectedVolumes(), podSpec.Volumes); diff != "" { + t.Errorf("volumes mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(expectedVolumeMounts(), podSpec.Containers[0].VolumeMounts); diff != "" { + t.Errorf("volume mounts mismatch (-want +got):\n%s", diff) + } + }) +} + +// A volume the role declares but never mounts is still a legitimate pod volume +// — the uploader sidecar or a future consumer may be its reader — so the +// builder passes it through rather than second-guessing it. +func TestStatefulSetExtraVolumeWithoutMount(t *testing.T) { + cluster := minimalCluster() + cluster.Spec.ExtraVolumes.Coordinators = []corev1.Volume{{ + Name: "scratch", + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, + }} + + podSpec := coordinatorStatefulSet(cluster).Spec.Template.Spec + if len(podSpec.Volumes) != 2 { + t.Errorf("volumes = %v, want the scratch volume alongside tmp", podSpec.Volumes) + } + if diff := cmp.Diff(expectedVolumeMounts(), podSpec.Containers[0].VolumeMounts); diff != "" { + t.Errorf("volume mounts mismatch (-want +got):\n%s", diff) + } +} + +// TestStatefulSetRetentionPolicy pins the mapping from the spec's retention +// policy onto the StatefulSet machinery that is the only deleter of this +// cluster's storage. Both whenDeleted and whenScaled follow it: the claim of a +// pod a scale-down removes is the same data as the claim of a pod a cluster +// deletion removes, so one knob answers for both. +func TestStatefulSetRetentionPolicy(t *testing.T) { + tests := []struct { + name string + policy memgraphcomv1alpha1.StorageRetentionPolicy + expected appsv1.PersistentVolumeClaimRetentionPolicyType + }{ + { + name: "unset defaults to retain", + policy: "", + expected: appsv1.RetainPersistentVolumeClaimRetentionPolicyType, + }, + { + name: "retain", + policy: memgraphcomv1alpha1.RetentionPolicyRetain, + expected: appsv1.RetainPersistentVolumeClaimRetentionPolicyType, + }, + { + name: "delete", + policy: memgraphcomv1alpha1.RetentionPolicyDelete, + expected: appsv1.DeletePersistentVolumeClaimRetentionPolicyType, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cluster := minimalCluster() + cluster.Spec.Storage.RetentionPolicy = tc.policy + + want := expectedRetentionPolicy(tc.expected) + for _, sts := range []*appsv1.StatefulSet{ + coordinatorStatefulSet(cluster), + dataStatefulSet(cluster), + } { + got := sts.Spec.PersistentVolumeClaimRetentionPolicy + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("%s retention policy mismatch (-want +got):\n%s", sts.Name, diff) + } + } + }) + } +} + +// The coordinator start script derives per-pod identity at runtime, so the +// configured coordinator port and cluster domain have to be baked into it — +// this is the same identity the operator registers with the cluster. +const expectedTunedCoordinatorScript = `ordinal="${POD_NAME##*-}" +exec /usr/lib/memgraph/memgraph \ + --coordinator-id="$((ordinal + 1))" \ + --coordinator-hostname="${POD_NAME}.example-coordinator.memgraph-test.svc.k8s.example.com" \ + --coordinator-port=12001 \ + "$@"` + +// The remaining flags — the configured ports among them — reach the wrapper as +// container arguments, which is what keeps a value with whitespace or shell +// metacharacters from being re-parsed by the shell. spec.extraArgs.coordinators +// comes last so it wins. +func expectedTunedCoordinatorArgs() []string { + return expectedCoordinatorArgs(customBoltPort, customManagementPort, logFilePath, "--log-level=WARNING") +} + +// TestStatefulSetPortsAndClusterDomain pins every place a configured port or +// cluster domain has to surface: the container ports, the flags Memgraph is +// started with, the ports the probes dial, and the coordinator's advertised +// hostname. +func TestStatefulSetPortsAndClusterDomain(t *testing.T) { + cluster := tunedCluster() + + t.Run(coordinatorComponent, func(t *testing.T) { + container := coordinatorStatefulSet(cluster).Spec.Template.Spec.Containers[0] + + wantPorts := []corev1.ContainerPort{ + {Name: boltPortName, ContainerPort: customBoltPort}, + {Name: managementPortName, ContainerPort: customManagementPort}, + {Name: coordinatorComponent, ContainerPort: customCoordinatorPort}, + } + if diff := cmp.Diff(wantPorts, container.Ports); diff != "" { + t.Errorf("container ports mismatch (-want +got):\n%s", diff) + } + wantCommand := expectedCommand(expectedTunedCoordinatorScript) + if diff := cmp.Diff(wantCommand, container.Command); diff != "" { + t.Errorf("start script mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(expectedTunedCoordinatorArgs(), container.Args); diff != "" { + t.Errorf("args mismatch (-want +got):\n%s", diff) + } + for name, probe := range map[string]*corev1.Probe{ + "startup": container.StartupProbe, + "readiness": container.ReadinessProbe, + "liveness": container.LivenessProbe, + } { + if got := probe.TCPSocket.Port; got != intstr.FromInt32(customCoordinatorPort) { + t.Errorf("%s probe dials %v, want the configured coordinator port %d", + name, got, customCoordinatorPort) + } + } + }) + + t.Run(dataComponent, func(t *testing.T) { + container := dataStatefulSet(cluster).Spec.Template.Spec.Containers[0] + + wantPorts := []corev1.ContainerPort{ + {Name: boltPortName, ContainerPort: customBoltPort}, + {Name: managementPortName, ContainerPort: customManagementPort}, + {Name: replicationPortName, ContainerPort: customReplicationPort}, + } + if diff := cmp.Diff(wantPorts, container.Ports); diff != "" { + t.Errorf("container ports mismatch (-want +got):\n%s", diff) + } + wantArgs := expectedArgs(customBoltPort, customManagementPort, logFilePath, + "--storage-snapshot-on-exit=true", "--memory-limit=2048") + if diff := cmp.Diff(wantArgs, container.Args); diff != "" { + t.Errorf("args mismatch (-want +got):\n%s", diff) + } + for name, probe := range map[string]*corev1.Probe{ + "startup": container.StartupProbe, + "readiness": container.ReadinessProbe, + "liveness": container.LivenessProbe, + } { + if got := probe.TCPSocket.Port; got != intstr.FromInt32(customBoltPort) { + t.Errorf("%s probe dials %v, want the configured bolt port %d", name, got, customBoltPort) + } + } + }) +} + +// TestStatefulSetExtraArgsAreNotShellParsed asserts an extra argument survives +// verbatim on both roles, whitespace and shell metacharacters included. The +// coordinators are the interesting half: they start through a /bin/sh wrapper, +// so an argument interpolated into that script would be word-split by the shell +// (or worse, run as a command) instead of reaching Memgraph as one flag. +func TestStatefulSetExtraArgsAreNotShellParsed(t *testing.T) { + hostile := []string{ + "--query-modules-directory=/var/lib/memgraph/my modules", + "--log-level=$(id)`id`;id", + "--experimental-enabled=text-search,'vector-search'", + } + cluster := minimalCluster() + cluster.Spec.ExtraArgs = memgraphcomv1alpha1.ExtraArgsSpec{Coordinators: hostile, Data: hostile} + + for _, tc := range []struct { + name string + sts *appsv1.StatefulSet + }{ + {coordinatorComponent, coordinatorStatefulSet(cluster)}, + {dataComponent, dataStatefulSet(cluster)}, + } { + t.Run(tc.name, func(t *testing.T) { + container := tc.sts.Spec.Template.Spec.Containers[0] + if got := container.Args[len(container.Args)-len(hostile):]; !slices.Equal(got, hostile) { + t.Errorf("trailing args = %q, want the extra args unmodified %q", got, hostile) + } + for _, arg := range hostile { + if strings.Contains(strings.Join(container.Command, "\x00"), arg) { + t.Errorf("command %q embeds the extra arg %q, which the shell would then parse", + container.Command, arg) + } + } + }) + } +} + +// TestStatefulSetProbeOverrides asserts probe timings are per role and per +// probe, and that a partially specified probe keeps the defaults for the +// timings it leaves out — including the data instances' 2h startup budget. +func TestStatefulSetProbeOverrides(t *testing.T) { + cluster := tunedCluster() + + tests := []struct { + name string + sts *appsv1.StatefulSet + startup, readiness, liveness *corev1.Probe + }{ + { + name: coordinatorComponent, + sts: coordinatorStatefulSet(cluster), + // Only the failure threshold was raised, so the timings default. + startup: tunedTCPProbe(customCoordinatorPort, 30, 10, 5), + // Timings tightened, failure threshold left at its default. + readiness: tunedTCPProbe(customCoordinatorPort, 20, 3, 2), + liveness: tunedTCPProbe(customCoordinatorPort, 20, 10, 5), + }, + { + name: dataComponent, + sts: dataStatefulSet(cluster), + startup: tunedTCPProbe(customBoltPort, 4320, 15, 10), + readiness: tunedTCPProbe(customBoltPort, 20, 10, 5), + liveness: tunedTCPProbe(customBoltPort, 6, 10, 5), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + container := tc.sts.Spec.Template.Spec.Containers[0] + if diff := cmp.Diff(tc.startup, container.StartupProbe); diff != "" { + t.Errorf("startup probe mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(tc.readiness, container.ReadinessProbe); diff != "" { + t.Errorf("readiness probe mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(tc.liveness, container.LivenessProbe); diff != "" { + t.Errorf("liveness probe mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestStatefulSetResourceOverrides(t *testing.T) { + cluster := tunedCluster() + + tests := []struct { + name string + sts *appsv1.StatefulSet + want corev1.ResourceRequirements + }{ + { + name: coordinatorComponent, + sts: coordinatorStatefulSet(cluster), + want: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("100m")}, + Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("512Mi")}, + }, + }, + { + name: dataComponent, + sts: dataStatefulSet(cluster), + want: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("4Gi")}, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.sts.Spec.Template.Spec.Containers[0].Resources + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("resources mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestStatefulSetLabelOverrides asserts custom labels land on the object they +// name: StatefulSet labels on the StatefulSet, pod labels on the pod template, +// and neither on the selector, which stays operator-owned. +func TestStatefulSetLabelOverrides(t *testing.T) { + cluster := tunedCluster() + + tests := []struct { + name string + sts *appsv1.StatefulSet + component string + stsLabels map[string]string + podLabels map[string]string + }{ + { + name: coordinatorComponent, + sts: coordinatorStatefulSet(cluster), + component: coordinatorComponent, + stsLabels: map[string]string{tierLabel: "control"}, + podLabels: map[string]string{teamLabel: platformTeam}, + }, + { + name: dataComponent, + sts: dataStatefulSet(cluster), + component: dataComponent, + stsLabels: map[string]string{tierLabel: "storage"}, + podLabels: map[string]string{teamLabel: dataComponent}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(expectedLabelsWith(tc.component, tc.stsLabels), tc.sts.Labels); diff != "" { + t.Errorf("StatefulSet labels mismatch (-want +got):\n%s", diff) + } + wantPodLabels := expectedLabelsWith(tc.component, tc.podLabels) + if diff := cmp.Diff(wantPodLabels, tc.sts.Spec.Template.Labels); diff != "" { + t.Errorf("pod labels mismatch (-want +got):\n%s", diff) + } + wantSelector := &metav1.LabelSelector{MatchLabels: expectedSelectorLabels(tc.component)} + if diff := cmp.Diff(wantSelector, tc.sts.Spec.Selector); diff != "" { + t.Errorf("selector mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// A custom label that collides with one of the operator's identity labels must +// lose: those labels are what the StatefulSet and Service select on, so a +// custom label winning would detach the pods from their cluster. +func TestStatefulSetCustomLabelsCannotOverrideIdentity(t *testing.T) { + cluster := minimalCluster() + hijack := map[string]string{ + nameLabel: "not-memgraph", + instanceLabel: "other-cluster", + componentLabel: dataComponent, + managedByLabel: "someone-else", + teamLabel: platformTeam, + } + cluster.Spec.Labels.Coordinators = memgraphcomv1alpha1.RoleLabelsSpec{ + PodLabels: hijack, + StatefulSetLabels: hijack, + ServiceLabels: hijack, + } + + want := expectedLabelsWith(coordinatorComponent, map[string]string{teamLabel: platformTeam}) + sts := coordinatorStatefulSet(cluster) + for name, got := range map[string]map[string]string{ + statefulSetKind: sts.Labels, + "pod": sts.Spec.Template.Labels, + serviceKind: resources.CoordinatorHeadlessService(cluster).Labels, + } { + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("%s labels mismatch (-want +got):\n%s", name, diff) + } + } +} + +// TestStatefulSetExtraEnv asserts the passthrough environment lands after the +// license variables the operator wires from the secrets block, and that no +// secret material can ride along with it. +func TestStatefulSetExtraEnv(t *testing.T) { + cluster := tunedCluster() + + tests := []struct { + name string + sts *appsv1.StatefulSet + want []corev1.EnvVar + }{ + { + name: coordinatorComponent, + sts: coordinatorStatefulSet(cluster), + want: append( + append([]corev1.EnvVar{{ + Name: "POD_NAME", + ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"}, + }, + }}, licenseEnv("memgraph-secrets", "MEMGRAPH_ENTERPRISE_LICENSE", "MEMGRAPH_ORGANIZATION_NAME")...), + corev1.EnvVar{Name: "COORDINATOR_LABEL", Value: "coord"}, + ), + }, + { + name: dataComponent, + sts: dataStatefulSet(cluster), + want: append( + licenseEnv("memgraph-secrets", "MEMGRAPH_ENTERPRISE_LICENSE", "MEMGRAPH_ORGANIZATION_NAME"), + corev1.EnvVar{Name: "DATA_LABEL_ONE", Value: "one"}, + corev1.EnvVar{Name: "DATA_LABEL_TWO", Value: "two"}, + ), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.sts.Spec.Template.Spec.Containers[0].Env + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("env mismatch (-want +got):\n%s", diff) + } + for _, variable := range got { + if variable.ValueFrom != nil && variable.ValueFrom.SecretKeyRef != nil { + if variable.Name != "MEMGRAPH_ENTERPRISE_LICENSE" && variable.Name != "MEMGRAPH_ORGANIZATION_NAME" { + t.Errorf("env %q reads a Secret; only the secrets block may", variable.Name) + } + } + } + }) + } +} diff --git a/internal/resources/topology.go b/internal/resources/topology.go new file mode 100644 index 0000000..4625aa2 --- /dev/null +++ b/internal/resources/topology.go @@ -0,0 +1,216 @@ +/* +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 resources + +import ( + "fmt" + + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" + "github.com/memgraph/kubernetes-operator/internal/memgraph" + "github.com/memgraph/kubernetes-operator/internal/planner" +) + +// DeclaredCoordinators is the number of coordinators the spec declares, with +// the CRD's schema default resolved so a spec that never passed admission reads +// the same as one that did. +func DeclaredCoordinators(cluster *memgraphcomv1alpha1.MemgraphCluster) int32 { + return normalize(cluster.Spec).coordinators +} + +// DeclaredDataInstances is the number of data instances the spec declares, with +// the CRD's schema default resolved. +func DeclaredDataInstances(cluster *memgraphcomv1alpha1.MemgraphCluster) int32 { + return normalize(cluster.Spec).dataInstances +} + +// CoordinatorID is the Raft coordinator ID of the coordinator running on the pod +// with the given ordinal. IDs are 1-based because Memgraph treats ID 0 as unset. +func CoordinatorID(ordinal int32) int32 { + return ordinal + 1 +} + +// CoordinatorInstanceName and DataInstanceName are the names the members running +// on a pod ordinal are known by in SHOW INSTANCES. +// +// They are exported because this mapping has to exist in exactly one place. +// Anything that matches an observed cluster row against a pod — the rolling +// restart, which has nothing but pods to work from — needs the same derivation the +// builders and the declared topology use, and a second spelling of it would fail +// quietly: a name that is merely wrong matches no row at all, so the caller +// concludes the instance is absent rather than that it asked the wrong question. +func CoordinatorInstanceName(ordinal int32) string { + return memgraph.CoordinatorSpec{ID: CoordinatorID(ordinal)}.Name() +} + +// DataInstanceName is the SHOW INSTANCES name of the data instance on the pod +// with the given ordinal. +func DataInstanceName(ordinal int32) string { + return fmt.Sprintf("instance_%d", ordinal) +} + +// CoordinatorOrdinal and DataInstanceOrdinal are the inverses: the ordinal of the +// pod running the member an observed view names. They live next to the functions +// they invert so the two cannot drift apart, and they are what anything holding a +// name and needing the pod behind it uses — the e2e suite reading MAIN out of +// SHOW INSTANCES, for one. +func CoordinatorOrdinal(name string) (int32, error) { + id, err := memgraph.CoordinatorIDFromName(name) + if err != nil { + return 0, err + } + return id - 1, nil +} + +// DataInstanceOrdinal is the ordinal of the pod running the named data instance. +func DataInstanceOrdinal(name string) (int32, error) { + var ordinal int32 + if _, err := fmt.Sscanf(name, "instance_%d", &ordinal); err != nil { + return 0, fmt.Errorf("parsing data instance name %q: %w", name, err) + } + return ordinal, nil +} + +// DeclaredTopology derives the registration topology the planner drives the +// cluster toward. Identity follows the pod ordinal exactly as the workload +// pods advertise it: coordinator ordinal N is Raft coordinator N+1 (Memgraph +// treats coordinator ID 0 as unset, so IDs stay 1-based), data ordinal N +// registers as instance_N, and every address is the pod's stable DNS name +// within its headless Service. +func DeclaredTopology(cluster *memgraphcomv1alpha1.MemgraphCluster) planner.Topology { + spec := normalize(cluster.Spec) + + topology := planner.Topology{ + Coordinators: make([]memgraph.CoordinatorSpec, 0, spec.coordinators), + DataInstances: make([]memgraph.DataInstanceSpec, 0, spec.dataInstances), + } + for ordinal := range spec.coordinators { + topology.Coordinators = append(topology.Coordinators, coordinator(cluster, spec, ordinal)) + } + for ordinal := range spec.dataInstances { + topology.DataInstances = append(topology.DataInstances, dataInstance(cluster, spec, ordinal)) + } + return topology +} + +// RetiringCoordinators is the coordinators a lowered coordinators count is +// shedding: pod ordinals [declared, applied), where applied is the replica count +// the operator's own previous apply left on the coordinator StatefulSet. It is +// empty while a cluster grows or holds its size. +// +// Because the count must stay odd, a shrink always retires an even number of +// coordinators, so the surviving Raft cluster keeps an odd membership throughout. +func RetiringCoordinators( + cluster *memgraphcomv1alpha1.MemgraphCluster, + applied int32, +) []memgraph.CoordinatorSpec { + spec := normalize(cluster.Spec) + if applied <= spec.coordinators { + return nil + } + + retiring := make([]memgraph.CoordinatorSpec, 0, applied-spec.coordinators) + for ordinal := spec.coordinators; ordinal < applied; ordinal++ { + retiring = append(retiring, coordinator(cluster, spec, ordinal)) + } + return retiring +} + +// RetiringDataInstances is the data instances a lowered dataInstances count is +// shedding: pod ordinals [declared, applied), where applied is the replica count +// the operator's own previous apply left on the data StatefulSet. It is empty +// while a cluster grows or holds its size. +// +// The operator never picks which member retires. A StatefulSet sheds its highest +// ordinals and nothing else, so the range is fully determined by the two counts — +// which is also what keeps an instance the operator did not create out of it. +func RetiringDataInstances( + cluster *memgraphcomv1alpha1.MemgraphCluster, + applied int32, +) []memgraph.DataInstanceSpec { + spec := normalize(cluster.Spec) + if applied <= spec.dataInstances { + return nil + } + + retiring := make([]memgraph.DataInstanceSpec, 0, applied-spec.dataInstances) + for ordinal := spec.dataInstances; ordinal < applied; ordinal++ { + retiring = append(retiring, dataInstance(cluster, spec, ordinal)) + } + return retiring +} + +// coordinator describes the coordinator running on the given pod ordinal, as the +// pod itself advertises it. Retiring coordinators are described the same way as +// declared ones: they are members of the Raft cluster under the ID and addresses +// the operator added them with, whether or not the spec still declares them. +func coordinator( + cluster *memgraphcomv1alpha1.MemgraphCluster, + spec normalizedSpec, + ordinal int32, +) memgraph.CoordinatorSpec { + fqdn := podFQDN(cluster, CoordinatorName(cluster), spec, ordinal) + return memgraph.CoordinatorSpec{ + ID: CoordinatorID(ordinal), + BoltServer: hostPort(fqdn, spec.ports.bolt), + CoordinatorServer: hostPort(fqdn, spec.ports.coordinator), + ManagementServer: hostPort(fqdn, spec.ports.management), + } +} + +// dataInstance describes the data instance running on the given pod ordinal, as +// the pod itself advertises it. Retiring instances are described the same way as +// declared ones: they are registered under the addresses the operator registered +// them with, whether or not the spec still declares them. +func dataInstance( + cluster *memgraphcomv1alpha1.MemgraphCluster, + spec normalizedSpec, + ordinal int32, +) memgraph.DataInstanceSpec { + fqdn := podFQDN(cluster, DataName(cluster), spec, ordinal) + return memgraph.DataInstanceSpec{ + Name: DataInstanceName(ordinal), + BoltServer: hostPort(fqdn, spec.ports.bolt), + ManagementServer: hostPort(fqdn, spec.ports.management), + ReplicationServer: hostPort(fqdn, spec.ports.replication), + } +} + +// podFQDNSuffix returns the DNS suffix a pod name is appended to for pods of +// the given headless Service: "..svc.", where the +// domain is the configured cluster domain. +func podFQDNSuffix( + cluster *memgraphcomv1alpha1.MemgraphCluster, + serviceName string, + spec normalizedSpec, +) string { + return fmt.Sprintf("%s.%s.svc.%s", serviceName, cluster.Namespace, spec.clusterDomain) +} + +// podFQDN returns the stable DNS name of the pod with the given ordinal in +// the StatefulSet backed by the given headless Service (both share one name). +func podFQDN( + cluster *memgraphcomv1alpha1.MemgraphCluster, + serviceName string, + spec normalizedSpec, + ordinal int32, +) string { + return fmt.Sprintf("%s-%d.%s", serviceName, ordinal, podFQDNSuffix(cluster, serviceName, spec)) +} + +func hostPort(host string, port int32) string { + return fmt.Sprintf("%s:%d", host, port) +} diff --git a/internal/resources/topology_test.go b/internal/resources/topology_test.go new file mode 100644 index 0000000..32e26e7 --- /dev/null +++ b/internal/resources/topology_test.go @@ -0,0 +1,355 @@ +/* +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 resources_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "k8s.io/utils/ptr" + + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" + "github.com/memgraph/kubernetes-operator/internal/memgraph" + "github.com/memgraph/kubernetes-operator/internal/planner" + "github.com/memgraph/kubernetes-operator/internal/resources" +) + +// secondDataInstance is the data instance on pod ordinal 1: the one the default +// topology's second replica registers as, and the first one a lowered count +// retires. +const secondDataInstance = "instance_1" + +func TestDeclaredTopologyDefaults(t *testing.T) { + got := resources.DeclaredTopology(minimalCluster()) + + coordinatorFQDN := func(ordinal int) string { + return fmt.Sprintf("%s-%d.%s.%s.svc.cluster.local", coordinatorName, ordinal, coordinatorName, testNamespace) + } + dataFQDN := func(ordinal int) string { + return fmt.Sprintf("%s-%d.%s.%s.svc.cluster.local", dataName, ordinal, dataName, testNamespace) + } + + want := planner.Topology{ + Coordinators: []memgraph.CoordinatorSpec{ + { + ID: 1, + BoltServer: coordinatorFQDN(0) + ":7687", + CoordinatorServer: coordinatorFQDN(0) + ":12000", + ManagementServer: coordinatorFQDN(0) + ":10000", + }, + { + ID: 2, + BoltServer: coordinatorFQDN(1) + ":7687", + CoordinatorServer: coordinatorFQDN(1) + ":12000", + ManagementServer: coordinatorFQDN(1) + ":10000", + }, + { + ID: 3, + BoltServer: coordinatorFQDN(2) + ":7687", + CoordinatorServer: coordinatorFQDN(2) + ":12000", + ManagementServer: coordinatorFQDN(2) + ":10000", + }, + }, + DataInstances: []memgraph.DataInstanceSpec{ + { + Name: "instance_0", + BoltServer: dataFQDN(0) + ":7687", + ManagementServer: dataFQDN(0) + ":10000", + ReplicationServer: dataFQDN(0) + ":20000", + }, + { + Name: secondDataInstance, + BoltServer: dataFQDN(1) + ":7687", + ManagementServer: dataFQDN(1) + ":10000", + ReplicationServer: dataFQDN(1) + ":20000", + }, + }, + } + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("DeclaredTopology() mismatch (-want +got):\n%s", diff) + } +} + +func TestDeclaredTopologyFollowsReplicaCounts(t *testing.T) { + got := resources.DeclaredTopology(specifiedCluster()) + + if len(got.Coordinators) != 5 { + t.Errorf("DeclaredTopology() declared %d coordinators, want 5", len(got.Coordinators)) + } + if len(got.DataInstances) != 3 { + t.Errorf("DeclaredTopology() declared %d data instances, want 3", len(got.DataInstances)) + } +} + +// TestRetiringDataInstances covers the range a lowered dataInstances count +// sheds: the pod ordinals the applied StatefulSet still runs beyond the declared +// count, and nothing else. The bounds are what keep an instance the operator did +// not create out of the range, so they are pinned in both directions. +func TestRetiringDataInstances(t *testing.T) { + cases := []struct { + name string + cluster *memgraphcomv1alpha1.MemgraphCluster + applied int32 + want []string + }{ + { + name: "a cluster holding its size retires nothing", + cluster: minimalCluster(), + applied: 2, + }, + { + name: "a growing cluster retires nothing", + cluster: minimalCluster(), + applied: 1, + }, + { + name: "the highest ordinal retires when the count drops by one", + cluster: dataInstancesCluster(2), + applied: 3, + want: []string{"instance_2"}, + }, + { + name: "every ordinal above the declared count retires at once", + cluster: dataInstancesCluster(1), + applied: 4, + want: []string{secondDataInstance, "instance_2", "instance_3"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var names []string + for _, instance := range resources.RetiringDataInstances(tc.cluster, tc.applied) { + names = append(names, instance.Name) + } + if diff := cmp.Diff(tc.want, names); diff != "" { + t.Errorf("RetiringDataInstances() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestRetiringCoordinators covers the range a lowered coordinators count sheds. +// The bounds matter more here than for data instances: every member in the range +// loses a Raft vote, so a range that reached past what the operator applied would +// try to remove a coordinator a human added. +func TestRetiringCoordinators(t *testing.T) { + cases := []struct { + name string + cluster *memgraphcomv1alpha1.MemgraphCluster + applied int32 + want []string + }{ + { + name: "a cluster holding its size retires nothing", + cluster: minimalCluster(), + applied: 3, + }, + { + name: "a growing cluster retires nothing", + cluster: coordinatorsCluster(5), + applied: 3, + }, + // The count must stay odd, so a shrink always retires an even number of + // coordinators and the surviving Raft membership stays odd throughout. + { + name: "both ordinals above the declared count retire at once", + cluster: coordinatorsCluster(3), + applied: 5, + want: []string{"coordinator_4", "coordinator_5"}, + }, + { + name: "a larger shrink retires every ordinal above the declared count", + cluster: coordinatorsCluster(3), + applied: 7, + want: []string{"coordinator_4", "coordinator_5", "coordinator_6", "coordinator_7"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var names []string + for _, coordinator := range resources.RetiringCoordinators(tc.cluster, tc.applied) { + names = append(names, coordinator.Name()) + } + if diff := cmp.Diff(tc.want, names); diff != "" { + t.Errorf("RetiringCoordinators() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// A retiring coordinator is a Raft member under the ID and addresses the operator +// added it with, so it must be described exactly as the declared coordinator on +// the same ordinal was — otherwise the plan would aim REMOVE COORDINATOR at the +// wrong ID. +func TestRetiringCoordinatorMatchesItsDeclaredForm(t *testing.T) { + // The tuned cluster (non-default ports and cluster domain) declares three + // coordinators. Lowering the count is not possible below three, so the declared + // form is taken from a five-coordinator variant of the same spec. + grown := tunedCluster() + grown.Spec.Coordinators = ptr.To(int32(5)) + declared := resources.DeclaredTopology(grown).Coordinators + + shrunk := tunedCluster() + shrunk.Spec.Coordinators = ptr.To(int32(3)) + got := resources.RetiringCoordinators(shrunk, int32(len(declared))) + + if diff := cmp.Diff(declared[3:], got); diff != "" { + t.Errorf("RetiringCoordinators() mismatch (-want +got):\n%s", diff) + } +} + +// coordinatorsCluster is the minimal cluster with a different coordinators count, +// the spec side of a coordinator scale. +func coordinatorsCluster(coordinators int32) *memgraphcomv1alpha1.MemgraphCluster { + cluster := minimalCluster() + cluster.Spec.Coordinators = ptr.To(coordinators) + return cluster +} + +// dataInstancesCluster is the minimal cluster with a lowered dataInstances count, +// the spec side of a scale-down. +func dataInstancesCluster(dataInstances int32) *memgraphcomv1alpha1.MemgraphCluster { + cluster := minimalCluster() + cluster.Spec.DataInstances = ptr.To(dataInstances) + return cluster +} + +// A retiring instance is registered under the addresses the operator registered +// it with, so it must be described exactly as the declared instance on the same +// ordinal was — otherwise the plan would aim its removal at a name the cluster +// does not know. +func TestRetiringDataInstanceMatchesItsDeclaredForm(t *testing.T) { + // The tuned cluster (non-default ports and cluster domain) declares two + // instances. Lowering the count to one leaves instance_1 retiring, which must + // equal the instance_1 the same spec declared before the edit, verbatim. + declared := resources.DeclaredTopology(tunedCluster()).DataInstances + + shrunk := tunedCluster() + shrunk.Spec.DataInstances = ptr.To(int32(1)) + got := resources.RetiringDataInstances(shrunk, int32(len(declared))) + + if diff := cmp.Diff(declared[1:], got); diff != "" { + t.Errorf("RetiringDataInstances() mismatch (-want +got):\n%s", diff) + } +} + +// The registration topology must advertise exactly the identity the +// coordinator pods derive for themselves at startup, otherwise the Raft +// cluster and the registrations disagree about who is who. +func TestDeclaredTopologyMatchesCoordinatorStartScript(t *testing.T) { + cluster := minimalCluster() + topology := resources.DeclaredTopology(cluster) + sts := coordinatorStatefulSet(cluster) + script := strings.Join(sts.Spec.Template.Spec.Containers[0].Command, "\n") + + // The script derives '.' from POD_NAME; every declared + // coordinator_server must be a pod FQDN under that same suffix. + suffix := fmt.Sprintf("%s.%s.svc.cluster.local", coordinatorName, testNamespace) + if !strings.Contains(script, `--coordinator-hostname="${POD_NAME}.`+suffix+`"`) { + t.Errorf("coordinator start script does not advertise the headless-service pod FQDN:\n%s", script) + } + for i, coordinator := range topology.Coordinators { + wantHost := fmt.Sprintf("%s-%d.%s:12000", coordinatorName, i, suffix) + if coordinator.CoordinatorServer != wantHost { + t.Errorf("coordinator %d advertises %q, want %q", coordinator.ID, coordinator.CoordinatorServer, wantHost) + } + } +} + +// TestDeclaredTopologyPortsAndClusterDomain asserts the configured ports and +// cluster domain reach every advertised address, since these are exactly the +// addresses the operator registers with the cluster. +func TestDeclaredTopologyPortsAndClusterDomain(t *testing.T) { + got := resources.DeclaredTopology(tunedCluster()) + + coordinatorFQDN := func(ordinal int) string { + return fmt.Sprintf("%s-%d.%s.%s.svc.k8s.example.com", coordinatorName, ordinal, coordinatorName, testNamespace) + } + dataFQDN := func(ordinal int) string { + return fmt.Sprintf("%s-%d.%s.%s.svc.k8s.example.com", dataName, ordinal, dataName, testNamespace) + } + + want := planner.Topology{ + Coordinators: []memgraph.CoordinatorSpec{ + { + ID: 1, + BoltServer: coordinatorFQDN(0) + ":7777", + CoordinatorServer: coordinatorFQDN(0) + ":12001", + ManagementServer: coordinatorFQDN(0) + ":10001", + }, + { + ID: 2, + BoltServer: coordinatorFQDN(1) + ":7777", + CoordinatorServer: coordinatorFQDN(1) + ":12001", + ManagementServer: coordinatorFQDN(1) + ":10001", + }, + { + ID: 3, + BoltServer: coordinatorFQDN(2) + ":7777", + CoordinatorServer: coordinatorFQDN(2) + ":12001", + ManagementServer: coordinatorFQDN(2) + ":10001", + }, + }, + DataInstances: []memgraph.DataInstanceSpec{ + { + Name: "instance_0", + BoltServer: dataFQDN(0) + ":7777", + ManagementServer: dataFQDN(0) + ":10001", + ReplicationServer: dataFQDN(0) + ":20001", + }, + { + Name: secondDataInstance, + BoltServer: dataFQDN(1) + ":7777", + ManagementServer: dataFQDN(1) + ":10001", + ReplicationServer: dataFQDN(1) + ":20001", + }, + }, + } + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("DeclaredTopology() mismatch (-want +got):\n%s", diff) + } +} + +// The coordinator pods must advertise the same non-default identity the +// registration topology declares for them, otherwise the Raft cluster and the +// registrations disagree about who is who. +func TestDeclaredTopologyMatchesTunedCoordinatorStartScript(t *testing.T) { + cluster := tunedCluster() + topology := resources.DeclaredTopology(cluster) + sts := coordinatorStatefulSet(cluster) + script := strings.Join(sts.Spec.Template.Spec.Containers[0].Command, "\n") + + suffix := fmt.Sprintf("%s.%s.svc.k8s.example.com", coordinatorName, testNamespace) + if !strings.Contains(script, `--coordinator-hostname="${POD_NAME}.`+suffix+`"`) { + t.Errorf("coordinator start script does not advertise the configured cluster domain:\n%s", script) + } + if !strings.Contains(script, "--coordinator-port=12001") { + t.Errorf("coordinator start script does not listen on the configured coordinator port:\n%s", script) + } + for i, coordinator := range topology.Coordinators { + wantHost := fmt.Sprintf("%s-%d.%s:12001", coordinatorName, i, suffix) + if coordinator.CoordinatorServer != wantHost { + t.Errorf("coordinator %d advertises %q, want %q", coordinator.ID, coordinator.CoordinatorServer, wantHost) + } + } +} diff --git a/internal/rollout/rollout.go b/internal/rollout/rollout.go new file mode 100644 index 0000000..31c3d1b --- /dev/null +++ b/internal/rollout/rollout.go @@ -0,0 +1,458 @@ +/* +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 rollout decides which single pod a changed pod template lets the +// operator restart next. Both role StatefulSets use updateStrategy OnDelete, so +// Kubernetes replaces no pod on its own and this is the only thing that ever +// does — which is also why the trigger is any revision change and not an image +// change: a template edit nobody rolls is a cluster frozen on an old spec. +// +// The decision is a pure function of what is observed — pods with their revision +// and readiness, the coordinator leader's SHOW INSTANCES view, and SHOW +// REPLICATION LAG — and it returns exactly one action per pass. One action and +// never a list, because every step is re-gated on a fresh observation: lag +// measured one pod ago says nothing about the next. +// +// Nothing is remembered between passes. The pods already carrying the new +// revision *are* the ones already restarted, so "what is left" and "what must +// have caught up" are both read off the cluster rather than tracked. That is what +// makes a spec reverted halfway through, or Raft moving MAIN or coordinator +// leadership mid-roll, self-correcting: the next pass simply re-derives the +// answer, with nothing to unwind. +// +// The order is the whole point. Data instances roll before coordinators, the +// observed MAIN is the last data pod to go, and the observed Raft leader is the +// last coordinator. Restarting the MAIN costs one coordinator-driven failover, so +// it happens once, at the end, when every instance Raft could promote in its +// place is already running the new revision. Kubernetes' own RollingUpdate cannot +// express that — it sweeps highest ordinal to lowest, and partition is a +// descending cutoff rather than a set, so a MAIN on any ordinal but 0 is restarted +// mid-sweep and each such restart buys another failover. +// +// The operator never promotes anything here. Killing the MAIN's pod leaves the +// promotion to the Raft coordinators, which is what keeps two control systems +// from choosing a MAIN at once. That is only safe on a Memgraph that reports an +// unreachable MAIN as role=main with health=down: a release that vacates the main +// row instead leaves planner.Plan believing the cluster has no MAIN, and it will +// race the failover with a promotion of its own on every single restart. +package rollout + +import ( + "fmt" + + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" + "github.com/memgraph/kubernetes-operator/internal/memgraph" +) + +// Action is what the operator does with the decision. +type Action int + +const ( + // Done means every pod of both roles runs its StatefulSet's current + // revision: there is nothing to restart. + Done Action = iota + + // Wait means a restart is outstanding but may not proceed yet. The reason + // and message say what is being waited for, and go straight onto the + // resource — "why is my upgrade not moving" has to be answerable from + // kubectl describe alone. + Wait + + // Delete means the named pod is the next one to restart. Its StatefulSet + // recreates it at the current revision. + Delete +) + +// Pod is one workload pod as the decision sees it: which Memgraph instance runs +// on it, which pod-template revision it carries, and whether Kubernetes +// considers it ready. +type Pod struct { + // Name is the pod to delete. + Name string + + // UID is the pod's identity at the moment it was observed. The delete is + // conditioned on it, so a pod the StatefulSet already recreated between the + // observation and the delete is never restarted a second time. + UID string + + // Instance is the name this pod's Memgraph instance is known by in SHOW + // INSTANCES — instance_N for data pods, coordinator_N+1 for coordinators. + // The decision matches observations by this name, never by pod name. + Instance string + + // Ordinal is the pod's StatefulSet ordinal, which orders restarts within a + // role. + Ordinal int32 + + // RevisionHash is the pod's controller-revision-hash label, set by the + // StatefulSet controller. + RevisionHash string + + // Ready is the pod's Kubernetes readiness, which for these pods is a TCP + // connect to a port. It is necessary but never sufficient: an instance + // answering on its port has not necessarily rejoined replication. + Ready bool +} + +// Role is one StatefulSet's pods with the revision they are measured against. +type Role struct { + // Replicas is how many pods the role must have. A pod that has been deleted + // and not yet recreated is missing from Pods, and a role short of its + // replicas is never acted on. + Replicas int32 + + // UpdateRevision is the StatefulSet's status.updateRevision — the revision + // its current pod template hashes to. Empty while the StatefulSet has no + // status yet, which reads as nothing to do rather than as everything being + // outdated. + UpdateRevision string + + Pods []Pod +} + +// Decision is the one action a pass may take. +type Decision struct { + Action Action + + // Pod is the pod to restart, set only for Delete, and carried whole rather + // than by name. Its UID is what makes the delete conditional, and that UID has + // to be the one this decision was made against: a caller that re-read the pod + // by name to find it would get whichever pod exists by then — possibly the + // replacement — so the precondition would always match and guard nothing. + Pod Pod + + // Reason and Message describe a Wait or a Delete for the resource's + // condition. Done needs neither: the caller reports its own converged + // message. + Reason string + Message string +} + +// InProgress reports whether the role has pods still to restart. It is what +// lets the caller loosen its readiness gate by the one pod a restart took down, +// and only while one is actually under way. +func InProgress(role Role) bool { + return len(outdated(role)) > 0 +} + +// Next returns the single action to take toward both roles running their +// StatefulSets' current pod template. +// +// Data instances are dealt with first and completely; coordinators only once no +// data pod is outstanding *and* the data plane is whole again, so that at most +// one pod of the cluster is ever down — across both roles, not per role. A role +// whose pods all carry the current revision contributes nothing, which is why a +// cluster with nothing to roll returns Done regardless of how healthy it is: +// readiness is the Ready and Converged conditions' business, not this one's. +func Next( + data, coordinators Role, + observed []memgraph.Instance, + lag []memgraph.ReplicationLag, +) Decision { + instances := index(observed) + lags := indexLag(lag) + + if len(outdated(data)) > 0 { + return nextDataInstance(data, instances, lags) + } + if len(outdated(coordinators)) == 0 { + return Decision{Action: Done} + } + // A coordinator's pod does not go while a data pod is missing or unready: at + // most one pod of the cluster is down at a time, across both roles. + // + // Replication lag deliberately does not gate this. A coordinator restart + // neither reduces the number of instances holding recent writes nor forces a + // promotion, so a replica still draining its backlog is no reason to hold it — + // and making it one would let a single chronically lagging replica freeze the + // coordinators' pod template indefinitely. + if wait, ok := present(data); !ok { + return wait + } + return nextCoordinator(coordinators, instances) +} + +// nextDataInstance picks the next data pod to restart, or says what it is waiting +// for. Non-MAIN pods go first, highest ordinal down, matching the order a +// StatefulSet would have used; the MAIN goes last and alone. +func nextDataInstance( + data Role, + instances map[string]memgraph.Instance, + lags map[string]memgraph.ReplicationLag, +) Decision { + if wait, ok := present(data); !ok { + return wait + } + + // Where MAIN sits comes from the role Raft reports, not from health: an + // unreachable MAIN is still the cluster's MAIN, and restarting another pod + // while believing there is none is exactly the mistake to avoid. + main := mainInstance(instances) + if main == "" { + return waiting(memgraphcomv1alpha1.ReasonNoMainElected, + "Waiting for a MAIN data instance before restarting any data pod") + } + + pending := outdated(data) + if next, ok := highestOrdinalExcept(pending, main); ok { + // Taking another replica down reduces the number of instances holding + // recent writes, so the ones already restarted have to be back in + // replication first. This is the gate that waits out a fresh volume's full + // snapshot resync. + if wait, ok := replicating(data, instances, lags); !ok { + return wait + } + return restarting(next, fmt.Sprintf( + "Restarting data instance pod %s, which is not MAIN", next.Name)) + } + + // Only the MAIN is left. Its restart costs a failover, so it is the one step + // with a precondition of its own. + mainPod := pending[0] + if data.Replicas == 1 { + // A single data instance has no replica to fail over to and never will, + // so the precondition below can never be satisfied. Refusing would leave + // its pod template frozen forever, which protects nothing: there is no + // high availability here to preserve. + return restarting(mainPod, fmt.Sprintf( + "Restarting the only data instance pod %s, which interrupts the cluster until it is back", mainPod.Name)) + } + if !instances[main].IsUp() { + return waiting(memgraphcomv1alpha1.ReasonWorkloadsNotReady, fmt.Sprintf( + "Waiting for MAIN data instance %s to be reachable before restarting it", main)) + } + // A survivor that is reachable and holds every transaction the MAIN has + // committed. One is enough because the coordinators promote the most + // up-to-date instance they can reach, so whichever wins is at least as + // current as the one proven here. Instances observed down are deliberately + // not counted and deliberately not disqualifying: Raft cannot promote them, + // and a permanently sick replica must not freeze the cluster's pod template. + for _, pod := range data.Pods { + if pod.Instance == main { + continue + } + if instances[pod.Instance].IsUp() && lags[pod.Instance].IsCaughtUp() { + return restarting(mainPod, fmt.Sprintf( + "Restarting MAIN data instance pod %s last; %s is caught up and can be promoted in its place", + mainPod.Name, pod.Instance)) + } + } + return waiting(memgraphcomv1alpha1.ReasonNoCaughtUpSurvivor, fmt.Sprintf( + "Waiting for a data instance that is reachable and caught up with MAIN %s before restarting it; "+ + "the cluster keeps serving until one is", main)) +} + +// nextCoordinator picks the next coordinator pod to restart. Non-leaders go +// first, highest ordinal down, and the Raft leader last — its restart costs an +// election, which is harmless while the data plane has a MAIN, but there is no +// reason to pay it more than once. +func nextCoordinator(coordinators Role, instances map[string]memgraph.Instance) Decision { + if wait, ok := present(coordinators); !ok { + return wait + } + // A coordinator is proven back by the leader reaching it, which with three or + // more coordinators and one pod down at a time is the quorum question itself. + // Raft membership is no use here: it survives a pod restart untouched, so it + // never reads as absent. + for _, pod := range updated(coordinators) { + if !instances[pod.Instance].IsUp() { + return waiting(memgraphcomv1alpha1.ReasonWorkloadsNotReady, fmt.Sprintf( + "Waiting for restarted coordinator %s to be reachable before restarting the next one", pod.Instance)) + } + } + + leader := leaderInstance(instances) + if leader == "" { + return waiting(memgraphcomv1alpha1.ReasonNoCoordinatorLeader, + "Waiting for the coordinators to elect a leader before restarting any coordinator pod") + } + + pending := outdated(coordinators) + if next, ok := highestOrdinalExcept(pending, leader); ok { + return restarting(next, fmt.Sprintf( + "Restarting coordinator pod %s, which does not hold Raft leadership", next.Name)) + } + return restarting(pending[0], fmt.Sprintf( + "Restarting coordinator pod %s last; it holds Raft leadership, so the surviving members elect a successor", + pending[0].Name)) +} + +// replicating reports whether every data pod already carrying the current +// revision — which is exactly the set this roll has restarted — is back in +// replication: reachable by the coordinator leader, and holding every transaction +// the MAIN has committed. +// +// Pods still on the old revision are held to readiness alone on purpose. They have +// not been touched yet, so an instance that was already lagging before the roll +// began does not get to block it; the step that genuinely needs a caught-up +// instance asks for one directly, and asks for one rather than all. +// +// The restarted pods are not required to report role=replica, even though that is +// what they will normally be. A failover unrelated to the roll can move MAIN onto +// one of them, and demanding replica there would deadlock the roll against a +// perfectly healthy cluster. Reachable and caught up is the property that matters, +// and the MAIN reports itself caught up by definition. +func replicating( + role Role, + instances map[string]memgraph.Instance, + lags map[string]memgraph.ReplicationLag, +) (Decision, bool) { + for _, pod := range updated(role) { + if !instances[pod.Instance].IsUp() { + return waiting(memgraphcomv1alpha1.ReasonWorkloadsNotReady, fmt.Sprintf( + "Waiting for restarted data instance %s to be reachable before restarting the next pod", + pod.Instance)), false + } + if !lags[pod.Instance].IsCaughtUp() { + return waiting(memgraphcomv1alpha1.ReasonWaitingForCatchUp, fmt.Sprintf( + "Waiting for restarted data instance %s to catch up with MAIN before restarting the next pod", + pod.Instance)), false + } + } + return Decision{}, true +} + +// present reports whether every pod of the role exists and is ready — the pod a +// previous pass deleted included, which is what serialises the restarts down to +// one at a time. +func present(role Role) (Decision, bool) { + if int32(len(role.Pods)) != role.Replicas { + return waiting(memgraphcomv1alpha1.ReasonWorkloadsNotReady, + "Waiting for the pod restarted last to be recreated"), false + } + for _, pod := range role.Pods { + if !pod.Ready { + return waiting(memgraphcomv1alpha1.ReasonWorkloadsNotReady, fmt.Sprintf( + "Waiting for pod %s to become ready", pod.Name)), false + } + } + return Decision{}, true +} + +// outdated are the role's pods not carrying its current revision, which is the +// work left to do. +// +// A StatefulSet without a status yet has no revision to compare against, and a +// pod without the label cannot be classified; both read as up to date. Guessing +// the other way would delete pods on the strength of a missing value. +func outdated(role Role) []Pod { + if role.UpdateRevision == "" { + return nil + } + var pending []Pod + for _, pod := range role.Pods { + if pod.RevisionHash != "" && pod.RevisionHash != role.UpdateRevision { + pending = append(pending, pod) + } + } + return pending +} + +// updated are the role's pods already carrying its current revision — the ones +// this roll has restarted, once it is under way. +func updated(role Role) []Pod { + if role.UpdateRevision == "" { + return nil + } + var done []Pod + for _, pod := range role.Pods { + if pod.RevisionHash == role.UpdateRevision { + done = append(done, pod) + } + } + return done +} + +// highestOrdinalExcept is the outstanding pod with the highest ordinal that does +// not run the named instance. +// +// The exclusion is the point: it is how "the MAIN last" and "the Raft leader +// last" are expressed. So is reporting false — that says the named instance is +// the only pod left to restart, which is the step both callers guard with +// preconditions the earlier ones do not need. +// +// Taking the highest ordinal is only a convention. Any deterministic order would +// be correct; this is the one a StatefulSet's own rolling update uses, so the +// restart sequence looks familiar and the tests can assert on it. +func highestOrdinalExcept(pending []Pod, instance string) (Pod, bool) { + var next Pod + found := false + for _, pod := range pending { + if pod.Instance == instance { + continue + } + if !found || pod.Ordinal > next.Ordinal { + next, found = pod, true + } + } + return next, found +} + +// mainInstance is the data instance Raft reports as MAIN, regardless of whether +// the coordinator leader can currently reach it, or empty when none is reported. +func mainInstance(instances map[string]memgraph.Instance) string { + for name, instance := range instances { + if instance.IsMain() { + return name + } + } + return "" +} + +// leaderInstance is the coordinator reported as Raft leader, or empty when none +// is. +func leaderInstance(instances map[string]memgraph.Instance) string { + for name, instance := range instances { + if instance.IsLeader() { + return name + } + } + return "" +} + +// create a map: instanceName -> instance +func index(observed []memgraph.Instance) map[string]memgraph.Instance { + instances := make(map[string]memgraph.Instance, len(observed)) + for _, instance := range observed { + instances[instance.Name] = instance + } + return instances +} + +// indexLag keys replication lag by instance name. A name the view does not cover +// reads back as the zero value, which reports itself as not caught up — the safe +// answer for an instance nothing is known about, and the answer for every +// instance when there is no MAIN to measure against. +func indexLag(lag []memgraph.ReplicationLag) map[string]memgraph.ReplicationLag { + lags := make(map[string]memgraph.ReplicationLag, len(lag)) + for _, instance := range lag { + lags[instance.Instance] = instance + } + return lags +} + +func waiting(reason, message string) Decision { + return Decision{Action: Wait, Reason: reason, Message: message} +} + +func restarting(pod Pod, message string) Decision { + return Decision{ + Action: Delete, + Pod: pod, + Reason: memgraphcomv1alpha1.ReasonRollingRestartInProgress, + Message: message, + } +} diff --git a/internal/rollout/rollout_test.go b/internal/rollout/rollout_test.go new file mode 100644 index 0000000..949deca --- /dev/null +++ b/internal/rollout/rollout_test.go @@ -0,0 +1,504 @@ +/* +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 rollout + +import ( + "fmt" + "testing" + + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" + "github.com/memgraph/kubernetes-operator/internal/memgraph" +) + +const ( + oldRevision = "cluster-data-6c9f8b7d5" + newRevision = "cluster-data-77b4c8f9d" + + // The pods the cases below expect to be restarted, named once so a changed + // expectation cannot silently pass against a typo. + dataPod0 = "cluster-data-0" + dataPod1 = "cluster-data-1" + dataPod2 = "cluster-data-2" + coordinatorPod0 = "cluster-coordinator-0" + coordinatorPod1 = "cluster-coordinator-1" + coordinatorPod2 = "cluster-coordinator-2" +) + +// dataRole builds a data role of the given size whose pods carry the given +// revisions, one per ordinal, all ready. A revision equal to newRevision is a pod +// this roll has already restarted. +func dataRole(revisions ...string) Role { + role := Role{Replicas: int32(len(revisions)), UpdateRevision: newRevision} + for ordinal, revision := range revisions { + role.Pods = append(role.Pods, Pod{ + Name: fmt.Sprintf("cluster-data-%d", ordinal), + UID: fmt.Sprintf("uid-data-%d", ordinal), + Instance: fmt.Sprintf("instance_%d", ordinal), + Ordinal: int32(ordinal), + RevisionHash: revision, + Ready: true, + }) + } + return role +} + +// coordinatorRole is dataRole for the coordinator StatefulSet, whose instances +// are named from a 1-based Raft ID. +func coordinatorRole(revisions ...string) Role { + role := Role{Replicas: int32(len(revisions)), UpdateRevision: newRevision} + for ordinal, revision := range revisions { + role.Pods = append(role.Pods, Pod{ + Name: fmt.Sprintf("cluster-coordinator-%d", ordinal), + UID: fmt.Sprintf("uid-coordinator-%d", ordinal), + Instance: fmt.Sprintf("coordinator_%d", ordinal+1), + Ordinal: int32(ordinal), + RevisionHash: revision, + Ready: true, + }) + } + return role +} + +// converged is a role whose every pod already runs the current revision. +func converged(role Role) Role { + for i := range role.Pods { + role.Pods[i].RevisionHash = role.UpdateRevision + } + return role +} + +// cluster is a SHOW INSTANCES view: the named data instance is MAIN, every other +// declared one is a replica, the first coordinator leads, and everything is up. +func cluster(dataInstances, coordinators int, main string) []memgraph.Instance { + view := make([]memgraph.Instance, 0, dataInstances+coordinators) + for ordinal := range dataInstances { + name := fmt.Sprintf("instance_%d", ordinal) + role := memgraph.RoleReplica + if name == main { + role = memgraph.RoleMain + } + view = append(view, memgraph.Instance{Name: name, Health: memgraph.HealthUp, Role: role}) + } + for ordinal := range coordinators { + role := memgraph.RoleFollower + if ordinal == 0 { + role = memgraph.RoleLeader + } + view = append(view, memgraph.Instance{ + Name: fmt.Sprintf("coordinator_%d", ordinal+1), + BoltServer: "coordinator:7687", + Health: memgraph.HealthUp, + Role: role, + }) + } + return view +} + +// down marks the named instance as one the coordinator leader cannot reach, +// leaving the role it is registered with intact — which is what Memgraph reports +// for an instance whose pod is gone. +func down(view []memgraph.Instance, name string) []memgraph.Instance { + out := make([]memgraph.Instance, len(view)) + copy(out, view) + for i := range out { + if out[i].Name == name { + out[i].Health = "down" + } + } + return out +} + +// caughtUp is the replication lag view with every named instance holding all of +// the MAIN's transactions. +func caughtUp(names ...string) []memgraph.ReplicationLag { + lag := make([]memgraph.ReplicationLag, 0, len(names)) + for _, name := range names { + lag = append(lag, memgraph.ReplicationLag{ + Instance: name, + Databases: []memgraph.DatabaseLag{{Database: "memgraph", CommittedTxns: 42, TxnsBehindMain: 0}}, + }) + } + return lag +} + +// behind is caughtUp for an instance that is missing transactions. +func behind(name string) memgraph.ReplicationLag { + return memgraph.ReplicationLag{ + Instance: name, + Databases: []memgraph.DatabaseLag{{Database: "memgraph", CommittedTxns: 40, TxnsBehindMain: 2}}, + } +} + +func TestNothingToRestart(t *testing.T) { + data := converged(dataRole(newRevision, newRevision, newRevision)) + coordinators := converged(coordinatorRole(newRevision, newRevision, newRevision)) + + decision := Next(data, coordinators, + cluster(3, 3, "instance_0"), caughtUp("instance_0", "instance_1", "instance_2")) + + if decision.Action != Done { + t.Fatalf("expected Done for a cluster already on the current revision, got %+v", decision) + } +} + +// A StatefulSet with no status yet has no revision to measure pods against. +// Reading that as "every pod is outdated" would delete pods on the strength of a +// missing value. +func TestRoleWithoutRevisionIsLeftAlone(t *testing.T) { + data := dataRole(oldRevision, oldRevision) + data.UpdateRevision = "" + coordinators := coordinatorRole(oldRevision, oldRevision, oldRevision) + coordinators.UpdateRevision = "" + + if decision := Next(data, coordinators, cluster(2, 3, "instance_0"), nil); decision.Action != Done { + t.Fatalf("expected Done while no revision is known, got %+v", decision) + } + if InProgress(data) { + t.Error("a role without an update revision has no restart in progress") + } +} + +func TestDataInstancesRestartHighestOrdinalFirstAndSkipMain(t *testing.T) { + // MAIN sits in the middle on purpose: a StatefulSet's own rolling update + // would take instance_2, then instance_1 — the MAIN — then instance_0. + data := dataRole(oldRevision, oldRevision, oldRevision) + coordinators := converged(coordinatorRole(newRevision, newRevision, newRevision)) + view := cluster(3, 3, "instance_1") + lag := caughtUp("instance_0", "instance_1", "instance_2") + + decision := Next(data, coordinators, view, lag) + if decision.Action != Delete || decision.Pod.Name != dataPod2 { + t.Fatalf("expected the highest non-MAIN ordinal first, got %+v", decision) + } + + // instance_2 restarted and caught up; instance_1 is MAIN, so instance_0 is next. + data.Pods[2].RevisionHash = newRevision + decision = Next(data, coordinators, view, lag) + if decision.Action != Delete || decision.Pod.Name != dataPod0 { + t.Fatalf("expected MAIN to be skipped for the lower ordinal, got %+v", decision) + } + + // Only the MAIN is left. + data.Pods[0].RevisionHash = newRevision + decision = Next(data, coordinators, view, lag) + if decision.Action != Delete || decision.Pod.Name != dataPod1 { + t.Fatalf("expected the MAIN's pod last, got %+v", decision) + } + if decision.Pod.UID != "uid-data-1" { + t.Errorf("expected the observed UID to be carried for a conditional delete, got %q", decision.Pod.UID) + } +} + +func TestRestartedInstanceMustBeReadyBeforeTheNextGoes(t *testing.T) { + data := dataRole(oldRevision, oldRevision, newRevision) + data.Pods[2].Ready = false + coordinators := converged(coordinatorRole(newRevision, newRevision, newRevision)) + + decision := Next(data, coordinators, cluster(3, 3, "instance_0"), + caughtUp("instance_0", "instance_1", "instance_2")) + + if decision.Action != Wait || decision.Reason != memgraphcomv1alpha1.ReasonWorkloadsNotReady { + t.Fatalf("expected to wait on the unready pod, got %+v", decision) + } +} + +// A pod deleted and not yet recreated is absent from the role, and the restart +// waits for it rather than treating one fewer pod as one fewer thing to check. +func TestMissingPodStopsTheRoll(t *testing.T) { + data := dataRole(oldRevision, oldRevision, newRevision) + data.Pods = data.Pods[:2] + coordinators := converged(coordinatorRole(newRevision, newRevision, newRevision)) + + decision := Next(data, coordinators, cluster(3, 3, "instance_0"), caughtUp("instance_0", "instance_1")) + + if decision.Action != Wait || decision.Reason != memgraphcomv1alpha1.ReasonWorkloadsNotReady { + t.Fatalf("expected to wait for the deleted pod to be recreated, got %+v", decision) + } +} + +func TestRestartedInstanceMustBeReachableAndCaughtUp(t *testing.T) { + data := dataRole(oldRevision, oldRevision, newRevision) + coordinators := converged(coordinatorRole(newRevision, newRevision, newRevision)) + view := cluster(3, 3, "instance_0") + + // Ready, but the coordinator leader does not reach it yet. + decision := Next(data, coordinators, down(view, "instance_2"), caughtUp("instance_0", "instance_2")) + if decision.Action != Wait || decision.Reason != memgraphcomv1alpha1.ReasonWorkloadsNotReady { + t.Fatalf("expected to wait for the restarted instance to be reachable, got %+v", decision) + } + + // Reachable, but still draining its backlog — the fresh-volume resync case. + decision = Next(data, coordinators, view, append(caughtUp("instance_0"), behind("instance_2"))) + if decision.Action != Wait || decision.Reason != memgraphcomv1alpha1.ReasonWaitingForCatchUp { + t.Fatalf("expected to wait for the restarted instance to catch up, got %+v", decision) + } + + // An empty lag view means nothing is known, which is not permission to proceed. + decision = Next(data, coordinators, view, nil) + if decision.Action != Wait || decision.Reason != memgraphcomv1alpha1.ReasonWaitingForCatchUp { + t.Fatalf("expected an unknown lag to read as not caught up, got %+v", decision) + } +} + +// An instance that was already lagging before the roll began must not block it: +// it has not been restarted, so it is held to readiness alone. The one step that +// genuinely needs a caught-up instance asks for one directly. +func TestNotYetRestartedInstanceIsNotHeldToLag(t *testing.T) { + data := dataRole(oldRevision, oldRevision, oldRevision) + coordinators := converged(coordinatorRole(newRevision, newRevision, newRevision)) + + decision := Next(data, coordinators, cluster(3, 3, "instance_0"), + append(caughtUp("instance_0"), behind("instance_1"))) + + if decision.Action != Delete || decision.Pod.Name != dataPod2 { + t.Fatalf("expected a lagging untouched instance not to block the roll, got %+v", decision) + } +} + +func TestMainIsNotRestartedWithoutACaughtUpSurvivor(t *testing.T) { + data := dataRole(newRevision, newRevision, oldRevision) + data.Pods[2].Instance = "instance_2" + coordinators := converged(coordinatorRole(newRevision, newRevision, newRevision)) + view := cluster(3, 3, "instance_2") + + // Both survivors are behind: the cluster keeps serving and the roll parks. + decision := Next(data, coordinators, view, + []memgraph.ReplicationLag{behind("instance_0"), behind("instance_1")}) + if decision.Action != Wait || decision.Reason != memgraphcomv1alpha1.ReasonNoCaughtUpSurvivor { + t.Fatalf("expected to park at the MAIN without a caught-up survivor, got %+v", decision) + } + + // One caught-up survivor is enough: the coordinators promote the most + // up-to-date instance they can reach, so whoever wins is at least as current. + decision = Next(data, coordinators, view, append(caughtUp("instance_1"), behind("instance_0"))) + if decision.Action != Delete || decision.Pod.Name != dataPod2 { + t.Fatalf("expected one caught-up survivor to permit the MAIN's restart, got %+v", decision) + } +} + +// A caught-up instance the leader cannot reach is not a promotion candidate: Raft +// cannot promote what it cannot see. Its registration outliving its reachability +// is exactly why health and lag are both asked, and why lag alone is never enough. +func TestUnreachableSurvivorIsNoSurvivor(t *testing.T) { + data := dataRole(newRevision, oldRevision) + coordinators := converged(coordinatorRole(newRevision, newRevision, newRevision)) + view := down(cluster(2, 3, "instance_1"), "instance_0") + + decision := Next(data, coordinators, view, caughtUp("instance_0", "instance_1")) + + if decision.Action != Wait || decision.Reason != memgraphcomv1alpha1.ReasonNoCaughtUpSurvivor { + t.Fatalf("expected the MAIN to park without a reachable survivor, got %+v", decision) + } +} + +// One chronically lagging replica must not be able to freeze the cluster's pod +// template. The MAIN's restart needs one survivor Raft could promote, not every +// survivor, so a replica that never catches up does not block it. +func TestOneLaggingReplicaDoesNotBlockTheMain(t *testing.T) { + data := dataRole(newRevision, newRevision, oldRevision) + coordinators := converged(coordinatorRole(newRevision, newRevision, newRevision)) + + decision := Next(data, coordinators, cluster(3, 3, "instance_2"), + append(caughtUp("instance_1"), behind("instance_0"))) + + if decision.Action != Delete || decision.Pod.Name != dataPod2 { + t.Fatalf("expected one caught-up survivor to be enough despite a lagging one, got %+v", decision) + } +} + +// A single data instance has no replica and never will, so the MAIN's +// precondition can never be met. Refusing would freeze its pod template forever +// and protect nothing. +func TestSingleDataInstanceIsRestartedWithAcknowledgedDowntime(t *testing.T) { + data := dataRole(oldRevision) + coordinators := converged(coordinatorRole(newRevision, newRevision, newRevision)) + + decision := Next(data, coordinators, cluster(1, 3, "instance_0"), caughtUp("instance_0")) + + if decision.Action != Delete || decision.Pod.Name != dataPod0 { + t.Fatalf("expected the only data instance to be restarted, got %+v", decision) + } + if !containsAll(decision.Message, "only data instance", "interrupts") { + t.Errorf("expected the message to name the interruption, got %q", decision.Message) + } +} + +// Without a MAIN there is nothing to measure lag against and no telling what the +// cluster is doing, so no data pod is taken down. +func TestNoMainStopsTheDataRoll(t *testing.T) { + data := dataRole(oldRevision, oldRevision) + coordinators := converged(coordinatorRole(newRevision, newRevision, newRevision)) + view := cluster(2, 3, "") + + decision := Next(data, coordinators, view, nil) + + if decision.Action != Wait || decision.Reason != memgraphcomv1alpha1.ReasonNoMainElected { + t.Fatalf("expected to wait for a MAIN before restarting data pods, got %+v", decision) + } +} + +// An unreachable MAIN keeps its role in Raft, so it is still found — but it is not +// restarted while the cluster cannot serve from it. +func TestUnreachableMainIsNotRestarted(t *testing.T) { + data := dataRole(newRevision, oldRevision) + coordinators := converged(coordinatorRole(newRevision, newRevision, newRevision)) + view := down(cluster(2, 3, "instance_1"), "instance_1") + + decision := Next(data, coordinators, view, caughtUp("instance_0")) + + if decision.Action != Wait || decision.Reason != memgraphcomv1alpha1.ReasonWorkloadsNotReady { + t.Fatalf("expected an unreachable MAIN not to be restarted, got %+v", decision) + } +} + +func TestCoordinatorsWaitForEveryDataPod(t *testing.T) { + data := converged(dataRole(newRevision, newRevision)) + coordinators := coordinatorRole(oldRevision, oldRevision, oldRevision) + lag := caughtUp("instance_0", "instance_1") + + // A data pod is still coming back. One pod of the cluster is down at a time + // across both roles, so no coordinator goes on top of it. + unready := converged(dataRole(newRevision, newRevision)) + unready.Pods[1].Ready = false + decision := Next(unready, coordinators, cluster(2, 3, "instance_0"), lag) + if decision.Action != Wait || decision.Reason != memgraphcomv1alpha1.ReasonWorkloadsNotReady { + t.Fatalf("expected coordinators to wait for every data pod to be ready, got %+v", decision) + } + + // Replication lag, by contrast, does not gate a coordinator restart: it neither + // reduces the instances holding recent writes nor forces a promotion. Making it + // a gate would let one lagging replica freeze the coordinators' template. + decision = Next(data, coordinators, cluster(2, 3, "instance_0"), + append(caughtUp("instance_0"), behind("instance_1"))) + if decision.Action != Delete || decision.Pod.Name != coordinatorPod2 { + t.Fatalf("expected a lagging replica not to block the coordinator roll, got %+v", decision) + } + + // Healthy: the coordinator roll starts, highest ordinal first, leader excluded. + decision = Next(data, coordinators, cluster(2, 3, "instance_0"), lag) + if decision.Action != Delete || decision.Pod.Name != coordinatorPod2 { + t.Fatalf("expected the highest non-leader coordinator first, got %+v", decision) + } +} + +func TestCoordinatorLeaderIsRestartedLast(t *testing.T) { + data := converged(dataRole(newRevision, newRevision)) + coordinators := coordinatorRole(oldRevision, oldRevision, oldRevision) + view := cluster(2, 3, "instance_0") + lag := caughtUp("instance_0", "instance_1") + + // coordinator_1, on ordinal 0, is the leader. + coordinators.Pods[2].RevisionHash = newRevision + decision := Next(data, coordinators, view, lag) + if decision.Action != Delete || decision.Pod.Name != coordinatorPod1 { + t.Fatalf("expected the leader to be skipped, got %+v", decision) + } + + coordinators.Pods[1].RevisionHash = newRevision + decision = Next(data, coordinators, view, lag) + if decision.Action != Delete || decision.Pod.Name != coordinatorPod0 { + t.Fatalf("expected the leader's pod last, got %+v", decision) + } +} + +// Raft membership survives a pod restart untouched, so reachability is what proves +// a coordinator is back — and it is asked before the next one goes. +func TestRestartedCoordinatorMustBeReachable(t *testing.T) { + data := converged(dataRole(newRevision, newRevision)) + coordinators := coordinatorRole(oldRevision, oldRevision, newRevision) + view := down(cluster(2, 3, "instance_0"), "coordinator_3") + + decision := Next(data, coordinators, view, caughtUp("instance_0", "instance_1")) + + if decision.Action != Wait || decision.Reason != memgraphcomv1alpha1.ReasonWorkloadsNotReady { + t.Fatalf("expected to wait for the restarted coordinator, got %+v", decision) + } +} + +func TestNoCoordinatorLeaderStopsTheCoordinatorRoll(t *testing.T) { + data := converged(dataRole(newRevision, newRevision)) + coordinators := coordinatorRole(oldRevision, oldRevision, oldRevision) + view := cluster(2, 0, "instance_0") + for ordinal := range 3 { + view = append(view, memgraph.Instance{ + Name: fmt.Sprintf("coordinator_%d", ordinal+1), + BoltServer: "coordinator:7687", + Health: memgraph.HealthUp, + Role: memgraph.RoleFollower, + }) + } + + decision := Next(data, coordinators, view, caughtUp("instance_0", "instance_1")) + + if decision.Action != Wait || decision.Reason != memgraphcomv1alpha1.ReasonNoCoordinatorLeader { + t.Fatalf("expected to wait for a Raft leader, got %+v", decision) + } +} + +// A spec reverted halfway through inverts which pods are outdated, and the roll +// walks back with nothing to unwind — the point of deriving the state every pass +// rather than tracking it. +func TestRevertedSpecRollsBack(t *testing.T) { + data := dataRole(newRevision, newRevision, oldRevision) + coordinators := converged(coordinatorRole(newRevision, newRevision, newRevision)) + + // The spec goes back: the old revision is now the current one, so the two pods + // already restarted are the outdated ones. + data.UpdateRevision = oldRevision + coordinators.UpdateRevision = oldRevision + for i := range coordinators.Pods { + coordinators.Pods[i].RevisionHash = oldRevision + } + + decision := Next(data, coordinators, cluster(3, 3, "instance_0"), + caughtUp("instance_0", "instance_1", "instance_2")) + + if decision.Action != Delete || decision.Pod.Name != dataPod1 { + t.Fatalf("expected the roll to reverse onto the highest re-outdated non-MAIN pod, got %+v", decision) + } +} + +// A failover unrelated to the roll can move MAIN onto a pod already restarted. +// Demanding role=replica of the restarted pods would deadlock against a perfectly +// healthy cluster, so reachable and caught up is what is asked. +func TestMainMovingOntoARestartedPodDoesNotDeadlock(t *testing.T) { + data := dataRole(newRevision, oldRevision, oldRevision) + coordinators := converged(coordinatorRole(newRevision, newRevision, newRevision)) + + decision := Next(data, coordinators, cluster(3, 3, "instance_0"), + caughtUp("instance_0", "instance_1", "instance_2")) + + if decision.Action != Delete || decision.Pod.Name != dataPod2 { + t.Fatalf("expected the roll to continue with MAIN on a restarted pod, got %+v", decision) + } +} + +func containsAll(s string, substrings ...string) bool { + for _, substring := range substrings { + found := false + for i := 0; i+len(substring) <= len(s); i++ { + if s[i:i+len(substring)] == substring { + found = true + break + } + } + if !found { + return false + } + } + return true +} diff --git a/specs/operator-mvp/PRD.md b/specs/operator-mvp/PRD.md new file mode 100644 index 0000000..bebae7d --- /dev/null +++ b/specs/operator-mvp/PRD.md @@ -0,0 +1,129 @@ +# PRD: Memgraph Kubernetes Operator — v1alpha1 MVP + +## Problem Statement + +Operating a Memgraph high-availability cluster on Kubernetes today means installing the `memgraph-high-availability` Helm chart, which has two structural problems from the user's perspective: + +1. **Cluster registration is fire-and-forget.** A post-install Job registers coordinators and data instances once, then exits. If a pod is later rescheduled and loses its registration state, nothing re-registers it — the user must notice the degraded cluster and intervene manually with mgconsole. +2. **The configuration surface fights the user.** Every coordinator and data instance is its own copy-pasted values block backing its own StatefulSet and Services. Growing the cluster means duplicating a ~20-line block and hand-assigning IDs; the topology is spread across many near-identical resources instead of being expressed as "3 coordinators, 2 data instances." + +Users of comparable databases (MongoDB, Elasticsearch, CockroachDB) expect a Kubernetes operator: declare the cluster as a single custom resource, and a controller continuously drives reality toward it. + +## Solution + +A Go operator (kubebuilder) exposing a `MemgraphCluster` custom resource in the `memgraph.com/v1alpha1` API group (short name `mgc`). The user writes one resource declaring topology as two numbers — coordinator count and data-instance count — plus standard pod-level knobs, and the operator: + +- Provisions **one StatefulSet per role** (coordinators, data instances) with headless Services, deriving per-pod identity (coordinator ID, advertised addresses) from pod ordinals — no per-instance configuration blocks. +- **Bootstraps the HA cluster**: adds coordinators, registers data instances, and promotes the initial MAIN. +- **Continuously reconciles registration**: every reconcile compares `SHOW INSTANCES` on the coordinator leader against the declared topology and re-issues only the missing registrations, so a wiped or rescheduled pod rejoins the cluster without human action. +- **Reports cluster state** on the CR status: which instance is MAIN, registration convergence, and readiness conditions. + +The operator is a redesign, not a port of the chart. It will reach feature parity with the HA chart over subsequent releases, after which the chart is frozen and deprecated with a documented migration path. The standalone `memgraph` and `memgraph-lab` charts are unaffected. + +This MVP is deliberately "provision, bootstrap, observe": both replica counts are immutable after creation, and day-2 operations are excluded. + +## User Stories + +1. As a database operator, I want to declare my entire HA cluster as a single `MemgraphCluster` resource, so that the cluster topology lives in one reviewable, GitOps-committable object. +2. As a database operator, I want to set the number of coordinators and data instances as single integer fields, so that I don't copy-paste per-instance configuration blocks. +3. As a database operator, I want the operator to register all coordinators and data instances automatically after the pods start, so that I never run mgconsole registration commands by hand. +4. As a database operator, I want the operator to promote an initial MAIN automatically during bootstrap, so that the cluster is writable without manual promotion. +5. As a database operator, I want a data instance that lost its registration (e.g. after rescheduling onto a fresh node) to be re-registered automatically, so that transient infrastructure events don't silently degrade my cluster. +6. As a database operator, I want the operator to leave failover decisions entirely to the Raft coordinators after bootstrap, so that two control systems never fight over which instance is MAIN. +7. As a database operator, I want to see which instance is currently MAIN in the CR status, so that I can inspect cluster health with `kubectl get mgc` instead of querying coordinators. +8. As a database operator, I want status conditions telling me whether the cluster is converged (all declared instances registered and healthy), so that my monitoring and GitOps tooling can gate on it. +9. As a database operator, I want to specify the Memgraph image repository, tag, and pull policy, so that I control exactly which Memgraph version runs. +10. As a database operator, I want to reference my existing Kubernetes Secret for the enterprise license and organization name (configurable secret name and key names, matching the chart's `secrets` block), so that credentials never appear in the CR and my current Secret works unchanged. +11. As a database operator, I want to configure PVC size, access mode, and storage class for lib and log storage per role, so that storage matches my cluster's capabilities. +12. As a database operator, I want to choose whether PVCs are retained or deleted when the CR is deleted (default: retained), so that production data survives accidental deletion while dev clusters clean up after themselves. +13. As a database operator, I want to configure resource requests and limits per role, so that pods are scheduled and bounded appropriately. +14. As a database operator, I want to tune probe timings per role (startup, readiness, liveness), so that large snapshot restores don't get killed mid-load. +15. As a database operator, I want to set custom labels on pods, StatefulSets, and Services per role, so that the resources integrate with my organization's selectors and policies. +16. As a database operator, I want to configure the internal ports (bolt, management, replication, coordinator), so that I can resolve port conflicts with other workloads or policies. +17. As a database operator, I want to set the cluster domain used in advertised FQDNs, so that the operator works on clusters with a non-default DNS domain. +18. As a database operator, I want to pass additional non-secret Memgraph flags and environment variables per role, so that I can use any Memgraph flag without waiting for a typed CRD field. +19. As a database operator, I want attempts to change the coordinator or data-instance count on a live cluster to be rejected at admission time with a clear message, so that I cannot accidentally trigger an unsupported topology change in v1. +20. As a database operator, I want the operator's registration logic to be idempotent (read cluster state before issuing commands), so that an operator crash or restart mid-reconcile causes no harm. +21. As a database operator, I want to install the operator itself with a Helm chart from the existing `memgraph.github.io/helm-charts` repository, so that I use the same helm repo I already have configured. +22. As a database operator, I want the operator to run namespaced with least-privilege RBAC generated by the install chart, so that it passes my cluster's security review. +23. As a platform engineer, I want the operator to run as a non-root user with a restricted security context (matching Memgraph's uid 101 / gid 103 conventions for workload pods), so that it complies with restricted Pod Security Standards. +24. As a platform engineer, I want the CR to be safe to store in git (no secret material in spec or status), so that GitOps workflows need no redaction. +25. As a developer evaluating Memgraph, I want a minimal `MemgraphCluster` example that boots a working cluster with only image, counts, and a license secret reference, so that first contact takes minutes. +26. As a Memgraph chart user planning migration, I want the operator's secret block and knob names to mirror the chart's vocabulary where concepts carry over, so that translating my values file is mechanical. +27. As a contributor, I want the resource-building and registration-planning logic to be pure and unit-testable without a cluster, so that I can develop and verify changes quickly. +28. As a maintainer, I want every pull request gated on unit, envtest, and KinD end-to-end suites, so that broken registration logic never merges. + +## Implementation Decisions + +### Strategy and repository layout + +- The operator ultimately **replaces the HA Helm chart**: build to functional parity, publish a migration guide, then freeze the chart (security fixes only) with a deprecation timeline. Standalone and Lab charts continue independently. +- Two repositories: `helm-charts` stays untouched (its GitHub Pages URL is load-bearing for existing users); the operator lives in the existing `memgraph/kubernetes-operator` repository. The prior contents are a discarded attempt: parked on an archive branch, with a fresh kubebuilder scaffold force-pushed to `main`. +- The **operator install chart lives in the operator repository** (next to the generated CRDs so they can never drift), and the release workflow cross-publishes the packaged chart into the existing `memgraph.github.io/helm-charts` index. +- Implementation is **Go with kubebuilder** — the operator's value is state-aware reconciliation over Bolt, which helm/ansible-based operators cannot express. + +### API + +- Kind `MemgraphCluster`, group `memgraph.com`, version `v1alpha1`, short name `mgc`. +- v1alpha1 supports **HA topology only**, but topology fields are shaped so a standalone (single-instance) mode can be added later without a breaking API change — no structurally mandatory HA-only fields. +- Topology is declared as **two integer replica counts** (coordinators, data instances). Both are **immutable after creation, enforced by CEL validation** — no webhook needed for this in v1. +- All pods within a role are **uniform**; identity-dependent flags (coordinator ID, advertised addresses) are derived from the StatefulSet pod ordinal. +- Spec knobs in v1: image (repository, tag, pull policy), cluster domain, internal ports, lib/log PVC configuration per role, storage retention policy, probe timings per role, resources per role, labels per role, and a freeform non-secret env/args passthrough per role. Knob vocabulary mirrors the HA chart where the concept carries over. +- License and organization are consumed via a **secret reference block identical in shape to the chart's** (`secrets.name`, `secrets.licenseKey`, `secrets.organizationKey`). A Bolt-auth secret reference joins this block in a later version. +- **No Memgraph version enforcement**: the image tag is plain user input; the operator assumes the HA query surface (`SHOW INSTANCES`, `REGISTER INSTANCE`, `ADD COORDINATOR`, `SET INSTANCE TO MAIN`) is stable across versions. +- Storage: `storage.retentionPolicy` (`Retain` | `Delete`, default `Retain`) maps directly onto both halves of the StatefulSet PVC retention policy (`whenDeleted` and `whenScaled`), so one knob covers a claim orphaned by deleting the cluster and one orphaned by a lowered replica count. The operator carries **no finalizer-based storage cleanup** — no destructive code paths in v1. + +### Workload architecture + +- **One StatefulSet for all coordinators and one for all data instances**, each backed by a headless Service — a deliberate redesign away from the chart's StatefulSet-per-instance model. +- Workload pods keep the established Memgraph security conventions: non-root (uid 101 / gid 103), seccomp RuntimeDefault, all capabilities dropped. + +### Reconciliation semantics + +- The reconcile loop does **continuous registration reconciliation, hands-off leadership**: each reconcile queries `SHOW INSTANCES` on the coordinator leader, diffs against declared topology, and issues only the missing `ADD COORDINATOR` / `REGISTER INSTANCE` commands. Registration state that a pod loses is restored automatically. +- The operator issues `SET INSTANCE TO MAIN` **exactly once, at bootstrap** (when no MAIN exists). After that, failover belongs to the Raft coordinators; the operator only observes and reports MAIN in status. +- Reconciliation is **idempotent and read-before-write**: cluster state is always queried before commands are issued, so operator crashes or restarts mid-reconcile are harmless. +- The CR status carries the observed MAIN instance and convergence/readiness conditions. + +### Module structure + +Seven modules, with two deep pure cores and one mock seam: + +1. **API types** — `MemgraphCluster` spec/status types with CEL markers; generated CRD manifests. The public contract. +2. **Resource builders** — pure functions from spec to desired Kubernetes objects (StatefulSets, headless Services, PVC templates, probes, ordinal-derived args, secret/env wiring). No API calls, no side effects. +3. **Memgraph HA client** — a narrow Go interface (show instances, register instance, add coordinator, set main) over the Bolt driver. All higher layers depend on the interface, never the driver — this is the mock seam for testing. +4. **Registration planner** — pure diff logic: declared topology plus observed instances in, ordered registration commands out (empty when converged). The reconciliation semantics above live here. +5. **Controller** — reconciler wiring: fetch CR, server-side-apply builder output, gate on pod readiness, run planner against the HA client, write status and conditions. +6. **Operator install chart** — CRDs, RBAC, controller Deployment; cross-published to the existing helm repo index at release time. +7. **E2E harness** — multi-node KinD suite booting a real licensed Memgraph cluster. + +## Testing Decisions + +- A good test asserts **external behavior, not implementation**: given a spec, the right Kubernetes objects exist; given a topology and an observed cluster state, the right registration commands (and only those) are planned; given a degraded real cluster, it converges. Tests never assert on internal call ordering or private state. +- **Resource builders**: golden-style unit tests — spec in, expected objects out — covering defaults, overrides, and ordinal-derived identity. +- **Registration planner**: pure unit tests over topology-diff cases — fresh cluster bootstrap, single lost registration, fully converged no-op, coordinator missing, MAIN already present (no promotion issued). +- **Controller**: envtest (real kube-apiserver, no kubelet) with the HA client mocked behind its interface — asserting created resources, status updates, and that immutability violations are rejected. +- **E2E**: KinD multi-node cluster, real Memgraph images, enterprise license supplied via repository secrets (established practice from the HA chart's CI); asserts a `MemgraphCluster` reaches a registered, MAIN-elected state, and that deleting a registered pod's state leads to automatic re-registration. +- **CI gate**: unit, envtest, and e2e suites all run on every pull request. Chaos/soak testing is explicitly deferred to the separate HA chaos-testing project. +- Prior art: the HA chart's CI already boots licensed multi-node clusters (Minikube) with the license as a repository secret; the e2e suite follows that pattern on KinD. + +## Out of Scope + +- **Day-2 operations**: rolling/no-downtime upgrades, scaling (both counts are immutable in v1), backup/restore orchestration, storage-mode changes. +- **External access** of any kind (LoadBalancer, NodePort, ingress, gateway) — deliberately deferred because the approach is expected to change; v1 is in-cluster access only. +- **Embedded ingress-nginx controller installation** — permanently dropped, not just deferred; users bring their own ingress controller. +- **TLS** (bolt and intra-cluster) — later version. +- **Monitoring** (Prometheus exporter, ServiceMonitor, Grafana dashboards, vmagent/Vector integrations) — later version. +- **Bolt authentication support** (and the operator authenticating its own connections) — later version. +- **Standalone (non-HA) topology** — designed-for but not implemented in v1alpha1. +- **In-place adoption of chart-deployed clusters** — never; migration is fresh-cluster only (backup/restore or replication cutover), documented when parity is reached. +- **Affinity strategies, tolerations, init containers, sidecar/user containers, core-dump handling, snapshot-restore fields** from the chart — parity roadmap, not MVP. +- **Memgraph version compatibility logic** — no version parsing, gating, or branching. +- **Coordinator or data-instance removal** (`REMOVE COORDINATOR`, `UNREGISTER INSTANCE`) — arrives with mutable counts post-v1. + +## Further Notes + +- Parity with the HA chart is the milestone for *deprecating the chart*, not for the first release; the MVP ships early as an alpha to validate the reconciliation core while parity features land incrementally. +- Post-v1 roadmap order (indicative): data-instance scale-up (registration on grow), then scale-down with MAIN-safety, then external access, TLS, monitoring, orchestrated upgrades. +- The chaos-testing project (ArgoCD, ChaosMesh, VictoriaMetrics on EKS) is the natural proving ground for the operator's re-registration behavior once both exist. +- The full decision log behind this PRD was produced in a structured design interview on 2026-07-23 (15 resolved decisions). diff --git a/specs/operator-mvp/issues/01-repo-reset-scaffold.md b/specs/operator-mvp/issues/01-repo-reset-scaffold.md new file mode 100644 index 0000000..fa14ef3 --- /dev/null +++ b/specs/operator-mvp/issues/01-repo-reset-scaffold.md @@ -0,0 +1,25 @@ +# Repo reset + kubebuilder scaffold + CI skeleton + +**Type**: HITL — involves a destructive force-push and archiving the old attempt; a human must bless and execute the push. + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +Reset the `memgraph/kubernetes-operator` repository for the fresh operator effort. Park the existing contents (a discarded prior attempt) on an archive branch, then force-push a clean kubebuilder scaffold to `main`: Go module, `MemgraphCluster` API skeleton in group `memgraph.com/v1alpha1` (short name `mgc`), a hello-world reconciler, and the PRD plus these issues carried into the new history. + +Stand up the CI skeleton alongside: lint, unit tests, and an envtest run against the placeholder reconciler, all triggered on every pull request. The suites may be near-empty — the point is that the pipeline exists and is green before feature work starts, so every later slice lands PR-gated. + +## Acceptance criteria + +- [ ] Old repository contents preserved on an `archive/`-prefixed branch +- [ ] `main` holds a fresh kubebuilder scaffold with kind `MemgraphCluster`, group `memgraph.com`, version `v1alpha1`, short name `mgc` +- [ ] `specs/operator-mvp/` (PRD + issues) committed as part of the new history +- [ ] CI runs lint, unit, and envtest suites on every pull request and is green +- [ ] Generated CRD manifests install cleanly on a local cluster and `kubectl get mgc` resolves + +## Blocked by + +None - can start immediately diff --git a/specs/operator-mvp/issues/02-provisioning-walking-skeleton.md b/specs/operator-mvp/issues/02-provisioning-walking-skeleton.md new file mode 100644 index 0000000..06e642f --- /dev/null +++ b/specs/operator-mvp/issues/02-provisioning-walking-skeleton.md @@ -0,0 +1,26 @@ +# Provisioning walking skeleton + +**Type**: AFK + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +The first real vertical slice: applying a minimal `MemgraphCluster` — coordinator count, data-instance count, image (repository/tag/pullPolicy), and the chart-compatible secrets block (`secrets.name`, `secrets.licenseKey`, `secrets.organizationKey`) — makes the controller provision one StatefulSet for all coordinators and one for all data instances, each backed by a headless Service. Pods boot licensed Memgraph in HA roles with identity-dependent flags (coordinator ID, advertised FQDN addresses) derived from the pod ordinal. No registration yet — the demo is "apply one CR, watch licensed coordinator and data pods reach ready." + +Structure the code along the module boundaries from the PRD: pure resource builders (spec in, desired objects out — no API calls) invoked by the controller via server-side apply. Workload pods follow Memgraph security conventions: non-root uid 101 / gid 103, seccomp RuntimeDefault, all capabilities dropped. + +## Acceptance criteria + +- [ ] Applying a minimal `MemgraphCluster` produces exactly two StatefulSets (coordinators, data) with matching headless Services +- [ ] All pods reach ready with the enterprise license consumed from the referenced Secret using the configured key names +- [ ] Coordinator IDs and advertised addresses are derived from pod ordinals; all pods within a role are uniform +- [ ] Resource builders are pure and covered by golden-style unit tests (defaults and ordinal-derived identity) +- [ ] Controller behavior covered by envtest: CR in, expected objects created; re-reconcile is idempotent +- [ ] Deleting the CR removes the workloads (PVC handling comes in a later slice) + +## Blocked by + +- `01-repo-reset-scaffold.md` diff --git a/specs/operator-mvp/issues/03-bootstrap-registration.md b/specs/operator-mvp/issues/03-bootstrap-registration.md new file mode 100644 index 0000000..d9869af --- /dev/null +++ b/specs/operator-mvp/issues/03-bootstrap-registration.md @@ -0,0 +1,26 @@ +# Bootstrap registration + +**Type**: AFK + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +Make a freshly provisioned cluster become an actual HA cluster without human action. Introduce the Memgraph HA client — a narrow Go interface (show instances, add coordinator, register instance, set main) over the Bolt driver, with all higher layers depending on the interface — and the registration planner: pure diff logic that takes declared topology plus observed `SHOW INSTANCES` output and returns the ordered commands needed (empty when converged). + +Wire both into the controller: once pods are ready, query the coordinator leader, plan, and execute — adding coordinators, registering data instances, and issuing the initial MAIN promotion exactly once (only when no MAIN exists). All interaction is idempotent and read-before-write, so an operator restart mid-bootstrap is harmless. The demo: apply a CR, then `SHOW INSTANCES` on a coordinator shows every declared instance registered with one MAIN elected. + +## Acceptance criteria + +- [ ] A fresh `MemgraphCluster` converges to fully registered: all coordinators added, all data instances registered, one MAIN promoted +- [ ] MAIN promotion is issued only when no MAIN exists; an existing MAIN is never overridden +- [ ] Killing and restarting the operator mid-bootstrap still converges with no duplicate or failed registrations +- [ ] Planner covered by pure unit tests: fresh-cluster bootstrap, partially registered cluster, fully converged no-op, MAIN already present +- [ ] Controller registration flow covered by envtest with the HA client mocked behind its interface +- [ ] The HA client interface is the only place the Bolt driver is referenced + +## Blocked by + +- `02-provisioning-walking-skeleton.md` diff --git a/specs/operator-mvp/issues/04-kind-e2e-harness.md b/specs/operator-mvp/issues/04-kind-e2e-harness.md new file mode 100644 index 0000000..1a7c1fc --- /dev/null +++ b/specs/operator-mvp/issues/04-kind-e2e-harness.md @@ -0,0 +1,25 @@ +# KinD e2e harness, PR-gated + +**Type**: AFK + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +A true end-to-end suite: a multi-node KinD cluster in CI, the operator image built and deployed into it, a `MemgraphCluster` applied with real Memgraph images and the enterprise license supplied via repository secrets (the established practice from the HA chart's CI). The suite asserts the cluster reaches a registered state with a MAIN elected — the real-world proof of slices 02 and 03. + +Wire the suite into CI as a required check on every pull request, alongside the existing lint/unit/envtest gates. Keep the harness structured so later slices can add scenarios (re-registration, storage retention) as additional cases rather than new pipelines. + +## Acceptance criteria + +- [ ] CI boots a multi-node KinD cluster, builds and deploys the operator image, and applies a `MemgraphCluster` +- [ ] The suite asserts all declared instances appear registered in `SHOW INSTANCES` with exactly one MAIN +- [ ] Enterprise license flows from repository secrets; no secret material appears in logs or the repo +- [ ] The e2e job is a required PR check and passes on the current main +- [ ] Adding a new e2e scenario requires only a new test case, not pipeline changes + +## Blocked by + +- `03-bootstrap-registration.md` diff --git a/specs/operator-mvp/issues/05-continuous-re-registration.md b/specs/operator-mvp/issues/05-continuous-re-registration.md new file mode 100644 index 0000000..b46fea6 --- /dev/null +++ b/specs/operator-mvp/issues/05-continuous-re-registration.md @@ -0,0 +1,27 @@ +# Continuous re-registration + +**Type**: AFK + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +The operator's reason to exist over the chart's one-shot setup Job: registration state that a pod loses (rescheduled onto a fresh node, wiped storage) is restored automatically. Extend the registration planner beyond bootstrap to full diff semantics — every reconcile compares declared topology against observed `SHOW INSTANCES` and issues only the missing registrations. Leadership stays hands-off: the planner never emits a MAIN promotion when a MAIN already exists; failover belongs to the Raft coordinators. + +Ensure the controller re-reconciles on relevant events (pod changes, periodic resync) so drift is detected without manual triggers. The demo: forcibly de-register or wipe a data instance, watch the operator converge the cluster back to fully registered with no human action. + +## Acceptance criteria + +- [ ] A data instance whose registration is lost is automatically re-registered on a subsequent reconcile +- [ ] A missing coordinator is automatically re-added +- [ ] A converged cluster produces zero commands on reconcile (verified no-op) +- [ ] No MAIN promotion is ever issued while a MAIN exists, including during recovery +- [ ] Planner unit tests cover: single lost registration, multiple lost, converged no-op, recovery with MAIN present +- [ ] E2E scenario: wipe one instance's registration state, assert the cluster converges back to fully registered + +## Blocked by + +- `03-bootstrap-registration.md` +- `04-kind-e2e-harness.md` diff --git a/specs/operator-mvp/issues/06-status-and-conditions.md b/specs/operator-mvp/issues/06-status-and-conditions.md new file mode 100644 index 0000000..2e30178 --- /dev/null +++ b/specs/operator-mvp/issues/06-status-and-conditions.md @@ -0,0 +1,25 @@ +# Status & conditions + +**Type**: AFK + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +Make cluster health inspectable without querying coordinators by hand. The CR status subresource reports the observed MAIN instance and standard conditions expressing convergence (all declared instances registered) and readiness. Status is observation only — it carries no secret material and is never used as reconcile input state. + +Add printer columns so `kubectl get mgc` answers the everyday questions at a glance: coordinator count, data-instance count, current MAIN, ready/converged state, age. GitOps tooling and monitoring should be able to gate on the conditions. + +## Acceptance criteria + +- [ ] Status reports the currently observed MAIN instance and updates when failover changes it +- [ ] Conditions express convergence and readiness, transitioning correctly through bootstrap, converged, and degraded states +- [ ] `kubectl get mgc` shows counts, MAIN, readiness, and age via printer columns +- [ ] Status updates use the status subresource and never modify spec +- [ ] Envtest coverage: status reflects mocked cluster states (bootstrapping, converged, degraded) + +## Blocked by + +- `03-bootstrap-registration.md` diff --git a/specs/operator-mvp/issues/07-cel-immutability-validation.md b/specs/operator-mvp/issues/07-cel-immutability-validation.md new file mode 100644 index 0000000..c1dfa98 --- /dev/null +++ b/specs/operator-mvp/issues/07-cel-immutability-validation.md @@ -0,0 +1,23 @@ +# CEL immutability + validation + +**Type**: AFK + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +Enforce the v1 contract that topology is fixed at creation: CEL validation rules on the CRD reject any change to the coordinator count or data-instance count on a live cluster, with a clear admission-time message telling the user scaling is not yet supported. Add creation-time validation (sane count ranges, required fields) and spec defaulting so a minimal CR is valid — all through CRD machinery, no admission webhook in v1. + +## Acceptance criteria + +- [ ] Updating either replica count on an existing `MemgraphCluster` is rejected at admission with a message stating counts are immutable in v1 +- [ ] Invalid counts and missing required fields are rejected at creation with actionable messages +- [ ] Optional fields receive documented defaults; a minimal CR (counts, image, secrets) validates +- [ ] No admission webhook is introduced; all rules live in the CRD schema +- [ ] Envtest coverage: mutation attempts rejected, valid creates accepted, defaults materialize + +## Blocked by + +- `02-provisioning-walking-skeleton.md` diff --git a/specs/operator-mvp/issues/08-storage-configuration.md b/specs/operator-mvp/issues/08-storage-configuration.md new file mode 100644 index 0000000..e78301d --- /dev/null +++ b/specs/operator-mvp/issues/08-storage-configuration.md @@ -0,0 +1,24 @@ +# Storage configuration + +**Type**: AFK + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +Give users control over persistence, mirroring the HA chart's storage vocabulary per role: lib and log PVC size, access mode, and storage class for coordinators and data instances. Add `storage.retentionPolicy` (`Retain` | `Delete`, default `Retain`) mapped directly onto the StatefulSet PVC retention policy, so deleting the CR preserves data by default while dev clusters can opt into self-cleanup. The operator carries no finalizer-based storage cleanup of its own — the StatefulSet machinery is the only deleter. + +## Acceptance criteria + +- [ ] PVC size, access mode, and storage class are configurable per role for lib and log volumes +- [ ] Default retention: deleting the CR leaves PVCs behind +- [ ] With `Delete` retention, deleting the CR removes the PVCs via StatefulSet retention machinery +- [ ] No operator-owned finalizer performs storage deletion +- [ ] Builder golden tests cover storage defaults, overrides, and both retention policies +- [ ] E2E scenario: default-retention CR deletion leaves PVCs intact + +## Blocked by + +- `02-provisioning-walking-skeleton.md` diff --git a/specs/operator-mvp/issues/09-pod-tuning-knobs.md b/specs/operator-mvp/issues/09-pod-tuning-knobs.md new file mode 100644 index 0000000..dc25b1e --- /dev/null +++ b/specs/operator-mvp/issues/09-pod-tuning-knobs.md @@ -0,0 +1,26 @@ +# Pod-tuning knobs + +**Type**: AFK + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +The remaining v1 configuration surface, per role, using the HA chart's vocabulary where concepts carry over: probe timings (startup, readiness, liveness — probe type stays fixed to the established TCP-socket convention), resource requests/limits, custom labels on pods/StatefulSets/Services, internal ports (bolt, management, replication, coordinator), the cluster domain used in advertised FQDNs, and a freeform non-secret env/args passthrough so any Memgraph flag is usable without a typed field. + +Ports and cluster domain are the delicate part: they feed the ordinal-derived advertised addresses, so changing them must flow consistently through builders, registration planning, and the HA client's connection targets. + +## Acceptance criteria + +- [ ] Probe timings, resources, and labels are configurable per role and land on the right objects +- [ ] Internal ports and cluster domain are configurable and propagate consistently to container ports, Services, advertised addresses, and registration commands +- [ ] Non-secret env vars and extra args pass through per role; secret material remains only in the secrets block +- [ ] A CR with all knobs defaulted behaves identically to before this slice +- [ ] Builder golden tests cover each knob's default and override, including non-default ports/domain flowing into advertised addresses +- [ ] Planner unit tests confirm registration commands use the configured ports and domain + +## Blocked by + +- `02-provisioning-walking-skeleton.md` diff --git a/specs/operator-mvp/issues/10-operator-install-chart.md b/specs/operator-mvp/issues/10-operator-install-chart.md new file mode 100644 index 0000000..c64aac1 --- /dev/null +++ b/specs/operator-mvp/issues/10-operator-install-chart.md @@ -0,0 +1,24 @@ +# Operator install chart + +**Type**: AFK + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +The Helm chart users install the operator with, living in this repository next to the generated CRDs so they can never drift from the controller version. The chart ships the CRDs, a least-privilege RBAC set scoped to what the controller actually touches (its CRD, StatefulSets, Services, Secrets read, events, leases), and the controller Deployment running non-root with a restricted security context. `helm install` from the local chart on a clean cluster must be the complete install story. + +## Acceptance criteria + +- [ ] `helm install` from the local chart on a clean cluster yields a running operator that reconciles a `MemgraphCluster` +- [ ] CRDs in the chart are generated from the Go types in the same commit — no hand-edited copies +- [ ] RBAC grants only the verbs/resources the controller uses; the e2e suite passes under that RBAC +- [ ] Controller pod runs non-root with a restricted security context +- [ ] Chart lints clean and install/uninstall is exercised in CI +- [ ] Image tag/repository, resources, and namespace are configurable chart values + +## Blocked by + +- `02-provisioning-walking-skeleton.md` diff --git a/specs/operator-mvp/issues/11-release-cross-publish.md b/specs/operator-mvp/issues/11-release-cross-publish.md new file mode 100644 index 0000000..5394e7b --- /dev/null +++ b/specs/operator-mvp/issues/11-release-cross-publish.md @@ -0,0 +1,36 @@ +# Release pipeline + cross-publish + +**Type**: HITL — requires an org-level fine-grained PAT or deploy key for the helm-charts repository, which only a maintainer can create and store. + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +The release path from a version tag to installable artifacts: build and push the operator container image, package the install chart, and cross-publish the packaged chart into the existing `memgraph.github.io/helm-charts` index — so users install the operator from the same helm repository they already have configured, while chart source and CRDs stay in this repository. Chart version, image tag, and git tag stay in lockstep per release. + +## Acceptance criteria + +- [x] Pushing a version tag builds and publishes the operator image with that version +- [x] The same pipeline packages the install chart and publishes it into the `memgraph.github.io/helm-charts` index +- [x] `helm repo update && helm install` from the existing Memgraph helm repo installs the tagged operator version end-to-end +- [x] The appVersion and the operator image tag agree for every release, and the chart's own version is bumped whenever the chart changes +- [x] The cross-repo credential is a scoped fine-grained PAT or deploy key stored as a repository secret, documented for rotation +- [x] A dry-run/prerelease path exists to validate the pipeline without polluting the public index + +> **Amended during implementation.** The fourth criterion originally read "Chart version, +> appVersion, and image tag agree for every release" — chart and operator versions locked in +> lockstep. That was relaxed to the Helm-standard arrangement, where the chart's `version` and its +> `appVersion` move independently, so a chart-only fix (a template bug, a new values knob) does +> not require a redundant operator release. The invariant that remains is the load-bearing one: +> `appVersion` is the operator image tag, and the image it names must exist. +> +> Tags therefore name the artifact they release — `v` the operator, `chart-` the +> chart alone. The known cost is that a chart-only release can ship CRDs generated after the +> operator its `appVersion` installs; the pipeline reports that drift in the run summary rather +> than refusing it. See `docs/releasing.md`. + +## Blocked by + +- `10-operator-install-chart.md` diff --git a/specs/operator-mvp/issues/12-quickstart-docs.md b/specs/operator-mvp/issues/12-quickstart-docs.md new file mode 100644 index 0000000..fd498f3 --- /dev/null +++ b/specs/operator-mvp/issues/12-quickstart-docs.md @@ -0,0 +1,24 @@ +# Quickstart example + README + +**Type**: AFK + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +The minutes-to-cluster story for a developer evaluating Memgraph: a README walkthrough covering install (operator chart), a minimal `MemgraphCluster` example manifest (counts, image, license secret reference — everything else defaulted), and how to verify the cluster (`kubectl get mgc`, connecting over Bolt). Document the v1 contract honestly: counts are immutable, day-2 operations / external access / TLS / monitoring are not yet supported, and the operator never interferes with coordinator-driven failover. State the relationship to the HA Helm chart (operator is its successor; migration is fresh-cluster only, guide to come at parity). + +## Acceptance criteria + +- [ ] A newcomer can go from empty cluster to a registered, MAIN-elected Memgraph HA cluster following only the README +- [ ] The minimal example manifest works verbatim with only the license Secret substituted +- [ ] v1 limitations (immutable counts, deferred features) are stated explicitly +- [ ] Verification steps show expected `kubectl get mgc` output and a Bolt connection +- [ ] The example manifest is exercised in CI so it cannot rot + +## Blocked by + +- `04-kind-e2e-harness.md` +- `06-status-and-conditions.md` diff --git a/specs/operator-mvp/issues/13-coordinator-leader-required.md b/specs/operator-mvp/issues/13-coordinator-leader-required.md new file mode 100644 index 0000000..63d7039 --- /dev/null +++ b/specs/operator-mvp/issues/13-coordinator-leader-required.md @@ -0,0 +1,30 @@ +# Require a coordinator leader before acting + +**Type**: AFK + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +Close a stale-view hole in `observeCluster`: it currently treats a coordinator that reports no leader as the fresh-cluster bootstrap case and uses that coordinator's `SHOW INSTANCES` view as the planning input. The premise is wrong. A fresh coordinator's initial Raft config contains exactly itself (`coordinator_state_manager.cpp:133-134`) and it is started as the leader of that one-member cluster (`raft_state.cpp:377-380` — "By setting it to false, all coordinators are started as leaders"), and the role column is derived from `GetLeaderId()` on the leader *and* follower paths alike (`coordinator_instance.cpp:227-228`). So a fresh coordinator always names itself leader, and an absent leader means `GetLeaderId() == -1`: quorum lost, or a coordinator that stepped down or was removed from the Raft cluster. Such a coordinator answers from its own stale state machine (`ShowInstancesStatusAsFollower`), and every mutating query the operator could issue needs `LEADER_READY` or forwards to a leader — so acting on that view plans against a cluster state that is not current and cannot be written to anyway. + +Use a coordinator's view only when it names a leader: itself, or another coordinator the operator then redirects to. Skip coordinators that name none, and when no coordinator names one, issue nothing and requeue. Report it as its own reason, `NoCoordinatorLeader`, rather than folding it into `CoordinatorUnreachable` — reachable coordinators without quorum and unreachable pods have different remedies. Widen the leader-by-name lookup so a reported leader outside the declared coordinator set resolves instead of failing, which also removes the "reported leader X, which is not declared" dead end and is the prerequisite for `16-coordinator-scale-down.md`, where the leader may be a coordinator on its way out. + +The fallback has been masking an unfaithful test seam, so `fakeMemgraph` is reworked with it. Today a fresh cluster is modelled as an empty `SHOW INSTANCES` and `ADD COORDINATOR` appends a row with role `follower`, so even a fully bootstrapped fake cluster has no leader at all, and both bootstrap tests pass only because the fallback swallows it — meaning the planner's empty-`bolt_server` rule (`planner.go:104-109`) is never exercised in the controller tests. The fake must serve a fresh coordinator's own row with role `leader` and an empty `bolt_server`, and `ADD COORDINATOR` must fill that server in rather than invent a row. + +## Acceptance criteria + +- [ ] A coordinator view naming no leader is never used as planning input; the next coordinator is tried instead +- [ ] When no coordinator names a leader, no command is issued, `Ready` and `Converged` go False with reason `NoCoordinatorLeader`, and the reconcile requeues +- [ ] A leader named outside the declared coordinator set is resolved and used, not rejected +- [ ] `fakeMemgraph` serves a fresh coordinator's own row as `leader` with an empty `bolt_server`, and `ADD COORDINATOR` fills the bolt server in +- [ ] Bootstrap envtest cases pass against the faithful fake, exercising the empty-`bolt_server` rule end to end +- [ ] Envtest coverage: coordinators reporting no leader (stale follower view) produce zero commands +- [ ] The comment claiming a fresh cluster has no leader because Raft is not yet formed is corrected + +## Blocked by + +- `05-continuous-re-registration.md` +- `06-status-and-conditions.md` diff --git a/specs/operator-mvp/issues/14-topology-scale-up.md b/specs/operator-mvp/issues/14-topology-scale-up.md new file mode 100644 index 0000000..b20b408 --- /dev/null +++ b/specs/operator-mvp/issues/14-topology-scale-up.md @@ -0,0 +1,33 @@ +# Mutable topology: scale-up + +**Type**: AFK + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +Make both replica counts mutable and support growing a live cluster, reversing the contract `07-cel-immutability-validation.md` established. The CEL immutability rules come off `coordinators` and `dataInstances`; the floors replace them: coordinators get `Minimum=3` (a hard floor at creation as well as on update — this operator builds real HA clusters) with the existing odd rule retained, data instances keep `Minimum=1`. Nothing else constrains a change: any odd coordinator target ≥ 3, any data target ≥ 1, both roles changeable in one edit, any step size. Bootstrap already issues three `ADD COORDINATOR`s back to back, so sequential Raft config changes are proven; a transient Raft rejection is a retryable error the next reconcile clears. + +Growth itself needs almost nothing new — the planner's existing diff already emits `ADD COORDINATOR` and `REGISTER INSTANCE` for declared members it does not observe. What this issue builds is the structure the shrink slices then hang off, so the API is cut once. Resource builders take the replica count as an argument (`CoordinatorStatefulSet(cluster, replicas)`), keeping them pure while the controller decides the count: `declared` when growing or unchanged, and deliberately `current` when `declared < current`, so this issue can never shrink a StatefulSet. `planner.Topology` gains `RetiringCoordinators` and `RetiringDataInstances`, empty here and populated by 15 and 16. The promotion rule is tightened while it is being touched: prefer the lowest-ordinal declared instance observed `up`, falling back to `declared[0]`, which also closes the bootstrap hole where `SET INSTANCE TO MAIN` against a down instance only writes Raft state and leaves the cluster MAIN-less until the coordinators retry. + +Observability and storage semantics move with the API. `status.coordinators` and `status.dataInstances` publish the registered counts as observed (pure observation — status stays out of reconcile input), surfaced as `priority=1` print columns so the default table stays narrow. `Converged` widens from "registration matches the declared topology" to "registration matches *and* both StatefulSets' replicas equal the declared counts", so `kubectl wait --for=condition=Converged` means the scale is genuinely finished. `persistentVolumeClaimRetentionPolicy.whenScaled` stops being hard-wired `Retain` and follows `spec.storage.retentionPolicy`, one knob meaning one thing; the comment at `statefulset.go:381-386` claiming nothing ever scales down is deleted. + +Collateral: the retention e2e declares `coordinators: 1`, now rejected, so it moves to 3 and its PVC assertion moves from 4 to 8; the envtest immutability cases invert to acceptance and gain floor rejections; `make manifests generate chart-sync` regenerates the CRDs and the chart's copy; README's scaling limitation section is rewritten. + +## Acceptance criteria + +- [ ] Increasing either count on a live `MemgraphCluster` is accepted and the new members are registered without human action +- [ ] `coordinators` below 3 or even is rejected at creation and on update; `dataInstances` below 1 is rejected +- [ ] Resource builders remain pure, taking the replica count as an argument; unit tests cover the count the controller derives, including that a lower declared count never shrinks the applied StatefulSet +- [ ] A MAIN promotion targets the lowest-ordinal declared instance observed `up`, with `declared[0]` as fallback; planner unit tests cover a down `instance_0` +- [ ] `status.coordinators` and `status.dataInstances` report observed registered counts and appear as `priority=1` print columns +- [ ] `Converged` is False while a StatefulSet's replicas differ from the declared count, True once registration and both replica counts match +- [ ] `whenScaled` follows `spec.storage.retentionPolicy`; builder tests cover both values +- [ ] E2E: a dedicated scaling cluster in its own namespace (`createLogStorageClaim: false`, explicit small resource requests, teardown awaited) grows 3/2 to 5/3 and reaches `Converged` with every new member registered +- [ ] Retention e2e updated for the coordinator floor; chart CRDs regenerated and `make chart-verify` green + +## Blocked by + +- `13-coordinator-leader-required.md` diff --git a/specs/operator-mvp/issues/15-data-instance-scale-down.md b/specs/operator-mvp/issues/15-data-instance-scale-down.md new file mode 100644 index 0000000..2f77237 --- /dev/null +++ b/specs/operator-mvp/issues/15-data-instance-scale-down.md @@ -0,0 +1,34 @@ +# Mutable topology: data-instance scale-down + +**Type**: AFK + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +Shrink the data-instance count, which means unregistering members Memgraph will not let go of while one of them is MAIN (`UNREGISTER INSTANCE` fails with `IS_MAIN`). A StatefulSet sheds only its highest ordinals — `spec.ordinals.start` slides the window but never removes a member from the middle — so the operator never chooses a victim: the retiring set is always the top ordinals, and the job is to make them safe to remove. Retiring ordinals are `[spec.dataInstances, liveStatefulSet.spec.replicas)`, derived from the operator's own prior apply, which bounds the range exactly and keeps the guarantee that an instance a human registered is never touched. + +The sequence runs inside one reconcile pass against one leader connection, because the planner can predict every intermediate state. `DEMOTE INSTANCE` is emitted only when a retiring instance is observed as MAIN, and it deliberately leaves the cluster MAIN-less: Memgraph triggers automatic failover only on a coordinator leadership change with zero MAINs or when the current MAIN fails its ping (`coordinator_instance.cpp:496`, `:1341`), so a manual demote hands the promotion choice back to the operator, which promotes the lowest-ordinal surviving instance observed `up` through the rule `14-topology-scale-up.md` already installed. `UNREGISTER INSTANCE` then removes each retiring member. Replicas are SYNC — `REGISTER INSTANCE` is issued without `AS ASYNC` or `AS STRICT_SYNC` — so promoting a survivor after a clean demote is not a data-loss gamble, and the MAIN-less window is the few milliseconds between two queries in the same pass. + +Unregistration happens before the pods go, so the coordinators never see a registered instance disappear. The smaller replica count is applied in exactly one place: at the end of the registration phase, once the plan is empty. That keeps the pre-apply replica rule from `14` free of any cluster knowledge — it never shrinks — and makes the shrink converge across two passes without a second observation source. The readiness gate stays strict: every pod of the held-at-current StatefulSet must be ready, retiring pods included, so a retiring pod that cannot become ready blocks its own removal and the resource says so rather than acting on a half-known cluster. That is a deliberate trade and belongs in the docs. + +Errors are not special-cased. Read-before-write means `ALREADY_REPLICA` or `NO_INSTANCE_WITH_NAME` only appear when something raced the operator; they surface, the reconcile retries, and the plan is recomputed from a fresh observation. + +## Acceptance criteria + +- [ ] Decreasing `dataInstances` unregisters the retiring instances and then shrinks the StatefulSet, in that order +- [ ] A retiring instance observed as MAIN is demoted and a surviving instance is promoted before it is unregistered; no `UNREGISTER INSTANCE` is ever issued against an observed MAIN +- [ ] Demote, promote and unregister issue within a single reconcile pass against one leader connection +- [ ] The StatefulSet is shrunk only after the plan is empty; a pass with pending commands never lowers the replica count +- [ ] Instances the cluster knows but the spec never declared and that fall outside the retiring range are left untouched +- [ ] `Converged` is False with reason `RetirementInProgress`, naming the retiring instances, until the pods are gone +- [ ] Planner unit tests: MAIN on a retiring ordinal, MAIN on a survivor, several retiring at once, shrink to a single instance, already-unregistered retiring member, mixed grow-and-shrink across roles +- [ ] Retiring PVCs follow `spec.storage.retentionPolicy` on scale-down +- [ ] E2E: on the scaling cluster, `instance_2` is deliberately made MAIN, then the count drops to 2 — MAIN moves to a survivor, `instance_2` is unregistered before its pod terminates, and the cluster reaches `Converged` +- [ ] Docs state that a retiring pod which cannot become ready blocks its own removal + +## Blocked by + +- `14-topology-scale-up.md` diff --git a/specs/operator-mvp/issues/16-coordinator-scale-down.md b/specs/operator-mvp/issues/16-coordinator-scale-down.md new file mode 100644 index 0000000..f5de02c --- /dev/null +++ b/specs/operator-mvp/issues/16-coordinator-scale-down.md @@ -0,0 +1,33 @@ +# Mutable topology: coordinator scale-down + +**Type**: AFK + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +Shrink the coordinator count, which means removing Raft members — and Raft refuses to remove its own leader (`REMOVE COORDINATOR` returns `RAFT_CANNOT_REMOVE_LEADER`). Since a StatefulSet sheds only its highest ordinals, the leader may well sit on one of them, so the operator moves leadership out of the retiring range first. `YIELD LEADERSHIP` is the lever (`MemgraphCypher.g4:669`): it must be issued *on* the current leader, which is the connection the controller already holds, and it cannot name a successor — `yield_leadership()` is called with no successor argument (`raft_state.cpp:557`), so NuRaft picks. That makes it the one command whose outcome the planner cannot predict, so it is always emitted **last** in a plan and is terminal: the controller stops after it and requeues to re-observe under whichever coordinator won. Everything the planner could safely order before it still issues in that same pass. Retiring ordinals are `[spec.coordinators, liveStatefulSet.spec.replicas)`, and because coordinators must stay odd and at or above 3, a shrink always retires an even number of them. + +Raft membership is given up before the pods are, so no removed member's pod outlives its vote. As in `15-data-instance-scale-down.md`, the smaller replica count is applied once the plan is empty, and the strict readiness gate still covers the retiring pods. + +A coordinator removed from Raft keeps running and keeps its state on purpose, and this is what makes re-growing safe. On committing the config that drops it, NuRaft fires `RemovedFromCluster` and sets `steps_to_down_ = 2` (`handle_commit.cxx:725-750`); after two election timeouts it persists `allow_election_timer(false)` and cancels its schedulers (`handle_timeout.cxx:208-234`). It never calls `system_exit`, so the container does not die and the readiness gate is not tripped — it simply goes dormant, still serving Bolt, not campaigning. Two comments state the intent outright: the removal of self from the persisted config is deliberately disabled "for the next launch", and the persistent election-timer flag exists "for the case re-joining this replica to the original cluster". A later `ADD COORDINATOR` is accepted unconditionally by `handle_join_cluster_req` (`handle_join_leave.cxx:138-196`), which takes the leader's term, saves the new config and resets commit indices; with `snapshot_distance_ = 5` and `reserved_log_items_ = 5` (`raft_state.cpp:325-326`) the leader's log is compacted hard enough that a rejoiner almost always receives a snapshot install, replacing its state machine wholesale. A dormant server appends nothing, so its log is always a prefix of the leader's and cannot diverge. No PVC wipe, no removal bookkeeping and no re-add guard are needed; a re-added coordinator on a retained volume is in the same position as one whose pod crashed and stayed down. Verify this once by hand during this issue rather than gating it in CI. + +The stale view a dormant coordinator would otherwise serve is already handled: `13-coordinator-leader-required.md` requires a named leader before any view is used, and a stepped-down coordinator reports none. + +## Acceptance criteria + +- [ ] Decreasing `coordinators` removes the retiring members from Raft and then shrinks the StatefulSet, in that order +- [ ] When the observed leader is retiring, the plan ends with `YIELD LEADERSHIP` and nothing after it; the following pass re-observes and removes under the new leader +- [ ] `REMOVE COORDINATOR` is never issued against the observed leader +- [ ] The StatefulSet is shrunk only after the plan is empty; a pass with pending commands never lowers the replica count +- [ ] `Converged` is False with reason `RetirementInProgress`, or `LeadershipTransferInProgress` while a yield is pending, until the pods are gone +- [ ] Planner unit tests: leader on a retiring ordinal, leader on a survivor, two coordinators retiring at once, an already-removed retiring member, a retiring coordinator alongside retiring data instances +- [ ] Retiring PVCs follow `spec.storage.retentionPolicy` on scale-down +- [ ] E2E: on the scaling cluster, leadership is forced onto `coordinator_4` via `YIELD LEADERSHIP`, then the count drops to 3 — leadership moves to a survivor, both retiring members leave the Raft cluster, and the cluster reaches `Converged` +- [ ] Manual verification recorded in this issue: shrink 5 to 3 and re-grow to 5 under `Retain`, confirming `SHOW INSTANCES` converges with the retained coordinator volumes + +## Blocked by + +- `15-data-instance-scale-down.md` diff --git a/specs/operator-mvp/issues/17-sequenced-rolling-restart.md b/specs/operator-mvp/issues/17-sequenced-rolling-restart.md new file mode 100644 index 0000000..57590dc --- /dev/null +++ b/specs/operator-mvp/issues/17-sequenced-rolling-restart.md @@ -0,0 +1,60 @@ +# Sequenced rolling restart: no-downtime upgrades + +**Type**: AFK + +## Parent + +`specs/operator-mvp/PRD.md` + +## What to build + +Replace Kubernetes' own pod replacement for both roles with a sequence the operator drives, so that a changed pod template — a new Memgraph image above all, but equally a resource limit, an env var or a probe timing — is rolled through the cluster without ever taking down an instance the cluster still needs. The trigger is deliberately not "the image changed": it is `pod.metadata.labels["controller-revision-hash"] != sts.Status.UpdateRevision`, because `updateStrategy: OnDelete` means nothing but the operator will ever restart a pod again, and a template change nobody rolls is a cluster frozen on an old spec with nothing reporting it. What this issue builds is therefore a *sequenced rolling restart*; upgrades are its reason to exist, not its scope. + +The default `RollingUpdate` cannot express the required order and no amount of `partition` fixes it. Kubernetes sweeps highest ordinal to lowest, and `partition` is a descending cutoff rather than a set, so a MAIN sitting on any ordinal but 0 is restarted somewhere in the middle of the sweep. Each such restart costs a failover, and the instance Raft promotes is arbitrary with respect to the sweep, so a five-instance cluster can pay up to four write outages for one upgrade. `PodManagementPolicy: ParallelPodManagement` (`statefulset.go:393`) does not enter into it — it governs creation and scaling, never updates. So both role StatefulSets move to `OnDelete` and the operator owns every pod deletion from then on, permanently. That is the real cost of this issue and it is accepted knowingly: if the operator is down, no pod template ever takes effect, and nothing in Kubernetes will say so. + +**This feature is unsafe on any Memgraph release that reports `role=unknown` for a data instance the coordinator leader cannot reach.** Deleting the MAIN's pod vacates its `main` row, `hasMain` (`planner.go:315-324`) goes false, and `Plan` takes the branch at `planner.go:354` to emit `SetInstanceToMain{promotionTarget(...)}` — the operator racing the coordinators' own failover on every single upgrade, which is exactly what the PRD forbids in user story 6 and the promotion rule at line 84. The prerequisite is the core change that makes a dead MAIN report `role=main, health=down`, keeping `hasMain` true so the planner stays out of the way. The PRD rules out detecting this at runtime ("no version parsing, gating, or branching"), and no `DefaultImageTag` floor is introduced here: which operator version is safe against which Memgraph release is documented outside this repository. + +The order is data plane first, then coordinators, and it is re-derived from observation on every pass rather than tracked in status. Among data instances: delete any outdated pod that is not the observed MAIN, highest ordinal first to match the StatefulSet convention, and only when the MAIN is the last outdated pod left is it deleted. Among coordinators: delete any outdated pod that is not the observed leader, and the leader last. Deriving both rules each pass is what makes leadership moving spontaneously mid-roll, or a spec reverted halfway through, self-correcting with no state to unwind — a revert simply inverts which pods are outdated and the roll walks back. Killing the coordinator leader costs the data plane nothing: per `15-data-instance-scale-down.md`, failover needs a leadership change *with zero MAINs* or a MAIN ping failure, and the data plane is whole throughout the coordinator roll. The operator's own connection dies with the pod it deletes, which is a requeue, not an error. + +Exactly one pod is down at a time, and what allows the next one to go is not pod readiness. Probes are TCP-socket checks against the bolt port (`statefulset.go:464`), so `Ready` says the port is open and nothing about the instance having rejoined replication. A restarted data instance needs no `REGISTER INSTANCE` — registration lives in the coordinators' Raft log, which is why `wipeInstanceRegistration` (`memgraphcluster_test.go:826`) simulates its loss with `UNREGISTER INSTANCE` rather than by touching a volume — but it does need the coordinator to re-attach it, and on a fresh volume it needs a full snapshot resync. So the per-step gate is: pod `Ready`, plus the leader observing it `up` with `role=replica`, plus `SHOW REPLICATION LAG` reporting it caught up through the existing `ReplicationLag.IsCaughtUp` (`client.go:126`). Waiting out a resync is the point, not a regression: it is what keeps recent writes on more than one machine for the whole roll. For coordinators the gate is leader-reported `health=up`, which with `coordinators >= 3` and one pod down at a time *is* the quorum question. `coordinatorRegistered` (`planner.go:484`) cannot serve here — it tests Raft membership, which a pod restart leaves untouched, so it never goes false. + +The MAIN's pod is deleted with no write fence, and the coordinators promote. The precondition is one fresh check: at least one data instance the leader observes `up`, other than the MAIN, is reported caught up. That is sufficient only because core promotes the most up-to-date alive instance — whoever wins is then at least as current as the instance that was verified, which also closes the case of a stale replica returning inside the failover window. `SET COORDINATOR SETTING "global_read_only"` was considered and rejected: the residual exposure without it is transactions committed after a caught-up SYNC replica silently fell behind, inside the few hundred milliseconds between the check and the deletion — the exposure an ordinary MAIN crash already carries, which registering replicas SYNC rather than `STRICT_SYNC` already accepts, and this path at least gets a caught-up check that a crash never does. The latch, by contrast, is cluster-wide and persisted in Raft, and the promoted MAIN inherits it, so writes would resume not when Raft promotes but when the operator next reconciles and clears it — trading an outage bounded by core for a longer one bounded by the operator, which strands the cluster read-only indefinitely if the operator dies in that window. `DEMOTE INSTANCE` as a self-clearing fence was rejected for the same reason it cannot be improved: `SHOW REPLICATION LAG` is served by the MAIN, so lag cannot be measured after a demote (`planner.go:124-126`), and the gain over doing nothing is one query's width. + +The decision itself lives in a new pure package, `internal/rollout` — an eighth module alongside the resource builders and the registration planner. It takes both roles' pods reduced to `{name, revisionHash, ready}`, each StatefulSet's `UpdateRevision`, the observed `[]memgraph.Instance` and `[]memgraph.ReplicationLag`, and returns exactly **one** action: `Done`, `Wait(reason)`, or `Delete(pod)`. Both roles go in together so the data-before-coordinators ordering and the MAIN-last and leader-last rules are unit-testable rather than living in controller flow. One action per pass and never a list, because every step must be re-gated on fresh observation and a precomputed list would act on stale lag; after a `Delete` the controller returns and requeues rather than deciding again against a cache that still shows the pod `Ready`. + +Wiring it needs the readiness gate loosened by exactly the absence the operator causes and no more. `reconcileRegistration` returns at `controller.go:327-338` before ever connecting to a coordinator, so a naive wiring deletes one pod and then never observes anything again — while the decision to proceed needs a live `SHOW INSTANCES` and `SHOW REPLICATION LAG` with one data pod deliberately down. `workloadsReady` (`controller.go:641`) therefore tolerates one *existing but unready* pod in a role, and only when that role is mid-roll (some pod's revision differs from `UpdateRevision`) **and** all of its pods exist (`sts.Status.Replicas == role.applied`). Both conditions are load-bearing: a blanket "tolerate one unready pod" would reintroduce precisely the informer-lag bug that function's doc comment warns about, where a 3→4 scale-up's stale `readyReplicas=3` against `applied=4` reads as ready and registration runs against a pod the API server has not been asked to create. The registration planner keeps running throughout a roll, on the same leader connection and the same observation; it should return empty on every pass, and if it ever does not, that is real drift — a coordinator that lost its Raft state, say — which the cluster wants repaired now rather than after a resync that can take hours. Scale converges first and pauses the roll: no roll step while the plan is non-empty, `planner.Retired` is false, or applied counts differ from spec, so the retirement's own MAIN handover (`DEMOTE INSTANCE` plus `SetInstanceToMain`) is never moving MAIN at the same time as this is. + +Observability follows the house style: one new steady-state condition `Updated`, True when every pod of both roles sits at its StatefulSet's `UpdateRevision` and False carrying the `rollout.Wait` reason otherwise, with new reasons `RollingRestartInProgress` and `WaitingForCatchUp` and reuse of `NoCaughtUpSurvivor` (`types.go:199`) for the stall and `WorkloadsNotReady` for a pod on its way back. `Converged` keeps meaning what it means; folding a roll into it would leave a user unable to tell registration drift from an upgrade merely walking. No progress counters are mirrored into the CR — `status.updatedReplicas`, `currentRevision` and `updateRevision` already carry them on the StatefulSets — and no Events, since this operator has no recorder and communicates through conditions alone. The core prerequisite forces one correction here: `observedMain` (`controller.go:547`) must require `IsMain() && IsUp()`, or a dead MAIN keeps `Ready=True` through the whole failover window while `readyOrNot`'s comment claims the cluster serves writes. `Ready` consequently goes False briefly during every upgrade, which is correct and is the one thing the docs must say plainly. + +Two pieces of collateral. Pod-level reads are unavoidable — `sts.Status.updatedReplicas` gives a count, never which pods — so the manager gains a Pod informer scoped by label selector to `app.kubernetes.io/managed-by=memgraph-operator` (`resources.go:73`, present on every pod the builders emit) in `cmd/main.go:158`, bounding its cache to this operator's own pods instead of every pod in the cluster; and a new `+kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;delete` marker joins `controller.go:89-93`, widening the ClusterRole users install with **delete on pods** — worth naming in the chart's release notes. `Owns(&appsv1.StatefulSet{})` already wakes the controller when `readyReplicas` and `updatedReplicas` move, so a roll gets event-driven wakeups with `requeueWhilePending` as the backstop. Separately, neither role sets `terminationGracePeriodSeconds` today, so every pod gets the Kubernetes default of 30 seconds before SIGKILL — harmless when nothing routinely deleted pods, wrong once the operator deletes every one of them on every template change, because an instance killed mid-shutdown recovers from WAL on startup and lengthens exactly the catch-up wait the roll blocks on. Both roles get `terminationGracePeriodSeconds: 300`, a ceiling and not a delay, with no new spec knob until someone asks for one. + +Two edges to get right rather than discover. `dataInstances: 1` is legal, has no replica, and can therefore never satisfy the MAIN-step precondition — so under `OnDelete` its pod would never be restarted by anything, ever. It gets a plain restart with a brief full outage, reported as such, because there is no high availability there to protect and freezing the template permanently protects nothing. The `dataInstances >= 2` stall is the opposite call: if no replica is ever caught up, the roll parks at the MAIN indefinitely and reports why, and never escalates to deleting the MAIN's pod anyway — the cluster is still fully serving, so waiting costs only the upgrade. Under data-first ordering that also freezes the coordinator roll for as long as the stall lasts, which is the right trade (one upgrade half-applied across two roles is worse) but must be stated in the condition and the docs, because one sick replica holding up the whole cluster's template looks like a hang otherwise. + +Deliberately out of scope. A `PodDisruptionBudget` is not built here: a node drain can still take the MAIN and a replica together, but that gap predates this issue and is unrelated to operator-driven rolls, so it belongs to its own. Proving the durability property — that no acked write is lost across a roll — is not attempted in CI either: it needs a write workload running through the whole sequence, and the chaos-testing project the PRD names as the proving ground is where that belongs. What CI asserts is the sequencing the operator actually owns. + +## Acceptance criteria + +- [ ] Both role StatefulSets use `updateStrategy: OnDelete`; a changed pod template restarts no pod until the operator deletes it +- [ ] Any pod-template change triggers a roll, not only an image change; builder tests cover a resource, env and probe edit producing the same outcome as an image edit +- [ ] Data instances roll before coordinators; at most one pod of the cluster is down at any point in a roll +- [ ] Among data instances, the observed MAIN is deleted last; among coordinators, the observed leader is deleted last +- [ ] The next pod is deleted only after the previous one is `Ready`, observed `up` with `role=replica`, and reported caught up by `SHOW REPLICATION LAG`; a coordinator only after it is observed `health=up` +- [ ] The MAIN's pod is deleted only while at least one other data instance is observed `up` and caught up, re-checked on the same pass as the deletion +- [ ] No `SET INSTANCE TO MAIN` is ever issued as part of a roll; planner unit test asserts `Plan` emits no promotion for a non-retiring MAIN observed `role=main, health=down` +- [ ] `dataInstances: 1` gets a plain restart, and the condition names the downtime while it happens +- [ ] A cluster with no caught-up replica parks at the MAIN indefinitely, reports `NoCaughtUpSurvivor`, and never deletes the MAIN's pod +- [ ] `internal/rollout` is pure — pods, revisions, instances and lag in, one `Done`/`Wait`/`Delete` action out — with unit tests for role ordering, MAIN-last, leader-last, mid-roll spec revert, and every wait reason +- [ ] `workloadsReady` tolerates one existing-but-unready pod only while that role is mid-roll and all its pods exist; a scale-up mid-roll still reads as not ready +- [ ] No roll step is taken while the registration plan is non-empty, `planner.Retired` is false, or applied counts differ from spec +- [ ] The registration planner runs on every pass of a roll, against the same observation and leader connection +- [ ] `Updated` is True only when every pod of both roles is at `UpdateRevision`, and False with the reason the rollout decision returned +- [ ] `observedMain` requires `IsMain() && IsUp()`, so `Ready` is False while the MAIN is down; envtest covers a down MAIN +- [ ] Both roles set `terminationGracePeriodSeconds: 300`; builder tests cover it +- [ ] The manager's Pod cache is restricted to `app.kubernetes.io/managed-by=memgraph-operator`; the pods RBAC marker is added and `make manifests chart-sync` regenerated with `make chart-verify` green +- [ ] E2E: a benign pod-template change on the existing cluster rolls every data pod then every coordinator pod, deleting the MAIN last and the leader last, with never more than one pod down, ending at `Updated=True` and one MAIN — ordering captured by polling pod UIDs during the roll +- [ ] Docs state the honest contract: `Ready` goes False for the failover window on every upgrade, single-instance clusters take downtime, and a roll waits on replica catch-up + +## Blocked by + +- `16-coordinator-scale-down.md` +- Core: `SHOW INSTANCES` reporting `role=main, health=down` for an unreachable MAIN instead of `role=unknown` (memgraph/memgraph) — this issue must not merge ahead of a Memgraph release carrying it diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index 63e2784..c8f0719 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -1,5 +1,8 @@ +//go:build e2e +// +build e2e + /* -Copyright 2024 Memgraph Ltd. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,15 +21,148 @@ package e2e import ( "fmt" + "os" + "os/exec" "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + + "github.com/memgraph/kubernetes-operator/test/utils" ) -// Run e2e tests using the Ginkgo runner. +const ( + // managerImageRepository and managerImageTag make up the manager image that + // is built, loaded into Kind, and installed through the chart's image values. + managerImageRepository = "example.com/kubernetes-operator" + managerImageTag = "v0.0.1" + + // managerImage is the manager image to be built and loaded for testing. + managerImage = managerImageRepository + ":" + managerImageTag +) + +// shouldCleanupCertManager tracks whether CertManager was installed by this suite. +var shouldCleanupCertManager = false + +// TestE2E runs the e2e test suite to validate the solution in an isolated environment. +// The default setup requires Kind and CertManager. +// +// To enable kubectl kuberc (use custom kubectl configurations), set: KUBECTL_KUBERC=true +// By default, kuberc is disabled to ensure consistent test behavior across different environments. +// To skip CertManager installation, set: CERT_MANAGER_INSTALL_SKIP=true func TestE2E(t *testing.T) { RegisterFailHandler(Fail) - fmt.Fprintf(GinkgoWriter, "Starting kubernetes-operator suite\n") + _, _ = fmt.Fprintf(GinkgoWriter, "Starting kubernetes-operator e2e test suite\n") RunSpecs(t, "e2e suite") } + +// The suite deploys the operator once, before any scenario runs: build and +// load the manager image, then helm install the local chart into a namespace +// that enforces the restricted Pod Security Standard. The install path is the +// users' install path — the same chart, the same CRDs, the same +// least-privilege RBAC — so every scenario below runs against exactly the +// permissions a real installation grants. Scenario containers (Describe +// blocks) then only exercise MemgraphCluster behavior, so a new scenario is a +// new test case, never new pipeline or deployment plumbing. +var _ = BeforeSuite(func() { + By("building the manager image") + cmd := exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", managerImage)) + _, err := utils.Run(cmd) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the manager image") + + // TODO(user): If you want to change the e2e test vendor from Kind, + // ensure the image is built and available, then remove the following block. + By("loading the manager image on Kind") + err = utils.LoadImageToKindClusterWithName(managerImage) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load the manager image into Kind") + + configureKubectlKubeRC() + setupCertManager() + + By("creating manager namespace") + cmd = exec.Command("kubectl", "create", "ns", namespace) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to create namespace") + + By("labeling the namespace to enforce the restricted security policy") + cmd = exec.Command("kubectl", "label", "--overwrite", "ns", namespace, + "pod-security.kubernetes.io/enforce=restricted") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to label namespace with restricted policy") + + By("installing the operator from the local install chart") + cmd = exec.Command("helm", "install", releaseName, chartDir, + "--namespace", namespace, + "--set-string", "image.repository="+managerImageRepository, + "--set-string", "image.tag="+managerImageTag, + "--wait", "--timeout", "5m", + ) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to install the operator chart") +}) + +var _ = AfterSuite(func() { + By("uninstalling the operator chart") + cmd := exec.Command("helm", "uninstall", releaseName, "--namespace", namespace, "--wait") + _, _ = utils.Run(cmd) + + // Helm never removes CRDs it installed, so the documented uninstall ends + // with deleting them explicitly. + By("uninstalling CRDs") + cmd = exec.Command("kubectl", "delete", "-f", chartDir+"/crds", "--ignore-not-found=true") + _, _ = utils.Run(cmd) + + By("removing manager namespace") + cmd = exec.Command("kubectl", "delete", "ns", namespace) + _, _ = utils.Run(cmd) + + teardownCertManager() +}) + +// Disable kubectl kuberc by default for test isolation. +// This prevents local kubectl configurations from affecting test behavior. +// To enable kuberc, set: KUBECTL_KUBERC=true +func configureKubectlKubeRC() { + if os.Getenv("KUBECTL_KUBERC") != "true" { + By("disabling kubectl kuberc for test isolation") + err := os.Setenv("KUBECTL_KUBERC", "false") + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to disable kubectl kuberc") + _, _ = fmt.Fprintf(GinkgoWriter, + "kubectl kuberc disabled for consistent test behavior (override with KUBECTL_KUBERC=true)\n") + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "kubectl kuberc enabled (KUBECTL_KUBERC=true)\n") + } +} + +// setupCertManager installs CertManager if needed for webhook tests. +// Skips installation if CERT_MANAGER_INSTALL_SKIP=true or if already present. +func setupCertManager() { + if os.Getenv("CERT_MANAGER_INSTALL_SKIP") == "true" { + _, _ = fmt.Fprintf(GinkgoWriter, "Skipping CertManager installation (CERT_MANAGER_INSTALL_SKIP=true)\n") + return + } + + By("checking if CertManager is already installed") + if utils.IsCertManagerCRDsInstalled() { + _, _ = fmt.Fprintf(GinkgoWriter, "CertManager is already installed. Skipping installation.\n") + return + } + + // Mark for cleanup before installation to handle interruptions and partial installs. + shouldCleanupCertManager = true + + By("installing CertManager") + Expect(utils.InstallCertManager()).To(Succeed(), "Failed to install CertManager") +} + +// teardownCertManager uninstalls CertManager if it was installed by setupCertManager. +// This ensures we only remove what we installed. +func teardownCertManager() { + if !shouldCleanupCertManager { + _, _ = fmt.Fprintf(GinkgoWriter, "Skipping CertManager cleanup (not installed by this suite)\n") + return + } + + By("uninstalling CertManager") + utils.UninstallCertManager() +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 8e55b96..28764b6 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -1,5 +1,8 @@ +//go:build e2e +// +build e2e + /* -Copyright 2024 Memgraph Ltd. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,8 +20,11 @@ limitations under the License. package e2e import ( + "encoding/json" "fmt" + "os" "os/exec" + "path/filepath" "time" . "github.com/onsi/ginkgo/v2" @@ -27,64 +33,96 @@ import ( "github.com/memgraph/kubernetes-operator/test/utils" ) -const namespace = "kubernetes-operator-system" +// namespace where the operator is installed +const namespace = "memgraph-operator-system" -var _ = Describe("controller", Ordered, func() { - BeforeAll(func() { - By("installing prometheus operator") - Expect(utils.InstallPrometheusOperator()).To(Succeed()) +// chartDir is the local install chart the suite installs, and releaseName the +// Helm release it installs it as. Every name below follows from that release +// name, exactly as it would for a user's installation. +const chartDir = "charts/memgraph-operator" +const releaseName = "memgraph-operator" - By("installing the cert-manager") - Expect(utils.InstallCertManager()).To(Succeed()) +// controllerDeploymentName is the name of the operator Deployment the chart creates +const controllerDeploymentName = releaseName + "-controller-manager" - By("creating manager namespace") - cmd := exec.Command("kubectl", "create", "ns", namespace) - _, _ = utils.Run(cmd) - }) +// serviceAccountName created for the operator +const serviceAccountName = releaseName + "-controller-manager" - AfterAll(func() { - By("uninstalling the Prometheus manager bundle") - utils.UninstallPrometheusOperator() +// metricsServiceName is the name of the metrics service of the operator +const metricsServiceName = releaseName + "-metrics-service" + +// metricsReaderRoleName is the ClusterRole the chart creates for scraping metrics +const metricsReaderRoleName = releaseName + "-metrics-reader" + +// metricsRoleBindingName is the name of the RBAC that will be created to allow get the metrics data +const metricsRoleBindingName = releaseName + "-metrics-binding" - By("uninstalling the cert-manager bundle") - utils.UninstallCertManager() +var _ = Describe("Manager", Ordered, func() { + var controllerPodName string - By("removing manager namespace") - cmd := exec.Command("kubectl", "delete", "ns", namespace) + // The operator itself (namespace, CRDs, controller Deployment) is set up + // once for the whole suite in BeforeSuite; this container only validates + // the already-deployed manager. + + // After all tests have been executed, clean up resources created by this container. + AfterAll(func() { + By("cleaning up the curl pod for metrics") + cmd := exec.Command("kubectl", "delete", "pod", "curl-metrics", "-n", namespace) _, _ = utils.Run(cmd) }) - Context("Operator", func() { - It("should run successfully", func() { - var controllerPodName string - var err error - - // projectimage stores the name of the image used in the example - var projectimage = "example.com/kubernetes-operator:v0.0.1" + // After each test, check for failures and collect logs, events, + // and pod descriptions for debugging. + AfterEach(func() { + specReport := CurrentSpecReport() + if specReport.Failed() { + By("Fetching controller manager pod logs") + cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace) + controllerLogs, err := utils.Run(cmd) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Controller logs:\n %s", controllerLogs) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Controller logs: %s", err) + } - By("building the manager(Operator) image") - cmd := exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", projectimage)) - _, err = utils.Run(cmd) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + By("Fetching Kubernetes events") + cmd = exec.Command("kubectl", "get", "events", "-n", namespace, "--sort-by=.lastTimestamp") + eventsOutput, err := utils.Run(cmd) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Kubernetes events:\n%s", eventsOutput) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Kubernetes events: %s", err) + } - By("loading the the manager(Operator) image on Kind") - err = utils.LoadImageToKindClusterWithName(projectimage) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + By("Fetching curl-metrics logs") + cmd = exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace) + metricsOutput, err := utils.Run(cmd) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Metrics logs:\n %s", metricsOutput) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get curl-metrics logs: %s", err) + } - By("installing CRDs") - cmd = exec.Command("make", "install") - _, err = utils.Run(cmd) + By("Fetching controller manager pod description") + cmd = exec.Command("kubectl", "describe", "pod", controllerPodName, "-n", namespace) + podDescription, err := utils.Run(cmd) + if err == nil { + fmt.Println("Pod description:\n", podDescription) + } else { + fmt.Println("Failed to describe controller pod") + } + } + }) - By("deploying the controller-manager") - cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", projectimage)) - _, err = utils.Run(cmd) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + SetDefaultEventuallyTimeout(2 * time.Minute) + SetDefaultEventuallyPollingInterval(time.Second) + Context("Manager", func() { + It("should run successfully", func() { By("validating that the controller-manager pod is running as expected") - verifyControllerUp := func() error { - // Get pod name - - cmd = exec.Command("kubectl", "get", + verifyControllerUp := func(g Gomega) { + By("getting the name of the controller-manager pod") + cmd := exec.Command("kubectl", "get", "pods", "-l", "control-plane=controller-manager", "-o", "go-template={{ range .items }}"+ "{{ if not .metadata.deletionTimestamp }}"+ @@ -94,28 +132,185 @@ var _ = Describe("controller", Ordered, func() { ) podOutput, err := utils.Run(cmd) - ExpectWithOffset(2, err).NotTo(HaveOccurred()) - podNames := utils.GetNonEmptyLines(string(podOutput)) - if len(podNames) != 1 { - return fmt.Errorf("expect 1 controller pods running, but got %d", len(podNames)) - } + g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve controller-manager pod information") + podNames := utils.GetNonEmptyLines(podOutput) + g.Expect(podNames).To(HaveLen(1), "expected 1 controller pod running") controllerPodName = podNames[0] - ExpectWithOffset(2, controllerPodName).Should(ContainSubstring("controller-manager")) + g.Expect(controllerPodName).To(ContainSubstring("controller-manager")) - // Validate pod status + By("validating the pod's status") cmd = exec.Command("kubectl", "get", "pods", controllerPodName, "-o", "jsonpath={.status.phase}", "-n", namespace, ) - status, err := utils.Run(cmd) - ExpectWithOffset(2, err).NotTo(HaveOccurred()) - if string(status) != "Running" { - return fmt.Errorf("controller pod in %s status", status) - } - return nil + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(Equal("Running"), "Incorrect controller-manager pod status") } - EventuallyWithOffset(1, verifyControllerUp, time.Minute, time.Second).Should(Succeed()) + Eventually(verifyControllerUp).Should(Succeed()) + }) + + It("should ensure the metrics endpoint is serving metrics", func() { + By("creating a ClusterRoleBinding for the service account to allow access to metrics") + cmd := exec.Command("kubectl", "create", "clusterrolebinding", metricsRoleBindingName, + "--clusterrole="+metricsReaderRoleName, + fmt.Sprintf("--serviceaccount=%s:%s", namespace, serviceAccountName), + ) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to create ClusterRoleBinding") + By("validating that the metrics service is available") + cmd = exec.Command("kubectl", "get", "service", metricsServiceName, "-n", namespace) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Metrics service should exist") + + By("getting the service account token") + token, err := serviceAccountToken() + Expect(err).NotTo(HaveOccurred()) + Expect(token).NotTo(BeEmpty()) + + By("ensuring the controller pod is ready") + verifyControllerPodReady := func(g Gomega) { + cmd := exec.Command("kubectl", "get", "pod", controllerPodName, "-n", namespace, + "-o", "jsonpath={.status.conditions[?(@.type=='Ready')].status}") + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(Equal("True"), "Controller pod not ready") + } + Eventually(verifyControllerPodReady, 3*time.Minute, time.Second).Should(Succeed()) + + By("verifying that the controller manager is serving the metrics server") + verifyMetricsServerStarted := func(g Gomega) { + cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace) + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(ContainSubstring("Serving metrics server"), + "Metrics server not yet started") + } + Eventually(verifyMetricsServerStarted, 3*time.Minute, time.Second).Should(Succeed()) + + // +kubebuilder:scaffold:e2e-metrics-webhooks-readiness + + By("creating the curl-metrics pod to access the metrics endpoint") + cmd = exec.Command("kubectl", "run", "curl-metrics", "--restart=Never", + "--namespace", namespace, + "--image=curlimages/curl:latest", + "--overrides", + fmt.Sprintf(`{ + "spec": { + "containers": [{ + "name": "curl", + "image": "curlimages/curl:latest", + "command": ["/bin/sh", "-c"], + "args": [ + "for i in $(seq 1 30); do curl -v -k -H 'Authorization: Bearer %s' https://%s.%s.svc.cluster.local:8443/metrics && exit 0 || sleep 2; done; exit 1" + ], + "securityContext": { + "readOnlyRootFilesystem": true, + "allowPrivilegeEscalation": false, + "capabilities": { + "drop": ["ALL"] + }, + "runAsNonRoot": true, + "runAsUser": 1000, + "seccompProfile": { + "type": "RuntimeDefault" + } + } + }], + "serviceAccountName": "%s" + } + }`, token, metricsServiceName, namespace, serviceAccountName)) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to create curl-metrics pod") + + By("waiting for the curl-metrics pod to complete.") + verifyCurlUp := func(g Gomega) { + cmd := exec.Command("kubectl", "get", "pods", "curl-metrics", + "-o", "jsonpath={.status.phase}", + "-n", namespace) + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(Equal("Succeeded"), "curl pod in wrong status") + } + Eventually(verifyCurlUp, 5*time.Minute).Should(Succeed()) + + By("getting the metrics by checking curl-metrics logs") + verifyMetricsAvailable := func(g Gomega) { + metricsOutput, err := getMetricsOutput() + g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod") + g.Expect(metricsOutput).NotTo(BeEmpty()) + g.Expect(metricsOutput).To(ContainSubstring("< HTTP/1.1 200 OK")) + } + Eventually(verifyMetricsAvailable, 2*time.Minute).Should(Succeed()) }) + + // +kubebuilder:scaffold:e2e-webhooks-checks + + // TODO: Customize the e2e test suite with scenarios specific to your project. + // Consider applying sample/CR(s) and check their status and/or verifying + // the reconciliation by using the metrics, i.e.: + // metricsOutput, err := getMetricsOutput() + // Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod") + // Expect(metricsOutput).To(ContainSubstring( + // fmt.Sprintf(`controller_runtime_reconcile_total{controller="%s",result="success"} 1`, + // strings.ToLower(), + // )) }) }) + +// serviceAccountToken returns a token for the specified service account in the given namespace. +// It uses the Kubernetes TokenRequest API to generate a token by directly sending a request +// and parsing the resulting token from the API response. +func serviceAccountToken() (string, error) { + const tokenRequestRawString = `{ + "apiVersion": "authentication.k8s.io/v1", + "kind": "TokenRequest" + }` + + By("creating temporary file to store the token request") + secretName := fmt.Sprintf("%s-token-request", serviceAccountName) + tokenRequestFile := filepath.Join("/tmp", secretName) + err := os.WriteFile(tokenRequestFile, []byte(tokenRequestRawString), os.FileMode(0o644)) + if err != nil { + return "", err + } + + var out string + verifyTokenCreation := func(g Gomega) { + By("executing kubectl command to create the token") + cmd := exec.Command("kubectl", "create", "--raw", fmt.Sprintf( + "/api/v1/namespaces/%s/serviceaccounts/%s/token", + namespace, + serviceAccountName, + ), "-f", tokenRequestFile) + + output, err := cmd.CombinedOutput() + g.Expect(err).NotTo(HaveOccurred()) + + By("parsing the JSON output to extract the token") + var token tokenRequest + err = json.Unmarshal(output, &token) + g.Expect(err).NotTo(HaveOccurred()) + + out = token.Status.Token + } + Eventually(verifyTokenCreation).Should(Succeed()) + + return out, err +} + +// getMetricsOutput retrieves and returns the logs from the curl pod used to access the metrics endpoint. +func getMetricsOutput() (string, error) { + By("getting the curl-metrics logs") + cmd := exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace) + return utils.Run(cmd) +} + +// tokenRequest is a simplified representation of the Kubernetes TokenRequest API response, +// containing only the token field that we need to extract. +type tokenRequest struct { + Status struct { + Token string `json:"token"` + } `json:"status"` +} diff --git a/test/e2e/kind-config.yaml b/test/e2e/kind-config.yaml new file mode 100644 index 0000000..9e0d2b6 --- /dev/null +++ b/test/e2e/kind-config.yaml @@ -0,0 +1,10 @@ +# Multi-node Kind cluster for the e2e suite. A Memgraph HA cluster is only a +# meaningful end-to-end proof when its coordinators and data instances spread +# across several nodes, mirroring the HA chart's multi-node CI. +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: + - role: control-plane + - role: worker + - role: worker + - role: worker diff --git a/test/e2e/memgraphcluster_test.go b/test/e2e/memgraphcluster_test.go new file mode 100644 index 0000000..0368471 --- /dev/null +++ b/test/e2e/memgraphcluster_test.go @@ -0,0 +1,1344 @@ +//go:build e2e +// +build e2e + +/* +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 e2e + +import ( + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "maps" + "os" + "os/exec" + "path/filepath" + "slices" + "sort" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sigs.k8s.io/yaml" + + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" + "github.com/memgraph/kubernetes-operator/internal/resources" + "github.com/memgraph/kubernetes-operator/test/utils" +) + +const ( + clusterNamespace = "memgraph-e2e" + + // exampleManifest is the quickstart manifest the README walks a newcomer + // through, applied here verbatim (only the namespace is supplied on the + // command line). Everything the suite needs to know about the cluster — + // its name, its topology, its image — is read out of the file below, so + // the example cannot drift from what CI proves works. + exampleManifest = "examples/minimal-cluster.yaml" + + // The env vars carrying the enterprise license into the suite follow the + // HA Helm chart's CI convention: repository secrets of these names are + // exported into the job environment and materialize as the one Kubernetes + // Secret the example references. + licenseEnvVar = "MEMGRAPH_ENTERPRISE_LICENSE" + organizationEnvVar = "MEMGRAPH_ORGANIZATION_NAME" + + // roleMain is the MAIN data-instance role reported in the SHOW INSTANCES role + // column. + roleMain = "main" + + // roleLeader is the Raft leader coordinator role reported in the same column. + // Which coordinator holds it decides whether a shrink can remove a member at + // all: Raft refuses to remove its own leader. + roleLeader = "leader" +) + +// example is the parsed quickstart manifest and the source of truth for the +// topology the specs assert on. +var example = loadExample() + +// The declared topology of the e2e cluster, as the example declares it. +var ( + clusterName = example.Name + coordinatorCount = declaredCount("coordinators", example.Spec.Coordinators) + dataInstanceCount = declaredCount("dataInstances", example.Spec.DataInstances) + + memgraphImage = example.Spec.Image.Repository + ":" + example.Spec.Image.Tag + + licenseSecretName = example.Spec.Secrets.Name + + // quickstartCluster is the cluster the example manifest boots, which most of + // the specs below observe. + quickstartCluster = clusterUnderTest{ + namespace: clusterNamespace, + name: clusterName, + coordinators: coordinatorCount, + dataInstances: dataInstanceCount, + } +) + +// clusterUnderTest is one MemgraphCluster a spec observes, together with the +// topology it declares. The suite runs several differently-shaped clusters — +// the quickstart one, the retention one, the one that is scaled — so every +// helper below takes its cluster rather than reaching for the quickstart +// globals. +type clusterUnderTest struct { + namespace string + name string + coordinators int32 + dataInstances int32 +} + +// withTopology returns the same cluster with a different declared topology, +// which is what the assertions switch to after a scale in either direction. +func (c clusterUnderTest) withTopology(coordinators, dataInstances int32) clusterUnderTest { + c.coordinators = coordinators + c.dataInstances = dataInstances + return c +} + +// declaredCount reads a replica count the example must state outright: the +// counts drive the assertions, and a count left to the CRD's default would +// leave the suite asserting on a topology the file never declared. +func declaredCount(field string, count *int32) int32 { + if count == nil { + panic(fmt.Sprintf("%s must declare spec.%s", exampleManifest, field)) + } + return *count +} + +// loadExample reads and decodes the quickstart manifest. Decoding is strict, +// so a field the example misspells fails the suite instead of being silently +// defaulted away by the API server. +func loadExample() *memgraphcomv1alpha1.MemgraphCluster { + projectDir, err := utils.GetProjectDir() + if err != nil { + panic(fmt.Sprintf("locating the project directory: %v", err)) + } + manifest, err := os.ReadFile(filepath.Join(projectDir, exampleManifest)) + if err != nil { + panic(fmt.Sprintf("reading %s: %v", exampleManifest, err)) + } + cluster := &memgraphcomv1alpha1.MemgraphCluster{} + if err := yaml.UnmarshalStrict(manifest, cluster); err != nil { + panic(fmt.Sprintf("decoding %s: %v", exampleManifest, err)) + } + return cluster +} + +// declaredInstances returns the instance names every coordinator and data +// instance must appear under in SHOW INSTANCES once the operator has converged +// registration: coordinator ordinal N registers as coordinator_N+1, data +// ordinal N as instance_N. +func (c clusterUnderTest) declaredInstances() []string { + names := make([]string, 0, c.coordinators+c.dataInstances) + for ordinal := range c.coordinators { + names = append(names, resources.CoordinatorInstanceName(ordinal)) + } + for ordinal := range c.dataInstances { + names = append(names, resources.DataInstanceName(ordinal)) + } + return names +} + +// MemgraphCluster is the end-to-end proof of the provisioning and bootstrap +// slices: a real multi-node Kind cluster, the deployed operator, real licensed +// Memgraph images, and assertions over SHOW INSTANCES. +// +// The container owns one cluster for all its specs: BeforeAll provisions the +// namespace, license Secret, and CR, so a new scenario against the same +// cluster is just another It block (with later Its observing earlier +// mutations, e.g. a deliberately wiped pod). A scenario needing a +// differently-shaped cluster gets its own Ordered container following this +// same pattern — no pipeline changes. +var _ = Describe("MemgraphCluster", Ordered, func() { + BeforeAll(func() { + license, organization := licenseFromEnv() + + preloadMemgraphImage() + createClusterNamespace(clusterNamespace) + + By("creating the enterprise license Secret") + createLicenseSecret(clusterNamespace, license, organization) + + By("applying the MemgraphCluster") + applyMemgraphCluster() + }) + + AfterAll(func() { + By("removing the cluster namespace") + cmd := exec.Command("kubectl", "delete", "ns", clusterNamespace, + "--ignore-not-found", "--wait=false") + _, _ = utils.Run(cmd) + }) + + AfterEach(func() { + dumpDiagnosticsOnFailure(clusterNamespace) + }) + + It("bootstraps every declared instance registered with exactly one MAIN", func() { + Eventually(quickstartCluster.verifyRegistered, 10*time.Minute, 10*time.Second).Should(Succeed()) + }) + + // The operator's reason to exist over the chart's one-shot Job: a data + // instance that loses its registration is re-registered with no human + // action. This runs after the bootstrap spec (Ordered) against the same + // converged cluster. + It("re-registers a data instance whose registration was wiped", func() { + const wiped = "instance_1" + + By("confirming the cluster is converged before wiping a registration") + Eventually(quickstartCluster.verifyRegistered, 10*time.Minute, 10*time.Second).Should(Succeed()) + + By("unregistering a data instance on the coordinator leader") + Expect(wipeInstanceRegistration(wiped)).To(Succeed()) + + By("confirming the instance really left the cluster view") + view, err := quickstartCluster.leaderView() + Expect(err).NotTo(HaveOccurred()) + names := make([]string, 0, len(view)) + for _, instance := range view { + names = append(names, instance.name) + } + Expect(names).NotTo(ContainElement(wiped), + "the wipe must actually remove the registration for the test to be meaningful") + + By("waiting for the operator to converge the cluster back to fully registered") + Eventually(quickstartCluster.verifyRegistered, 10*time.Minute, 10*time.Second).Should(Succeed()) + }) + + // The coordinator analogue of the data-instance re-registration: a + // coordinator removed from the Raft cluster is re-added by the operator's + // continuous ADD COORDINATOR reconciliation, with no human action. This + // proves the re-registration loop covers coordinators, not just data + // instances. Runs after the preceding specs (Ordered) against the same + // converged cluster. + It("re-adds a coordinator that was removed from the cluster", func() { + By("confirming the cluster is converged before removing a coordinator") + Eventually(quickstartCluster.verifyRegistered, 10*time.Minute, 10*time.Second).Should(Succeed()) + + By("removing a follower coordinator on the coordinator leader") + removed, err := removeCoordinatorRegistration() + Expect(err).NotTo(HaveOccurred()) + + By("confirming the coordinator really left the cluster view") + view, err := quickstartCluster.leaderView() + Expect(err).NotTo(HaveOccurred()) + names := make([]string, 0, len(view)) + for _, instance := range view { + names = append(names, instance.name) + } + Expect(names).NotTo(ContainElement(removed), + "the removal must actually drop the coordinator for the test to be meaningful") + + By("waiting for the operator to converge the cluster back to fully registered") + Eventually(quickstartCluster.verifyRegistered, 10*time.Minute, 10*time.Second).Should(Succeed()) + }) + + // Storage survives the cluster under the default retention policy: an + // accidental `kubectl delete mgc` must not take a production database with + // it. This deletes the CR, so it runs last in this Ordered container. + // The sequenced rolling restart, on the converged cluster the preceding specs + // left behind (Ordered). Both StatefulSets use updateStrategy OnDelete, so + // Kubernetes replaces nothing on its own: every pod here moves because the + // operator deleted it, in an order it chose. + // + // The trigger is a benign pod-template edit rather than an image bump. The + // operator has no version logic at all, so what is under test is the ordering, + // and an extra environment variable exercises the identical revision change + // without a second image pull or Memgraph version skew in the way. + // + // What is deliberately not asserted here: that no acknowledged write is lost + // across the roll. That needs a write workload running through the whole + // sequence, and it belongs to the chaos-testing project rather than a spec that + // gates every pull request. + It("rolls a changed pod template through the data instances before the coordinators", func() { + By("confirming the cluster is converged before changing the pod template") + Eventually(quickstartCluster.verifyRegistered, 10*time.Minute, 10*time.Second).Should(Succeed()) + quickstartCluster.awaitConverged(2 * time.Minute) + + By("recording which pods exist and which instance is MAIN") + before, err := quickstartCluster.podUIDs() + Expect(err).NotTo(HaveOccurred()) + Expect(before).To(HaveLen(int(quickstartCluster.coordinators + quickstartCluster.dataInstances))) + + view, err := quickstartCluster.leaderView() + Expect(err).NotTo(HaveOccurred()) + main := mainOf(view) + Expect(main).NotTo(BeEmpty(), "the roll's order is defined against the MAIN") + mainOrdinal, err := resources.DataInstanceOrdinal(main) + Expect(err).NotTo(HaveOccurred()) + mainPod := fmt.Sprintf("%s-data-%d", quickstartCluster.name, mainOrdinal) + + By("adding an environment variable to both roles, which changes the pod template") + cmd := exec.Command("kubectl", "patch", "memgraphcluster", quickstartCluster.name, + "-n", quickstartCluster.namespace, "--type=merge", "-p", + `{"spec":{"extraEnv":{`+ + `"coordinators":[{"name":"E2E_ROLLING_RESTART","value":"1"}],`+ + `"data":[{"name":"E2E_ROLLING_RESTART","value":"1"}]}}}`) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "the operator must accept an extra environment variable") + + By("watching the operator replace every pod, one at a time") + order, maxDown := quickstartCluster.watchRoll(before, 25*time.Minute) + + Expect(order).To(HaveLen(len(before)), "every pod must be replaced exactly once") + Expect(maxDown).To(BeNumerically("<=", 1), + "at most one pod of the cluster may be unready at a time; observed %d", maxDown) + + By("confirming data instances went before coordinators, and MAIN last of its role") + var dataOrder, coordinatorOrder []string + for _, pod := range order { + if strings.Contains(pod, "-data-") { + dataOrder = append(dataOrder, pod) + continue + } + coordinatorOrder = append(coordinatorOrder, pod) + Expect(dataOrder).To(HaveLen(int(quickstartCluster.dataInstances)), + "a coordinator pod (%s) was restarted before the data plane finished: %v", pod, order) + } + Expect(dataOrder).To(HaveLen(int(quickstartCluster.dataInstances))) + Expect(coordinatorOrder).To(HaveLen(int(quickstartCluster.coordinators))) + Expect(dataOrder[len(dataOrder)-1]).To(Equal(mainPod), + "the MAIN's pod must be the last data pod restarted, order was %v", dataOrder) + + By("confirming the cluster converges with the new template and one MAIN") + Eventually(quickstartCluster.verifyRegistered, 10*time.Minute, 10*time.Second).Should(Succeed()) + cmd = exec.Command("kubectl", "wait", "--for=condition=Updated", + "memgraphcluster/"+quickstartCluster.name, "-n", quickstartCluster.namespace, "--timeout=5m") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "the MemgraphCluster never reported Updated") + }) + + It("leaves the PVCs behind when the default-retention CR is deleted", func() { + By("confirming the cluster is converged before deleting it") + Eventually(quickstartCluster.verifyRegistered, 10*time.Minute, 10*time.Second).Should(Succeed()) + + By("recording the provisioned PVCs") + before, err := listPVCs(clusterNamespace) + Expect(err).NotTo(HaveOccurred()) + // Two claims (lib and log) per coordinator and data instance pod. + Expect(before).To(HaveLen(int(2 * (coordinatorCount + dataInstanceCount)))) + + By("deleting the MemgraphCluster") + cmd := exec.Command("kubectl", "delete", "memgraphcluster", clusterName, + "-n", clusterNamespace, "--wait=true") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to delete the MemgraphCluster") + + By("waiting for garbage collection to remove the workloads") + Eventually(func(g Gomega) { + cmd := exec.Command("kubectl", "get", "statefulsets", "-n", clusterNamespace, + "-o", "jsonpath={.items[*].metadata.name}") + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(strings.TrimSpace(output)).To(BeEmpty()) + }, 5*time.Minute, 5*time.Second).Should(Succeed()) + + By("confirming every PVC is still there") + // Consistently, not Eventually: the failure mode is a delayed deletion, + // which a single post-condition check would race straight past. + Consistently(func(g Gomega) { + after, err := listPVCs(clusterNamespace) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(after).To(ConsistOf(before)) + }, 30*time.Second, 5*time.Second).Should(Succeed()) + }) +}) + +// The Delete retention policy is the dev-cluster counterpart of the spec +// above: the StatefulSet machinery takes the claims down with the CR. It gets +// its own container and namespace because it needs a differently-configured +// cluster, and it never waits for registration to converge — the StatefulSet +// controller provisions the claims as soon as the pods are created, so the +// retention behavior is observable long before Memgraph is. +var _ = Describe("MemgraphCluster with Delete storage retention", Ordered, func() { + const retentionNamespace = "memgraph-e2e-retention" + const retentionCluster = "retention" + + BeforeAll(func() { + By("creating the cluster namespace") + cmd := exec.Command("kubectl", "create", "ns", retentionNamespace) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to create namespace") + }) + + AfterAll(func() { + By("removing the cluster namespace") + cmd := exec.Command("kubectl", "delete", "ns", retentionNamespace, + "--ignore-not-found", "--wait=false") + _, _ = utils.Run(cmd) + }) + + It("removes the PVCs when the CR is deleted", func() { + By("applying a MemgraphCluster with Delete retention") + manifest := fmt.Sprintf(`apiVersion: memgraph.com/v1alpha1 +kind: MemgraphCluster +metadata: + name: %s + namespace: %s +spec: + coordinators: 3 + dataInstances: 1 + image: + repository: %s + tag: %s + storage: + retentionPolicy: Delete +`, retentionCluster, retentionNamespace, + example.Spec.Image.Repository, example.Spec.Image.Tag) + cmd := exec.Command("kubectl", "apply", "-f", "-") + _, err := utils.RunWithInput(cmd, manifest) + Expect(err).NotTo(HaveOccurred(), "Failed to apply the MemgraphCluster") + + By("waiting for the claims to be provisioned and adopted by their StatefulSets") + // One lib and one log claim per pod: three coordinators and one data + // instance, the smallest topology admission accepts. Adoption is what + // the spec has to wait for, not mere existence: the Delete policy + // reaches a claim as the StatefulSet owner reference the controller + // attaches a sync *after* it creates the claim, and a claim whose set + // is deleted before it is adopted is stranded for good, not merely + // collected late. + // + // The claims are named rather than counted. A count is satisfied by any + // eight adopted claims, so it cannot distinguish the state this guard + // exists to wait for from any other view that happens to be eight rows + // long — and a guard that passes before adoption completes hands the rest + // of the spec the exact race it was put here to exclude. + wantClaims := expectedClaims(retentionCluster, map[string]int{"coordinator": 3, "data": 1}) + Eventually(func(g Gomega) { + claims, err := listAdoptedPVCs(retentionNamespace) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(claims).To(ConsistOf(wantClaims)) + }, 5*time.Minute, 5*time.Second).Should(Succeed()) + + By("deleting the MemgraphCluster") + cmd = exec.Command("kubectl", "delete", "memgraphcluster", retentionCluster, + "-n", retentionNamespace, "--wait=true") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to delete the MemgraphCluster") + + // Claim deletion is garbage collection following the owner reference, so + // the sets have to be gone before their absence can take the claims with + // them. Waiting on that first is also what separates the two ways this can + // fail: sets that linger name the workloads, claims that linger after the + // sets are gone name the collection. + By("waiting for garbage collection to remove the workloads") + Eventually(func(g Gomega) { + cmd := exec.Command("kubectl", "get", "statefulsets", "-n", retentionNamespace, + "-o", "jsonpath={.items[*].metadata.name}") + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(strings.TrimSpace(output)).To(BeEmpty()) + }, 5*time.Minute, 5*time.Second).Should(Succeed()) + + By("waiting for the StatefulSet machinery to take the claims down with it") + Eventually(func(g Gomega) { + claims, err := listPVCs(retentionNamespace) + g.Expect(err).NotTo(HaveOccurred()) + // A claim that outlives its owner says nothing on its own about why, so + // the owner references it still carries go into the failure: whether it + // was never adopted, or adopted by a set that is somehow still around, + // is the whole diagnosis and it is not recoverable after the fact. + g.Expect(claims).To(BeEmpty(), "claims left behind, with their owners:\n%s", + describeClaimOwners(retentionNamespace)) + }, 5*time.Minute, 5*time.Second).Should(Succeed()) + }) +}) + +// Scaling a live cluster in both directions: the end-to-end proof that raising a +// count registers the members it adds, and that lowering the data-instance count +// retires the members it drops safely — MAIN moved off them, unregistered before +// their pods go. It gets its own container and namespace because it needs a +// cluster it may reshape, and its teardown is awaited — eight Memgraph pods are a +// large share of a Kind cluster's capacity, which the scenarios that may run +// after it need back. +var _ = Describe("MemgraphCluster topology scaling", Ordered, func() { + const scalingNamespace = "memgraph-e2e-scaling" + const scalingClusterName = "scaling" + + // The cluster starts at the default topology and grows to five coordinators + // and three data instances. + initial := clusterUnderTest{ + namespace: scalingNamespace, name: scalingClusterName, coordinators: 3, dataInstances: 2, + } + grown := initial.withTopology(5, 3) + + BeforeAll(func() { + license, organization := licenseFromEnv() + + preloadMemgraphImage() + createClusterNamespace(scalingNamespace) + + By("creating the enterprise license Secret") + createLicenseSecret(scalingNamespace, license, organization) + + By("applying the MemgraphCluster to scale") + // No log claim and explicit small requests: this cluster runs up to eight + // pods on the same Kind nodes as the other scenarios', so it asks for as + // little as it can while still being a real HA cluster. + manifest := fmt.Sprintf(`apiVersion: memgraph.com/v1alpha1 +kind: MemgraphCluster +metadata: + name: %s + namespace: %s +spec: + coordinators: %d + dataInstances: %d + image: + repository: %s + tag: %s + secrets: + name: %s + storage: + coordinators: + createLogStorageClaim: false + data: + createLogStorageClaim: false + resources: + coordinators: + requests: + cpu: 50m + memory: 200Mi + data: + requests: + cpu: 50m + memory: 300Mi +`, scalingClusterName, scalingNamespace, initial.coordinators, initial.dataInstances, + example.Spec.Image.Repository, example.Spec.Image.Tag, licenseSecretName) + cmd := exec.Command("kubectl", "apply", "-f", "-") + _, err := utils.RunWithInput(cmd, manifest) + Expect(err).NotTo(HaveOccurred(), "Failed to apply the MemgraphCluster") + }) + + AfterAll(func() { + By("removing the cluster namespace and waiting for its pods to go") + cmd := exec.Command("kubectl", "delete", "ns", scalingNamespace, + "--ignore-not-found", "--wait=true", "--timeout=10m") + _, _ = utils.Run(cmd) + }) + + AfterEach(func() { + dumpDiagnosticsOnFailure(scalingNamespace) + }) + + It("bootstraps the initial topology", func() { + Eventually(initial.verifyRegistered, 10*time.Minute, 10*time.Second).Should(Succeed()) + initial.awaitConverged(2 * time.Minute) + }) + + // Both counts are raised in one edit, in different step sizes, which is the + // whole contract: nothing constrains a change beyond the target counts + // themselves. + It("grows both roles in one edit and registers every added member", func() { + By("raising both counts on the live cluster") + cmd := exec.Command("kubectl", "patch", "memgraphcluster", scalingClusterName, + "-n", scalingNamespace, "--type=merge", "-p", + fmt.Sprintf(`{"spec":{"coordinators":%d,"dataInstances":%d}}`, + grown.coordinators, grown.dataInstances)) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "the operator must accept a raised topology count") + + By("waiting for every member of the grown topology to be registered") + Eventually(grown.verifyRegistered, 10*time.Minute, 10*time.Second).Should(Succeed()) + + By("confirming the resource reports the scale as finished") + grown.awaitConverged(5 * time.Minute) + Expect(grown.replicas("coordinator")).To(Equal("5")) + Expect(grown.replicas("data")).To(Equal("3")) + coordinators, dataInstances := grown.registeredCounts() + Expect(coordinators).To(Equal("5")) + Expect(dataInstances).To(Equal("3")) + }) + + // The shrink, against the 5/3 cluster the growth spec left behind (Ordered), + // with MAIN deliberately parked on the ordinal that has to go — the case the + // whole safety argument is about: Memgraph refuses to unregister a MAIN, so the + // operator has to move it first, and it has to unregister before the pod goes + // or the coordinators are left expecting an instance that is not there. + It("retires a data instance holding MAIN and only then sheds its pod", func() { + shrunk := grown.withTopology(5, 2) + const retiring = "instance_2" + + By("parking MAIN on the data instance the shrink retires") + // Retried as a whole: the operator promotes a survivor itself if it + // observes the cluster MAIN-less between the two statements. + Eventually(func(g Gomega) { + g.Expect(grown.makeMain(retiring)).To(Succeed()) + view, err := grown.leaderView() + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(mainOf(view)).To(Equal(retiring)) + }, 5*time.Minute, 10*time.Second).Should(Succeed()) + + By("lowering the data-instance count on the live cluster") + cmd := exec.Command("kubectl", "patch", "memgraphcluster", scalingClusterName, + "-n", scalingNamespace, "--type=merge", "-p", + fmt.Sprintf(`{"spec":{"dataInstances":%d}}`, shrunk.dataInstances)) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "the operator must accept a lowered topology count") + + By("waiting for the retiring instance to leave the cluster while its pod is still there") + Eventually(func(g Gomega) { + // The replica count is read before the registration view, and that + // order carries the whole assertion. A view read afterwards that still + // lists the retiring instance proves it was registered at a moment the + // StatefulSet had already been shrunk — the reverse order the operator + // must never produce, because the coordinators would be left expecting + // an instance whose pod is gone. Reading the view first would prove + // nothing: the operator's own UNREGISTER can land between the two + // reads, so a pre-unregistration view paired with a post-shrink count + // is the correct sequence misread as a violation. + replicas, err := shrunk.replicas("data") + g.Expect(err).NotTo(HaveOccurred()) + + view, err := shrunk.leaderView() + g.Expect(err).NotTo(HaveOccurred()) + names := instanceNames(view) + + if replicas != "3" && slices.Contains(names, retiring) { + // Not something to retry: the ordering this catches is broken for + // good by the time it is observable. + StopTrying(fmt.Sprintf( + "the data StatefulSet was scaled to %s replicas while %s was still registered", + replicas, retiring)).Now() + } + g.Expect(names).NotTo(ContainElement(retiring)) + }, 10*time.Minute, 5*time.Second).Should(Succeed()) + + By("waiting for its pod to be shed") + // Comfortably above terminationGracePeriodSeconds: a pod that needs its full + // shutdown budget takes five minutes to go, so a five-minute timeout here + // would be a coin flip rather than an assertion. + Eventually(func(g Gomega) { + g.Expect(shrunk.replicas("data")).To(Equal("2")) + g.Expect(shrunk.podExists("data", 2)).To(BeFalse()) + }, 8*time.Minute, 5*time.Second).Should(Succeed()) + + By("confirming the shrunk cluster is registered, converged, and led by a survivor") + Eventually(shrunk.verifyRegistered, 10*time.Minute, 10*time.Second).Should(Succeed()) + shrunk.awaitConverged(5 * time.Minute) + _, dataInstances := shrunk.registeredCounts() + Expect(dataInstances).To(Equal("2")) + view, err := shrunk.leaderView() + Expect(err).NotTo(HaveOccurred()) + Expect(mainOf(view)).To(BeElementOf("instance_0", "instance_1")) + + By("confirming the retired instance's claims are kept by the default retention policy") + claims, err := listPVCs(scalingNamespace) + Expect(err).NotTo(HaveOccurred()) + Expect(claims).To(ContainElement(fmt.Sprintf("lib-storage-%s-data-2", scalingClusterName)), + "whenScaled follows spec.storage.retentionPolicy, which defaults to Retain") + }) + + // The coordinator shrink, against the 5-coordinator cluster the specs above left + // behind (Ordered), with Raft leadership deliberately parked on a coordinator + // that has to go — the case the whole safety argument is about: Raft returns + // RAFT_CANNOT_REMOVE_LEADER for its own leader, and a StatefulSet sheds only its + // highest ordinals, so the operator has to move leadership out of the retiring + // range before it can remove anything there. + It("retires the coordinator holding Raft leadership and only then sheds its pod", func() { + // The topology the data shrink left running, and the one this spec drops to. + // Three is the floor, so a shrink from five retires an even number of members + // and the surviving Raft cluster keeps an odd membership throughout. + running := grown.withTopology(5, 2) + shrunk := grown.withTopology(3, 2) + retiring := []string{"coordinator_4", "coordinator_5"} + + By("forcing Raft leadership onto a coordinator the shrink retires") + // Retried as a whole: YIELD LEADERSHIP names no successor, so each attempt + // hands leadership to whichever member NuRaft nominates. Either retiring + // member satisfies the precondition — what the spec is about is leadership + // sitting inside the retiring range, not on one particular ordinal, and + // insisting on one would make the wait depend on which peer NuRaft happens + // to nominate. + Eventually(func(g Gomega) { + g.Expect(running.makeLeader(retiring...)).To(Succeed()) + view, err := running.leaderView() + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(coordinatorLeaderOf(view)).To(BeElementOf(retiring)) + }, 10*time.Minute, 10*time.Second).Should(Succeed()) + + By("lowering the coordinator count on the live cluster") + cmd := exec.Command("kubectl", "patch", "memgraphcluster", scalingClusterName, + "-n", scalingNamespace, "--type=merge", "-p", + fmt.Sprintf(`{"spec":{"coordinators":%d}}`, shrunk.coordinators)) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "the operator must accept a lowered coordinator count") + + By("waiting for both retiring members to leave the Raft cluster while their pods are still there") + Eventually(func(g Gomega) { + // The replica count is read before the membership view for the reason the + // data shrink above spells out: a view read afterwards that still lists a + // retiring member proves it held a vote at a moment its pod had already + // been scaled away, which is the order the operator must never produce. + replicas, err := shrunk.replicas("coordinator") + g.Expect(err).NotTo(HaveOccurred()) + + // Read through the five-coordinator view: while leadership is still moving + // it may sit on a retiring ordinal, which the shrunk cluster does not scan. + view, err := running.leaderView() + g.Expect(err).NotTo(HaveOccurred()) + names := instanceNames(view) + + for _, name := range retiring { + if replicas != "5" && slices.Contains(names, name) { + // Not something to retry: the ordering this catches is broken for + // good by the time it is observable. + StopTrying(fmt.Sprintf( + "the coordinator StatefulSet was scaled to %s replicas while %s was still a Raft member", + replicas, name)).Now() + } + g.Expect(names).NotTo(ContainElement(name)) + } + }, 10*time.Minute, 5*time.Second).Should(Succeed()) + + By("waiting for their pods to be shed") + // Above terminationGracePeriodSeconds, for the reason the data-instance + // shrink's own shed assertion is. + Eventually(func(g Gomega) { + g.Expect(shrunk.replicas("coordinator")).To(Equal("3")) + g.Expect(shrunk.podExists("coordinator", 3)).To(BeFalse()) + g.Expect(shrunk.podExists("coordinator", 4)).To(BeFalse()) + }, 8*time.Minute, 5*time.Second).Should(Succeed()) + + By("confirming the shrunk cluster is registered, converged, and led by a survivor") + Eventually(shrunk.verifyRegistered, 10*time.Minute, 10*time.Second).Should(Succeed()) + shrunk.awaitConverged(5 * time.Minute) + coordinators, _ := shrunk.registeredCounts() + Expect(coordinators).To(Equal("3")) + view, err := shrunk.leaderView() + Expect(err).NotTo(HaveOccurred()) + Expect(coordinatorLeaderOf(view)).To(BeElementOf("coordinator_1", "coordinator_2", "coordinator_3")) + + By("confirming the retired coordinators' claims are kept by the default retention policy") + claims, err := listPVCs(scalingNamespace) + Expect(err).NotTo(HaveOccurred()) + for _, ordinal := range []int{3, 4} { + Expect(claims).To(ContainElement( + fmt.Sprintf("lib-storage-%s-coordinator-%d", scalingClusterName, ordinal)), + "whenScaled follows spec.storage.retentionPolicy, which defaults to Retain") + } + }) +}) + +// listAdoptedPVCs returns the names of the PersistentVolumeClaims a +// StatefulSet has taken ownership of. Only the owner reference makes a claim +// follow its StatefulSet into deletion, so this is the precondition a spec +// asserting the Delete retention policy must wait for before it deletes +// anything. It gates a spec rather than asserting one — the retention +// assertion itself stays on the claims a user would see. +// expectedClaims is every claim name the given cluster's StatefulSets provision: +// the two volume claim templates, for each pod of each role. Claim names are +// "