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/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..af348f0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,77 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + +permissions: {} + +jobs: + unit: + permissions: + contents: read + name: Unit tests + 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: Run unit tests + run: make test-unit + + envtest: + permissions: + contents: read + name: Envtest + 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: Run tests against envtest + run: make test + + e2e: + permissions: + contents: read + name: E2E tests + 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: Install Kind + run: go install sigs.k8s.io/kind@v0.32.0 + + - name: Run e2e tests against Kind + env: + # Enterprise license for the Memgraph HA cluster the suite boots, + # following the HA chart CI's repository-secret practice. + MEMGRAPH_ENTERPRISE_LICENSE: ${{ secrets.MEMGRAPH_ENTERPRISE_LICENSE }} + MEMGRAPH_ORGANIZATION_NAME: ${{ secrets.MEMGRAPH_ORGANIZATION_NAME }} + # The operator has no webhooks in v1, so the suite needs no CertManager. + CERT_MANAGER_INSTALL_SKIP: "true" + run: make test-e2e diff --git a/.gitignore b/.gitignore index 6a96658..9f0f3a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,30 @@ - # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib -bin +bin/* +Dockerfile.cross + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Go workspace file +go.work + +# Kubernetes Generated files - skip generated files, except for vendored files +!vendor/**/zz_generated.* # editor and IDE paraphernalia .idea +.vscode *.swp *.swo *~ -./manager +# Kubeconfig might contain secrets +*.kubeconfig diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 0163692..0000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "helm-charts"] - path = helm-charts - url = https://github.com/memgraph/helm-charts.git - branch = main diff --git a/.golangci.yml b/.golangci.yml index aed8644..b139f79 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,40 +1,69 @@ +version: "2" run: - deadline: 5m allow-parallel-runners: true - -issues: - # don't skip warning about doc comments - # don't exclude the default set of lint - exclude-use-default: false - # restore some of the defaults - # (fill in the rest as needed) - exclude-rules: - - path: "api/*" - linters: - - lll - - path: "internal/*" - linters: - - dupl - - lll linters: - disable-all: true + default: none enable: + - copyloopvar + - depguard - dupl - errcheck - - exportloopref + - ginkgolinter - goconst - gocyclo - - gofmt - - goimports - - gosimple - govet - ineffassign - lll + - modernize - misspell - nakedret - prealloc + - revive - staticcheck - - typecheck - unconvert - unparam - unused + - logcheck + settings: + custom: + logcheck: + type: "module" + description: Checks Go logging calls for Kubernetes logging conventions. + depguard: + rules: + forbid-sort-pkg: + deny: + - pkg: sort + desc: Should be replaced with slices package + revive: + rules: + - name: comment-spacings + - name: import-shadowing + modernize: + disable: + - omitzero + - newexpr + exclusions: + generated: lax + rules: + - linters: + - lll + path: api/* + - linters: + - dupl + - lll + path: internal/* + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 2ca863e..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - exclude: ^charts/memgraph/templates/ - - id: check-json - - id: mixed-line-ending - - id: check-merge-conflict - - id: detect-private-key - -- repo: https://github.com/tekwizely/pre-commit-golang - rev: v1.0.0-rc.1 - hooks: - - id: go-mod-tidy - - id: go-fmt diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b1080a0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,320 @@ +# kubernetes-operator - AI Agent Guide + +## Project Structure + +**Single-group layout (default):** +``` +cmd/main.go Manager entry (registers controllers/webhooks) +api//*_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..fa4ce27 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,62 @@ +# 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 +``` + +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`, 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. 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 **immutable after creation, enforced by CEL** in 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, register instance, add coordinator, set main) 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. `SET INSTANCE TO MAIN` is issued exactly once at bootstrap (when no MAIN exists); after that, failover belongs to the Raft coordinators — the operator only observes. +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** — lives in this repo, cross-published to `memgraph.github.io/helm-charts` at release. +7. **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, 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. +- No destructive code paths in v1: no finalizer-based storage cleanup, no instance unregistration; PVC retention maps to the StatefulSet PVC retention policy (default `Retain`). +- Workload pods: non-root uid 101 / gid 103, seccomp RuntimeDefault, all capabilities dropped. +- 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..1f1ce45 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,65 @@ 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; \ } + @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 +151,18 @@ 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 + ##@ Deployment ifndef ignore-not-found @@ -182,127 +171,107 @@ 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 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..660d29a 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,140 @@ # 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.** This repository was reset for a fresh operator effort; the previous attempt is preserved on the `archive/pre-operator-mvp` branch. The product 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 +## Getting Started -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. +### Prerequisites +- go version v1.24.6+ +- docker version 17.03+. +- kubectl version v1.11.3+. +- Access to a Kubernetes v1.11.3+ cluster. -## Documentation +### To Deploy on the cluster +**Build and push your image to the location specified by `IMG`:** -Check our [Documentation](/docs) to start using our Kubernetes operator. +```sh +make docker-build docker-push IMG=/kubernetes-operator:tag +``` -1. [Install the Memgraph Kubernetes Operator](docs/installation.md) +**NOTE:** This image ought to be published in the personal registry you specified. +And it is required to have access to pull the image from the working environment. +Make sure you have the proper permission to the registry if the above commands don’t work. + +**Install the CRDs into the cluster:** + +```sh +make install +``` + +**Deploy the Manager to the cluster with the image specified by `IMG`:** + +```sh +make deploy IMG=/kubernetes-operator:tag +``` + +> **NOTE**: If you encounter RBAC errors, you may need to grant yourself cluster-admin +privileges or be logged in as admin. + +**Create instances of your solution** +You can apply the samples (examples) from the config/sample: + +```sh +kubectl apply -k config/samples/ +``` + +>**NOTE**: Ensure that the samples has default values to test it out. + +### To Uninstall +**Delete the instances (CRs) from the cluster:** + +```sh +kubectl delete -k config/samples/ +``` + +**Delete the APIs(CRDs) from the cluster:** + +```sh +make uninstall +``` + +**UnDeploy the controller from the cluster:** + +```sh +make undeploy +``` + +## Project Distribution + +Following the options to release and provide this solution to the users. + +### By providing a bundle with all YAML files + +1. Build the installer for the image built and published in the registry: + +```sh +make build-installer IMG=/kubernetes-operator:tag +``` + +**NOTE:** The makefile target mentioned above generates an 'install.yaml' +file in the dist directory. This file contains all the resources built +with Kustomize, which are necessary to install this project without its +dependencies. + +2. Using the installer + +Users can just run 'kubectl apply -f ' to install +the project, i.e.: + +```sh +kubectl apply -f https://raw.githubusercontent.com//kubernetes-operator//dist/install.yaml +``` + +### By providing a Helm Chart + +1. Build the chart using the optional helm plugin + +```sh +kubebuilder edit --plugins=helm/v2-alpha +``` + +2. See that a chart was generated under 'dist/chart', and users +can obtain this solution from there. + +**NOTE:** If you change the project, you need to update the Helm Chart +using the same command above to sync the latest changes. Furthermore, +if you create webhooks, you need to use the above command with +the '--force' flag and manually ensure that any custom configuration +previously added to 'dist/chart/values.yaml' or 'dist/chart/manager/manager.yaml' +is manually re-applied afterwards. + +## Contributing + +Development is sliced into PR-gated issues under [`specs/operator-mvp/issues/`](specs/operator-mvp/issues). Every pull request runs lint, unit, and envtest suites. + +**NOTE:** Run `make help` for more information on all potential `make` targets + +More information can be found via the [Kubebuilder Documentation](https://book.kubebuilder.io/introduction.html) ## 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..d4bee34 --- /dev/null +++ b/api/v1alpha1/memgraphcluster_types.go @@ -0,0 +1,173 @@ +/* +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" + 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" +) + +// ImageSpec selects the Memgraph container image run by all cluster pods. +type ImageSpec struct { + // repository is the Memgraph container image repository. + // +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: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. +type SecretsSpec struct { + // name is the name of the Secret in the cluster's namespace. + // +kubebuilder:default="memgraph-secrets" + // +optional + Name string `json:"name,omitempty"` + + // licenseKey is the key within the Secret holding the enterprise license. + // +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:default="MEMGRAPH_ORGANIZATION_NAME" + // +optional + OrganizationKey string `json:"organizationKey,omitempty"` +} + +// MemgraphClusterSpec defines the desired state of MemgraphCluster. +// +// Storage, port, and pod-tuning fields land in subsequent slices of the +// operator MVP (see specs/operator-mvp/PRD.md). +type MemgraphClusterSpec struct { + // coordinators is the number of Raft coordinator instances. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:default=3 + // +optional + Coordinators *int32 `json:"coordinators,omitempty"` + + // dataInstances is the number of data instances. + // +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"` +} + +// MemgraphClusterStatus defines the observed state of MemgraphCluster. +type MemgraphClusterStatus struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + // Important: Run "make" to regenerate code after modifying this file + + // For Kubernetes API conventions, see: + // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties + + // 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 + +// 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..1ba4413 --- /dev/null +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,164 @@ +//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/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 *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 *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 +} + +// 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([]v1.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 *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 +} diff --git a/cmd/main.go b/cmd/main.go index d9afff3..bddce0c 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. @@ -31,12 +31,14 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "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" + // +kubebuilder:scaffold:imports ) var ( @@ -47,19 +49,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 +90,117 @@ 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, + 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..0d05254 --- /dev/null +++ b/config/crd/bases/memgraph.com_memgraphclusters.yaml @@ -0,0 +1,183 @@ +--- +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: + - 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: + coordinators: + default: 3 + description: coordinators is the number of Raft coordinator instances. + format: int32 + minimum: 1 + type: integer + dataInstances: + default: 2 + description: dataInstances is the number of data instances. + format: int32 + minimum: 1 + type: integer + 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. + type: string + 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". + type: string + 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. + type: string + name: + default: memgraph-secrets + description: name is the name of the Secret in the cluster's namespace. + type: string + organizationKey: + default: MEMGRAPH_ORGANIZATION_NAME + description: |- + organizationKey is the key within the Secret holding the organization + name the license was issued to. + 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 + 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..2172b4d 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -2,15 +2,12 @@ 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 @@ -31,22 +28,10 @@ rules: - patch - update - watch -- apiGroups: - - batch - resources: - - jobs - verbs: - - create - - delete - - get - - list - - patch - - update - - watch - apiGroups: - memgraph.com resources: - - memgraphhas + - memgraphclusters verbs: - create - delete @@ -58,13 +43,13 @@ rules: - apiGroups: - memgraph.com resources: - - memgraphhas/finalizers + - memgraphclusters/finalizers verbs: - update - apiGroups: - memgraph.com resources: - - memgraphhas/status + - memgraphclusters/status verbs: - get - patch 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..4658833 --- /dev/null +++ b/config/samples/v1alpha1_memgraphcluster.yaml @@ -0,0 +1,19 @@ +apiVersion: memgraph.com/v1alpha1 +kind: MemgraphCluster +metadata: + labels: + app.kubernetes.io/name: kubernetes-operator + app.kubernetes.io/managed-by: kustomize + name: memgraphcluster-sample +spec: + coordinators: 3 + dataInstances: 2 + image: + repository: docker.io/memgraph/memgraph + tag: 3.12.0-relwithdebinfo + # 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 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/go.mod b/go.mod index f541a53..7ff709f 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 ) 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 + sigs.k8s.io/yaml v1.6.0 // 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/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..e66eafa --- /dev/null +++ b/internal/controller/fake_memgraph_test.go @@ -0,0 +1,163 @@ +/* +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" + "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 + connectAttempts int + // 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++ + return &fakeClient{cluster: f, address: address}, nil +} + +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) +} + +type fakeClient struct { + cluster *fakeMemgraph + address string + closed bool +} + +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) + } + return slices.Clone(c.cluster.instances), nil +} + +func (c *fakeClient) AddCoordinator(_ context.Context, coordinator memgraph.CoordinatorSpec) error { + return c.execute(fmt.Sprintf("ADD COORDINATOR %d", coordinator.ID), func() error { + if c.cluster.hasInstance(coordinator.Name()) { + return fmt.Errorf("fake memgraph: coordinator %s already exists", coordinator.Name()) + } + c.cluster.instances = append(c.cluster.instances, memgraph.Instance{ + Name: coordinator.Name(), + BoltServer: coordinator.BoltServer, + CoordinatorServer: coordinator.CoordinatorServer, + ManagementServer: coordinator.ManagementServer, + Health: "up", + Role: memgraph.RoleFollower, + }) + 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) + }) +} + +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) + } + if err := apply(); err != nil { + return err + } + c.cluster.executed = append(c.cluster.executed, c.address+": "+command) + return 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..012c4ec --- /dev/null +++ b/internal/controller/memgraphcluster_controller.go @@ -0,0 +1,297 @@ +/* +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" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/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" +) + +// 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 +} + +// +kubebuilder:rbac:groups=memgraph.com,resources=memgraphclusters,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=memgraph.com,resources=memgraphclusters/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=memgraph.com,resources=memgraphclusters/finalizers,verbs=update +// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;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. 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. 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. 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) + } + + desired := []client.Object{ + resources.CoordinatorHeadlessService(&cluster), + resources.DataHeadlessService(&cluster), + resources.CoordinatorStatefulSet(&cluster), + resources.DataStatefulSet(&cluster), + } + for _, obj := range desired { + if err := controllerutil.SetControllerReference(&cluster, obj, r.Scheme); err != nil { + return ctrl.Result{}, fmt.Errorf("setting owner reference on %T %s: %w", obj, obj.GetName(), err) + } + if err := r.apply(ctx, obj); err != nil { + return ctrl.Result{}, fmt.Errorf("applying %T %s: %w", obj, obj.GetName(), err) + } + } + + log.Info("Applied desired workload objects for MemgraphCluster", "memgraphcluster", req.NamespacedName) + + return r.reconcileRegistration(ctx, &cluster) +} + +// reconcileRegistration converges cluster registration once the workloads are +// ready: find the coordinator leader, plan against its SHOW INSTANCES view, +// and execute the missing commands. 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. +func (r *MemgraphClusterReconciler) reconcileRegistration( + ctx context.Context, + cluster *memgraphcomv1alpha1.MemgraphCluster, +) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + ready, err := r.workloadsReady(ctx, cluster) + if err != nil { + return ctrl.Result{}, err + } + if !ready { + log.Info("Waited for workload pods to become ready before registration") + return ctrl.Result{RequeueAfter: requeueWhilePending}, nil + } + + topology := resources.DeclaredTopology(cluster) + leader, observed, err := r.observeCluster(ctx, topology) + if err != nil { + log.Info("Deferred registration because no coordinator answered", "reason", err.Error()) + return ctrl.Result{RequeueAfter: requeueWhilePending}, nil + } + defer func() { + if err := leader.Close(ctx); err != nil { + log.Error(err, "Failed to close coordinator connection") + } + }() + + commands := planner.Plan(topology, observed) + if len(commands) == 0 { + // 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") + return ctrl.Result{RequeueAfter: resyncInterval}, nil + } + for _, command := range commands { + if err := command.Run(ctx, leader); err != nil { + return ctrl.Result{}, fmt.Errorf("executing registration command %q: %w", command, err) + } + 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 +} + +// 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. +func (r *MemgraphClusterReconciler) workloadsReady( + ctx context.Context, + cluster *memgraphcomv1alpha1.MemgraphCluster, +) (bool, error) { + for _, name := range []string{resources.CoordinatorName(cluster), resources.DataName(cluster)} { + var sts appsv1.StatefulSet + if err := r.Get(ctx, types.NamespacedName{Name: name, Namespace: cluster.Namespace}, &sts); err != nil { + return false, fmt.Errorf("getting StatefulSet %s: %w", name, err) + } + if sts.Spec.Replicas == nil || sts.Status.ReadyReplicas < *sts.Spec.Replicas { + return false, nil + } + } + return true, nil +} + +// 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, a follower redirects to the leader it reports, and when no +// leader exists yet (fresh cluster, Raft not formed) the first reachable +// coordinator is used — adding coordinators to it makes it the leader, +// mirroring the HA chart's bootstrap against its first coordinator. +func (r *MemgraphClusterReconciler) observeCluster( + ctx context.Context, + topology planner.Topology, +) (memgraph.Client, []memgraph.Instance, error) { + var errs []error + for _, coordinator := range topology.Coordinators { + leader, observed, err := r.showInstances(ctx, coordinator) + if err != nil { + errs = append(errs, err) + continue + } + + leaderName := "" + for _, instance := range observed { + if instance.IsLeader() { + leaderName = instance.Name + break + } + } + if leaderName == "" || leaderName == coordinator.Name() { + return leader, observed, nil + } + + // This coordinator is a follower; redirect to the leader it reports. + if err := leader.Close(ctx); err != nil { + errs = append(errs, err) + } + candidate, found := coordinatorByName(topology, leaderName) + if !found { + errs = append(errs, fmt.Errorf("%s reported leader %s, which is not declared", coordinator.Name(), leaderName)) + continue + } + leader, observed, err = r.showInstances(ctx, candidate) + if err != nil { + errs = append(errs, err) + continue + } + return leader, observed, nil + } + return nil, nil, fmt.Errorf("no coordinator leader reachable: %w", errors.Join(errs...)) +} + +func coordinatorByName(topology planner.Topology, name string) (memgraph.CoordinatorSpec, bool) { + for _, coordinator := range topology.Coordinators { + if coordinator.Name() == name { + return coordinator, true + } + } + return memgraph.CoordinatorSpec{}, false +} + +// showInstances connects to one coordinator and fetches its cluster view, +// closing the connection again on query failure. +func (r *MemgraphClusterReconciler) showInstances( + ctx context.Context, + coordinator memgraph.CoordinatorSpec, +) (memgraph.Client, []memgraph.Instance, error) { + c, err := r.Memgraph.Connect(ctx, coordinator.BoltServer) + 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..cc0af55 --- /dev/null +++ b/internal/controller/memgraphcluster_controller_test.go @@ -0,0 +1,467 @@ +/* +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" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + 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" +) + +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 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() { + resource := &memgraphcomv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{Name: resourceName, Namespace: resourceNamespace}, + Spec: memgraphcomv1alpha1.MemgraphClusterSpec{ + Coordinators: ptr.To(int32(1)), + DataInstances: ptr.To(int32(1)), + 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", + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).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 := range []string{coordinatorSuffix, dataSuffix} { + sts := &appsv1.StatefulSet{} + get(resourceName+suffix, sts) + Expect(sts.Spec.Replicas).To(HaveValue(Equal(int32(1)))) + + 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("my-license")) + Expect(licenseRef.Key).To(Equal("license")) + organizationRef := container.Env[len(container.Env)-1].ValueFrom.SecretKeyRef + Expect(organizationRef.Name).To(Equal("my-license")) + Expect(organizationRef.Key).To(Equal("organization")) + } + }) + + It("should reject a spec violating the schema", func() { + invalid := &memgraphcomv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "mgc-invalid", Namespace: resourceNamespace}, + Spec: memgraphcomv1alpha1.MemgraphClusterSpec{ + Coordinators: ptr.To(int32(0)), + }, + } + Expect(k8sClient.Create(ctx, invalid)).NotTo(Succeed()) + }) + }) + + 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", + })) + }) + + // 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") + } + }) + }) +}) 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..cbec9ef --- /dev/null +++ b/internal/memgraph/bolt.go @@ -0,0 +1,123 @@ +/* +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" + + "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) 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) 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"), + } +} + +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 +} diff --git a/internal/memgraph/client.go b/internal/memgraph/client.go new file mode 100644 index 0000000..f9a3fda --- /dev/null +++ b/internal/memgraph/client.go @@ -0,0 +1,98 @@ +/* +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, add +// coordinator, register instance, set main. 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" +) + +// 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) +} + +// 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("coordinator_%d", c.ID) +} + +// DataInstanceSpec declares one data instance to register with the cluster. +type DataInstanceSpec struct { + Name string + BoltServer string + ManagementServer string + ReplicationServer string +} + +// 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) + AddCoordinator(ctx context.Context, coordinator CoordinatorSpec) error + RegisterInstance(ctx context.Context, instance DataInstanceSpec) error + SetInstanceToMain(ctx context.Context, name string) 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..f19b9a9 --- /dev/null +++ b/internal/memgraph/queries.go @@ -0,0 +1,50 @@ +/* +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" + +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) +} diff --git a/internal/memgraph/queries_test.go b/internal/memgraph/queries_test.go new file mode 100644 index 0000000..572d64e --- /dev/null +++ b/internal/memgraph/queries_test.go @@ -0,0 +1,98 @@ +/* +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" + +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 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..336efbb --- /dev/null +++ b/internal/planner/planner.go @@ -0,0 +1,120 @@ +/* +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 the +// initial MAIN promotion happens exactly once, when no MAIN exists. After +// bootstrap, failover belongs to the Raft coordinators; the planner never +// overrides an existing MAIN. +package planner + +import ( + "context" + "fmt" + + "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. +type Topology struct { + Coordinators []memgraph.CoordinatorSpec + DataInstances []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 the named data instance to MAIN at bootstrap. +type SetInstanceToMain struct { + Name string +} + +// Run implements Command. +func (c SetInstanceToMain) Run(ctx context.Context, client memgraph.Client) error { + return client.SetInstanceToMain(ctx, c.Name) +} + +func (c SetInstanceToMain) String() string { + return fmt.Sprintf("SET INSTANCE %s TO MAIN", c.Name) +} + +// 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), the initial MAIN +// promotion last. Instances the cluster knows but the topology does not +// declare are left untouched — unregistration is out of scope for v1. +func Plan(declared Topology, observed []memgraph.Instance) []Command { + registered := make(map[string]memgraph.Instance, len(observed)) + hasMain := false + for _, instance := range observed { + registered[instance.Name] = instance + hasMain = hasMain || instance.IsMain() + } + + var commands []Command + for _, coordinator := range declared.Coordinators { + // 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. + if observed, ok := registered[coordinator.Name()]; !ok || observed.BoltServer == "" { + commands = append(commands, AddCoordinator{Coordinator: coordinator}) + } + } + for _, instance := range declared.DataInstances { + if _, ok := registered[instance.Name]; !ok { + commands = append(commands, RegisterInstance{Instance: instance}) + } + } + if !hasMain && len(declared.DataInstances) > 0 { + commands = append(commands, SetInstanceToMain{Name: declared.DataInstances[0].Name}) + } + return commands +} diff --git a/internal/planner/planner_test.go b/internal/planner/planner_test.go new file mode 100644 index 0000000..5433f3c --- /dev/null +++ b/internal/planner/planner_test.go @@ -0,0 +1,224 @@ +/* +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" + + "github.com/memgraph/kubernetes-operator/internal/memgraph" + "github.com/memgraph/kubernetes-operator/internal/planner" +) + +// declaredTopology is the canonical 3-coordinator, 2-data-instance fixture +// the cases below diff observed cluster states against. +func declaredTopology() planner.Topology { + topology := planner.Topology{} + for id := int32(1); id <= 3; id++ { + topology.Coordinators = append(topology.Coordinators, coordinatorSpec(id)) + } + for i := range 2 { + 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, + } +} + +func observedDataInstance(i int, role string) memgraph.Instance { + spec := dataInstanceSpec(i) + return memgraph.Instance{ + Name: spec.Name, + BoltServer: spec.BoltServer, + ManagementServer: spec.ManagementServer, + Health: "up", + Role: role, + } +} + +func TestPlan(t *testing.T) { + declared := declaredTopology() + + cases := []struct { + name string + observed []memgraph.Instance + 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)}, + planner.SetInstanceToMain{Name: "instance_0"}, + }, + }, + { + 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{ + planner.SetInstanceToMain{Name: "instance_0"}, + }, + }, + { + 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)}, + }, + }, + { + 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, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := planner.Plan(declared, tc.observed) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("Plan() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/resources/resources.go b/internal/resources/resources.go new file mode 100644 index 0000000..3e6da3e --- /dev/null +++ b/internal/resources/resources.go @@ -0,0 +1,142 @@ +/* +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 ( + corev1 "k8s.io/api/core/v1" + + memgraphcomv1alpha1 "github.com/memgraph/kubernetes-operator/api/v1alpha1" +) + +// Internal Memgraph ports. These mirror the memgraph-high-availability Helm +// chart's defaults and become spec knobs in a later slice. +const ( + BoltPort int32 = 7687 + ManagementPort int32 = 10000 + ReplicationPort int32 = 20000 + CoordinatorPort int32 = 12000 +) + +const ( + // clusterDomain is the Kubernetes cluster domain used in advertised FQDN + // addresses. It becomes a spec knob in a later slice. + clusterDomain = "cluster.local" + + // 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" +) + +// 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. +func labels(cluster *memgraphcomv1alpha1.MemgraphCluster, component string) map[string]string { + l := selectorLabels(cluster, component) + l["app.kubernetes.io/managed-by"] = "memgraph-operator" + return l +} + +// 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 CRD schema default, so builders behave correctly on specs that never +// passed admission. +type normalizedSpec struct { + coordinators int32 + dataInstances int32 + image string + pullPolicy corev1.PullPolicy + secretName string + licenseKey string + organizationKey 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, + } + if spec.Coordinators != nil { + n.coordinators = *spec.Coordinators + } + if spec.DataInstances != nil { + n.dataInstances = *spec.DataInstances + } + 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 + } + 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..cb9a393 --- /dev/null +++ b/internal/resources/service.go @@ -0,0 +1,69 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package 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 { + return headlessService(cluster, coordinatorComponent, CoordinatorName(cluster), []corev1.ServicePort{ + {Name: boltPortName, Port: BoltPort}, + {Name: managementPortName, Port: ManagementPort}, + {Name: coordinatorPortName, Port: CoordinatorPort}, + }) +} + +// DataHeadlessService builds the headless Service backing the data-instance +// StatefulSet's per-pod DNS identities. +func DataHeadlessService(cluster *memgraphcomv1alpha1.MemgraphCluster) *corev1.Service { + return headlessService(cluster, dataComponent, DataName(cluster), []corev1.ServicePort{ + {Name: boltPortName, Port: BoltPort}, + {Name: managementPortName, Port: ManagementPort}, + {Name: replicationPortName, Port: ReplicationPort}, + }) +} + +func headlessService( + cluster *memgraphcomv1alpha1.MemgraphCluster, + component, name 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), + }, + 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..ac7d278 --- /dev/null +++ b/internal/resources/service_test.go @@ -0,0 +1,79 @@ +/* +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: "Service"}, + 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: "Service"}, + 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) + } +} diff --git a/internal/resources/statefulset.go b/internal/resources/statefulset.go new file mode 100644 index 0000000..c784c2a --- /dev/null +++ b/internal/resources/statefulset.go @@ -0,0 +1,228 @@ +/* +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" + "strings" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + 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" +) + +// 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. +func CoordinatorStatefulSet(cluster *memgraphcomv1alpha1.MemgraphCluster) *appsv1.StatefulSet { + spec := normalize(cluster.Spec) + + container := memgraphContainer(spec) + // 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)} + container.Env = append([]corev1.EnvVar{{ + Name: "POD_NAME", + ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"}, + }, + }}, container.Env...) + container.Ports = []corev1.ContainerPort{ + {Name: boltPortName, ContainerPort: BoltPort}, + {Name: managementPortName, ContainerPort: ManagementPort}, + {Name: coordinatorPortName, ContainerPort: CoordinatorPort}, + } + container.StartupProbe = tcpProbe(CoordinatorPort, 20) + container.ReadinessProbe = tcpProbe(CoordinatorPort, 20) + container.LivenessProbe = tcpProbe(CoordinatorPort, 20) + + return statefulSet(cluster, coordinatorComponent, CoordinatorName(cluster), spec.coordinators, container) +} + +// DataStatefulSet builds the single StatefulSet running all data instances. +func DataStatefulSet(cluster *memgraphcomv1alpha1.MemgraphCluster) *appsv1.StatefulSet { + spec := normalize(cluster.Spec) + + container := memgraphContainer(spec) + container.Args = dataArgs() + container.Ports = []corev1.ContainerPort{ + {Name: boltPortName, ContainerPort: BoltPort}, + {Name: managementPortName, ContainerPort: ManagementPort}, + {Name: replicationPortName, ContainerPort: ReplicationPort}, + } + // A generous startup budget so large snapshot restores are not killed + // mid-load (mirrors the HA chart's default of 1440 * 5s = 2h). + container.StartupProbe = tcpProbe(BoltPort, 1440) + container.ReadinessProbe = tcpProbe(BoltPort, 20) + container.LivenessProbe = tcpProbe(BoltPort, 20) + + return statefulSet(cluster, dataComponent, DataName(cluster), spec.dataInstances, 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. +func coordinatorStartScript(cluster *memgraphcomv1alpha1.MemgraphCluster) string { + fqdnSuffix := podFQDNSuffix(cluster, CoordinatorName(cluster)) + return fmt.Sprintf(`ordinal="${POD_NAME##*-}" +exec %s \ + --coordinator-id="$((ordinal + 1))" \ + --coordinator-hostname="${POD_NAME}.%s" \ + --coordinator-port=%d \ + %s`, memgraphBinary, fqdnSuffix, CoordinatorPort, shellJoin(commonArgs())) +} + +func dataArgs() []string { + return commonArgs() +} + +// commonArgs are the Memgraph flags shared by both roles, mirroring the HA +// chart's auto-appended and default logging arguments. +func commonArgs() []string { + return []string{ + fmt.Sprintf("--bolt-port=%d", BoltPort), + fmt.Sprintf("--management-port=%d", ManagementPort), + "--data-directory=" + dataDirectory, + "--log-level=TRACE", + "--also-log-to-stderr", + "--log-file=" + logFile, + "--log-retention-days=35", + } +} + +func shellJoin(args []string) string { + return strings.Join(args, " \\\n ") +} + +// memgraphContainer builds the parts of the Memgraph container shared by both +// roles: image, license env wiring, storage mounts, and the restricted +// security context. +func memgraphContainer(spec normalizedSpec) corev1.Container { + return corev1.Container{ + Name: "memgraph", + Image: spec.image, + ImagePullPolicy: spec.pullPolicy, + Env: []corev1.EnvVar{ + { + Name: "MEMGRAPH_ENTERPRISE_LICENSE", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: spec.secretName}, + Key: spec.licenseKey, + }, + }, + }, + { + Name: "MEMGRAPH_ORGANIZATION_NAME", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: spec.secretName}, + Key: spec.organizationKey, + }, + }, + }, + }, + VolumeMounts: []corev1.VolumeMount{ + {Name: "lib-storage", MountPath: libMountPath}, + {Name: "log-storage", MountPath: logMountPath}, + {Name: "tmp", MountPath: tmpMountPath}, + }, + SecurityContext: &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 statefulSet( + cluster *memgraphcomv1alpha1.MemgraphCluster, + component, name string, + 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), + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(replicas), + ServiceName: name, + PodManagementPolicy: appsv1.ParallelPodManagement, + Selector: &metav1.LabelSelector{MatchLabels: selectorLabels(cluster, component)}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels(cluster, component), + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{container}, + 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}, + }, + // Storage is ephemeral in this slice; PVC templates and + // retention policy land with the storage-configuration + // slice (specs/operator-mvp/issues/08). + Volumes: []corev1.Volume{ + {Name: "lib-storage", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, + {Name: "log-storage", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, + {Name: "tmp", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, + }, + }, + }, + }, + } +} + +func tcpProbe(port, failureThreshold int32) *corev1.Probe { + return &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + TCPSocket: &corev1.TCPSocketAction{Port: intstr.FromInt32(port)}, + }, + FailureThreshold: failureThreshold, + TimeoutSeconds: 10, + PeriodSeconds: 5, + } +} diff --git a/internal/resources/statefulset_test.go b/internal/resources/statefulset_test.go new file mode 100644 index 0000000..d07a8bf --- /dev/null +++ b/internal/resources/statefulset_test.go @@ -0,0 +1,315 @@ +/* +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" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + 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" +) + +// 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}, + } +} + +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", + }, + }, + } +} + +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, + }, + }, + }, + } +} + +func tcpProbe(port, failureThreshold int32) *corev1.Probe { + return &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + TCPSocket: &corev1.TCPSocketAction{Port: intstr.FromInt32(port)}, + }, + FailureThreshold: failureThreshold, + TimeoutSeconds: 10, + PeriodSeconds: 5, + } +} + +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: "tmp", MountPath: "/tmp"}, + } +} + +func expectedVolumes() []corev1.Volume { + return []corev1.Volume{ + {Name: "lib-storage", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, + {Name: "log-storage", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, + {Name: "tmp", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, + } +} + +func expectedLabels(component string) map[string]string { + return map[string]string{ + "app.kubernetes.io/name": memgraphName, + "app.kubernetes.io/instance": clusterName, + "app.kubernetes.io/component": component, + "app.kubernetes.io/managed-by": "memgraph-operator", + } +} + +func expectedSelectorLabels(component string) map[string]string { + return map[string]string{ + "app.kubernetes.io/name": memgraphName, + "app.kubernetes.io/instance": clusterName, + "app.kubernetes.io/component": 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 \ + --bolt-port=7687 \ + --management-port=10000 \ + --data-directory=/var/lib/memgraph/mg_data \ + --log-level=TRACE \ + --also-log-to-stderr \ + --log-file=/var/log/memgraph/memgraph.log \ + --log-retention-days=35` + +func TestCoordinatorStatefulSetDefaults(t *testing.T) { + want := &appsv1.StatefulSet{ + TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "StatefulSet"}, + ObjectMeta: metav1.ObjectMeta{ + Name: coordinatorName, + Namespace: testNamespace, + Labels: expectedLabels(coordinatorComponent), + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + ServiceName: coordinatorName, + PodManagementPolicy: appsv1.ParallelPodManagement, + Selector: &metav1.LabelSelector{MatchLabels: expectedSelectorLabels(coordinatorComponent)}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: expectedLabels(coordinatorComponent)}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: memgraphName, + Image: "docker.io/memgraph/memgraph:3.12.0-relwithdebinfo", + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"/bin/sh", "-ec", expectedCoordinatorScript}, + 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 := resources.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: "StatefulSet"}, + ObjectMeta: metav1.ObjectMeta{ + Name: dataName, + Namespace: testNamespace, + Labels: expectedLabels(dataComponent), + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(2)), + ServiceName: dataName, + PodManagementPolicy: appsv1.ParallelPodManagement, + Selector: &metav1.LabelSelector{MatchLabels: expectedSelectorLabels(dataComponent)}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: expectedLabels(dataComponent)}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: memgraphName, + Image: "docker.io/memgraph/memgraph:3.12.0-relwithdebinfo", + ImagePullPolicy: corev1.PullIfNotPresent, + Args: []string{ + "--bolt-port=7687", + "--management-port=10000", + "--data-directory=/var/lib/memgraph/mg_data", + "--log-level=TRACE", + "--also-log-to-stderr", + "--log-file=/var/log/memgraph/memgraph.log", + "--log-retention-days=35", + }, + 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 := resources.DataStatefulSet(minimalCluster()) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("DataStatefulSet() mismatch (-want +got):\n%s", diff) + } +} + +func TestStatefulSetSpecOverrides(t *testing.T) { + cluster := specifiedCluster() + + tests := []struct { + name string + sts *appsv1.StatefulSet + replicas int32 + }{ + {name: coordinatorComponent, sts: resources.CoordinatorStatefulSet(cluster), replicas: 5}, + {name: dataComponent, sts: resources.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) + } + }) + } +} diff --git a/internal/resources/topology.go b/internal/resources/topology.go new file mode 100644 index 0000000..9c5370b --- /dev/null +++ b/internal/resources/topology.go @@ -0,0 +1,75 @@ +/* +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" +) + +// 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 { + fqdn := podFQDN(cluster, CoordinatorName(cluster), ordinal) + topology.Coordinators = append(topology.Coordinators, memgraph.CoordinatorSpec{ + ID: ordinal + 1, + BoltServer: hostPort(fqdn, BoltPort), + CoordinatorServer: hostPort(fqdn, CoordinatorPort), + ManagementServer: hostPort(fqdn, ManagementPort), + }) + } + for ordinal := range spec.dataInstances { + fqdn := podFQDN(cluster, DataName(cluster), ordinal) + topology.DataInstances = append(topology.DataInstances, memgraph.DataInstanceSpec{ + Name: fmt.Sprintf("instance_%d", ordinal), + BoltServer: hostPort(fqdn, BoltPort), + ManagementServer: hostPort(fqdn, ManagementPort), + ReplicationServer: hostPort(fqdn, ReplicationPort), + }) + } + return topology +} + +// podFQDNSuffix returns the DNS suffix a pod name is appended to for pods of +// the given headless Service: "..svc.". +func podFQDNSuffix(cluster *memgraphcomv1alpha1.MemgraphCluster, serviceName string) string { + return fmt.Sprintf("%s.%s.svc.%s", serviceName, cluster.Namespace, 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, ordinal int32) string { + return fmt.Sprintf("%s-%d.%s", serviceName, ordinal, podFQDNSuffix(cluster, serviceName)) +} + +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..0ee9d6a --- /dev/null +++ b/internal/resources/topology_test.go @@ -0,0 +1,115 @@ +/* +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" + + "github.com/memgraph/kubernetes-operator/internal/memgraph" + "github.com/memgraph/kubernetes-operator/internal/planner" + "github.com/memgraph/kubernetes-operator/internal/resources" +) + +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: "instance_1", + 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)) + } +} + +// 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 := resources.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) + } + } +} diff --git a/specs/operator-mvp/PRD.md b/specs/operator-mvp/PRD.md new file mode 100644 index 0000000..fb3d6e3 --- /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 the StatefulSet PVC retention policy (`whenDeleted`). 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..ce2d96b --- /dev/null +++ b/specs/operator-mvp/issues/11-release-cross-publish.md @@ -0,0 +1,24 @@ +# 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 + +- [ ] Pushing a version tag builds and publishes the operator image with that version +- [ ] The same pipeline packages the install chart and publishes it into the `memgraph.github.io/helm-charts` index +- [ ] `helm repo update && helm install` from the existing Memgraph helm repo installs the tagged operator version end-to-end +- [ ] Chart version, appVersion, and image tag agree for every release +- [ ] The cross-repo credential is a scoped fine-grained PAT or deploy key stored as a repository secret, documented for rotation +- [ ] A dry-run/prerelease path exists to validate the pipeline without polluting the public index + +## 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/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index 63e2784..ec5d6bd 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,137 @@ package e2e import ( "fmt" + "os" + "os/exec" "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + + "github.com/memgraph/kubernetes-operator/test/utils" +) + +var ( + // managerImage is the manager image to be built and loaded for testing. + managerImage = "example.com/kubernetes-operator:v0.0.1" + // shouldCleanupCertManager tracks whether CertManager was installed by this suite. + shouldCleanupCertManager = false ) -// Run e2e tests using the Ginkgo runner. +// 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, install the CRDs, and deploy the controller into its +// namespace. 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 CRDs") + cmd = exec.Command("make", "install") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to install CRDs") + + By("deploying the controller-manager") + cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", managerImage)) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager") +}) + +var _ = AfterSuite(func() { + By("undeploying the controller-manager") + cmd := exec.Command("make", "undeploy") + _, _ = utils.Run(cmd) + + By("uninstalling CRDs") + cmd = exec.Command("make", "uninstall") + _, _ = 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..dbd3573 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,84 @@ import ( "github.com/memgraph/kubernetes-operator/test/utils" ) +// namespace where the project is deployed in const namespace = "kubernetes-operator-system" -var _ = Describe("controller", Ordered, func() { - BeforeAll(func() { - By("installing prometheus operator") - Expect(utils.InstallPrometheusOperator()).To(Succeed()) +// serviceAccountName created for the project +const serviceAccountName = "kubernetes-operator-controller-manager" - By("installing the cert-manager") - Expect(utils.InstallCertManager()).To(Succeed()) +// metricsServiceName is the name of the metrics service of the project +const metricsServiceName = "kubernetes-operator-controller-manager-metrics-service" - By("creating manager namespace") - cmd := exec.Command("kubectl", "create", "ns", namespace) - _, _ = utils.Run(cmd) - }) +// metricsRoleBindingName is the name of the RBAC that will be created to allow get the metrics data +const metricsRoleBindingName = "kubernetes-operator-metrics-binding" - AfterAll(func() { - By("uninstalling the Prometheus manager bundle") - utils.UninstallPrometheusOperator() +var _ = Describe("Manager", Ordered, func() { + var controllerPodName string - By("uninstalling the cert-manager bundle") - utils.UninstallCertManager() + // 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. - By("removing manager namespace") - cmd := exec.Command("kubectl", "delete", "ns", namespace) + // 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 +120,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") + } + 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=kubernetes-operator-metrics-reader", + 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") } - EventuallyWithOffset(1, verifyControllerUp, time.Minute, time.Second).Should(Succeed()) + 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..9dab002 --- /dev/null +++ b/test/e2e/memgraphcluster_test.go @@ -0,0 +1,455 @@ +//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" + "os" + "os/exec" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/memgraph/kubernetes-operator/test/utils" +) + +// The declared topology of the e2e cluster and the identities the operator +// derives from it: coordinator ordinal N registers as coordinator_N+1, data +// ordinal N as instance_N. +const ( + clusterNamespace = "memgraph-e2e" + clusterName = "memgraph" + + coordinatorCount = 3 + dataInstanceCount = 2 + + memgraphImageRepository = "docker.io/memgraph/memgraph" + memgraphImageTag = "3.12.0" + + // licenseSecretName and the env var names below follow the HA Helm chart's + // CI convention: repository secrets of the same names are exported into the + // job environment and materialize as one Kubernetes Secret the CR + // references. + licenseSecretName = "memgraph-secrets" + licenseEnvVar = "MEMGRAPH_ENTERPRISE_LICENSE" + organizationEnvVar = "MEMGRAPH_ORGANIZATION_NAME" + + // roleMain is the MAIN data-instance role reported in the SHOW INSTANCES role + // column. + roleMain = "main" +) + +// declaredInstances returns the instance names every coordinator and data +// instance must appear under in SHOW INSTANCES once the operator has converged +// registration. +func declaredInstances() []string { + names := make([]string, 0, coordinatorCount+dataInstanceCount) + for ordinal := range coordinatorCount { + names = append(names, fmt.Sprintf("coordinator_%d", ordinal+1)) + } + for ordinal := range dataInstanceCount { + names = append(names, fmt.Sprintf("instance_%d", 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 := os.Getenv(licenseEnvVar) + organization := os.Getenv(organizationEnvVar) + Expect(license).NotTo(BeEmpty(), + "%s must be set: the e2e suite boots a licensed Memgraph HA cluster", licenseEnvVar) + Expect(organization).NotTo(BeEmpty(), + "%s must be set: the e2e suite boots a licensed Memgraph HA cluster", organizationEnvVar) + + By("preloading the Memgraph image into the Kind cluster") + memgraphImage := memgraphImageRepository + ":" + memgraphImageTag + cmd := exec.Command("docker", "pull", memgraphImage) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to pull the Memgraph image") + Expect(utils.LoadImageToKindClusterWithName(memgraphImage)).To(Succeed(), + "Failed to load the Memgraph image into Kind") + + By("creating the cluster namespace") + cmd = exec.Command("kubectl", "create", "ns", clusterNamespace) + _, 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", clusterNamespace, + "pod-security.kubernetes.io/enforce=restricted") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to label namespace with restricted policy") + + By("creating the enterprise license Secret") + createLicenseSecret(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) + }) + + // On failure, dump everything needed to debug a broken bootstrap from CI + // logs alone. + AfterEach(func() { + if !CurrentSpecReport().Failed() { + return + } + for _, args := range [][]string{ + {"get", "pods", "-n", clusterNamespace, "-o", "wide"}, + {"get", "memgraphclusters", "-n", clusterNamespace, "-o", "yaml"}, + {"get", "events", "-n", clusterNamespace, "--sort-by=.lastTimestamp"}, + {"logs", "deploy/kubernetes-operator-controller-manager", "-n", namespace}, + } { + cmd := exec.Command("kubectl", args...) + output, err := utils.Run(cmd) + if err != nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to collect diagnostics %v: %s\n", args, err) + continue + } + _, _ = fmt.Fprintf(GinkgoWriter, "Diagnostics kubectl %v:\n%s\n", args, output) + } + }) + + It("bootstraps every declared instance registered with exactly one MAIN", func() { + Eventually(verifyClusterRegistered, 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(verifyClusterRegistered, 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 := 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(verifyClusterRegistered, 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(verifyClusterRegistered, 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 := 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(verifyClusterRegistered, 10*time.Minute, 10*time.Second).Should(Succeed()) + }) +}) + +// verifyClusterRegistered asserts the coordinator leader reports every declared +// instance registered and healthy with exactly one MAIN — the converged steady +// state both the bootstrap and re-registration specs check for. +func verifyClusterRegistered(g Gomega) { + view, err := leaderView() + g.Expect(err).NotTo(HaveOccurred()) + + names := make([]string, 0, len(view)) + mains := make([]string, 0, 1) + for _, instance := range view { + names = append(names, instance.name) + g.Expect(instance.health).To(Equal("up"), + "instance %s is registered but unhealthy", instance.name) + if instance.role == roleMain { + mains = append(mains, instance.name) + } + } + g.Expect(names).To(ConsistOf(declaredInstances())) + g.Expect(mains).To(HaveLen(1), "expected exactly one MAIN, got %v", mains) +} + +// wipeInstanceRegistration unregisters the named data instance on the +// coordinator leader, simulating registration state a pod loses when it is +// rescheduled onto a fresh node. UNREGISTER INSTANCE must run on the leader — +// only it holds the authoritative cluster view — so the leader is located the +// same way leaderView does: the coordinator that reports a MAIN. +func wipeInstanceRegistration(name string) error { + var errs []error + for ordinal := range coordinatorCount { + pod := fmt.Sprintf("%s-coordinator-%d", clusterName, ordinal) + view, err := showInstances(pod) + if err != nil { + errs = append(errs, err) + continue + } + isLeader := false + for _, instance := range view { + if instance.role == roleMain { + isLeader = true + break + } + } + if !isLeader { + continue + } + cmd := exec.Command("kubectl", "exec", pod, "-n", clusterNamespace, "-c", "memgraph", "--", + "bash", "-c", fmt.Sprintf("echo 'UNREGISTER INSTANCE %s;' | mgconsole", name)) + if _, err := utils.Run(cmd); err != nil { + return fmt.Errorf("unregistering %s on %s: %w", name, pod, err) + } + return nil + } + return fmt.Errorf("no coordinator leader found to unregister %s: %w", name, errors.Join(errs...)) +} + +// removeCoordinatorRegistration removes a follower coordinator from the Raft +// cluster on the coordinator leader, simulating a coordinator that fell out of +// the cluster view (e.g. rescheduled onto a fresh node). REMOVE COORDINATOR +// mutates Raft membership, so it must run on the leader — located the same way +// leaderView does: the coordinator that reports a MAIN. A follower is chosen +// (never the leader itself) so the leader keeps the authoritative view it needs +// to accept the removal and observe the operator's re-ADD. It returns the +// instance name of the coordinator it removed. +func removeCoordinatorRegistration() (string, error) { + var errs []error + for ordinal := range coordinatorCount { + pod := fmt.Sprintf("%s-coordinator-%d", clusterName, ordinal) + view, err := showInstances(pod) + if err != nil { + errs = append(errs, err) + continue + } + isLeader := false + for _, instance := range view { + if instance.role == roleMain { + isLeader = true + break + } + } + if !isLeader { + continue + } + // The leader hosts coordinator_ordinal+1; remove a different + // coordinator so the leader keeps quorum and its authoritative view. + leaderID := ordinal + 1 + removeID := 1 + if leaderID == 1 { + removeID = 2 + } + name := fmt.Sprintf("coordinator_%d", removeID) + cmd := exec.Command("kubectl", "exec", pod, "-n", clusterNamespace, "-c", "memgraph", "--", + "bash", "-c", fmt.Sprintf("echo 'REMOVE COORDINATOR %d;' | mgconsole", removeID)) + if _, err := utils.Run(cmd); err != nil { + return "", fmt.Errorf("removing coordinator %d on %s: %w", removeID, pod, err) + } + return name, nil + } + return "", fmt.Errorf("no coordinator leader found to remove a coordinator: %w", errors.Join(errs...)) +} + +// createLicenseSecret applies the Secret the MemgraphCluster references. The +// manifest is piped over stdin so no secret material ever reaches the logged +// command line. +func createLicenseSecret(license, organization string) { + secret := map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{ + "name": licenseSecretName, + "namespace": clusterNamespace, + }, + "stringData": map[string]string{ + licenseEnvVar: license, + organizationEnvVar: organization, + }, + } + manifest, err := json.Marshal(secret) + Expect(err).NotTo(HaveOccurred(), "Failed to marshal the license Secret") + + cmd := exec.Command("kubectl", "apply", "-f", "-") + _, err = utils.RunWithInput(cmd, string(manifest)) + Expect(err).NotTo(HaveOccurred(), "Failed to apply the license Secret") +} + +// applyMemgraphCluster applies the CR under test: the minimal spec of the PRD's +// first-contact story — image, counts, and a license secret reference. +func applyMemgraphCluster() { + 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 + licenseKey: %s + organizationKey: %s +`, clusterName, clusterNamespace, coordinatorCount, dataInstanceCount, + memgraphImageRepository, memgraphImageTag, + licenseSecretName, licenseEnvVar, organizationEnvVar) + + cmd := exec.Command("kubectl", "apply", "-f", "-") + _, err := utils.RunWithInput(cmd, manifest) + Expect(err).NotTo(HaveOccurred(), "Failed to apply the MemgraphCluster") +} + +// instanceRow is one parsed row of SHOW INSTANCES. +type instanceRow struct { + name string + health string + role string +} + +// leaderView returns the SHOW INSTANCES view of the first coordinator that +// reports a MAIN. Only the coordinator leader health-checks data instances and +// reports their roles (followers show them as unknown), so a view containing a +// MAIN is the leader's authoritative view. +func leaderView() ([]instanceRow, error) { + var errs []error + for ordinal := range coordinatorCount { + pod := fmt.Sprintf("%s-coordinator-%d", clusterName, ordinal) + view, err := showInstances(pod) + if err != nil { + errs = append(errs, err) + continue + } + for _, instance := range view { + if instance.role == roleMain { + return view, nil + } + } + errs = append(errs, fmt.Errorf("%s reports no MAIN among %d instances", pod, len(view))) + } + return nil, errors.Join(errs...) +} + +// showInstances runs SHOW INSTANCES through mgconsole inside the given +// coordinator pod (the Memgraph image ships the client) and parses the CSV +// output. +func showInstances(pod string) ([]instanceRow, error) { + cmd := exec.Command("kubectl", "exec", pod, "-n", clusterNamespace, "-c", "memgraph", "--", + "bash", "-c", "echo 'SHOW INSTANCES;' | mgconsole --output-format=csv") + output, err := utils.Run(cmd) + if err != nil { + return nil, err + } + return parseInstances(output) +} + +// parseInstances parses mgconsole CSV output into rows keyed by the header +// columns, so the assertion survives added or reordered columns across +// Memgraph versions. +func parseInstances(output string) ([]instanceRow, error) { + lines := utils.GetNonEmptyLines(output) + header := -1 + for i, line := range lines { + if strings.Contains(line, "name") && strings.Contains(line, "bolt_server") { + header = i + break + } + } + if header == -1 { + return nil, fmt.Errorf("no SHOW INSTANCES header in mgconsole output: %q", output) + } + + reader := csv.NewReader(strings.NewReader(strings.Join(lines[header:], "\n"))) + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("parsing mgconsole CSV output: %w", err) + } + + columns := map[string]int{} + for i, column := range records[0] { + columns[strings.TrimSpace(column)] = i + } + for _, column := range []string{"name", "health", "role"} { + if _, ok := columns[column]; !ok { + return nil, fmt.Errorf("SHOW INSTANCES output has no %q column: %q", column, records[0]) + } + } + + instances := make([]instanceRow, 0, len(records)-1) + for _, record := range records[1:] { + instances = append(instances, instanceRow{ + name: unquoteCell(record[columns["name"]]), + health: unquoteCell(record[columns["health"]]), + role: unquoteCell(record[columns["role"]]), + }) + } + return instances, nil +} + +// unquoteCell strips the residual double quotes mgconsole wraps around string +// cells in CSV output. mgconsole emits string values already double-quoted, so +// after the CSV reader unwraps its own layer a value like main still arrives as +// "main"; the operator and assertions compare against the bare token. +func unquoteCell(cell string) string { + return strings.Trim(strings.TrimSpace(cell), `"`) +} diff --git a/test/utils/utils.go b/test/utils/utils.go index 2df8b9d..3e9fb17 100644 --- a/test/utils/utils.go +++ b/test/utils/utils.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. @@ -17,62 +17,54 @@ limitations under the License. package utils import ( + "bufio" + "bytes" "fmt" "os" "os/exec" "strings" - . "github.com/onsi/ginkgo/v2" //nolint:golint,revive + . "github.com/onsi/ginkgo/v2" // nolint:revive,staticcheck ) const ( - prometheusOperatorVersion = "v0.68.0" - prometheusOperatorURL = "https://github.com/prometheus-operator/prometheus-operator/" + - "releases/download/%s/bundle.yaml" + certmanagerVersion = "v1.20.2" + certmanagerURLTmpl = "https://github.com/cert-manager/cert-manager/releases/download/%s/cert-manager.yaml" - certmanagerVersion = "v1.5.3" - certmanagerURLTmpl = "https://github.com/jetstack/cert-manager/releases/download/%s/cert-manager.yaml" + defaultKindBinary = "kind" + defaultKindCluster = "kind" ) func warnError(err error) { - fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) + _, _ = fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) } -// InstallPrometheusOperator installs the prometheus Operator to be used to export the enabled metrics. -func InstallPrometheusOperator() error { - url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion) - cmd := exec.Command("kubectl", "create", "-f", url) - _, err := Run(cmd) - return err +// RunWithInput executes the provided command with the given string fed to its +// standard input. Only the command line is logged, never the input, so it is +// safe for manifests carrying secret material (e.g. a license Secret). +func RunWithInput(cmd *exec.Cmd, input string) (string, error) { + cmd.Stdin = strings.NewReader(input) + return Run(cmd) } // Run executes the provided command within this context -func Run(cmd *exec.Cmd) ([]byte, error) { +func Run(cmd *exec.Cmd) (string, error) { dir, _ := GetProjectDir() cmd.Dir = dir if err := os.Chdir(cmd.Dir); err != nil { - fmt.Fprintf(GinkgoWriter, "chdir dir: %s\n", err) + _, _ = fmt.Fprintf(GinkgoWriter, "chdir dir: %q\n", err) } cmd.Env = append(os.Environ(), "GO111MODULE=on") command := strings.Join(cmd.Args, " ") - fmt.Fprintf(GinkgoWriter, "running: %s\n", command) + _, _ = fmt.Fprintf(GinkgoWriter, "running: %q\n", command) output, err := cmd.CombinedOutput() if err != nil { - return output, fmt.Errorf("%s failed with error: (%v) %s", command, err, string(output)) + return string(output), fmt.Errorf("%q failed with error %q: %w", command, string(output), err) } - return output, nil -} - -// UninstallPrometheusOperator uninstalls the prometheus -func UninstallPrometheusOperator() { - url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion) - cmd := exec.Command("kubectl", "delete", "-f", url) - if _, err := Run(cmd); err != nil { - warnError(err) - } + return string(output), nil } // UninstallCertManager uninstalls the cert manager @@ -82,6 +74,19 @@ func UninstallCertManager() { if _, err := Run(cmd); err != nil { warnError(err) } + + // Delete leftover leases in kube-system (not cleaned by default) + kubeSystemLeases := []string{ + "cert-manager-cainjector-leader-election", + "cert-manager-controller", + } + for _, lease := range kubeSystemLeases { + cmd = exec.Command("kubectl", "delete", "lease", lease, + "-n", "kube-system", "--ignore-not-found", "--force", "--grace-period=0") + if _, err := Run(cmd); err != nil { + warnError(err) + } + } } // InstallCertManager installs the cert manager bundle. @@ -103,14 +108,51 @@ func InstallCertManager() error { return err } -// LoadImageToKindCluster loads a local docker image to the kind cluster +// IsCertManagerCRDsInstalled checks if any Cert Manager CRDs are installed +// by verifying the existence of key CRDs related to Cert Manager. +func IsCertManagerCRDsInstalled() bool { + // List of common Cert Manager CRDs + certManagerCRDs := []string{ + "certificates.cert-manager.io", + "issuers.cert-manager.io", + "clusterissuers.cert-manager.io", + "certificaterequests.cert-manager.io", + "orders.acme.cert-manager.io", + "challenges.acme.cert-manager.io", + } + + // Execute the kubectl command to get all CRDs + cmd := exec.Command("kubectl", "get", "crds") + output, err := Run(cmd) + if err != nil { + return false + } + + // Check if any of the Cert Manager CRDs are present + crdList := GetNonEmptyLines(output) + for _, crd := range certManagerCRDs { + for _, line := range crdList { + if strings.Contains(line, crd) { + return true + } + } + } + + return false +} + +// LoadImageToKindClusterWithName loads a local docker image to the kind cluster func LoadImageToKindClusterWithName(name string) error { - cluster := "kind" + cluster := defaultKindCluster if v, ok := os.LookupEnv("KIND_CLUSTER"); ok { cluster = v } kindOptions := []string{"load", "docker-image", name, "--name", cluster} - cmd := exec.Command("kind", kindOptions...) + kindBinary := defaultKindBinary + if v, ok := os.LookupEnv("KIND"); ok { + kindBinary = v + } + cmd := exec.Command(kindBinary, kindOptions...) _, err := Run(cmd) return err } @@ -119,8 +161,8 @@ func LoadImageToKindClusterWithName(name string) error { // according to line breakers, and ignores the empty elements in it. func GetNonEmptyLines(output string) []string { var res []string - elements := strings.Split(output, "\n") - for _, element := range elements { + elements := strings.SplitSeq(output, "\n") + for element := range elements { if element != "" { res = append(res, element) } @@ -133,8 +175,60 @@ func GetNonEmptyLines(output string) []string { func GetProjectDir() (string, error) { wd, err := os.Getwd() if err != nil { - return wd, err + return wd, fmt.Errorf("failed to get current working directory: %w", err) } - wd = strings.Replace(wd, "/test/e2e", "", -1) + wd = strings.ReplaceAll(wd, "/test/e2e", "") return wd, nil } + +// UncommentCode searches for target in the file and remove the comment prefix +// of the target content. The target content may span multiple lines. +func UncommentCode(filename, target, prefix string) error { + // false positive + // nolint:gosec + content, err := os.ReadFile(filename) + if err != nil { + return fmt.Errorf("failed to read file %q: %w", filename, err) + } + strContent := string(content) + + idx := strings.Index(strContent, target) + if idx < 0 { + return fmt.Errorf("unable to find the code %q to be uncommented", target) + } + + out := new(bytes.Buffer) + _, err = out.Write(content[:idx]) + if err != nil { + return fmt.Errorf("failed to write to output: %w", err) + } + + scanner := bufio.NewScanner(bytes.NewBufferString(target)) + if !scanner.Scan() { + return nil + } + for { + if _, err = out.WriteString(strings.TrimPrefix(scanner.Text(), prefix)); err != nil { + return fmt.Errorf("failed to write to output: %w", err) + } + // Avoid writing a newline in case the previous line was the last in target. + if !scanner.Scan() { + break + } + if _, err = out.WriteString("\n"); err != nil { + return fmt.Errorf("failed to write to output: %w", err) + } + } + + if _, err = out.Write(content[idx+len(target):]); err != nil { + return fmt.Errorf("failed to write to output: %w", err) + } + + // false positive + // nolint:gosec + if err = os.WriteFile(filename, out.Bytes(), 0644); err != nil { + return fmt.Errorf("failed to write file %q: %w", filename, err) + } + + return nil +}