From 1aeb5ab9f41bf5d188984c0b677dce97466ef4fc Mon Sep 17 00:00:00 2001 From: Alex Rodriguez <131964409+ezekiel-alexrod@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:11:04 +0000 Subject: [PATCH 01/12] scripts: add upgrade-operator-sdk.py Add a script to upgrade the operator-sdk It detects the latest compatible versions of operator-sdk, Go toolchain and golangci-lint from their respective public APIs, scaffolds fresh projects, and merges the existing custom code back. It applies to both `operator/` and `storage-operator/`. Closes: MK8S-190 --- .gitignore | 5 +- BUMPING.md | 97 ++- scripts/pyproject.toml | 39 ++ scripts/upgrade-operator-sdk.py | 1063 +++++++++++++++++++++++++++++++ 4 files changed, 1191 insertions(+), 13 deletions(-) create mode 100644 scripts/pyproject.toml create mode 100755 scripts/upgrade-operator-sdk.py diff --git a/.gitignore b/.gitignore index ef16e98ddb..9b05e2006a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,7 @@ junit/ # Buildchain artifacts /_build/ -vagrant_config.rb \ No newline at end of file +vagrant_config.rb + +# Binaries downloaded by the upgrade-operator-sdk.py script +/.tmp/ \ No newline at end of file diff --git a/BUMPING.md b/BUMPING.md index aa9f542b2b..4916492748 100644 --- a/BUMPING.md +++ b/BUMPING.md @@ -126,18 +126,91 @@ A few tips to bump image versions and SHAs: ## Operator-sdk and Go version -This guide is applied for both `metalk8s-operator` and `storage-operator`. - - - check [documentation](https://sdk.operatorframework.io/docs/upgrading-sdk-version/$version) - for important changes and apply them. - - bump version in Makefile. - - if necessary, bump go version in pre_merge github action. - - if necessary, bump go version in Dockerfile. - - if necessary, bump go dependencies versions. - - in the root of each operator, run `go mod tidy`. - - run `make metalk8s` - - check a diff between the two latest versions of this [test project](https://github.com/operator-framework/operator-sdk/tree/master/testdata/go/v4/memcached-operator) - - the diff in this repo and the test project should be more or less the same +The upgrade is automated by `scripts/upgrade-operator-sdk.py`. The script detects +the latest compatible versions of operator-sdk, Go toolchain and golangci-lint from +their respective public APIs, scaffolds fresh projects, and merges the existing +custom code back. It applies to both `operator/` and `storage-operator/`. + +### Prerequisites + +- `go` and `curl` in `PATH`. +- A GitHub personal access token is optional but strongly recommended: without it, + GitHub API calls are subject to a 60 requests/hour anonymous rate limit. The token + must be **exported** so child processes inherit it: + + ``` + export GITHUB_TOKEN= + ``` + + Setting the variable without `export` (e.g. `GITHUB_TOKEN=xxx`) is silently + ignored by the script because Python's `os.environ` only sees exported variables. + +### Running the upgrade + +``` +python3 scripts/upgrade-operator-sdk.py +``` + +The script will display the resolved versions and prompt for confirmation before +making any changes. Use `--yes` to skip the confirmation (e.g. in CI). The original +operator directories are preserved as `.bak/` for the duration of the review. + +Options: + +``` +--operator-only Only process operator/ +--storage-only Only process storage-operator/ +--skip-backup Reuse an existing .bak directory (no new backup) +--clean-tools Delete .tmp/bin/ after the upgrade (~150 MB, re-downloaded next run) +--yes, -y Skip the confirmation prompt +``` + +The script caches `operator-sdk` and `golangci-lint` in `.tmp/bin/` so they are not +re-downloaded on repeated runs. Use `--clean-tools` to reclaim disk space once the +upgrade is validated. + +### What to review after the upgrade + +After a successful run: + +1. Compare the backup against the result to spot unexpected differences: + + ``` + diff -r operator.bak/ operator/ + diff -r storage-operator.bak/ storage-operator/ + ``` + +2. Run the unit test suite for each operator: + + ``` + cd operator && make test + cd storage-operator && make test + ``` + +3. Check that generated CRD scopes are correct: + `config/crd/bases/` — `ClusterConfig` must be `Cluster`-scoped, + `VirtualIPPool` must be `Namespaced`, `Volume` must be `Cluster`-scoped. + +4. Check that the generated RBAC is complete: + `config/rbac/role.yaml` in each operator. + +5. Check that the MetalK8s manifests contain the correct Jinja template: + `deploy/manifests.yaml` must contain + `{{ build_image_name("metalk8s-operator") }}` / `{{ build_image_name("storage-operator") }}`. + +6. Remove the backup directories once satisfied: + + ``` + rm -rf operator.bak/ storage-operator.bak/ + ``` + +### Stale compatibility fixes + +The `OPERATORS` dict in `scripts/upgrade-operator-sdk.py` contains a `fixes` tuple +per operator. These entries are one-shot source-level corrections applied after the +backup merge (e.g. deprecated API replacements). Once the backup no longer contains +the old pattern — i.e. after the script has been run at least once — the entry +becomes a no-op and should be removed to keep the script clean. ## Calico diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml new file mode 100644 index 0000000000..04377f2fe4 --- /dev/null +++ b/scripts/pyproject.toml @@ -0,0 +1,39 @@ +# Linting and formatting configuration for scripts/upgrade-operator-sdk.py +# Run from the scripts/ directory: +# python3 -m black upgrade-operator-sdk.py +# python3 -m ruff check upgrade-operator-sdk.py +# python3 -m mypy upgrade-operator-sdk.py + +[tool.black] +line-length = 88 +target-version = ["py39"] + +[tool.ruff] +line-length = 88 +target-version = "py39" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes (undefined names, unused imports, …) + "I", # isort (import ordering) + "N", # pep8-naming conventions + "UP", # pyupgrade (modernise Python syntax) + "B", # flake8-bugbear (likely bugs and design issues) + "C4", # flake8-comprehensions (better list/dict/set comprehensions) + "SIM", # flake8-simplify (simplifiable code patterns) + "RET", # flake8-return (return statement issues) + "PTH", # flake8-use-pathlib (prefer pathlib over os.path) + "TRY", # tryceratops (exception handling anti-patterns) +] +ignore = [ + "RET504", # allow x = ...; return x (readability) + "TRY003", # allow long messages in raise/die() calls + "TRY300", # allow return inside try block +] + +[tool.mypy] +strict = true +ignore_missing_imports = true +python_version = "3.9" diff --git a/scripts/upgrade-operator-sdk.py b/scripts/upgrade-operator-sdk.py new file mode 100755 index 0000000000..6a4b847c91 --- /dev/null +++ b/scripts/upgrade-operator-sdk.py @@ -0,0 +1,1063 @@ +#!/usr/bin/env python3 +"""Automates the upgrade of operator-sdk based projects. + +Backs up operator/ and storage-operator/, scaffolds fresh projects with the +target SDK version, merges custom code from the backup, and verifies the build. + +Usage: + python3 scripts/upgrade-operator-sdk.py [OPTIONS] + +Options: + --operator-only Only process the operator/ project + --storage-only Only process the storage-operator/ project + --skip-backup Skip the backup step (assumes .bak already exists) + --clean-tools Remove .tmp/bin/ after the upgrade (forces re-download next run) + --yes, -y Skip the confirmation prompt + -h, --help Show this help message + +Environment variables: + GITHUB_TOKEN GitHub personal-access token; avoids the 60 req/hour + anonymous rate limit when querying the releases API. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Final, NoReturn + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +REPO_ROOT: Final = Path(__file__).resolve().parent.parent +TOOLS_BIN: Final = REPO_ROOT / ".tmp" / "bin" +_SDK_BIN: Final = TOOLS_BIN / "operator-sdk" +_GOLANGCI_LINT_BIN: Final = TOOLS_BIN / "golangci-lint" + +# All file I/O uses this encoding explicitly. +_ENCODING: Final = "utf-8" + +# --------------------------------------------------------------------------- +# HTTP configuration +# --------------------------------------------------------------------------- + +# Number of retry attempts for transient network errors. +_HTTP_RETRIES: Final = 3 + +# --------------------------------------------------------------------------- +# URLs +# --------------------------------------------------------------------------- + +# Generic pattern for GitHub's latest-release API. +_GITHUB_RELEASES_URL: Final = "https://api.github.com/repos/{repo}/releases/latest" + +_GITHUB_REPO_OPERATOR_SDK: Final = "operator-framework/operator-sdk" +_GITHUB_REPO_GOLANGCI_LINT: Final = "golangci/golangci-lint" + +_URL_OPERATOR_SDK_GOMOD: Final = ( + "https://raw.githubusercontent.com/" + + _GITHUB_REPO_OPERATOR_SDK + + "/{version}/go.mod" +) +_URL_OPERATOR_SDK_DOWNLOAD: Final = ( + "https://github.com/" + + _GITHUB_REPO_OPERATOR_SDK + + "/releases/download/{version}/operator-sdk_{goos}_{goarch}" +) +_URL_GO_RELEASES: Final = "https://go.dev/dl/?mode=json&include=all" +_URL_GOLANGCI_INSTALL: Final = ( + "https://raw.githubusercontent.com/" + + _GITHUB_REPO_GOLANGCI_LINT + + "/HEAD/install.sh" +) + +# --------------------------------------------------------------------------- +# Makefile fragment templates +# +# __PLACEHOLDER__ tokens replace Python f-string escaping, which is especially +# confusing around the Jinja-style {{ }} delimiters used in the Makefile. +# --------------------------------------------------------------------------- + +# Inserted after the ENVTEST_K8S_VERSION line in the generated Makefile. +_GOTOOLCHAIN_BLOCK: Final = ( + "\n" + "# Force Go toolchain version to prevent automatic selection issues\n" + "# See: https://go.dev/doc/toolchain\n" + "export GOTOOLCHAIN = __TOOLCHAIN__\n" +) + +# Appended to the Makefile; __IMAGE__ is replaced by spec.image_name at runtime. +# The outer {{ }} are Jinja2 delimiters — literal in the resulting Makefile. +_METALK8S_MAKE_TARGET: Final = ( + "\n" + ".PHONY: metalk8s\n" + "metalk8s: manifests kustomize ## Generate MetalK8s resulting manifests\n" + "\tmkdir -p deploy\n" + "\t$(KUSTOMIZE) build config/metalk8s | \\\n" + "\tsed 's/BUILD_IMAGE_CLUSTER_OPERATOR:latest/" + '{{ build_image_name("__IMAGE__") }}/\'' + " > deploy/manifests.yaml\n" +) + +# --------------------------------------------------------------------------- +# Merge policy +# +# After scaffolding, every file from the backup is copied to the new project +# UNLESS it matches the scaffold-only rules below. Custom code is therefore +# preserved automatically without maintaining an explicit restore list. +# +# Scaffold-only — keep new scaffold version, do NOT copy from backup: +# Directories: .devcontainer .github bin cmd config (except metalk8s/) +# Root files: .dockerignore .gitignore go.mod go.sum Makefile PROJECT +# README.md +# Generated: *zz_generated* internal/controller/suite_test.go +# --------------------------------------------------------------------------- + +_SCAFFOLD_ONLY_DIRS: Final[frozenset[str]] = frozenset( + { + ".devcontainer", + ".github", + "bin", + "cmd", + "config", + # e2e test templates generated by operator-sdk (added in v1.42.x) + "test", + } +) + +# Exact relative paths (root files + specific generated files). +_SCAFFOLD_ONLY_FILES: Final[frozenset[str]] = frozenset( + { + ".dockerignore", + ".gitignore", + "go.mod", + "go.sum", + "Makefile", + "PROJECT", + "README.md", + # test coverage artefact — regenerated by `make test`, not custom code + "cover.out", + # scaffold-generated test setup (version-specific, not custom code) + "internal/controller/suite_test.go", + } +) + +# All root-level entries expected from `operator-sdk init` + `create api`. +# If a fresh scaffold produces something outside this set, it may be a new +# scaffold addition that needs classifying in the sets above. +_KNOWN_SCAFFOLD_ROOTS: Final[frozenset[str]] = frozenset( + _SCAFFOLD_ONLY_DIRS + | {f.split("/")[0] for f in _SCAFFOLD_ONLY_FILES} + | { + "api", + "hack", + "internal", # created by `create api` + # root files scaffold creates that we intentionally overwrite from backup + ".golangci.yml", + "Dockerfile", + } +) + + +def _should_merge(rel: str) -> bool: + """Return True if a backup file should be copied into the new project.""" + # config/metalk8s/ is our custom MetalK8s kustomize overlay. + if rel.startswith("config/metalk8s/"): + return True + if "zz_generated" in rel: + return False + if rel in _SCAFFOLD_ONLY_FILES: + return False + return rel.split("/")[0] not in _SCAFFOLD_ONLY_DIRS + + +# --------------------------------------------------------------------------- +# golangci-lint config versioning +# --------------------------------------------------------------------------- + +# Minimum golangci-lint config version we consider "already migrated". +_GOLANGCI_MIN_CONFIG_VERSION: Final = 2 + + +def _golangci_config_version(content: str) -> int | None: + """Return the numeric config version from a .golangci.yml, or None.""" + m = re.search(r'^version:\s+"(\d+)"', content, re.MULTILINE) + return int(m.group(1)) if m else None + + +# --------------------------------------------------------------------------- +# Detected versions +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class VersionInfo: + """Resolved tool versions, populated once by detect_versions().""" + + operator_sdk: str + go_toolchain: str + golangci_lint: str + + @property + def go_major_minor(self) -> str: + """Extract the Go major.minor from the toolchain string. + + Example: 'go1.24.13' -> '1.24'. + """ + return re.sub(r"^go(\d+\.\d+).*", r"\1", self.go_toolchain) + + +# Module-level reference; replaced by detect_versions() before any phase runs. +# Sentinel values make it obvious if versions is accidentally read early. +_UNSET = "" +versions: VersionInfo = VersionInfo( + operator_sdk=_UNSET, go_toolchain=_UNSET, golangci_lint=_UNSET +) + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +_GREEN: Final = "\033[32m" +_YELLOW: Final = "\033[33m" +_RED: Final = "\033[31m" +_BLUE_BOLD: Final = "\033[1;34m" +_BOLD: Final = "\033[1m" +_RESET: Final = "\033[0m" + + +def log_info(msg: str) -> None: + print(f"{_GREEN}[INFO]{_RESET} {msg}") + + +def log_warn(msg: str) -> None: + print(f"{_YELLOW}[WARN]{_RESET} {msg}", file=sys.stderr) + + +def log_error(msg: str) -> None: + print(f"{_RED}[ERROR]{_RESET} {msg}", file=sys.stderr) + + +def log_step(msg: str) -> None: + print(f"\n{_BLUE_BOLD}==>{_RESET} {_BOLD}{msg}{_RESET}") + + +def die(msg: str) -> NoReturn: + log_error(msg) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + + +def _github_headers() -> dict[str, str]: + """Return HTTP headers for GitHub API requests. + + Includes ``Authorization`` when ``GITHUB_TOKEN`` is set, raising the rate + limit from 60 to 5000 requests per hour. + """ + headers: dict[str, str] = {} + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + log_info("Using GITHUB_TOKEN for authenticated GitHub API requests") + return headers + + +def _http_get(url: str, *, headers: dict[str, str] | None = None) -> bytes: + """GET *url*, retrying up to *_HTTP_RETRIES* times on transient errors. + + HTTP errors (4xx/5xx) are not retried — they fail immediately. + Transient ``URLError`` (timeouts, DNS failures) use exponential backoff. + """ + req = urllib.request.Request(url, headers=headers or {}) + for attempt in range(_HTTP_RETRIES): + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data: bytes = resp.read() + return data + except urllib.error.HTTPError: + raise # not transient + except urllib.error.URLError as exc: + if attempt < _HTTP_RETRIES - 1: + delay = 2 ** (attempt + 1) + log_warn(f"Request failed ({exc.reason}), retrying in {delay}s…") + time.sleep(delay) + else: + raise + raise RuntimeError("_http_get: unreachable") # satisfy static analysis + + +def _fetch_json(url: str, *, headers: dict[str, str] | None = None) -> Any: + try: + return json.loads(_http_get(url, headers=headers)) + except urllib.error.HTTPError as e: + die(f"HTTP {e.code} for {url}") + except urllib.error.URLError as e: + die(f"Failed to fetch {url}: {e.reason}") + + +def _fetch_text(url: str, *, headers: dict[str, str] | None = None) -> str: + try: + return _http_get(url, headers=headers).decode(_ENCODING) + except urllib.error.HTTPError as e: + die(f"HTTP {e.code} for {url}") + except urllib.error.URLError as e: + die(f"Failed to fetch {url}: {e.reason}") + + +def _fetch_latest_github_release(repo: str) -> str: + """Return the latest release tag for a GitHub repository (e.g. 'owner/repo'). + + Respects ``GITHUB_TOKEN`` to avoid the anonymous rate limit. + """ + data = _fetch_json( + _GITHUB_RELEASES_URL.format(repo=repo), + headers=_github_headers(), + ) + return str(data["tag_name"]) + + +# --------------------------------------------------------------------------- +# Version detection +# +# Resolution chain: +# 1. operator-sdk <- GitHub releases/latest +# 2. Go major.minor <- operator-sdk's go.mod at that tag +# 3. Go toolchain <- latest stable patch from go.dev for that minor +# 4. golangci-lint <- GitHub releases/latest +# --------------------------------------------------------------------------- + + +def _detect_operator_sdk_version() -> str: + log_info("Querying GitHub for latest operator-sdk release...") + ver = _fetch_latest_github_release(_GITHUB_REPO_OPERATOR_SDK) + log_info(f" operator-sdk: {ver}") + return ver + + +def _detect_go_toolchain(sdk_version: str) -> str: + """Return the latest stable Go patch for the minor pinned by *sdk_version*.""" + log_info("Querying operator-sdk go.mod for Go version...") + gomod = _fetch_text(_URL_OPERATOR_SDK_GOMOD.format(version=sdk_version)) + m = re.search(r"^go\s+(\d+\.\d+)(?:\.\d+)?", gomod, re.MULTILINE) + if not m: + die("Failed to parse Go version from operator-sdk go.mod") + # m.group(0) is e.g. "go 1.24.6"; m.group(1) is the major.minor "1.24" + go_version = m.group(0).split()[1] + go_major_minor = m.group(1) + log_info(f" operator-sdk targets Go {go_version} (minor: {go_major_minor})") + + log_info(f"Querying go.dev for latest Go {go_major_minor}.x patch...") + releases = _fetch_json(_URL_GO_RELEASES) + prefix = f"go{go_major_minor}." + toolchain = next( + ( + r["version"] + for r in releases + if r["version"].startswith(prefix) and r.get("stable") + ), + f"go{go_major_minor}.0", + ) + log_info(f" Go toolchain: {toolchain}") + return toolchain + + +def _detect_golangci_lint_version() -> str: + log_info("Querying GitHub for latest golangci-lint release...") + ver = _fetch_latest_github_release(_GITHUB_REPO_GOLANGCI_LINT) + log_info(f" golangci-lint: {ver}") + return ver + + +def detect_versions() -> VersionInfo: + """Fetch the latest compatible versions from public APIs and return them.""" + log_step("Detecting latest compatible versions") + sdk = _detect_operator_sdk_version() + return VersionInfo( + operator_sdk=sdk, + go_toolchain=_detect_go_toolchain(sdk), + golangci_lint=_detect_golangci_lint_version(), + ) + + +def confirm_versions(targets: list[str]) -> None: + """Print the resolved versions and ask the user to confirm before proceeding.""" + print() + print(f"{_BOLD}The following upgrade will be performed:{_RESET}") + print() + print(f" operator-sdk {versions.operator_sdk}") + print(f" Go toolchain {versions.go_toolchain}") + print(f" golangci-lint {versions.golangci_lint}") + print() + print(f" Targets: {' '.join(targets)}") + print(f" Repository: {REPO_ROOT}") + print() + answer = input(f"{_BOLD}Proceed? [y/N] {_RESET}").strip().lower() + if answer not in ("y", "yes"): + log_info("Aborted by user.") + sys.exit(0) + + +# --------------------------------------------------------------------------- +# Process execution +# --------------------------------------------------------------------------- + + +def _tool_env() -> dict[str, str]: + """Return environment overrides that put our tools first in PATH. + + Raises ``RuntimeError`` if called before ``detect_versions()``, making + the implicit dependency on the ``versions`` singleton explicit and loud. + """ + if versions.go_toolchain == _UNSET: + raise RuntimeError("versions not initialised — call detect_versions() first") + return { + "PATH": f"{TOOLS_BIN}:{os.environ.get('PATH', '')}", + "GOTOOLCHAIN": versions.go_toolchain, + } + + +def run( + cmd: list[str], + *, + cwd: Path | None = None, + check: bool = True, + capture: bool = False, +) -> subprocess.CompletedProcess[Any]: + """Run a command list, inheriting stdout/stderr unless *capture* is set.""" + merged_env = {**os.environ, **_tool_env()} + kwargs: dict[str, Any] = {"cwd": cwd, "env": merged_env} + if capture: + kwargs["capture_output"] = True + kwargs["text"] = True + return subprocess.run(cmd, check=check, **kwargs) + + +def run_shell( + cmd: str, *, cwd: Path | None = None +) -> subprocess.CompletedProcess[bytes]: + """Run a shell string (use sparingly — only when pipes are required).""" + return subprocess.run( + cmd, shell=True, cwd=cwd, env={**os.environ, **_tool_env()}, check=True + ) + + +# --------------------------------------------------------------------------- +# File helpers +# --------------------------------------------------------------------------- + + +def file_replace(path: Path, old: str, new: str) -> None: + path.write_text( + path.read_text(encoding=_ENCODING).replace(old, new), encoding=_ENCODING + ) + + +def file_regex_replace(path: Path, pattern: str, repl: str) -> None: + path.write_text( + re.sub(pattern, repl, path.read_text(encoding=_ENCODING)), encoding=_ENCODING + ) + + +# --------------------------------------------------------------------------- +# Operator descriptor +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class SourceFix: + """A text replacement applied to a source file after merge. + + Fixes are idempotent: if *old* is not found, the file is left unchanged. + Each fix should carry a comment in OPERATORS explaining its context and + indicating when it can safely be removed. + """ + + path: str # relative path from the operator root + old: str # literal string to replace (or regex pattern when regex=True) + new: str # replacement value + regex: bool = False + + +@dataclass(frozen=True) +class ApiDef: + """Describes a single CRD/API to scaffold.""" + + group: str + version: str + kind: str + + +@dataclass(frozen=True) +class OperatorSpec: + """Static configuration for one operator project. + + Add entries to *fixes* for any source-level corrections that need to be + applied after merging the backup. They are idempotent, so stale entries + are safe (they become no-ops), but should be removed to keep the file clean. + """ + + name: str + repo: str + apis: tuple[ApiDef, ...] + image_name: str = "" + fixes: tuple[SourceFix, ...] = () + + @property + def op_dir(self) -> Path: + """Absolute path to this operator's project directory.""" + return REPO_ROOT / self.name + + @property + def bak_dir(self) -> Path: + """Absolute path to this operator's backup directory.""" + return REPO_ROOT / f"{self.name}.bak" + + +OPERATORS: Final[dict[str, OperatorSpec]] = { + "operator": OperatorSpec( + name="operator", + repo="github.com/scality/metalk8s/operator", + apis=( + ApiDef("", "v1alpha1", "ClusterConfig"), + ApiDef("", "v1alpha1", "VirtualIPPool"), + ), + image_name="metalk8s-operator", + fixes=( + # Go 1.24+: go vet rejects non-constant format strings in fmt.Errorf. + # Remove once the backup no longer contains this pattern + # (i.e., after this script has run at least once from v1.37.0). + SourceFix( + path="pkg/controller/clusterconfig/controlplane/ingress.go", + old=r"fmt\.Errorf\(([a-zA-Z_]\w*)\)", + new=r'fmt.Errorf("%s", \1)', + regex=True, + ), + ), + ), + "storage-operator": OperatorSpec( + name="storage-operator", + repo="github.com/scality/metalk8s/storage-operator", + apis=(ApiDef("storage", "v1alpha1", "Volume"),), + image_name="storage-operator", + fixes=( + # Go 1.16: io/ioutil deprecated. + # Remove once the backup no longer imports io/ioutil + # (i.e., after this script has run at least once from v1.37.0). + SourceFix( + path="internal/controller/volume_controller.go", + old='"io/ioutil"', + new='"os"', + ), + SourceFix( + path="internal/controller/volume_controller.go", + old="ioutil.ReadFile", + new="os.ReadFile", + ), + ), + ), +} + +# Validate that every dict key matches the embedded spec.name. +assert all( + k == v.name for k, v in OPERATORS.items() +), "OPERATORS key must match spec.name" + + +# =================================================================== +# Phase 0 — Install tools +# =================================================================== + + +def _check_prerequisites() -> None: + """Fail early with a clear message if required system tools are missing.""" + missing = [tool for tool in ("go", "curl") if shutil.which(tool) is None] + if missing: + die(f"Required tools not found in PATH: {', '.join(missing)}") + + +def _is_installed(bin_path: Path, version: str) -> bool: + """Return True if *bin_path* exists and reports *version* in its output.""" + if not bin_path.exists(): + return False + result = run([str(bin_path), "version"], capture=True, check=False) + return version.lstrip("v") in result.stdout + + +def install_operator_sdk() -> None: + log_step(f"Installing operator-sdk {versions.operator_sdk}") + TOOLS_BIN.mkdir(parents=True, exist_ok=True) + + if _is_installed(_SDK_BIN, versions.operator_sdk): + log_info("Already installed") + return + + goos = run(["go", "env", "GOOS"], capture=True).stdout.strip() + goarch = run(["go", "env", "GOARCH"], capture=True).stdout.strip() + url = _URL_OPERATOR_SDK_DOWNLOAD.format( + version=versions.operator_sdk, goos=goos, goarch=goarch + ) + log_info(f"Downloading for {goos}/{goarch}...") + run(["curl", "-sSLo", str(_SDK_BIN), url]) + _SDK_BIN.chmod(0o755) + ver = run([str(_SDK_BIN), "version"], capture=True).stdout.strip().split("\n")[0] + log_info(f"Installed: {ver}") + + +def install_golangci_lint() -> None: + log_step(f"Installing golangci-lint {versions.golangci_lint}") + TOOLS_BIN.mkdir(parents=True, exist_ok=True) + + if _is_installed(_GOLANGCI_LINT_BIN, versions.golangci_lint): + log_info("Already installed") + return + + log_info("Downloading...") + run_shell( + f"curl -sSfL {_URL_GOLANGCI_INSTALL}" + f" | sh -s -- -b {shlex.quote(str(TOOLS_BIN))}" + f" {shlex.quote(versions.golangci_lint)}", + ) + ver = ( + run([str(_GOLANGCI_LINT_BIN), "version"], capture=True) + .stdout.strip() + .split("\n")[0] + ) + log_info(f"Installed: {ver}") + + +# =================================================================== +# Phase 1 — Backup +# =================================================================== + + +def backup_operator(spec: OperatorSpec) -> None: + log_step(f"Phase 1: Backing up {spec.name}") + op_dir = spec.op_dir + bak = spec.bak_dir + + if bak.exists(): + log_warn(f"Removing existing backup {bak}") + shutil.rmtree(bak) + if not op_dir.exists(): + die(f"{op_dir} does not exist") + + op_dir.rename(bak) + log_info(f"{op_dir} -> {bak}") + + +# =================================================================== +# Phase 2 — Scaffold fresh project +# =================================================================== + + +def scaffold_project(spec: OperatorSpec) -> None: + log_step(f"Phase 2: Scaffolding {spec.name}") + op_dir = spec.op_dir + sdk = str(_SDK_BIN) + + op_dir.mkdir(parents=True, exist_ok=True) + + run( + [ + sdk, + "init", + "--domain", + "metalk8s.scality.com", + "--repo", + spec.repo, + "--project-name", + spec.name, + ], + cwd=op_dir, + ) + + for api in spec.apis: + _create_api(op_dir, sdk, api) + + # Remove scaffold additions we don't want in the project. + devcontainer = op_dir / ".devcontainer" + if devcontainer.exists(): + shutil.rmtree(devcontainer) + log_info("Removed .devcontainer/ (not needed)") + + # Warn about any scaffold-generated root entries not yet in our policy. + _check_scaffold_completeness(op_dir) + + log_info("Scaffold complete") + + +def _create_api(op_dir: Path, sdk: str, api: ApiDef) -> None: + # Build the common trailing arguments once to avoid duplication. + tail = ["--version", api.version, "--kind", api.kind, "--resource", "--controller"] + + result = run( + [sdk, "create", "api", "--group", api.group, *tail], cwd=op_dir, check=False + ) + if result.returncode == 0: + log_info(f"Created {api.kind} API (group={api.group!r})") + return + + if api.group: + die(f"Failed to create API {api.kind}") + + # operator-sdk may reject an empty group; retry with a placeholder and then + # scrub it from PROJECT so the CRD group stays empty. + log_warn(f"Empty group rejected for {api.kind}, retrying with placeholder") + run([sdk, "create", "api", "--group", "metalk8s", *tail], cwd=op_dir) + file_regex_replace(op_dir / "PROJECT", r"(?m)^ group: metalk8s\n", "") + log_info("Patched PROJECT: removed placeholder group") + + +def _check_scaffold_completeness(op_dir: Path) -> None: + """Warn about scaffold root entries not yet classified in the merge policy. + + When operator-sdk adds new root-level directories or files, they may be + silently merged from backup (or silently dropped). This check flags them + so a maintainer can update ``_SCAFFOLD_ONLY_DIRS`` or + ``_SCAFFOLD_ONLY_FILES`` accordingly. + """ + for entry in sorted(op_dir.iterdir()): + if entry.name not in _KNOWN_SCAFFOLD_ROOTS: + log_warn( + f"Unclassified scaffold entry: {entry.name!r} — " + "add to _SCAFFOLD_ONLY_DIRS or _SCAFFOLD_ONLY_FILES " + "if scaffold-generated" + ) + + +# =================================================================== +# Phase 3 — Merge custom code from backup +# =================================================================== + + +def merge_backup(spec: OperatorSpec) -> None: + log_step(f"Phase 3: Merging custom code for {spec.name}") + op_dir = spec.op_dir + bak = spec.bak_dir + + merged: list[str] = [] + for src in sorted(bak.rglob("*")): + if not src.is_file(): + continue + rel = src.relative_to(bak).as_posix() + if not _should_merge(rel): + continue + dst = op_dir / rel + dst.parent.mkdir(parents=True, exist_ok=True) + # shutil.copy (not copy2): gives merged files current timestamps so + # that make does not mistake them for stale relative to generated artefacts. + shutil.copy(src, dst) + log_info(f" {rel}") + merged.append(rel) + + log_info(f"Custom code merged: {len(merged)} file(s)") + + +# =================================================================== +# Phase 4 — Adapt and fix +# =================================================================== + + +def adapt_project(spec: OperatorSpec) -> None: + """Apply all post-merge adaptations.""" + _adapt_makefile(spec) + _adapt_dockerfile(spec) + _adapt_golangci_lint(spec) + _apply_source_fixes(spec) + _remove_incompatible_scaffold_tests(spec.op_dir) + + +def _adapt_makefile(spec: OperatorSpec) -> None: + log_info("Adapting Makefile...") + makefile = spec.op_dir / "Makefile" + text = makefile.read_text(encoding=_ENCODING) + + if "export GOTOOLCHAIN" not in text: + gotoolchain_block = _GOTOOLCHAIN_BLOCK.replace( + "__TOOLCHAIN__", versions.go_toolchain + ) + new_text, count = re.subn( + r"(^#?ENVTEST_K8S_VERSION[^\n]*\n)", + rf"\1{gotoolchain_block}", + text, + count=1, + flags=re.MULTILINE, + ) + if count == 0: + # The scaffold template may change; fall back to appending. + log_warn( + "ENVTEST_K8S_VERSION not found in Makefile; " + "appending GOTOOLCHAIN block at end" + ) + text += gotoolchain_block + else: + text = new_text + + text = re.sub( + r"^GOLANGCI_LINT_VERSION \?=.*$", + f"GOLANGCI_LINT_VERSION ?= {versions.golangci_lint}", + text, + flags=re.MULTILINE, + ) + + # Guard against appending twice when --skip-backup is used on an + # already-upgraded project. + if ".PHONY: metalk8s" not in text: + text += _METALK8S_MAKE_TARGET.replace("__IMAGE__", spec.image_name) + + makefile.write_text(text, encoding=_ENCODING) + log_info("Makefile adapted") + + +def _adapt_dockerfile(spec: OperatorSpec) -> None: + log_info("Adapting Dockerfile...") + # Dockerfile comes from backup via merge_backup; we only update the Go image. + dst = spec.op_dir / "Dockerfile" + file_regex_replace( + dst, r"FROM golang:\d+\.\d+", f"FROM golang:{versions.go_major_minor}" + ) + log_info(f"Dockerfile updated (Go base image -> {versions.go_major_minor})") + + +def _adapt_golangci_lint(spec: OperatorSpec) -> None: + log_info("Adapting .golangci.yml...") + dst_cfg = spec.op_dir / ".golangci.yml" + content = dst_cfg.read_text(encoding=_ENCODING) + + # Check config version numerically to remain correct when golangci-lint + # releases a future config format (e.g. version "3"). + cfg_version = _golangci_config_version(content) + if cfg_version is not None and cfg_version >= _GOLANGCI_MIN_CONFIG_VERSION: + log_info(f".golangci.yml already at config version {cfg_version}") + return + + # Normalize deprecated v1 fields that the migrate command rejects. + if " deadline:" in content: + content = content.replace(" deadline:", " timeout:") + dst_cfg.write_text(content, encoding=_ENCODING) + + result = run([str(_GOLANGCI_LINT_BIN), "migrate"], cwd=spec.op_dir, check=False) + if result.returncode != 0: + log_warn("golangci-lint migrate reported warnings (review manually)") + + for bck in ( + dst_cfg.with_suffix(".yml.bck"), + dst_cfg.parent / ".golangci.bck.yml", + dst_cfg.with_suffix(".yml.bak"), + ): + bck.unlink(missing_ok=True) + + log_info(".golangci.yml migrated to v2") + + +def _apply_source_fix(op_dir: Path, fix: SourceFix) -> None: + """Apply a single SourceFix; writes the file only if content changed.""" + path = op_dir / fix.path + if not path.exists(): + return + text = path.read_text(encoding=_ENCODING) + updated = ( + re.sub(fix.old, fix.new, text) if fix.regex else text.replace(fix.old, fix.new) + ) + if updated != text: + log_info(f"Applied fix: {Path(fix.path).name}") + path.write_text(updated, encoding=_ENCODING) + + +def _apply_source_fixes(spec: OperatorSpec) -> None: + """Apply the operator-specific source fixes declared in spec.fixes.""" + for fix in spec.fixes: + _apply_source_fix(spec.op_dir, fix) + + +def _remove_incompatible_scaffold_tests(op_dir: Path) -> None: + """Remove scaffold controller tests incompatible with our delegation pattern. + + operator-sdk generates *_controller_test.go stubs that call .Reconcile() + directly on the reconciler struct. Our controllers use a delegation pattern + where the inner struct registered with the manager does not expose Reconcile(), + so these stubs must be removed. This applies to every operator-sdk upgrade. + """ + ctrl_dir = op_dir / "internal" / "controller" + if not ctrl_dir.exists(): + return + for test_file in ctrl_dir.glob("*_controller_test.go"): + if ".Reconcile(" in test_file.read_text(encoding=_ENCODING): + log_info(f"Removing incompatible scaffold test: {test_file.name}") + test_file.unlink() + + +# =================================================================== +# Phase 5 — Generate and build +# =================================================================== + + +def generate_and_build(spec: OperatorSpec) -> None: + log_step(f"Phase 5: Generate & build {spec.name}") + op_dir = spec.op_dir + bin_dir = op_dir / "bin" + if bin_dir.exists(): + shutil.rmtree(bin_dir) + + steps = [ + ("go mod tidy...", ["go", "mod", "tidy"]), + ("make manifests generate...", ["make", "manifests", "generate"]), + ("make fmt vet...", ["make", "fmt", "vet"]), + ("make build...", ["make", "build"]), + ("make metalk8s...", ["make", "metalk8s"]), + ] + for msg, cmd in steps: + log_info(msg) + run(cmd, cwd=op_dir) + + log_info(f"Build succeeded for {spec.name}") + + +# =================================================================== +# Cleanup +# =================================================================== + + +def _clean_tools() -> None: + """Remove the script's tool cache (.tmp/bin/). + + This forces operator-sdk and golangci-lint to be re-downloaded on the next + run, which is useful to reclaim disk space (~150 MB) after the upgrade. + The operator bin/ directories created during scaffolding are not affected; + they are controlled by each operator's Makefile. + """ + if TOOLS_BIN.exists(): + log_step(f"Cleaning tool cache ({TOOLS_BIN.relative_to(REPO_ROOT)}/)") + shutil.rmtree(TOOLS_BIN) + log_info(f"Removed {TOOLS_BIN}") + else: + log_info("Tool cache already empty") + + +# =================================================================== +# Recovery +# =================================================================== + + +def _log_recovery_hint(name: str) -> None: + """Log recovery instructions after an interrupted or failed upgrade.""" + op_dir = REPO_ROOT / name + bak = REPO_ROOT / f"{name}.bak" + log_error(f"Processing of '{name}' was interrupted or failed") + if bak.exists(): + log_warn(f"Backup preserved at: {bak}") + if op_dir.exists() and bak.exists(): + log_warn("Partial build detected. To restore the original state:") + log_warn(f" rm -rf {op_dir} && mv {bak} {op_dir}") + elif bak.exists(): + log_warn(f"To restore: mv {bak} {op_dir}") + + +# =================================================================== +# Main +# =================================================================== + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Upgrade operator-sdk projects by scaffolding fresh and " + "merging custom code from backup.", + ) + parser.add_argument( + "--operator-only", action="store_true", help="Only process operator/" + ) + parser.add_argument( + "--storage-only", action="store_true", help="Only process storage-operator/" + ) + parser.add_argument( + "--skip-backup", action="store_true", help="Skip backup (assumes .bak exists)" + ) + parser.add_argument( + "--clean-tools", + action="store_true", + help=f"Remove {TOOLS_BIN.relative_to(REPO_ROOT)}/ after upgrade " + "(forces a fresh download on the next run)", + ) + parser.add_argument( + "--yes", "-y", action="store_true", help="Skip the confirmation prompt" + ) + args = parser.parse_args() + + if args.operator_only: + targets = ["operator"] + elif args.storage_only: + targets = ["storage-operator"] + else: + targets = ["operator", "storage-operator"] + + _check_prerequisites() + + global versions + versions = detect_versions() + + if not args.yes: + confirm_versions(targets) + + log_step(f"Operator SDK Upgrade -> {versions.operator_sdk}") + + install_operator_sdk() + install_golangci_lint() + + for name in targets: + spec = OPERATORS[name] + log_step(f"========== Processing {name} ==========") + + if not args.skip_backup: + backup_operator(spec) + else: + log_info("Skipping backup (--skip-backup)") + if not spec.bak_dir.exists(): + die( + f"{spec.bak_dir} does not exist; cannot use --skip-backup " + "without an existing backup directory" + ) + if spec.op_dir.exists(): + shutil.rmtree(spec.op_dir) + + try: + scaffold_project(spec) + merge_backup(spec) + adapt_project(spec) + generate_and_build(spec) + except BaseException: + _log_recovery_hint(name) + raise + + if args.clean_tools: + _clean_tools() + + log_step("Upgrade complete!") + print() + log_info("Backups preserved at:") + for name in targets: + log_info(f" {OPERATORS[name].bak_dir}/") + print() + log_info("Recommended next steps:") + log_info(" 1. diff -r .bak/ / Review changes") + log_info(" 2. cd && make test Run tests") + log_info(" 3. Review config/crd/bases/ Check generated CRDs") + log_info(" 4. Review config/rbac/role.yaml Check generated RBAC") + log_info(" 5. Review deploy/manifests.yaml Check MetalK8s manifests") + + +if __name__ == "__main__": + main() From 7121a7d65284b928a959495acf009c50e443325e Mon Sep 17 00:00:00 2001 From: Alex Rodriguez <131964409+ezekiel-alexrod@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:34:46 +0000 Subject: [PATCH 02/12] scripts: fetch last patch for k8s go libraries --- scripts/upgrade-operator-sdk.py | 123 +++++++++++++++++++++++++++----- 1 file changed, 106 insertions(+), 17 deletions(-) diff --git a/scripts/upgrade-operator-sdk.py b/scripts/upgrade-operator-sdk.py index 6a4b847c91..596835a090 100755 --- a/scripts/upgrade-operator-sdk.py +++ b/scripts/upgrade-operator-sdk.py @@ -208,6 +208,8 @@ class VersionInfo: operator_sdk: str go_toolchain: str golangci_lint: str + controller_runtime: str # sigs.k8s.io/controller-runtime + k8s_libs: str # k8s.io/{api,apimachinery,client-go} — always in sync @property def go_major_minor(self) -> str: @@ -222,7 +224,11 @@ def go_major_minor(self) -> str: # Sentinel values make it obvious if versions is accidentally read early. _UNSET = "" versions: VersionInfo = VersionInfo( - operator_sdk=_UNSET, go_toolchain=_UNSET, golangci_lint=_UNSET + operator_sdk=_UNSET, + go_toolchain=_UNSET, + golangci_lint=_UNSET, + controller_runtime=_UNSET, + k8s_libs=_UNSET, ) # --------------------------------------------------------------------------- @@ -334,27 +340,27 @@ def _fetch_latest_github_release(repo: str) -> str: # Version detection # # Resolution chain: -# 1. operator-sdk <- GitHub releases/latest -# 2. Go major.minor <- operator-sdk's go.mod at that tag -# 3. Go toolchain <- latest stable patch from go.dev for that minor -# 4. golangci-lint <- GitHub releases/latest +# 1. operator-sdk <- GitHub releases/latest +# 2. Go major.minor <- operator-sdk's go.mod at that tag (fetched once) +# 3. Go toolchain <- latest stable patch from go.dev for that minor +# 4. controller-runtime <- operator-sdk's go.mod (same fetch) +# 5. k8s.io libs <- controller-runtime's go.mod at that tag +# 6. golangci-lint <- GitHub releases/latest # --------------------------------------------------------------------------- def _detect_operator_sdk_version() -> str: log_info("Querying GitHub for latest operator-sdk release...") ver = _fetch_latest_github_release(_GITHUB_REPO_OPERATOR_SDK) - log_info(f" operator-sdk: {ver}") + log_info(f" operator-sdk: {ver}") return ver -def _detect_go_toolchain(sdk_version: str) -> str: - """Return the latest stable Go patch for the minor pinned by *sdk_version*.""" - log_info("Querying operator-sdk go.mod for Go version...") - gomod = _fetch_text(_URL_OPERATOR_SDK_GOMOD.format(version=sdk_version)) +def _detect_go_toolchain_from_gomod(gomod: str) -> str: + """Return the latest stable Go patch for the minor declared in *gomod* content.""" m = re.search(r"^go\s+(\d+\.\d+)(?:\.\d+)?", gomod, re.MULTILINE) if not m: - die("Failed to parse Go version from operator-sdk go.mod") + die("Failed to parse Go version from go.mod") # m.group(0) is e.g. "go 1.24.6"; m.group(1) is the major.minor "1.24" go_version = m.group(0).split()[1] go_major_minor = m.group(1) @@ -371,14 +377,78 @@ def _detect_go_toolchain(sdk_version: str) -> str: ), f"go{go_major_minor}.0", ) - log_info(f" Go toolchain: {toolchain}") + log_info(f" Go toolchain: {toolchain}") return toolchain +def _latest_k8s_patch(base_version: str) -> str: + """Return the latest stable patch for the k8s.io major.minor of *base_version*. + + Queries the Go module proxy for ``k8s.io/api`` — which drives the patch + cadence for all three libs — and returns the highest patch in the same + major.minor series. Falls back to *base_version* on any parse error. + """ + m = re.match(_PAT_SEMVER_MAJOR_MINOR, base_version) + if not m: + return base_version + prefix = m.group(1) + "." + + url = _URL_GO_MODULE_VERSIONS.format(module=_K8S_LIB_MODULE) + log_info(f"Querying Go module proxy for latest k8s.io {m.group(1)}.x patch...") + content = _fetch_text(url) + + candidates = [ + v.strip() + for v in content.splitlines() + # Only stable releases of the right minor; skip pre-releases (contain "-") + if v.strip().startswith(prefix) and "-" not in v.strip() + ] + if not candidates: + return base_version + + def _patch(v: str) -> int: + try: + return int(v.rsplit(".", 1)[-1]) + except ValueError: + return -1 + + latest = max(candidates, key=_patch) + log_info(f" k8s.io libs: {latest}") + return latest + + +def _detect_controller_runtime_and_k8s(sdk_gomod: str) -> tuple[str, str]: + """Return (controller_runtime_version, k8s_libs_latest_patch). + + Both versions are derived from the operator-sdk go.mod content. + The k8s.io version is bumped to the latest compatible patch via the + Go module proxy (k8s.io/api, apimachinery and client-go are in lock-step). + """ + # controller-runtime version is declared in operator-sdk's own go.mod. + m_cr = re.search(_PAT_CONTROLLER_RUNTIME_IN_GOMOD, sdk_gomod) + if not m_cr: + die("Failed to parse controller-runtime version from operator-sdk go.mod") + cr_version = m_cr.group(1) + log_info(f" controller-runtime: {cr_version}") + + # The minimum compatible k8s.io/api version comes from controller-runtime. + log_info("Querying controller-runtime go.mod for k8s.io base version...") + cr_gomod = _fetch_text(_URL_CONTROLLER_RUNTIME_GOMOD.format(version=cr_version)) + m_k8s = re.search(_PAT_K8S_API_IN_GOMOD, cr_gomod) + if not m_k8s: + die("Failed to parse k8s.io/api version from controller-runtime go.mod") + base_k8s = m_k8s.group(1) + + # Bump to the latest patch of that major.minor. + k8s_version = _latest_k8s_patch(base_k8s) + + return cr_version, k8s_version + + def _detect_golangci_lint_version() -> str: log_info("Querying GitHub for latest golangci-lint release...") ver = _fetch_latest_github_release(_GITHUB_REPO_GOLANGCI_LINT) - log_info(f" golangci-lint: {ver}") + log_info(f" golangci-lint: {ver}") return ver @@ -386,10 +456,21 @@ def detect_versions() -> VersionInfo: """Fetch the latest compatible versions from public APIs and return them.""" log_step("Detecting latest compatible versions") sdk = _detect_operator_sdk_version() + + # Fetch operator-sdk's go.mod once; reuse content for Go toolchain and + # controller-runtime detection to avoid redundant network requests. + log_info("Querying operator-sdk go.mod...") + sdk_gomod = _fetch_text(_URL_OPERATOR_SDK_GOMOD.format(version=sdk)) + + go_toolchain = _detect_go_toolchain_from_gomod(sdk_gomod) + cr_version, k8s_version = _detect_controller_runtime_and_k8s(sdk_gomod) + return VersionInfo( operator_sdk=sdk, - go_toolchain=_detect_go_toolchain(sdk), + go_toolchain=go_toolchain, golangci_lint=_detect_golangci_lint_version(), + controller_runtime=cr_version, + k8s_libs=k8s_version, ) @@ -398,9 +479,11 @@ def confirm_versions(targets: list[str]) -> None: print() print(f"{_BOLD}The following upgrade will be performed:{_RESET}") print() - print(f" operator-sdk {versions.operator_sdk}") - print(f" Go toolchain {versions.go_toolchain}") - print(f" golangci-lint {versions.golangci_lint}") + print(f" operator-sdk {versions.operator_sdk}") + print(f" controller-runtime {versions.controller_runtime}") + print(f" k8s.io libs {versions.k8s_libs} (api, apimachinery, client-go)") + print(f" Go toolchain {versions.go_toolchain}") + print(f" golangci-lint {versions.golangci_lint}") print() print(f" Targets: {' '.join(targets)}") print(f" Repository: {REPO_ROOT}") @@ -913,6 +996,12 @@ def generate_and_build(spec: OperatorSpec) -> None: if bin_dir.exists(): shutil.rmtree(bin_dir) + # Explicitly pin k8s.io libs to the latest compatible patch before tidy, + # so `go mod tidy` does not silently keep an older patch from the scaffold. + k8s_get_args = [f"{lib}@{versions.k8s_libs}" for lib in _K8S_LIBS] + log_info(f"Bumping k8s.io libs to {versions.k8s_libs}...") + run(["go", "get", *k8s_get_args], cwd=op_dir) + steps = [ ("go mod tidy...", ["go", "mod", "tidy"]), ("make manifests generate...", ["make", "manifests", "generate"]), From 669c94c18d7576fb40dc6f7a9b9e79815e9640c0 Mon Sep 17 00:00:00 2001 From: Alex Rodriguez <131964409+ezekiel-alexrod@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:41:46 +0000 Subject: [PATCH 03/12] scripts: constants for regexp --- scripts/upgrade-operator-sdk.py | 52 +++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/scripts/upgrade-operator-sdk.py b/scripts/upgrade-operator-sdk.py index 596835a090..2d2fea18f4 100755 --- a/scripts/upgrade-operator-sdk.py +++ b/scripts/upgrade-operator-sdk.py @@ -70,6 +70,10 @@ + _GITHUB_REPO_OPERATOR_SDK + "/{version}/go.mod" ) +_URL_CONTROLLER_RUNTIME_GOMOD: Final = "https://raw.githubusercontent.com/kubernetes-sigs/controller-runtime/{version}/go.mod" +# Go module proxy — returns a newline-separated list of available versions. +_URL_GO_MODULE_VERSIONS: Final = "https://proxy.golang.org/{module}/@v/list" + _URL_OPERATOR_SDK_DOWNLOAD: Final = ( "https://github.com/" + _GITHUB_REPO_OPERATOR_SDK @@ -82,6 +86,42 @@ + "/HEAD/install.sh" ) +# k8s.io libraries that are always released in lock-step. +_K8S_LIBS: Final = ("k8s.io/api", "k8s.io/apimachinery", "k8s.io/client-go") +# The lib whose version drives the cadence for all three (queried for latest patch). +_K8S_LIB_MODULE: Final = _K8S_LIBS[0] + +# --------------------------------------------------------------------------- +# Regex patterns +# +# Centralised here so the business logic functions stay free of raw string +# literals and a change in format only needs updating in one place. +# Patterns used inside file_regex_replace() embed flags (e.g. (?m)) because +# that helper does not accept a separate flags argument. +# --------------------------------------------------------------------------- + +# Go version strings +_PAT_GO_MAJOR_MINOR: Final = r"^go(\d+\.\d+).*" +_PAT_GO_VERSION_IN_GOMOD: Final = r"^go\s+(\d+\.\d+)(?:\.\d+)?" +_PAT_SEMVER_MAJOR_MINOR: Final = r"(v\d+\.\d+)\." + +# Dependency versions in go.mod files +_PAT_CONTROLLER_RUNTIME_IN_GOMOD: Final = r"sigs\.k8s\.io/controller-runtime\s+(v\S+)" +_PAT_K8S_API_IN_GOMOD: Final = r"k8s\.io/api\s+(v\S+)" + +# golangci-lint configuration +_PAT_GOLANGCI_CONFIG_VERSION: Final = r'^version:\s+"(\d+)"' + +# Makefile lines (MULTILINE flag kept at call site for clarity) +_PAT_MAKEFILE_ENVTEST_LINE: Final = r"(^#?ENVTEST_K8S_VERSION[^\n]*\n)" +_PAT_MAKEFILE_GOLANGCI_VERSION: Final = r"^GOLANGCI_LINT_VERSION \?=.*$" + +# Dockerfile (passed to file_regex_replace — no separate flags argument) +_PAT_DOCKERFILE_FROM_GOLANG: Final = r"FROM golang:\d+\.\d+" + +# operator-sdk PROJECT file ((?m) embedded because used in file_regex_replace) +_PAT_PROJECT_GROUP_LINE: Final = r"(?m)^ group: metalk8s\n" + # --------------------------------------------------------------------------- # Makefile fragment templates # @@ -192,7 +232,7 @@ def _should_merge(rel: str) -> bool: def _golangci_config_version(content: str) -> int | None: """Return the numeric config version from a .golangci.yml, or None.""" - m = re.search(r'^version:\s+"(\d+)"', content, re.MULTILINE) + m = re.search(_PAT_GOLANGCI_CONFIG_VERSION, content, re.MULTILINE) return int(m.group(1)) if m else None @@ -217,7 +257,7 @@ def go_major_minor(self) -> str: Example: 'go1.24.13' -> '1.24'. """ - return re.sub(r"^go(\d+\.\d+).*", r"\1", self.go_toolchain) + return re.sub(_PAT_GO_MAJOR_MINOR, r"\1", self.go_toolchain) # Module-level reference; replaced by detect_versions() before any phase runs. @@ -801,7 +841,7 @@ def _create_api(op_dir: Path, sdk: str, api: ApiDef) -> None: # scrub it from PROJECT so the CRD group stays empty. log_warn(f"Empty group rejected for {api.kind}, retrying with placeholder") run([sdk, "create", "api", "--group", "metalk8s", *tail], cwd=op_dir) - file_regex_replace(op_dir / "PROJECT", r"(?m)^ group: metalk8s\n", "") + file_regex_replace(op_dir / "PROJECT", _PAT_PROJECT_GROUP_LINE, "") log_info("Patched PROJECT: removed placeholder group") @@ -874,7 +914,7 @@ def _adapt_makefile(spec: OperatorSpec) -> None: "__TOOLCHAIN__", versions.go_toolchain ) new_text, count = re.subn( - r"(^#?ENVTEST_K8S_VERSION[^\n]*\n)", + _PAT_MAKEFILE_ENVTEST_LINE, rf"\1{gotoolchain_block}", text, count=1, @@ -891,7 +931,7 @@ def _adapt_makefile(spec: OperatorSpec) -> None: text = new_text text = re.sub( - r"^GOLANGCI_LINT_VERSION \?=.*$", + _PAT_MAKEFILE_GOLANGCI_VERSION, f"GOLANGCI_LINT_VERSION ?= {versions.golangci_lint}", text, flags=re.MULTILINE, @@ -911,7 +951,7 @@ def _adapt_dockerfile(spec: OperatorSpec) -> None: # Dockerfile comes from backup via merge_backup; we only update the Go image. dst = spec.op_dir / "Dockerfile" file_regex_replace( - dst, r"FROM golang:\d+\.\d+", f"FROM golang:{versions.go_major_minor}" + dst, _PAT_DOCKERFILE_FROM_GOLANG, f"FROM golang:{versions.go_major_minor}" ) log_info(f"Dockerfile updated (Go base image -> {versions.go_major_minor})") From 7e6dd521ab1d02b4f984ce4c55531c6ca8208a7b Mon Sep 17 00:00:00 2001 From: Alex Rodriguez <131964409+ezekiel-alexrod@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:48:12 +0000 Subject: [PATCH 04/12] scripts: update target python version to 3.10 --- scripts/pyproject.toml | 6 +++--- scripts/upgrade-operator-sdk.py | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml index 04377f2fe4..37686f5abb 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -6,11 +6,11 @@ [tool.black] line-length = 88 -target-version = ["py39"] +target-version = ["py310"] [tool.ruff] line-length = 88 -target-version = "py39" +target-version = "py310" [tool.ruff.lint] select = [ @@ -36,4 +36,4 @@ ignore = [ [tool.mypy] strict = true ignore_missing_imports = true -python_version = "3.9" +python_version = "3.10" diff --git a/scripts/upgrade-operator-sdk.py b/scripts/upgrade-operator-sdk.py index 2d2fea18f4..be4e723d6a 100755 --- a/scripts/upgrade-operator-sdk.py +++ b/scripts/upgrade-operator-sdk.py @@ -20,8 +20,6 @@ anonymous rate limit when querying the releases API. """ -from __future__ import annotations - import argparse import json import os From 9569a9d1d07c934e229b311b1638e7c910912d37 Mon Sep 17 00:00:00 2001 From: Alex Rodriguez <131964409+ezekiel-alexrod@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:36:37 +0000 Subject: [PATCH 05/12] scripts: use scaffold Dockerfile and .golangci.yml instead of backup Keep scaffold-generated versions for Dockerfile and .golangci.yml, applying MetalK8s customizations programmatically on top (extra COPY layers, ldflags, LABEL block) via a new DockerfilePatch dataclass. This ensures upstream scaffold improvements are picked up automatically on future upgrades. Also cleans up redundant entries (.devcontainer, cover.out) from the merge policy and removes the golangci-lint install/migrate logic. --- BUMPING.md | 11 +- scripts/upgrade-operator-sdk.py | 241 +++++++++++++++++--------------- 2 files changed, 134 insertions(+), 118 deletions(-) diff --git a/BUMPING.md b/BUMPING.md index 4916492748..98c6faa7be 100644 --- a/BUMPING.md +++ b/BUMPING.md @@ -126,10 +126,7 @@ A few tips to bump image versions and SHAs: ## Operator-sdk and Go version -The upgrade is automated by `scripts/upgrade-operator-sdk.py`. The script detects -the latest compatible versions of operator-sdk, Go toolchain and golangci-lint from -their respective public APIs, scaffolds fresh projects, and merges the existing -custom code back. It applies to both `operator/` and `storage-operator/`. +This guide is applied for both `metalk8s-operator` and `storage-operator`. ### Prerequisites @@ -165,9 +162,9 @@ Options: --yes, -y Skip the confirmation prompt ``` -The script caches `operator-sdk` and `golangci-lint` in `.tmp/bin/` so they are not -re-downloaded on repeated runs. Use `--clean-tools` to reclaim disk space once the -upgrade is validated. +The script caches `operator-sdk` in `.tmp/bin/` so it is not re-downloaded on +repeated runs. Use `--clean-tools` to reclaim disk space once the upgrade is +validated. ### What to review after the upgrade diff --git a/scripts/upgrade-operator-sdk.py b/scripts/upgrade-operator-sdk.py index be4e723d6a..0a901d073d 100755 --- a/scripts/upgrade-operator-sdk.py +++ b/scripts/upgrade-operator-sdk.py @@ -24,7 +24,6 @@ import json import os import re -import shlex import shutil import subprocess import sys @@ -41,7 +40,6 @@ REPO_ROOT: Final = Path(__file__).resolve().parent.parent TOOLS_BIN: Final = REPO_ROOT / ".tmp" / "bin" _SDK_BIN: Final = TOOLS_BIN / "operator-sdk" -_GOLANGCI_LINT_BIN: Final = TOOLS_BIN / "golangci-lint" # All file I/O uses this encoding explicitly. _ENCODING: Final = "utf-8" @@ -78,11 +76,6 @@ + "/releases/download/{version}/operator-sdk_{goos}_{goarch}" ) _URL_GO_RELEASES: Final = "https://go.dev/dl/?mode=json&include=all" -_URL_GOLANGCI_INSTALL: Final = ( - "https://raw.githubusercontent.com/" - + _GITHUB_REPO_GOLANGCI_LINT - + "/HEAD/install.sh" -) # k8s.io libraries that are always released in lock-step. _K8S_LIBS: Final = ("k8s.io/api", "k8s.io/apimachinery", "k8s.io/client-go") @@ -100,22 +93,21 @@ # Go version strings _PAT_GO_MAJOR_MINOR: Final = r"^go(\d+\.\d+).*" -_PAT_GO_VERSION_IN_GOMOD: Final = r"^go\s+(\d+\.\d+)(?:\.\d+)?" _PAT_SEMVER_MAJOR_MINOR: Final = r"(v\d+\.\d+)\." # Dependency versions in go.mod files _PAT_CONTROLLER_RUNTIME_IN_GOMOD: Final = r"sigs\.k8s\.io/controller-runtime\s+(v\S+)" _PAT_K8S_API_IN_GOMOD: Final = r"k8s\.io/api\s+(v\S+)" -# golangci-lint configuration -_PAT_GOLANGCI_CONFIG_VERSION: Final = r'^version:\s+"(\d+)"' - # Makefile lines (MULTILINE flag kept at call site for clarity) _PAT_MAKEFILE_ENVTEST_LINE: Final = r"(^#?ENVTEST_K8S_VERSION[^\n]*\n)" _PAT_MAKEFILE_GOLANGCI_VERSION: Final = r"^GOLANGCI_LINT_VERSION \?=.*$" -# Dockerfile (passed to file_regex_replace — no separate flags argument) +# Dockerfile patterns _PAT_DOCKERFILE_FROM_GOLANG: Final = r"FROM golang:\d+\.\d+" +_PAT_DOCKERFILE_LAST_SCAFFOLD_COPY: Final = ( + r"(COPY internal/controller/ internal/controller/\n)" +) # operator-sdk PROJECT file ((?m) embedded because used in file_regex_replace) _PAT_PROJECT_GROUP_LINE: Final = r"(?m)^ group: metalk8s\n" @@ -148,6 +140,54 @@ " > deploy/manifests.yaml\n" ) +# --------------------------------------------------------------------------- +# Dockerfile fragment template +# +# Appended after ENTRYPOINT in the scaffold-generated Dockerfile. +# __NAME__, __DESCRIPTION__, and __TAGS__ are replaced at runtime. +# --------------------------------------------------------------------------- + +_DOCKERFILE_LABEL_BLOCK: Final = ( + "\n" + "# Timestamp of the build, formatted as RFC3339\n" + "ARG BUILD_DATE\n" + "# Git revision o the tree at build time\n" + "ARG VCS_REF\n" + "# Version of the image\n" + "ARG VERSION\n" + "# Version of the project, e.g. `git describe --always --long --dirty --broken`\n" + "ARG METALK8S_VERSION\n" + "\n" + "# These contain BUILD_DATE so should come 'late' for layer caching\n" + 'LABEL maintainer="squad-metalk8s@scality.com" \\\n' + " # http://label-schema.org/rc1/\n" + ' org.label-schema.build-date="$BUILD_DATE" \\\n' + ' org.label-schema.name="__NAME__" \\\n' + ' org.label-schema.description="__DESCRIPTION__" \\\n' + ' org.label-schema.url="https://github.com/scality/metalk8s/" \\\n' + ' org.label-schema.vcs-url="https://github.com/scality/metalk8s.git" \\\n' + ' org.label-schema.vcs-ref="$VCS_REF" \\\n' + ' org.label-schema.vendor="Scality" \\\n' + ' org.label-schema.version="$VERSION" \\\n' + ' org.label-schema.schema-version="1.0" \\\n' + " # https://github.com/opencontainers/image-spec/blob/master/annotations.md\n" + ' org.opencontainers.image.created="$BUILD_DATE" \\\n' + ' org.opencontainers.image.authors="squad-metalk8s@scality.com" \\\n' + ' org.opencontainers.image.url="https://github.com/scality/metalk8s/" \\\n' + " org.opencontainers.image.source=" + '"https://github.com/scality/metalk8s.git" \\\n' + ' org.opencontainers.image.version="$VERSION" \\\n' + ' org.opencontainers.image.revision="$VCS_REF" \\\n' + ' org.opencontainers.image.vendor="Scality" \\\n' + ' org.opencontainers.image.title="__NAME__" \\\n' + ' org.opencontainers.image.description="__DESCRIPTION__" \\\n' + " # https://docs.openshift.org/latest/creating_images/metadata.html\n" + ' io.openshift.tags="__TAGS__" \\\n' + ' io.k8s.description="__DESCRIPTION__" \\\n' + " # Various\n" + ' com.scality.metalk8s.version="$METALK8S_VERSION"\n' +) + # --------------------------------------------------------------------------- # Merge policy # @@ -156,15 +196,17 @@ # preserved automatically without maintaining an explicit restore list. # # Scaffold-only — keep new scaffold version, do NOT copy from backup: -# Directories: .devcontainer .github bin cmd config (except metalk8s/) -# Root files: .dockerignore .gitignore go.mod go.sum Makefile PROJECT -# README.md +# Directories: .github bin cmd config (except config/metalk8s/) +# Root files: .dockerignore .gitignore .golangci.yml Dockerfile +# go.mod go.sum Makefile PROJECT README.md # Generated: *zz_generated* internal/controller/suite_test.go +# +# NOTE: .devcontainer/ is removed explicitly by scaffold_project() and is +# not listed here — it is gitignored and never present in backups. # --------------------------------------------------------------------------- _SCAFFOLD_ONLY_DIRS: Final[frozenset[str]] = frozenset( { - ".devcontainer", ".github", "bin", "cmd", @@ -179,13 +221,13 @@ { ".dockerignore", ".gitignore", + ".golangci.yml", + "Dockerfile", "go.mod", "go.sum", "Makefile", "PROJECT", "README.md", - # test coverage artefact — regenerated by `make test`, not custom code - "cover.out", # scaffold-generated test setup (version-specific, not custom code) "internal/controller/suite_test.go", } @@ -198,12 +240,10 @@ _SCAFFOLD_ONLY_DIRS | {f.split("/")[0] for f in _SCAFFOLD_ONLY_FILES} | { + ".devcontainer", # removed by scaffold_project(), not in _SCAFFOLD_ONLY_DIRS "api", "hack", "internal", # created by `create api` - # root files scaffold creates that we intentionally overwrite from backup - ".golangci.yml", - "Dockerfile", } ) @@ -220,20 +260,6 @@ def _should_merge(rel: str) -> bool: return rel.split("/")[0] not in _SCAFFOLD_ONLY_DIRS -# --------------------------------------------------------------------------- -# golangci-lint config versioning -# --------------------------------------------------------------------------- - -# Minimum golangci-lint config version we consider "already migrated". -_GOLANGCI_MIN_CONFIG_VERSION: Final = 2 - - -def _golangci_config_version(content: str) -> int | None: - """Return the numeric config version from a .golangci.yml, or None.""" - m = re.search(_PAT_GOLANGCI_CONFIG_VERSION, content, re.MULTILINE) - return int(m.group(1)) if m else None - - # --------------------------------------------------------------------------- # Detected versions # --------------------------------------------------------------------------- @@ -567,26 +593,11 @@ def run( return subprocess.run(cmd, check=check, **kwargs) -def run_shell( - cmd: str, *, cwd: Path | None = None -) -> subprocess.CompletedProcess[bytes]: - """Run a shell string (use sparingly — only when pipes are required).""" - return subprocess.run( - cmd, shell=True, cwd=cwd, env={**os.environ, **_tool_env()}, check=True - ) - - # --------------------------------------------------------------------------- # File helpers # --------------------------------------------------------------------------- -def file_replace(path: Path, old: str, new: str) -> None: - path.write_text( - path.read_text(encoding=_ENCODING).replace(old, new), encoding=_ENCODING - ) - - def file_regex_replace(path: Path, pattern: str, repl: str) -> None: path.write_text( re.sub(pattern, repl, path.read_text(encoding=_ENCODING)), encoding=_ENCODING @@ -598,6 +609,20 @@ def file_regex_replace(path: Path, pattern: str, repl: str) -> None: # --------------------------------------------------------------------------- +@dataclass(frozen=True) +class DockerfilePatch: + """Customizations applied on top of the scaffold-generated Dockerfile. + + The scaffold Dockerfile is kept as the base; these fields describe the + MetalK8s-specific additions (extra COPY layers, ldflags, OCI labels). + """ + + extra_copy_dirs: tuple[str, ...] + ldflags: str + label_description: str + openshift_tags: str + + @dataclass(frozen=True) class SourceFix: """A text replacement applied to a source file after merge. @@ -635,6 +660,7 @@ class OperatorSpec: repo: str apis: tuple[ApiDef, ...] image_name: str = "" + dockerfile: DockerfilePatch = DockerfilePatch((), "", "", "") fixes: tuple[SourceFix, ...] = () @property @@ -657,6 +683,14 @@ def bak_dir(self) -> Path: ApiDef("", "v1alpha1", "VirtualIPPool"), ), image_name="metalk8s-operator", + dockerfile=DockerfilePatch( + extra_copy_dirs=("pkg/", "version/"), + ldflags="-X 'github.com/scality/metalk8s/operator/" + "version.Version=${METALK8S_VERSION}'", + label_description="Kubernetes Operator for managing " + "MetalK8s cluster config", + openshift_tags="metalk8s,operator", + ), fixes=( # Go 1.24+: go vet rejects non-constant format strings in fmt.Errorf. # Remove once the backup no longer contains this pattern @@ -674,6 +708,13 @@ def bak_dir(self) -> Path: repo="github.com/scality/metalk8s/storage-operator", apis=(ApiDef("storage", "v1alpha1", "Volume"),), image_name="storage-operator", + dockerfile=DockerfilePatch( + extra_copy_dirs=("salt/",), + ldflags="", + label_description="Kubernetes Operator for managing " + "PersistentVolumes in MetalK8s", + openshift_tags="metalk8s,storage,operator", + ), fixes=( # Go 1.16: io/ioutil deprecated. # Remove once the backup no longer imports io/ioutil @@ -738,28 +779,6 @@ def install_operator_sdk() -> None: log_info(f"Installed: {ver}") -def install_golangci_lint() -> None: - log_step(f"Installing golangci-lint {versions.golangci_lint}") - TOOLS_BIN.mkdir(parents=True, exist_ok=True) - - if _is_installed(_GOLANGCI_LINT_BIN, versions.golangci_lint): - log_info("Already installed") - return - - log_info("Downloading...") - run_shell( - f"curl -sSfL {_URL_GOLANGCI_INSTALL}" - f" | sh -s -- -b {shlex.quote(str(TOOLS_BIN))}" - f" {shlex.quote(versions.golangci_lint)}", - ) - ver = ( - run([str(_GOLANGCI_LINT_BIN), "version"], capture=True) - .stdout.strip() - .split("\n")[0] - ) - log_info(f"Installed: {ver}") - - # =================================================================== # Phase 1 — Backup # =================================================================== @@ -897,7 +916,6 @@ def adapt_project(spec: OperatorSpec) -> None: """Apply all post-merge adaptations.""" _adapt_makefile(spec) _adapt_dockerfile(spec) - _adapt_golangci_lint(spec) _apply_source_fixes(spec) _remove_incompatible_scaffold_tests(spec.op_dir) @@ -945,44 +963,46 @@ def _adapt_makefile(spec: OperatorSpec) -> None: def _adapt_dockerfile(spec: OperatorSpec) -> None: + """Apply MetalK8s customizations on top of the scaffold-generated Dockerfile.""" log_info("Adapting Dockerfile...") - # Dockerfile comes from backup via merge_backup; we only update the Go image. - dst = spec.op_dir / "Dockerfile" - file_regex_replace( - dst, _PAT_DOCKERFILE_FROM_GOLANG, f"FROM golang:{versions.go_major_minor}" - ) - log_info(f"Dockerfile updated (Go base image -> {versions.go_major_minor})") - + df = spec.op_dir / "Dockerfile" + text = df.read_text(encoding=_ENCODING) + patch = spec.dockerfile -def _adapt_golangci_lint(spec: OperatorSpec) -> None: - log_info("Adapting .golangci.yml...") - dst_cfg = spec.op_dir / ".golangci.yml" - content = dst_cfg.read_text(encoding=_ENCODING) - - # Check config version numerically to remain correct when golangci-lint - # releases a future config format (e.g. version "3"). - cfg_version = _golangci_config_version(content) - if cfg_version is not None and cfg_version >= _GOLANGCI_MIN_CONFIG_VERSION: - log_info(f".golangci.yml already at config version {cfg_version}") - return - - # Normalize deprecated v1 fields that the migrate command rejects. - if " deadline:" in content: - content = content.replace(" deadline:", " timeout:") - dst_cfg.write_text(content, encoding=_ENCODING) + text = re.sub( + _PAT_DOCKERFILE_FROM_GOLANG, + f"FROM golang:{versions.go_major_minor}", + text, + ) - result = run([str(_GOLANGCI_LINT_BIN), "migrate"], cwd=spec.op_dir, check=False) - if result.returncode != 0: - log_warn("golangci-lint migrate reported warnings (review manually)") + if patch.extra_copy_dirs: + copies = "".join(f"COPY {d} {d}\n" for d in patch.extra_copy_dirs) + text = re.sub(_PAT_DOCKERFILE_LAST_SCAFFOLD_COPY, rf"\g<1>{copies}", text) + + if patch.ldflags: + text = text.replace( + "\n# Build\n", + "\n# Version of the project, e.g. " + "`git describe --always --long --dirty --broken`\n" + "ARG METALK8S_VERSION\n" + "\n# Build\n", + ) + text = text.replace( + "go build -a -o manager cmd/main.go", + "go build -a -o manager \\\n" + f' -ldflags "{patch.ldflags}" \\\n' + " cmd/main.go", + ) - for bck in ( - dst_cfg.with_suffix(".yml.bck"), - dst_cfg.parent / ".golangci.bck.yml", - dst_cfg.with_suffix(".yml.bak"), - ): - bck.unlink(missing_ok=True) + label = ( + _DOCKERFILE_LABEL_BLOCK.replace("__NAME__", spec.image_name) + .replace("__DESCRIPTION__", patch.label_description) + .replace("__TAGS__", patch.openshift_tags) + ) + text += label - log_info(".golangci.yml migrated to v2") + df.write_text(text, encoding=_ENCODING) + log_info("Dockerfile adapted") def _apply_source_fix(op_dir: Path, fix: SourceFix) -> None: @@ -1062,10 +1082,10 @@ def generate_and_build(spec: OperatorSpec) -> None: def _clean_tools() -> None: """Remove the script's tool cache (.tmp/bin/). - This forces operator-sdk and golangci-lint to be re-downloaded on the next - run, which is useful to reclaim disk space (~150 MB) after the upgrade. - The operator bin/ directories created during scaffolding are not affected; - they are controlled by each operator's Makefile. + This forces operator-sdk to be re-downloaded on the next run, which is + useful to reclaim disk space after the upgrade. The operator bin/ + directories created during scaffolding are not affected; they are + controlled by each operator's Makefile. """ if TOOLS_BIN.exists(): log_step(f"Cleaning tool cache ({TOOLS_BIN.relative_to(REPO_ROOT)}/)") @@ -1142,7 +1162,6 @@ def main() -> None: log_step(f"Operator SDK Upgrade -> {versions.operator_sdk}") install_operator_sdk() - install_golangci_lint() for name in targets: spec = OPERATORS[name] From 521bae082d0c0cc12638fa537a9f6c94c2ee2e4f Mon Sep 17 00:00:00 2001 From: Alex Rodriguez <131964409+ezekiel-alexrod@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:12:38 +0000 Subject: [PATCH 06/12] scripts: corrected regex for Dockerfile --- scripts/upgrade-operator-sdk.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/upgrade-operator-sdk.py b/scripts/upgrade-operator-sdk.py index 0a901d073d..cfdcb497bb 100755 --- a/scripts/upgrade-operator-sdk.py +++ b/scripts/upgrade-operator-sdk.py @@ -105,9 +105,10 @@ # Dockerfile patterns _PAT_DOCKERFILE_FROM_GOLANG: Final = r"FROM golang:\d+\.\d+" -_PAT_DOCKERFILE_LAST_SCAFFOLD_COPY: Final = ( - r"(COPY internal/controller/ internal/controller/\n)" -) +# Last COPY before the "# Build" section; used as insertion anchor for extra dirs. +# Matches any COPY line whose target starts with "internal" (the scaffold's last +# source COPY, regardless of whether it copies internal/ or internal/controller/). +_PAT_DOCKERFILE_LAST_SCAFFOLD_COPY: Final = r"(COPY internal\S* internal\S*\n)" # operator-sdk PROJECT file ((?m) embedded because used in file_regex_replace) _PAT_PROJECT_GROUP_LINE: Final = r"(?m)^ group: metalk8s\n" From 800dc946d2830cbb4851d6a00b9ec000eba23489 Mon Sep 17 00:00:00 2001 From: Alex Rodriguez <131964409+ezekiel-alexrod@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:20:05 +0000 Subject: [PATCH 07/12] scripts: change strategy for GNU patch files --- BUMPING.md | 63 ++++- scripts/patches/operator/Dockerfile.patch | 65 +++++ scripts/patches/operator/Makefile.patch | 19 ++ .../patches/storage-operator/Dockerfile.patch | 51 ++++ .../patches/storage-operator/Makefile.patch | 19 ++ scripts/upgrade-operator-sdk.py | 251 +++++------------- 6 files changed, 277 insertions(+), 191 deletions(-) create mode 100644 scripts/patches/operator/Dockerfile.patch create mode 100644 scripts/patches/operator/Makefile.patch create mode 100644 scripts/patches/storage-operator/Dockerfile.patch create mode 100644 scripts/patches/storage-operator/Makefile.patch diff --git a/BUMPING.md b/BUMPING.md index 98c6faa7be..c03d35c2b0 100644 --- a/BUMPING.md +++ b/BUMPING.md @@ -130,7 +130,7 @@ This guide is applied for both `metalk8s-operator` and `storage-operator`. ### Prerequisites -- `go` and `curl` in `PATH`. +- `go`, `curl`, and `patch` in `PATH`. - A GitHub personal access token is optional but strongly recommended: without it, GitHub API calls are subject to a 60 requests/hour anonymous rate limit. The token must be **exported** so child processes inherit it: @@ -201,6 +201,67 @@ After a successful run: rm -rf operator.bak/ storage-operator.bak/ ``` +### Patch files + +MetalK8s-specific customizations to scaffold-generated files (`Dockerfile`, `Makefile`) +are stored as standard GNU unified diff files in `scripts/patches//`: + +``` +scripts/patches/ + operator/ + Dockerfile.patch # extra COPY dirs, ldflags, Scality LABEL block + Makefile.patch # GOTOOLCHAIN export, metalk8s make target + storage-operator/ + Dockerfile.patch # extra COPY salt/, Scality LABEL block + Makefile.patch # GOTOOLCHAIN export, metalk8s make target +``` + +The script applies them with `patch -p1` after scaffolding. If a patch does not +apply cleanly (e.g. because the scaffold changed significantly), the script warns +but continues — look for `.rej` files in the operator directory and resolve manually. + +#### Placeholders + +Patch files use `__PLACEHOLDER__` tokens for values that are only known at runtime. +The script replaces them after applying the patches: + +| Placeholder | Replaced with | File | +|---|---|---| +| `__GOTOOLCHAIN__` | Detected Go toolchain (e.g. `go1.25.8`) | `Makefile` | +| `__IMAGE__` | Jinja2 `build_image_name(...)` expression | `Makefile` | + +The `FROM golang:X.Y` line in `Dockerfile` and `GOLANGCI_LINT_VERSION` in `Makefile` +are updated by simple regex substitutions (not via patches), since their values change +with every upgrade. + +#### How to add or update a patch + +Patches are plain `diff -u` output — you can edit them by hand or regenerate them. +To regenerate after modifying an operator customization: + +```bash +# 1. Run the upgrade script with --skip-backup to get a fresh scaffold +python3 scripts/upgrade-operator-sdk.py --operator-only --skip-backup --yes + +# 2. The script applies existing patches; to start fresh, reset the file: +git checkout operator/Dockerfile + +# 3. Make your changes to the scaffold file +vim operator/Dockerfile + +# 4. Generate the new patch (a/ b/ prefixes are required for patch -p1) +diff -u <(git show HEAD:operator/Dockerfile) operator/Dockerfile \ + | sed '1s|.*|--- a/Dockerfile|;2s|.*|+++ b/Dockerfile|' \ + > scripts/patches/operator/Dockerfile.patch + +# 5. Verify it applies cleanly +git checkout operator/Dockerfile +patch -p1 --dry-run -d operator < scripts/patches/operator/Dockerfile.patch +``` + +To add a patch for a new file (e.g. `README.md`), create a new `.patch` file in +the same directory — the script automatically picks up all `*.patch` files. + ### Stale compatibility fixes The `OPERATORS` dict in `scripts/upgrade-operator-sdk.py` contains a `fixes` tuple diff --git a/scripts/patches/operator/Dockerfile.patch b/scripts/patches/operator/Dockerfile.patch new file mode 100644 index 0000000000..bb01219219 --- /dev/null +++ b/scripts/patches/operator/Dockerfile.patch @@ -0,0 +1,65 @@ +--- a/Dockerfile ++++ b/Dockerfile +@@ -15,13 +15,20 @@ + COPY cmd/main.go cmd/main.go + COPY api/ api/ + COPY internal/ internal/ ++COPY pkg/ pkg/ ++COPY version/ version/ ++ ++# Version of the project, e.g. `git describe --always --long --dirty --broken` ++ARG METALK8S_VERSION + + # Build + # the GOARCH has not a default value to allow the binary 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. +-RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go ++RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager \ ++ -ldflags "-X 'github.com/scality/metalk8s/operator/version.Version=${METALK8S_VERSION}'" \ ++ cmd/main.go + + # Use distroless as minimal base image to package the manager binary + # Refer to https://github.com/GoogleContainerTools/distroless for more details +@@ -31,3 +38,40 @@ + USER 65532:65532 + + ENTRYPOINT ["/manager"] ++ ++# Timestamp of the build, formatted as RFC3339 ++ARG BUILD_DATE ++# Git revision o the tree at build time ++ARG VCS_REF ++# Version of the image ++ARG VERSION ++# Version of the project, e.g. `git describe --always --long --dirty --broken` ++ARG METALK8S_VERSION ++ ++# These contain BUILD_DATE so should come 'late' for layer caching ++LABEL maintainer="squad-metalk8s@scality.com" \ ++ # http://label-schema.org/rc1/ ++ org.label-schema.build-date="$BUILD_DATE" \ ++ org.label-schema.name="metalk8s-operator" \ ++ org.label-schema.description="Kubernetes Operator for managing MetalK8s cluster config" \ ++ org.label-schema.url="https://github.com/scality/metalk8s/" \ ++ org.label-schema.vcs-url="https://github.com/scality/metalk8s.git" \ ++ org.label-schema.vcs-ref="$VCS_REF" \ ++ org.label-schema.vendor="Scality" \ ++ org.label-schema.version="$VERSION" \ ++ org.label-schema.schema-version="1.0" \ ++ # https://github.com/opencontainers/image-spec/blob/master/annotations.md ++ org.opencontainers.image.created="$BUILD_DATE" \ ++ org.opencontainers.image.authors="squad-metalk8s@scality.com" \ ++ org.opencontainers.image.url="https://github.com/scality/metalk8s/" \ ++ org.opencontainers.image.source="https://github.com/scality/metalk8s.git" \ ++ org.opencontainers.image.version="$VERSION" \ ++ org.opencontainers.image.revision="$VCS_REF" \ ++ org.opencontainers.image.vendor="Scality" \ ++ org.opencontainers.image.title="metalk8s-operator" \ ++ org.opencontainers.image.description="Kubernetes Operator for managing MetalK8s cluster config" \ ++ # https://docs.openshift.org/latest/creating_images/metadata.html ++ io.openshift.tags="metalk8s,operator" \ ++ io.k8s.description="Kubernetes Operator for managing MetalK8s cluster config" \ ++ # Various ++ com.scality.metalk8s.version="$METALK8S_VERSION" diff --git a/scripts/patches/operator/Makefile.patch b/scripts/patches/operator/Makefile.patch new file mode 100644 index 0000000000..5ea8335c27 --- /dev/null +++ b/scripts/patches/operator/Makefile.patch @@ -0,0 +1,19 @@ +--- a/Makefile ++++ b/Makefile +@@ -1,2 +1,6 @@ + #ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31) ++ ++# Force Go toolchain version to prevent automatic selection issues ++# See: https://go.dev/doc/toolchain ++export GOTOOLCHAIN = __GOTOOLCHAIN__ + ENVTEST_K8S_VERSION ?= $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}') +@@ -4,3 +8,9 @@ + .PHONY: catalog-push + catalog-push: ## Push a catalog image. + $(MAKE) docker-push IMG=$(CATALOG_IMG) ++ ++.PHONY: metalk8s ++metalk8s: manifests kustomize ## Generate MetalK8s resulting manifests ++ mkdir -p deploy ++ $(KUSTOMIZE) build config/metalk8s | \ ++ sed 's/BUILD_IMAGE_CLUSTER_OPERATOR:latest/__IMAGE__/' > deploy/manifests.yaml diff --git a/scripts/patches/storage-operator/Dockerfile.patch b/scripts/patches/storage-operator/Dockerfile.patch new file mode 100644 index 0000000000..f1851655f1 --- /dev/null +++ b/scripts/patches/storage-operator/Dockerfile.patch @@ -0,0 +1,51 @@ +--- a/Dockerfile ++++ b/Dockerfile +@@ -15,6 +15,7 @@ + COPY cmd/main.go cmd/main.go + COPY api/ api/ + COPY internal/ internal/ ++COPY salt/ salt/ + + # Build + # the GOARCH has not a default value to allow the binary be built according to the host where the command +@@ -31,3 +32,40 @@ + USER 65532:65532 + + ENTRYPOINT ["/manager"] ++ ++# Timestamp of the build, formatted as RFC3339 ++ARG BUILD_DATE ++# Git revision o the tree at build time ++ARG VCS_REF ++# Version of the image ++ARG VERSION ++# Version of the project, e.g. `git describe --always --long --dirty --broken` ++ARG METALK8S_VERSION ++ ++# These contain BUILD_DATE so should come 'late' for layer caching ++LABEL maintainer="squad-metalk8s@scality.com" \ ++ # http://label-schema.org/rc1/ ++ org.label-schema.build-date="$BUILD_DATE" \ ++ org.label-schema.name="storage-operator" \ ++ org.label-schema.description="Kubernetes Operator for managing PersistentVolumes in MetalK8s" \ ++ org.label-schema.url="https://github.com/scality/metalk8s/" \ ++ org.label-schema.vcs-url="https://github.com/scality/metalk8s.git" \ ++ org.label-schema.vcs-ref="$VCS_REF" \ ++ org.label-schema.vendor="Scality" \ ++ org.label-schema.version="$VERSION" \ ++ org.label-schema.schema-version="1.0" \ ++ # https://github.com/opencontainers/image-spec/blob/master/annotations.md ++ org.opencontainers.image.created="$BUILD_DATE" \ ++ org.opencontainers.image.authors="squad-metalk8s@scality.com" \ ++ org.opencontainers.image.url="https://github.com/scality/metalk8s/" \ ++ org.opencontainers.image.source="https://github.com/scality/metalk8s.git" \ ++ org.opencontainers.image.version="$VERSION" \ ++ org.opencontainers.image.revision="$VCS_REF" \ ++ org.opencontainers.image.vendor="Scality" \ ++ org.opencontainers.image.title="storage-operator" \ ++ org.opencontainers.image.description="Kubernetes Operator for managing PersistentVolumes in MetalK8s" \ ++ # https://docs.openshift.org/latest/creating_images/metadata.html ++ io.openshift.tags="metalk8s,storage,operator" \ ++ io.k8s.description="Kubernetes Operator for managing PersistentVolumes in MetalK8s" \ ++ # Various ++ com.scality.metalk8s.version="$METALK8S_VERSION" diff --git a/scripts/patches/storage-operator/Makefile.patch b/scripts/patches/storage-operator/Makefile.patch new file mode 100644 index 0000000000..5ea8335c27 --- /dev/null +++ b/scripts/patches/storage-operator/Makefile.patch @@ -0,0 +1,19 @@ +--- a/Makefile ++++ b/Makefile +@@ -1,2 +1,6 @@ + #ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31) ++ ++# Force Go toolchain version to prevent automatic selection issues ++# See: https://go.dev/doc/toolchain ++export GOTOOLCHAIN = __GOTOOLCHAIN__ + ENVTEST_K8S_VERSION ?= $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}') +@@ -4,3 +8,9 @@ + .PHONY: catalog-push + catalog-push: ## Push a catalog image. + $(MAKE) docker-push IMG=$(CATALOG_IMG) ++ ++.PHONY: metalk8s ++metalk8s: manifests kustomize ## Generate MetalK8s resulting manifests ++ mkdir -p deploy ++ $(KUSTOMIZE) build config/metalk8s | \ ++ sed 's/BUILD_IMAGE_CLUSTER_OPERATOR:latest/__IMAGE__/' > deploy/manifests.yaml diff --git a/scripts/upgrade-operator-sdk.py b/scripts/upgrade-operator-sdk.py index cfdcb497bb..52fd1358c2 100755 --- a/scripts/upgrade-operator-sdk.py +++ b/scripts/upgrade-operator-sdk.py @@ -40,6 +40,7 @@ REPO_ROOT: Final = Path(__file__).resolve().parent.parent TOOLS_BIN: Final = REPO_ROOT / ".tmp" / "bin" _SDK_BIN: Final = TOOLS_BIN / "operator-sdk" +_PATCHES_DIR: Final = REPO_ROOT / "scripts" / "patches" # All file I/O uses this encoding explicitly. _ENCODING: Final = "utf-8" @@ -99,96 +100,13 @@ _PAT_CONTROLLER_RUNTIME_IN_GOMOD: Final = r"sigs\.k8s\.io/controller-runtime\s+(v\S+)" _PAT_K8S_API_IN_GOMOD: Final = r"k8s\.io/api\s+(v\S+)" -# Makefile lines (MULTILINE flag kept at call site for clarity) -_PAT_MAKEFILE_ENVTEST_LINE: Final = r"(^#?ENVTEST_K8S_VERSION[^\n]*\n)" -_PAT_MAKEFILE_GOLANGCI_VERSION: Final = r"^GOLANGCI_LINT_VERSION \?=.*$" - -# Dockerfile patterns +# Version substitution patterns applied after patch files _PAT_DOCKERFILE_FROM_GOLANG: Final = r"FROM golang:\d+\.\d+" -# Last COPY before the "# Build" section; used as insertion anchor for extra dirs. -# Matches any COPY line whose target starts with "internal" (the scaffold's last -# source COPY, regardless of whether it copies internal/ or internal/controller/). -_PAT_DOCKERFILE_LAST_SCAFFOLD_COPY: Final = r"(COPY internal\S* internal\S*\n)" +_PAT_MAKEFILE_GOLANGCI_VERSION: Final = r"^GOLANGCI_LINT_VERSION \?=.*$" # operator-sdk PROJECT file ((?m) embedded because used in file_regex_replace) _PAT_PROJECT_GROUP_LINE: Final = r"(?m)^ group: metalk8s\n" -# --------------------------------------------------------------------------- -# Makefile fragment templates -# -# __PLACEHOLDER__ tokens replace Python f-string escaping, which is especially -# confusing around the Jinja-style {{ }} delimiters used in the Makefile. -# --------------------------------------------------------------------------- - -# Inserted after the ENVTEST_K8S_VERSION line in the generated Makefile. -_GOTOOLCHAIN_BLOCK: Final = ( - "\n" - "# Force Go toolchain version to prevent automatic selection issues\n" - "# See: https://go.dev/doc/toolchain\n" - "export GOTOOLCHAIN = __TOOLCHAIN__\n" -) - -# Appended to the Makefile; __IMAGE__ is replaced by spec.image_name at runtime. -# The outer {{ }} are Jinja2 delimiters — literal in the resulting Makefile. -_METALK8S_MAKE_TARGET: Final = ( - "\n" - ".PHONY: metalk8s\n" - "metalk8s: manifests kustomize ## Generate MetalK8s resulting manifests\n" - "\tmkdir -p deploy\n" - "\t$(KUSTOMIZE) build config/metalk8s | \\\n" - "\tsed 's/BUILD_IMAGE_CLUSTER_OPERATOR:latest/" - '{{ build_image_name("__IMAGE__") }}/\'' - " > deploy/manifests.yaml\n" -) - -# --------------------------------------------------------------------------- -# Dockerfile fragment template -# -# Appended after ENTRYPOINT in the scaffold-generated Dockerfile. -# __NAME__, __DESCRIPTION__, and __TAGS__ are replaced at runtime. -# --------------------------------------------------------------------------- - -_DOCKERFILE_LABEL_BLOCK: Final = ( - "\n" - "# Timestamp of the build, formatted as RFC3339\n" - "ARG BUILD_DATE\n" - "# Git revision o the tree at build time\n" - "ARG VCS_REF\n" - "# Version of the image\n" - "ARG VERSION\n" - "# Version of the project, e.g. `git describe --always --long --dirty --broken`\n" - "ARG METALK8S_VERSION\n" - "\n" - "# These contain BUILD_DATE so should come 'late' for layer caching\n" - 'LABEL maintainer="squad-metalk8s@scality.com" \\\n' - " # http://label-schema.org/rc1/\n" - ' org.label-schema.build-date="$BUILD_DATE" \\\n' - ' org.label-schema.name="__NAME__" \\\n' - ' org.label-schema.description="__DESCRIPTION__" \\\n' - ' org.label-schema.url="https://github.com/scality/metalk8s/" \\\n' - ' org.label-schema.vcs-url="https://github.com/scality/metalk8s.git" \\\n' - ' org.label-schema.vcs-ref="$VCS_REF" \\\n' - ' org.label-schema.vendor="Scality" \\\n' - ' org.label-schema.version="$VERSION" \\\n' - ' org.label-schema.schema-version="1.0" \\\n' - " # https://github.com/opencontainers/image-spec/blob/master/annotations.md\n" - ' org.opencontainers.image.created="$BUILD_DATE" \\\n' - ' org.opencontainers.image.authors="squad-metalk8s@scality.com" \\\n' - ' org.opencontainers.image.url="https://github.com/scality/metalk8s/" \\\n' - " org.opencontainers.image.source=" - '"https://github.com/scality/metalk8s.git" \\\n' - ' org.opencontainers.image.version="$VERSION" \\\n' - ' org.opencontainers.image.revision="$VCS_REF" \\\n' - ' org.opencontainers.image.vendor="Scality" \\\n' - ' org.opencontainers.image.title="__NAME__" \\\n' - ' org.opencontainers.image.description="__DESCRIPTION__" \\\n' - " # https://docs.openshift.org/latest/creating_images/metadata.html\n" - ' io.openshift.tags="__TAGS__" \\\n' - ' io.k8s.description="__DESCRIPTION__" \\\n' - " # Various\n" - ' com.scality.metalk8s.version="$METALK8S_VERSION"\n' -) - # --------------------------------------------------------------------------- # Merge policy # @@ -610,20 +528,6 @@ def file_regex_replace(path: Path, pattern: str, repl: str) -> None: # --------------------------------------------------------------------------- -@dataclass(frozen=True) -class DockerfilePatch: - """Customizations applied on top of the scaffold-generated Dockerfile. - - The scaffold Dockerfile is kept as the base; these fields describe the - MetalK8s-specific additions (extra COPY layers, ldflags, OCI labels). - """ - - extra_copy_dirs: tuple[str, ...] - ldflags: str - label_description: str - openshift_tags: str - - @dataclass(frozen=True) class SourceFix: """A text replacement applied to a source file after merge. @@ -661,7 +565,6 @@ class OperatorSpec: repo: str apis: tuple[ApiDef, ...] image_name: str = "" - dockerfile: DockerfilePatch = DockerfilePatch((), "", "", "") fixes: tuple[SourceFix, ...] = () @property @@ -684,14 +587,6 @@ def bak_dir(self) -> Path: ApiDef("", "v1alpha1", "VirtualIPPool"), ), image_name="metalk8s-operator", - dockerfile=DockerfilePatch( - extra_copy_dirs=("pkg/", "version/"), - ldflags="-X 'github.com/scality/metalk8s/operator/" - "version.Version=${METALK8S_VERSION}'", - label_description="Kubernetes Operator for managing " - "MetalK8s cluster config", - openshift_tags="metalk8s,operator", - ), fixes=( # Go 1.24+: go vet rejects non-constant format strings in fmt.Errorf. # Remove once the backup no longer contains this pattern @@ -709,13 +604,6 @@ def bak_dir(self) -> Path: repo="github.com/scality/metalk8s/storage-operator", apis=(ApiDef("storage", "v1alpha1", "Volume"),), image_name="storage-operator", - dockerfile=DockerfilePatch( - extra_copy_dirs=("salt/",), - ldflags="", - label_description="Kubernetes Operator for managing " - "PersistentVolumes in MetalK8s", - openshift_tags="metalk8s,storage,operator", - ), fixes=( # Go 1.16: io/ioutil deprecated. # Remove once the backup no longer imports io/ioutil @@ -747,7 +635,7 @@ def bak_dir(self) -> Path: def _check_prerequisites() -> None: """Fail early with a clear message if required system tools are missing.""" - missing = [tool for tool in ("go", "curl") if shutil.which(tool) is None] + missing = [tool for tool in ("go", "curl", "patch") if shutil.which(tool) is None] if missing: die(f"Required tools not found in PATH: {', '.join(missing)}") @@ -915,95 +803,78 @@ def merge_backup(spec: OperatorSpec) -> None: def adapt_project(spec: OperatorSpec) -> None: """Apply all post-merge adaptations.""" - _adapt_makefile(spec) - _adapt_dockerfile(spec) + _apply_patches(spec) + _substitute_versions(spec) _apply_source_fixes(spec) _remove_incompatible_scaffold_tests(spec.op_dir) -def _adapt_makefile(spec: OperatorSpec) -> None: - log_info("Adapting Makefile...") - makefile = spec.op_dir / "Makefile" - text = makefile.read_text(encoding=_ENCODING) +def _apply_patches(spec: OperatorSpec) -> None: + """Apply GNU patch files from scripts/patches//. - if "export GOTOOLCHAIN" not in text: - gotoolchain_block = _GOTOOLCHAIN_BLOCK.replace( - "__TOOLCHAIN__", versions.go_toolchain - ) - new_text, count = re.subn( - _PAT_MAKEFILE_ENVTEST_LINE, - rf"\1{gotoolchain_block}", - text, - count=1, - flags=re.MULTILINE, + Each ``.patch`` file is a unified diff against the scaffold output. + Patches use ``__PLACEHOLDER__`` tokens for dynamic values that are + filled in by ``_substitute_versions()`` afterwards. + + On failure the script warns but does not abort — the user is expected + to resolve rejected hunks manually. + """ + patch_dir = _PATCHES_DIR / spec.name + if not patch_dir.is_dir(): + log_warn(f"No patch directory found at {patch_dir}") + return + + for patch_file in sorted(patch_dir.glob("*.patch")): + log_info(f"Applying {patch_file.name}...") + result = subprocess.run( + ["patch", "-p1", "--no-backup-if-mismatch", "-i", str(patch_file)], + cwd=spec.op_dir, + capture_output=True, + text=True, ) - if count == 0: - # The scaffold template may change; fall back to appending. + if result.returncode != 0: log_warn( - "ENVTEST_K8S_VERSION not found in Makefile; " - "appending GOTOOLCHAIN block at end" + f"{patch_file.name} did not apply cleanly " + f"(exit {result.returncode}); resolve manually:\n" + f" {result.stdout.strip()}" ) - text += gotoolchain_block else: - text = new_text - - text = re.sub( - _PAT_MAKEFILE_GOLANGCI_VERSION, - f"GOLANGCI_LINT_VERSION ?= {versions.golangci_lint}", - text, - flags=re.MULTILINE, - ) - - # Guard against appending twice when --skip-backup is used on an - # already-upgraded project. - if ".PHONY: metalk8s" not in text: - text += _METALK8S_MAKE_TARGET.replace("__IMAGE__", spec.image_name) - - makefile.write_text(text, encoding=_ENCODING) - log_info("Makefile adapted") + log_info(f" {patch_file.name} applied") -def _adapt_dockerfile(spec: OperatorSpec) -> None: - """Apply MetalK8s customizations on top of the scaffold-generated Dockerfile.""" - log_info("Adapting Dockerfile...") - df = spec.op_dir / "Dockerfile" - text = df.read_text(encoding=_ENCODING) - patch = spec.dockerfile +def _substitute_versions(spec: OperatorSpec) -> None: + """Replace dynamic version placeholders and scaffold defaults. - text = re.sub( - _PAT_DOCKERFILE_FROM_GOLANG, - f"FROM golang:{versions.go_major_minor}", - text, - ) - - if patch.extra_copy_dirs: - copies = "".join(f"COPY {d} {d}\n" for d in patch.extra_copy_dirs) - text = re.sub(_PAT_DOCKERFILE_LAST_SCAFFOLD_COPY, rf"\g<1>{copies}", text) - - if patch.ldflags: - text = text.replace( - "\n# Build\n", - "\n# Version of the project, e.g. " - "`git describe --always --long --dirty --broken`\n" - "ARG METALK8S_VERSION\n" - "\n# Build\n", + Runs after ``_apply_patches()`` to fill in values that are only known + at runtime (Go toolchain, golangci-lint version, image name). + """ + op = spec.op_dir + + dockerfile = op / "Dockerfile" + if dockerfile.exists(): + text = dockerfile.read_text(encoding=_ENCODING) + text = re.sub( + _PAT_DOCKERFILE_FROM_GOLANG, + f"FROM golang:{versions.go_major_minor}", + text, ) - text = text.replace( - "go build -a -o manager cmd/main.go", - "go build -a -o manager \\\n" - f' -ldflags "{patch.ldflags}" \\\n' - " cmd/main.go", + dockerfile.write_text(text, encoding=_ENCODING) + + makefile = op / "Makefile" + if makefile.exists(): + text = makefile.read_text(encoding=_ENCODING) + text = text.replace("__GOTOOLCHAIN__", versions.go_toolchain) + jinja_image = '{{ build_image_name("' + spec.image_name + '") }}' + text = text.replace("__IMAGE__", jinja_image) + text = re.sub( + _PAT_MAKEFILE_GOLANGCI_VERSION, + f"GOLANGCI_LINT_VERSION ?= {versions.golangci_lint}", + text, + flags=re.MULTILINE, ) + makefile.write_text(text, encoding=_ENCODING) - label = ( - _DOCKERFILE_LABEL_BLOCK.replace("__NAME__", spec.image_name) - .replace("__DESCRIPTION__", patch.label_description) - .replace("__TAGS__", patch.openshift_tags) - ) - text += label - - df.write_text(text, encoding=_ENCODING) - log_info("Dockerfile adapted") + log_info("Version substitutions applied") def _apply_source_fix(op_dir: Path, fix: SourceFix) -> None: From 17afab5f7d0ded98b8955b941d76fcc3e19e770f Mon Sep 17 00:00:00 2001 From: Alex Rodriguez <131964409+ezekiel-alexrod@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:52:00 +0000 Subject: [PATCH 08/12] scripts: rewrite upgrade-operator-sdk as generic YAML-driven tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the monolithic script with a generic, config-driven tool that takes an operator directory as argument. Each operator has its own config.yaml and patches/ directory under scripts/upgrade-operator-sdk/. All versions (operator-sdk, Go toolchain, k8s.io libs) are now pinned in the YAML config — the script makes no API calls for version detection. Key changes per review feedback: - YAML config per operator instead of hardcoded OPERATORS dict - GNU patch files for scaffold customizations (Dockerfile, Makefile) - Inclusion-list merge (backup_paths) instead of exclusion-list - No global mutable state (VersionInfo removed) - No SourceFix mechanism (manual fixes by the developer) - API scope (--namespaced) configurable per CRD - Empty group hack removed (operator-sdk v1.42.1 supports it) - Backup/scaffold file conflicts shown as errors with diff - Extra commands (make metalk8s) configurable in YAML --- BUMPING.md | 157 +-- scripts/upgrade-operator-sdk.py | 1081 ----------------- .../upgrade-operator-sdk/operator/config.yaml | 28 + .../operator/patches}/Dockerfile.patch | 0 .../operator/patches}/Makefile.patch | 0 .../{ => upgrade-operator-sdk}/pyproject.toml | 8 +- .../storage-operator/config.yaml | 25 + .../patches}/Dockerfile.patch | 0 .../storage-operator/patches}/Makefile.patch | 0 scripts/upgrade-operator-sdk/upgrade.py | 588 +++++++++ 10 files changed, 691 insertions(+), 1196 deletions(-) delete mode 100755 scripts/upgrade-operator-sdk.py create mode 100644 scripts/upgrade-operator-sdk/operator/config.yaml rename scripts/{patches/operator => upgrade-operator-sdk/operator/patches}/Dockerfile.patch (100%) rename scripts/{patches/operator => upgrade-operator-sdk/operator/patches}/Makefile.patch (100%) rename scripts/{ => upgrade-operator-sdk}/pyproject.toml (85%) create mode 100644 scripts/upgrade-operator-sdk/storage-operator/config.yaml rename scripts/{patches/storage-operator => upgrade-operator-sdk/storage-operator/patches}/Dockerfile.patch (100%) rename scripts/{patches/storage-operator => upgrade-operator-sdk/storage-operator/patches}/Makefile.patch (100%) create mode 100755 scripts/upgrade-operator-sdk/upgrade.py diff --git a/BUMPING.md b/BUMPING.md index c03d35c2b0..44bc76fff4 100644 --- a/BUMPING.md +++ b/BUMPING.md @@ -131,144 +131,79 @@ This guide is applied for both `metalk8s-operator` and `storage-operator`. ### Prerequisites - `go`, `curl`, and `patch` in `PATH`. -- A GitHub personal access token is optional but strongly recommended: without it, - GitHub API calls are subject to a 60 requests/hour anonymous rate limit. The token - must be **exported** so child processes inherit it: +- `pyyaml` Python package: `pip install pyyaml` - ``` - export GITHUB_TOKEN= - ``` +### Updating the versions - Setting the variable without `export` (e.g. `GITHUB_TOKEN=xxx`) is silently - ignored by the script because Python's `os.environ` only sees exported variables. +Before running the script, update the target versions in the YAML config files at +`scripts/upgrade-operator-sdk//config.yaml`: + +```yaml +operator_sdk_version: v1.42.1 # target operator-sdk release +go_toolchain: go1.25.8 # Go toolchain (for GOTOOLCHAIN + FROM golang:X.Y) +k8s_libs: v0.33.9 # k8s.io/{api,apimachinery,client-go} version +``` + +The script makes **no version-detection API calls**; all versions are read from the +YAML config. ### Running the upgrade -``` -python3 scripts/upgrade-operator-sdk.py +The script processes one operator at a time. Run it once per operator: + +```bash +python3 scripts/upgrade-operator-sdk/upgrade.py operator +python3 scripts/upgrade-operator-sdk/upgrade.py storage-operator ``` -The script will display the resolved versions and prompt for confirmation before -making any changes. Use `--yes` to skip the confirmation (e.g. in CI). The original -operator directories are preserved as `.bak/` for the duration of the review. +The argument is the name of the config directory next to the script +(i.e. `scripts/upgrade-operator-sdk//`). A full path can also be +given for configs stored elsewhere. Options: ``` ---operator-only Only process operator/ ---storage-only Only process storage-operator/ --skip-backup Reuse an existing .bak directory (no new backup) ---clean-tools Delete .tmp/bin/ after the upgrade (~150 MB, re-downloaded next run) +--clean-tools Delete .tmp/bin/ after the upgrade --yes, -y Skip the confirmation prompt ``` -The script caches `operator-sdk` in `.tmp/bin/` so it is not re-downloaded on -repeated runs. Use `--clean-tools` to reclaim disk space once the upgrade is -validated. - -### What to review after the upgrade - -After a successful run: - -1. Compare the backup against the result to spot unexpected differences: - - ``` - diff -r operator.bak/ operator/ - diff -r storage-operator.bak/ storage-operator/ - ``` - -2. Run the unit test suite for each operator: - - ``` - cd operator && make test - cd storage-operator && make test - ``` - -3. Check that generated CRD scopes are correct: - `config/crd/bases/` — `ClusterConfig` must be `Cluster`-scoped, - `VirtualIPPool` must be `Namespaced`, `Volume` must be `Cluster`-scoped. - -4. Check that the generated RBAC is complete: - `config/rbac/role.yaml` in each operator. - -5. Check that the MetalK8s manifests contain the correct Jinja template: - `deploy/manifests.yaml` must contain - `{{ build_image_name("metalk8s-operator") }}` / `{{ build_image_name("storage-operator") }}`. +### YAML config files -6. Remove the backup directories once satisfied: +Each operator has a config directory at `scripts/upgrade-operator-sdk//` containing +`config.yaml` and a `patches/` subdirectory. The config fields are: - ``` - rm -rf operator.bak/ storage-operator.bak/ - ``` +- **Versions**: `operator_sdk_version`, `go_toolchain`, `k8s_libs` +- **Scaffold**: `repo`, `domain`, `apis` (with `group`, `version`, `kind`, `namespaced`). The operator name is derived from the config directory name. +- **Paths**: `operator_dir`, `patches_dir`, `backup_paths` +- **Post-processing**: `image_placeholder`, `extra_commands` ### Patch files MetalK8s-specific customizations to scaffold-generated files (`Dockerfile`, `Makefile`) -are stored as standard GNU unified diff files in `scripts/patches//`: +are stored as GNU unified diff files in the `patches/` subdirectory next to `config.yaml`. The script +applies them with `patch -p1` after scaffolding. If a patch does not apply cleanly, +look for `.rej` files and resolve manually. -``` -scripts/patches/ - operator/ - Dockerfile.patch # extra COPY dirs, ldflags, Scality LABEL block - Makefile.patch # GOTOOLCHAIN export, metalk8s make target - storage-operator/ - Dockerfile.patch # extra COPY salt/, Scality LABEL block - Makefile.patch # GOTOOLCHAIN export, metalk8s make target -``` - -The script applies them with `patch -p1` after scaffolding. If a patch does not -apply cleanly (e.g. because the scaffold changed significantly), the script warns -but continues — look for `.rej` files in the operator directory and resolve manually. - -#### Placeholders +Patch files use `__PLACEHOLDER__` tokens for values from the YAML config: -Patch files use `__PLACEHOLDER__` tokens for values that are only known at runtime. -The script replaces them after applying the patches: +| Placeholder | Replaced with | Source | +| ----------------- | -------------------------------------------- | ---------- | +| `__GOTOOLCHAIN__` | `go_toolchain` from config (e.g. `go1.25.8`) | `Makefile` | +| `__IMAGE__` | `image_placeholder` from config | `Makefile` | -| Placeholder | Replaced with | File | -|---|---|---| -| `__GOTOOLCHAIN__` | Detected Go toolchain (e.g. `go1.25.8`) | `Makefile` | -| `__IMAGE__` | Jinja2 `build_image_name(...)` expression | `Makefile` | +The `FROM golang:X.Y` in `Dockerfile` is derived from `go_toolchain` in the config. -The `FROM golang:X.Y` line in `Dockerfile` and `GOLANGCI_LINT_VERSION` in `Makefile` -are updated by simple regex substitutions (not via patches), since their values change -with every upgrade. - -#### How to add or update a patch - -Patches are plain `diff -u` output — you can edit them by hand or regenerate them. -To regenerate after modifying an operator customization: - -```bash -# 1. Run the upgrade script with --skip-backup to get a fresh scaffold -python3 scripts/upgrade-operator-sdk.py --operator-only --skip-backup --yes +New `.patch` files in the patches directory are automatically picked up. -# 2. The script applies existing patches; to start fresh, reset the file: -git checkout operator/Dockerfile - -# 3. Make your changes to the scaffold file -vim operator/Dockerfile - -# 4. Generate the new patch (a/ b/ prefixes are required for patch -p1) -diff -u <(git show HEAD:operator/Dockerfile) operator/Dockerfile \ - | sed '1s|.*|--- a/Dockerfile|;2s|.*|+++ b/Dockerfile|' \ - > scripts/patches/operator/Dockerfile.patch - -# 5. Verify it applies cleanly -git checkout operator/Dockerfile -patch -p1 --dry-run -d operator < scripts/patches/operator/Dockerfile.patch -``` - -To add a patch for a new file (e.g. `README.md`), create a new `.patch` file in -the same directory — the script automatically picks up all `*.patch` files. - -### Stale compatibility fixes +### What to review after the upgrade -The `OPERATORS` dict in `scripts/upgrade-operator-sdk.py` contains a `fixes` tuple -per operator. These entries are one-shot source-level corrections applied after the -backup merge (e.g. deprecated API replacements). Once the backup no longer contains -the old pattern — i.e. after the script has been run at least once — the entry -becomes a no-op and should be removed to keep the script clean. +1. `git diff` to review all changes +2. `cd && make test` to run tests +3. Check `config/crd/bases/` for correct CRD scopes +4. Check `config/rbac/role.yaml` for RBAC completeness +5. Check `deploy/manifests.yaml` for correct Jinja templates +6. Remove backup: `rm -rf .bak/` ## Calico diff --git a/scripts/upgrade-operator-sdk.py b/scripts/upgrade-operator-sdk.py deleted file mode 100755 index 52fd1358c2..0000000000 --- a/scripts/upgrade-operator-sdk.py +++ /dev/null @@ -1,1081 +0,0 @@ -#!/usr/bin/env python3 -"""Automates the upgrade of operator-sdk based projects. - -Backs up operator/ and storage-operator/, scaffolds fresh projects with the -target SDK version, merges custom code from the backup, and verifies the build. - -Usage: - python3 scripts/upgrade-operator-sdk.py [OPTIONS] - -Options: - --operator-only Only process the operator/ project - --storage-only Only process the storage-operator/ project - --skip-backup Skip the backup step (assumes .bak already exists) - --clean-tools Remove .tmp/bin/ after the upgrade (forces re-download next run) - --yes, -y Skip the confirmation prompt - -h, --help Show this help message - -Environment variables: - GITHUB_TOKEN GitHub personal-access token; avoids the 60 req/hour - anonymous rate limit when querying the releases API. -""" - -import argparse -import json -import os -import re -import shutil -import subprocess -import sys -import time -import urllib.error -import urllib.request -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Final, NoReturn - -# --------------------------------------------------------------------------- -# Paths -# --------------------------------------------------------------------------- -REPO_ROOT: Final = Path(__file__).resolve().parent.parent -TOOLS_BIN: Final = REPO_ROOT / ".tmp" / "bin" -_SDK_BIN: Final = TOOLS_BIN / "operator-sdk" -_PATCHES_DIR: Final = REPO_ROOT / "scripts" / "patches" - -# All file I/O uses this encoding explicitly. -_ENCODING: Final = "utf-8" - -# --------------------------------------------------------------------------- -# HTTP configuration -# --------------------------------------------------------------------------- - -# Number of retry attempts for transient network errors. -_HTTP_RETRIES: Final = 3 - -# --------------------------------------------------------------------------- -# URLs -# --------------------------------------------------------------------------- - -# Generic pattern for GitHub's latest-release API. -_GITHUB_RELEASES_URL: Final = "https://api.github.com/repos/{repo}/releases/latest" - -_GITHUB_REPO_OPERATOR_SDK: Final = "operator-framework/operator-sdk" -_GITHUB_REPO_GOLANGCI_LINT: Final = "golangci/golangci-lint" - -_URL_OPERATOR_SDK_GOMOD: Final = ( - "https://raw.githubusercontent.com/" - + _GITHUB_REPO_OPERATOR_SDK - + "/{version}/go.mod" -) -_URL_CONTROLLER_RUNTIME_GOMOD: Final = "https://raw.githubusercontent.com/kubernetes-sigs/controller-runtime/{version}/go.mod" -# Go module proxy — returns a newline-separated list of available versions. -_URL_GO_MODULE_VERSIONS: Final = "https://proxy.golang.org/{module}/@v/list" - -_URL_OPERATOR_SDK_DOWNLOAD: Final = ( - "https://github.com/" - + _GITHUB_REPO_OPERATOR_SDK - + "/releases/download/{version}/operator-sdk_{goos}_{goarch}" -) -_URL_GO_RELEASES: Final = "https://go.dev/dl/?mode=json&include=all" - -# k8s.io libraries that are always released in lock-step. -_K8S_LIBS: Final = ("k8s.io/api", "k8s.io/apimachinery", "k8s.io/client-go") -# The lib whose version drives the cadence for all three (queried for latest patch). -_K8S_LIB_MODULE: Final = _K8S_LIBS[0] - -# --------------------------------------------------------------------------- -# Regex patterns -# -# Centralised here so the business logic functions stay free of raw string -# literals and a change in format only needs updating in one place. -# Patterns used inside file_regex_replace() embed flags (e.g. (?m)) because -# that helper does not accept a separate flags argument. -# --------------------------------------------------------------------------- - -# Go version strings -_PAT_GO_MAJOR_MINOR: Final = r"^go(\d+\.\d+).*" -_PAT_SEMVER_MAJOR_MINOR: Final = r"(v\d+\.\d+)\." - -# Dependency versions in go.mod files -_PAT_CONTROLLER_RUNTIME_IN_GOMOD: Final = r"sigs\.k8s\.io/controller-runtime\s+(v\S+)" -_PAT_K8S_API_IN_GOMOD: Final = r"k8s\.io/api\s+(v\S+)" - -# Version substitution patterns applied after patch files -_PAT_DOCKERFILE_FROM_GOLANG: Final = r"FROM golang:\d+\.\d+" -_PAT_MAKEFILE_GOLANGCI_VERSION: Final = r"^GOLANGCI_LINT_VERSION \?=.*$" - -# operator-sdk PROJECT file ((?m) embedded because used in file_regex_replace) -_PAT_PROJECT_GROUP_LINE: Final = r"(?m)^ group: metalk8s\n" - -# --------------------------------------------------------------------------- -# Merge policy -# -# After scaffolding, every file from the backup is copied to the new project -# UNLESS it matches the scaffold-only rules below. Custom code is therefore -# preserved automatically without maintaining an explicit restore list. -# -# Scaffold-only — keep new scaffold version, do NOT copy from backup: -# Directories: .github bin cmd config (except config/metalk8s/) -# Root files: .dockerignore .gitignore .golangci.yml Dockerfile -# go.mod go.sum Makefile PROJECT README.md -# Generated: *zz_generated* internal/controller/suite_test.go -# -# NOTE: .devcontainer/ is removed explicitly by scaffold_project() and is -# not listed here — it is gitignored and never present in backups. -# --------------------------------------------------------------------------- - -_SCAFFOLD_ONLY_DIRS: Final[frozenset[str]] = frozenset( - { - ".github", - "bin", - "cmd", - "config", - # e2e test templates generated by operator-sdk (added in v1.42.x) - "test", - } -) - -# Exact relative paths (root files + specific generated files). -_SCAFFOLD_ONLY_FILES: Final[frozenset[str]] = frozenset( - { - ".dockerignore", - ".gitignore", - ".golangci.yml", - "Dockerfile", - "go.mod", - "go.sum", - "Makefile", - "PROJECT", - "README.md", - # scaffold-generated test setup (version-specific, not custom code) - "internal/controller/suite_test.go", - } -) - -# All root-level entries expected from `operator-sdk init` + `create api`. -# If a fresh scaffold produces something outside this set, it may be a new -# scaffold addition that needs classifying in the sets above. -_KNOWN_SCAFFOLD_ROOTS: Final[frozenset[str]] = frozenset( - _SCAFFOLD_ONLY_DIRS - | {f.split("/")[0] for f in _SCAFFOLD_ONLY_FILES} - | { - ".devcontainer", # removed by scaffold_project(), not in _SCAFFOLD_ONLY_DIRS - "api", - "hack", - "internal", # created by `create api` - } -) - - -def _should_merge(rel: str) -> bool: - """Return True if a backup file should be copied into the new project.""" - # config/metalk8s/ is our custom MetalK8s kustomize overlay. - if rel.startswith("config/metalk8s/"): - return True - if "zz_generated" in rel: - return False - if rel in _SCAFFOLD_ONLY_FILES: - return False - return rel.split("/")[0] not in _SCAFFOLD_ONLY_DIRS - - -# --------------------------------------------------------------------------- -# Detected versions -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class VersionInfo: - """Resolved tool versions, populated once by detect_versions().""" - - operator_sdk: str - go_toolchain: str - golangci_lint: str - controller_runtime: str # sigs.k8s.io/controller-runtime - k8s_libs: str # k8s.io/{api,apimachinery,client-go} — always in sync - - @property - def go_major_minor(self) -> str: - """Extract the Go major.minor from the toolchain string. - - Example: 'go1.24.13' -> '1.24'. - """ - return re.sub(_PAT_GO_MAJOR_MINOR, r"\1", self.go_toolchain) - - -# Module-level reference; replaced by detect_versions() before any phase runs. -# Sentinel values make it obvious if versions is accidentally read early. -_UNSET = "" -versions: VersionInfo = VersionInfo( - operator_sdk=_UNSET, - go_toolchain=_UNSET, - golangci_lint=_UNSET, - controller_runtime=_UNSET, - k8s_libs=_UNSET, -) - -# --------------------------------------------------------------------------- -# Logging -# --------------------------------------------------------------------------- -_GREEN: Final = "\033[32m" -_YELLOW: Final = "\033[33m" -_RED: Final = "\033[31m" -_BLUE_BOLD: Final = "\033[1;34m" -_BOLD: Final = "\033[1m" -_RESET: Final = "\033[0m" - - -def log_info(msg: str) -> None: - print(f"{_GREEN}[INFO]{_RESET} {msg}") - - -def log_warn(msg: str) -> None: - print(f"{_YELLOW}[WARN]{_RESET} {msg}", file=sys.stderr) - - -def log_error(msg: str) -> None: - print(f"{_RED}[ERROR]{_RESET} {msg}", file=sys.stderr) - - -def log_step(msg: str) -> None: - print(f"\n{_BLUE_BOLD}==>{_RESET} {_BOLD}{msg}{_RESET}") - - -def die(msg: str) -> NoReturn: - log_error(msg) - sys.exit(1) - - -# --------------------------------------------------------------------------- -# HTTP helpers -# --------------------------------------------------------------------------- - - -def _github_headers() -> dict[str, str]: - """Return HTTP headers for GitHub API requests. - - Includes ``Authorization`` when ``GITHUB_TOKEN`` is set, raising the rate - limit from 60 to 5000 requests per hour. - """ - headers: dict[str, str] = {} - token = os.environ.get("GITHUB_TOKEN") - if token: - headers["Authorization"] = f"Bearer {token}" - log_info("Using GITHUB_TOKEN for authenticated GitHub API requests") - return headers - - -def _http_get(url: str, *, headers: dict[str, str] | None = None) -> bytes: - """GET *url*, retrying up to *_HTTP_RETRIES* times on transient errors. - - HTTP errors (4xx/5xx) are not retried — they fail immediately. - Transient ``URLError`` (timeouts, DNS failures) use exponential backoff. - """ - req = urllib.request.Request(url, headers=headers or {}) - for attempt in range(_HTTP_RETRIES): - try: - with urllib.request.urlopen(req, timeout=30) as resp: - data: bytes = resp.read() - return data - except urllib.error.HTTPError: - raise # not transient - except urllib.error.URLError as exc: - if attempt < _HTTP_RETRIES - 1: - delay = 2 ** (attempt + 1) - log_warn(f"Request failed ({exc.reason}), retrying in {delay}s…") - time.sleep(delay) - else: - raise - raise RuntimeError("_http_get: unreachable") # satisfy static analysis - - -def _fetch_json(url: str, *, headers: dict[str, str] | None = None) -> Any: - try: - return json.loads(_http_get(url, headers=headers)) - except urllib.error.HTTPError as e: - die(f"HTTP {e.code} for {url}") - except urllib.error.URLError as e: - die(f"Failed to fetch {url}: {e.reason}") - - -def _fetch_text(url: str, *, headers: dict[str, str] | None = None) -> str: - try: - return _http_get(url, headers=headers).decode(_ENCODING) - except urllib.error.HTTPError as e: - die(f"HTTP {e.code} for {url}") - except urllib.error.URLError as e: - die(f"Failed to fetch {url}: {e.reason}") - - -def _fetch_latest_github_release(repo: str) -> str: - """Return the latest release tag for a GitHub repository (e.g. 'owner/repo'). - - Respects ``GITHUB_TOKEN`` to avoid the anonymous rate limit. - """ - data = _fetch_json( - _GITHUB_RELEASES_URL.format(repo=repo), - headers=_github_headers(), - ) - return str(data["tag_name"]) - - -# --------------------------------------------------------------------------- -# Version detection -# -# Resolution chain: -# 1. operator-sdk <- GitHub releases/latest -# 2. Go major.minor <- operator-sdk's go.mod at that tag (fetched once) -# 3. Go toolchain <- latest stable patch from go.dev for that minor -# 4. controller-runtime <- operator-sdk's go.mod (same fetch) -# 5. k8s.io libs <- controller-runtime's go.mod at that tag -# 6. golangci-lint <- GitHub releases/latest -# --------------------------------------------------------------------------- - - -def _detect_operator_sdk_version() -> str: - log_info("Querying GitHub for latest operator-sdk release...") - ver = _fetch_latest_github_release(_GITHUB_REPO_OPERATOR_SDK) - log_info(f" operator-sdk: {ver}") - return ver - - -def _detect_go_toolchain_from_gomod(gomod: str) -> str: - """Return the latest stable Go patch for the minor declared in *gomod* content.""" - m = re.search(r"^go\s+(\d+\.\d+)(?:\.\d+)?", gomod, re.MULTILINE) - if not m: - die("Failed to parse Go version from go.mod") - # m.group(0) is e.g. "go 1.24.6"; m.group(1) is the major.minor "1.24" - go_version = m.group(0).split()[1] - go_major_minor = m.group(1) - log_info(f" operator-sdk targets Go {go_version} (minor: {go_major_minor})") - - log_info(f"Querying go.dev for latest Go {go_major_minor}.x patch...") - releases = _fetch_json(_URL_GO_RELEASES) - prefix = f"go{go_major_minor}." - toolchain = next( - ( - r["version"] - for r in releases - if r["version"].startswith(prefix) and r.get("stable") - ), - f"go{go_major_minor}.0", - ) - log_info(f" Go toolchain: {toolchain}") - return toolchain - - -def _latest_k8s_patch(base_version: str) -> str: - """Return the latest stable patch for the k8s.io major.minor of *base_version*. - - Queries the Go module proxy for ``k8s.io/api`` — which drives the patch - cadence for all three libs — and returns the highest patch in the same - major.minor series. Falls back to *base_version* on any parse error. - """ - m = re.match(_PAT_SEMVER_MAJOR_MINOR, base_version) - if not m: - return base_version - prefix = m.group(1) + "." - - url = _URL_GO_MODULE_VERSIONS.format(module=_K8S_LIB_MODULE) - log_info(f"Querying Go module proxy for latest k8s.io {m.group(1)}.x patch...") - content = _fetch_text(url) - - candidates = [ - v.strip() - for v in content.splitlines() - # Only stable releases of the right minor; skip pre-releases (contain "-") - if v.strip().startswith(prefix) and "-" not in v.strip() - ] - if not candidates: - return base_version - - def _patch(v: str) -> int: - try: - return int(v.rsplit(".", 1)[-1]) - except ValueError: - return -1 - - latest = max(candidates, key=_patch) - log_info(f" k8s.io libs: {latest}") - return latest - - -def _detect_controller_runtime_and_k8s(sdk_gomod: str) -> tuple[str, str]: - """Return (controller_runtime_version, k8s_libs_latest_patch). - - Both versions are derived from the operator-sdk go.mod content. - The k8s.io version is bumped to the latest compatible patch via the - Go module proxy (k8s.io/api, apimachinery and client-go are in lock-step). - """ - # controller-runtime version is declared in operator-sdk's own go.mod. - m_cr = re.search(_PAT_CONTROLLER_RUNTIME_IN_GOMOD, sdk_gomod) - if not m_cr: - die("Failed to parse controller-runtime version from operator-sdk go.mod") - cr_version = m_cr.group(1) - log_info(f" controller-runtime: {cr_version}") - - # The minimum compatible k8s.io/api version comes from controller-runtime. - log_info("Querying controller-runtime go.mod for k8s.io base version...") - cr_gomod = _fetch_text(_URL_CONTROLLER_RUNTIME_GOMOD.format(version=cr_version)) - m_k8s = re.search(_PAT_K8S_API_IN_GOMOD, cr_gomod) - if not m_k8s: - die("Failed to parse k8s.io/api version from controller-runtime go.mod") - base_k8s = m_k8s.group(1) - - # Bump to the latest patch of that major.minor. - k8s_version = _latest_k8s_patch(base_k8s) - - return cr_version, k8s_version - - -def _detect_golangci_lint_version() -> str: - log_info("Querying GitHub for latest golangci-lint release...") - ver = _fetch_latest_github_release(_GITHUB_REPO_GOLANGCI_LINT) - log_info(f" golangci-lint: {ver}") - return ver - - -def detect_versions() -> VersionInfo: - """Fetch the latest compatible versions from public APIs and return them.""" - log_step("Detecting latest compatible versions") - sdk = _detect_operator_sdk_version() - - # Fetch operator-sdk's go.mod once; reuse content for Go toolchain and - # controller-runtime detection to avoid redundant network requests. - log_info("Querying operator-sdk go.mod...") - sdk_gomod = _fetch_text(_URL_OPERATOR_SDK_GOMOD.format(version=sdk)) - - go_toolchain = _detect_go_toolchain_from_gomod(sdk_gomod) - cr_version, k8s_version = _detect_controller_runtime_and_k8s(sdk_gomod) - - return VersionInfo( - operator_sdk=sdk, - go_toolchain=go_toolchain, - golangci_lint=_detect_golangci_lint_version(), - controller_runtime=cr_version, - k8s_libs=k8s_version, - ) - - -def confirm_versions(targets: list[str]) -> None: - """Print the resolved versions and ask the user to confirm before proceeding.""" - print() - print(f"{_BOLD}The following upgrade will be performed:{_RESET}") - print() - print(f" operator-sdk {versions.operator_sdk}") - print(f" controller-runtime {versions.controller_runtime}") - print(f" k8s.io libs {versions.k8s_libs} (api, apimachinery, client-go)") - print(f" Go toolchain {versions.go_toolchain}") - print(f" golangci-lint {versions.golangci_lint}") - print() - print(f" Targets: {' '.join(targets)}") - print(f" Repository: {REPO_ROOT}") - print() - answer = input(f"{_BOLD}Proceed? [y/N] {_RESET}").strip().lower() - if answer not in ("y", "yes"): - log_info("Aborted by user.") - sys.exit(0) - - -# --------------------------------------------------------------------------- -# Process execution -# --------------------------------------------------------------------------- - - -def _tool_env() -> dict[str, str]: - """Return environment overrides that put our tools first in PATH. - - Raises ``RuntimeError`` if called before ``detect_versions()``, making - the implicit dependency on the ``versions`` singleton explicit and loud. - """ - if versions.go_toolchain == _UNSET: - raise RuntimeError("versions not initialised — call detect_versions() first") - return { - "PATH": f"{TOOLS_BIN}:{os.environ.get('PATH', '')}", - "GOTOOLCHAIN": versions.go_toolchain, - } - - -def run( - cmd: list[str], - *, - cwd: Path | None = None, - check: bool = True, - capture: bool = False, -) -> subprocess.CompletedProcess[Any]: - """Run a command list, inheriting stdout/stderr unless *capture* is set.""" - merged_env = {**os.environ, **_tool_env()} - kwargs: dict[str, Any] = {"cwd": cwd, "env": merged_env} - if capture: - kwargs["capture_output"] = True - kwargs["text"] = True - return subprocess.run(cmd, check=check, **kwargs) - - -# --------------------------------------------------------------------------- -# File helpers -# --------------------------------------------------------------------------- - - -def file_regex_replace(path: Path, pattern: str, repl: str) -> None: - path.write_text( - re.sub(pattern, repl, path.read_text(encoding=_ENCODING)), encoding=_ENCODING - ) - - -# --------------------------------------------------------------------------- -# Operator descriptor -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class SourceFix: - """A text replacement applied to a source file after merge. - - Fixes are idempotent: if *old* is not found, the file is left unchanged. - Each fix should carry a comment in OPERATORS explaining its context and - indicating when it can safely be removed. - """ - - path: str # relative path from the operator root - old: str # literal string to replace (or regex pattern when regex=True) - new: str # replacement value - regex: bool = False - - -@dataclass(frozen=True) -class ApiDef: - """Describes a single CRD/API to scaffold.""" - - group: str - version: str - kind: str - - -@dataclass(frozen=True) -class OperatorSpec: - """Static configuration for one operator project. - - Add entries to *fixes* for any source-level corrections that need to be - applied after merging the backup. They are idempotent, so stale entries - are safe (they become no-ops), but should be removed to keep the file clean. - """ - - name: str - repo: str - apis: tuple[ApiDef, ...] - image_name: str = "" - fixes: tuple[SourceFix, ...] = () - - @property - def op_dir(self) -> Path: - """Absolute path to this operator's project directory.""" - return REPO_ROOT / self.name - - @property - def bak_dir(self) -> Path: - """Absolute path to this operator's backup directory.""" - return REPO_ROOT / f"{self.name}.bak" - - -OPERATORS: Final[dict[str, OperatorSpec]] = { - "operator": OperatorSpec( - name="operator", - repo="github.com/scality/metalk8s/operator", - apis=( - ApiDef("", "v1alpha1", "ClusterConfig"), - ApiDef("", "v1alpha1", "VirtualIPPool"), - ), - image_name="metalk8s-operator", - fixes=( - # Go 1.24+: go vet rejects non-constant format strings in fmt.Errorf. - # Remove once the backup no longer contains this pattern - # (i.e., after this script has run at least once from v1.37.0). - SourceFix( - path="pkg/controller/clusterconfig/controlplane/ingress.go", - old=r"fmt\.Errorf\(([a-zA-Z_]\w*)\)", - new=r'fmt.Errorf("%s", \1)', - regex=True, - ), - ), - ), - "storage-operator": OperatorSpec( - name="storage-operator", - repo="github.com/scality/metalk8s/storage-operator", - apis=(ApiDef("storage", "v1alpha1", "Volume"),), - image_name="storage-operator", - fixes=( - # Go 1.16: io/ioutil deprecated. - # Remove once the backup no longer imports io/ioutil - # (i.e., after this script has run at least once from v1.37.0). - SourceFix( - path="internal/controller/volume_controller.go", - old='"io/ioutil"', - new='"os"', - ), - SourceFix( - path="internal/controller/volume_controller.go", - old="ioutil.ReadFile", - new="os.ReadFile", - ), - ), - ), -} - -# Validate that every dict key matches the embedded spec.name. -assert all( - k == v.name for k, v in OPERATORS.items() -), "OPERATORS key must match spec.name" - - -# =================================================================== -# Phase 0 — Install tools -# =================================================================== - - -def _check_prerequisites() -> None: - """Fail early with a clear message if required system tools are missing.""" - missing = [tool for tool in ("go", "curl", "patch") if shutil.which(tool) is None] - if missing: - die(f"Required tools not found in PATH: {', '.join(missing)}") - - -def _is_installed(bin_path: Path, version: str) -> bool: - """Return True if *bin_path* exists and reports *version* in its output.""" - if not bin_path.exists(): - return False - result = run([str(bin_path), "version"], capture=True, check=False) - return version.lstrip("v") in result.stdout - - -def install_operator_sdk() -> None: - log_step(f"Installing operator-sdk {versions.operator_sdk}") - TOOLS_BIN.mkdir(parents=True, exist_ok=True) - - if _is_installed(_SDK_BIN, versions.operator_sdk): - log_info("Already installed") - return - - goos = run(["go", "env", "GOOS"], capture=True).stdout.strip() - goarch = run(["go", "env", "GOARCH"], capture=True).stdout.strip() - url = _URL_OPERATOR_SDK_DOWNLOAD.format( - version=versions.operator_sdk, goos=goos, goarch=goarch - ) - log_info(f"Downloading for {goos}/{goarch}...") - run(["curl", "-sSLo", str(_SDK_BIN), url]) - _SDK_BIN.chmod(0o755) - ver = run([str(_SDK_BIN), "version"], capture=True).stdout.strip().split("\n")[0] - log_info(f"Installed: {ver}") - - -# =================================================================== -# Phase 1 — Backup -# =================================================================== - - -def backup_operator(spec: OperatorSpec) -> None: - log_step(f"Phase 1: Backing up {spec.name}") - op_dir = spec.op_dir - bak = spec.bak_dir - - if bak.exists(): - log_warn(f"Removing existing backup {bak}") - shutil.rmtree(bak) - if not op_dir.exists(): - die(f"{op_dir} does not exist") - - op_dir.rename(bak) - log_info(f"{op_dir} -> {bak}") - - -# =================================================================== -# Phase 2 — Scaffold fresh project -# =================================================================== - - -def scaffold_project(spec: OperatorSpec) -> None: - log_step(f"Phase 2: Scaffolding {spec.name}") - op_dir = spec.op_dir - sdk = str(_SDK_BIN) - - op_dir.mkdir(parents=True, exist_ok=True) - - run( - [ - sdk, - "init", - "--domain", - "metalk8s.scality.com", - "--repo", - spec.repo, - "--project-name", - spec.name, - ], - cwd=op_dir, - ) - - for api in spec.apis: - _create_api(op_dir, sdk, api) - - # Remove scaffold additions we don't want in the project. - devcontainer = op_dir / ".devcontainer" - if devcontainer.exists(): - shutil.rmtree(devcontainer) - log_info("Removed .devcontainer/ (not needed)") - - # Warn about any scaffold-generated root entries not yet in our policy. - _check_scaffold_completeness(op_dir) - - log_info("Scaffold complete") - - -def _create_api(op_dir: Path, sdk: str, api: ApiDef) -> None: - # Build the common trailing arguments once to avoid duplication. - tail = ["--version", api.version, "--kind", api.kind, "--resource", "--controller"] - - result = run( - [sdk, "create", "api", "--group", api.group, *tail], cwd=op_dir, check=False - ) - if result.returncode == 0: - log_info(f"Created {api.kind} API (group={api.group!r})") - return - - if api.group: - die(f"Failed to create API {api.kind}") - - # operator-sdk may reject an empty group; retry with a placeholder and then - # scrub it from PROJECT so the CRD group stays empty. - log_warn(f"Empty group rejected for {api.kind}, retrying with placeholder") - run([sdk, "create", "api", "--group", "metalk8s", *tail], cwd=op_dir) - file_regex_replace(op_dir / "PROJECT", _PAT_PROJECT_GROUP_LINE, "") - log_info("Patched PROJECT: removed placeholder group") - - -def _check_scaffold_completeness(op_dir: Path) -> None: - """Warn about scaffold root entries not yet classified in the merge policy. - - When operator-sdk adds new root-level directories or files, they may be - silently merged from backup (or silently dropped). This check flags them - so a maintainer can update ``_SCAFFOLD_ONLY_DIRS`` or - ``_SCAFFOLD_ONLY_FILES`` accordingly. - """ - for entry in sorted(op_dir.iterdir()): - if entry.name not in _KNOWN_SCAFFOLD_ROOTS: - log_warn( - f"Unclassified scaffold entry: {entry.name!r} — " - "add to _SCAFFOLD_ONLY_DIRS or _SCAFFOLD_ONLY_FILES " - "if scaffold-generated" - ) - - -# =================================================================== -# Phase 3 — Merge custom code from backup -# =================================================================== - - -def merge_backup(spec: OperatorSpec) -> None: - log_step(f"Phase 3: Merging custom code for {spec.name}") - op_dir = spec.op_dir - bak = spec.bak_dir - - merged: list[str] = [] - for src in sorted(bak.rglob("*")): - if not src.is_file(): - continue - rel = src.relative_to(bak).as_posix() - if not _should_merge(rel): - continue - dst = op_dir / rel - dst.parent.mkdir(parents=True, exist_ok=True) - # shutil.copy (not copy2): gives merged files current timestamps so - # that make does not mistake them for stale relative to generated artefacts. - shutil.copy(src, dst) - log_info(f" {rel}") - merged.append(rel) - - log_info(f"Custom code merged: {len(merged)} file(s)") - - -# =================================================================== -# Phase 4 — Adapt and fix -# =================================================================== - - -def adapt_project(spec: OperatorSpec) -> None: - """Apply all post-merge adaptations.""" - _apply_patches(spec) - _substitute_versions(spec) - _apply_source_fixes(spec) - _remove_incompatible_scaffold_tests(spec.op_dir) - - -def _apply_patches(spec: OperatorSpec) -> None: - """Apply GNU patch files from scripts/patches//. - - Each ``.patch`` file is a unified diff against the scaffold output. - Patches use ``__PLACEHOLDER__`` tokens for dynamic values that are - filled in by ``_substitute_versions()`` afterwards. - - On failure the script warns but does not abort — the user is expected - to resolve rejected hunks manually. - """ - patch_dir = _PATCHES_DIR / spec.name - if not patch_dir.is_dir(): - log_warn(f"No patch directory found at {patch_dir}") - return - - for patch_file in sorted(patch_dir.glob("*.patch")): - log_info(f"Applying {patch_file.name}...") - result = subprocess.run( - ["patch", "-p1", "--no-backup-if-mismatch", "-i", str(patch_file)], - cwd=spec.op_dir, - capture_output=True, - text=True, - ) - if result.returncode != 0: - log_warn( - f"{patch_file.name} did not apply cleanly " - f"(exit {result.returncode}); resolve manually:\n" - f" {result.stdout.strip()}" - ) - else: - log_info(f" {patch_file.name} applied") - - -def _substitute_versions(spec: OperatorSpec) -> None: - """Replace dynamic version placeholders and scaffold defaults. - - Runs after ``_apply_patches()`` to fill in values that are only known - at runtime (Go toolchain, golangci-lint version, image name). - """ - op = spec.op_dir - - dockerfile = op / "Dockerfile" - if dockerfile.exists(): - text = dockerfile.read_text(encoding=_ENCODING) - text = re.sub( - _PAT_DOCKERFILE_FROM_GOLANG, - f"FROM golang:{versions.go_major_minor}", - text, - ) - dockerfile.write_text(text, encoding=_ENCODING) - - makefile = op / "Makefile" - if makefile.exists(): - text = makefile.read_text(encoding=_ENCODING) - text = text.replace("__GOTOOLCHAIN__", versions.go_toolchain) - jinja_image = '{{ build_image_name("' + spec.image_name + '") }}' - text = text.replace("__IMAGE__", jinja_image) - text = re.sub( - _PAT_MAKEFILE_GOLANGCI_VERSION, - f"GOLANGCI_LINT_VERSION ?= {versions.golangci_lint}", - text, - flags=re.MULTILINE, - ) - makefile.write_text(text, encoding=_ENCODING) - - log_info("Version substitutions applied") - - -def _apply_source_fix(op_dir: Path, fix: SourceFix) -> None: - """Apply a single SourceFix; writes the file only if content changed.""" - path = op_dir / fix.path - if not path.exists(): - return - text = path.read_text(encoding=_ENCODING) - updated = ( - re.sub(fix.old, fix.new, text) if fix.regex else text.replace(fix.old, fix.new) - ) - if updated != text: - log_info(f"Applied fix: {Path(fix.path).name}") - path.write_text(updated, encoding=_ENCODING) - - -def _apply_source_fixes(spec: OperatorSpec) -> None: - """Apply the operator-specific source fixes declared in spec.fixes.""" - for fix in spec.fixes: - _apply_source_fix(spec.op_dir, fix) - - -def _remove_incompatible_scaffold_tests(op_dir: Path) -> None: - """Remove scaffold controller tests incompatible with our delegation pattern. - - operator-sdk generates *_controller_test.go stubs that call .Reconcile() - directly on the reconciler struct. Our controllers use a delegation pattern - where the inner struct registered with the manager does not expose Reconcile(), - so these stubs must be removed. This applies to every operator-sdk upgrade. - """ - ctrl_dir = op_dir / "internal" / "controller" - if not ctrl_dir.exists(): - return - for test_file in ctrl_dir.glob("*_controller_test.go"): - if ".Reconcile(" in test_file.read_text(encoding=_ENCODING): - log_info(f"Removing incompatible scaffold test: {test_file.name}") - test_file.unlink() - - -# =================================================================== -# Phase 5 — Generate and build -# =================================================================== - - -def generate_and_build(spec: OperatorSpec) -> None: - log_step(f"Phase 5: Generate & build {spec.name}") - op_dir = spec.op_dir - bin_dir = op_dir / "bin" - if bin_dir.exists(): - shutil.rmtree(bin_dir) - - # Explicitly pin k8s.io libs to the latest compatible patch before tidy, - # so `go mod tidy` does not silently keep an older patch from the scaffold. - k8s_get_args = [f"{lib}@{versions.k8s_libs}" for lib in _K8S_LIBS] - log_info(f"Bumping k8s.io libs to {versions.k8s_libs}...") - run(["go", "get", *k8s_get_args], cwd=op_dir) - - steps = [ - ("go mod tidy...", ["go", "mod", "tidy"]), - ("make manifests generate...", ["make", "manifests", "generate"]), - ("make fmt vet...", ["make", "fmt", "vet"]), - ("make build...", ["make", "build"]), - ("make metalk8s...", ["make", "metalk8s"]), - ] - for msg, cmd in steps: - log_info(msg) - run(cmd, cwd=op_dir) - - log_info(f"Build succeeded for {spec.name}") - - -# =================================================================== -# Cleanup -# =================================================================== - - -def _clean_tools() -> None: - """Remove the script's tool cache (.tmp/bin/). - - This forces operator-sdk to be re-downloaded on the next run, which is - useful to reclaim disk space after the upgrade. The operator bin/ - directories created during scaffolding are not affected; they are - controlled by each operator's Makefile. - """ - if TOOLS_BIN.exists(): - log_step(f"Cleaning tool cache ({TOOLS_BIN.relative_to(REPO_ROOT)}/)") - shutil.rmtree(TOOLS_BIN) - log_info(f"Removed {TOOLS_BIN}") - else: - log_info("Tool cache already empty") - - -# =================================================================== -# Recovery -# =================================================================== - - -def _log_recovery_hint(name: str) -> None: - """Log recovery instructions after an interrupted or failed upgrade.""" - op_dir = REPO_ROOT / name - bak = REPO_ROOT / f"{name}.bak" - log_error(f"Processing of '{name}' was interrupted or failed") - if bak.exists(): - log_warn(f"Backup preserved at: {bak}") - if op_dir.exists() and bak.exists(): - log_warn("Partial build detected. To restore the original state:") - log_warn(f" rm -rf {op_dir} && mv {bak} {op_dir}") - elif bak.exists(): - log_warn(f"To restore: mv {bak} {op_dir}") - - -# =================================================================== -# Main -# =================================================================== - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Upgrade operator-sdk projects by scaffolding fresh and " - "merging custom code from backup.", - ) - parser.add_argument( - "--operator-only", action="store_true", help="Only process operator/" - ) - parser.add_argument( - "--storage-only", action="store_true", help="Only process storage-operator/" - ) - parser.add_argument( - "--skip-backup", action="store_true", help="Skip backup (assumes .bak exists)" - ) - parser.add_argument( - "--clean-tools", - action="store_true", - help=f"Remove {TOOLS_BIN.relative_to(REPO_ROOT)}/ after upgrade " - "(forces a fresh download on the next run)", - ) - parser.add_argument( - "--yes", "-y", action="store_true", help="Skip the confirmation prompt" - ) - args = parser.parse_args() - - if args.operator_only: - targets = ["operator"] - elif args.storage_only: - targets = ["storage-operator"] - else: - targets = ["operator", "storage-operator"] - - _check_prerequisites() - - global versions - versions = detect_versions() - - if not args.yes: - confirm_versions(targets) - - log_step(f"Operator SDK Upgrade -> {versions.operator_sdk}") - - install_operator_sdk() - - for name in targets: - spec = OPERATORS[name] - log_step(f"========== Processing {name} ==========") - - if not args.skip_backup: - backup_operator(spec) - else: - log_info("Skipping backup (--skip-backup)") - if not spec.bak_dir.exists(): - die( - f"{spec.bak_dir} does not exist; cannot use --skip-backup " - "without an existing backup directory" - ) - if spec.op_dir.exists(): - shutil.rmtree(spec.op_dir) - - try: - scaffold_project(spec) - merge_backup(spec) - adapt_project(spec) - generate_and_build(spec) - except BaseException: - _log_recovery_hint(name) - raise - - if args.clean_tools: - _clean_tools() - - log_step("Upgrade complete!") - print() - log_info("Backups preserved at:") - for name in targets: - log_info(f" {OPERATORS[name].bak_dir}/") - print() - log_info("Recommended next steps:") - log_info(" 1. diff -r .bak/ / Review changes") - log_info(" 2. cd && make test Run tests") - log_info(" 3. Review config/crd/bases/ Check generated CRDs") - log_info(" 4. Review config/rbac/role.yaml Check generated RBAC") - log_info(" 5. Review deploy/manifests.yaml Check MetalK8s manifests") - - -if __name__ == "__main__": - main() diff --git a/scripts/upgrade-operator-sdk/operator/config.yaml b/scripts/upgrade-operator-sdk/operator/config.yaml new file mode 100644 index 0000000000..e9be059e7e --- /dev/null +++ b/scripts/upgrade-operator-sdk/operator/config.yaml @@ -0,0 +1,28 @@ +repo: github.com/scality/metalk8s/operator +domain: metalk8s.scality.com +operator_dir: operator + +operator_sdk_version: v1.42.1 +go_toolchain: go1.25.8 +k8s_libs: v0.33.9 + +apis: + - version: v1alpha1 + kind: ClusterConfig + namespaced: false + - version: v1alpha1 + kind: VirtualIPPool + namespaced: true + +backup_paths: + - pkg/ + - version/ + - config/metalk8s/ + - api/ + - hack/ + - internal/controller/ + +image_placeholder: '{{ build_image_name("metalk8s-operator") }}' + +extra_commands: + - ["make", "metalk8s"] diff --git a/scripts/patches/operator/Dockerfile.patch b/scripts/upgrade-operator-sdk/operator/patches/Dockerfile.patch similarity index 100% rename from scripts/patches/operator/Dockerfile.patch rename to scripts/upgrade-operator-sdk/operator/patches/Dockerfile.patch diff --git a/scripts/patches/operator/Makefile.patch b/scripts/upgrade-operator-sdk/operator/patches/Makefile.patch similarity index 100% rename from scripts/patches/operator/Makefile.patch rename to scripts/upgrade-operator-sdk/operator/patches/Makefile.patch diff --git a/scripts/pyproject.toml b/scripts/upgrade-operator-sdk/pyproject.toml similarity index 85% rename from scripts/pyproject.toml rename to scripts/upgrade-operator-sdk/pyproject.toml index 37686f5abb..5116f496c8 100644 --- a/scripts/pyproject.toml +++ b/scripts/upgrade-operator-sdk/pyproject.toml @@ -1,8 +1,8 @@ -# Linting and formatting configuration for scripts/upgrade-operator-sdk.py +# Linting and formatting configuration for scripts/upgrade-operator-sdk/upgrade.py # Run from the scripts/ directory: -# python3 -m black upgrade-operator-sdk.py -# python3 -m ruff check upgrade-operator-sdk.py -# python3 -m mypy upgrade-operator-sdk.py +# python3 -m black upgrade-operator-sdk/upgrade.py +# python3 -m ruff check upgrade-operator-sdk/upgrade.py +# python3 -m mypy upgrade-operator-sdk/upgrade.py [tool.black] line-length = 88 diff --git a/scripts/upgrade-operator-sdk/storage-operator/config.yaml b/scripts/upgrade-operator-sdk/storage-operator/config.yaml new file mode 100644 index 0000000000..3e99315997 --- /dev/null +++ b/scripts/upgrade-operator-sdk/storage-operator/config.yaml @@ -0,0 +1,25 @@ +repo: github.com/scality/metalk8s/storage-operator +domain: metalk8s.scality.com +operator_dir: storage-operator + +operator_sdk_version: v1.42.1 +go_toolchain: go1.25.8 +k8s_libs: v0.33.9 + +apis: + - group: storage + version: v1alpha1 + kind: Volume + namespaced: false + +backup_paths: + - api/ + - hack/ + - internal/controller/ + - config/metalk8s/ + - salt/ + +image_placeholder: '{{ build_image_name("storage-operator") }}' + +extra_commands: + - ["make", "metalk8s"] diff --git a/scripts/patches/storage-operator/Dockerfile.patch b/scripts/upgrade-operator-sdk/storage-operator/patches/Dockerfile.patch similarity index 100% rename from scripts/patches/storage-operator/Dockerfile.patch rename to scripts/upgrade-operator-sdk/storage-operator/patches/Dockerfile.patch diff --git a/scripts/patches/storage-operator/Makefile.patch b/scripts/upgrade-operator-sdk/storage-operator/patches/Makefile.patch similarity index 100% rename from scripts/patches/storage-operator/Makefile.patch rename to scripts/upgrade-operator-sdk/storage-operator/patches/Makefile.patch diff --git a/scripts/upgrade-operator-sdk/upgrade.py b/scripts/upgrade-operator-sdk/upgrade.py new file mode 100755 index 0000000000..1addf83edf --- /dev/null +++ b/scripts/upgrade-operator-sdk/upgrade.py @@ -0,0 +1,588 @@ +#!/usr/bin/env python3 +"""Automates the upgrade of operator-sdk based projects. + +Scaffolds a fresh project, restores custom code from a backup, applies +GNU patch files, and runs the build pipeline. All versions are pinned +in the YAML config file — the script makes no version-detection API calls. + +Usage: + python3 scripts/upgrade-operator-sdk/upgrade.py [OPTIONS] + +Examples: + python3 scripts/upgrade-operator-sdk/upgrade.py operator + python3 scripts/upgrade-operator-sdk/upgrade.py storage-operator + +The is resolved relative to the script directory. A full path +can also be given for configs stored elsewhere. + +Options: + --skip-backup Skip the backup step (assumes .bak already exists) + --clean-tools Remove .tmp/bin/ after the upgrade (forces re-download) + --yes, -y Skip the confirmation prompt + -h, --help Show this help message + +Requires: go, curl, patch, pyyaml (pip install pyyaml) +""" + +import argparse +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any, Final, NoReturn + +try: + import yaml +except ImportError: + print("pyyaml is required: pip install pyyaml", file=sys.stderr) + sys.exit(1) + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +REPO_ROOT: Final = Path(__file__).resolve().parent.parent.parent +TOOLS_BIN: Final = REPO_ROOT / ".tmp" / "bin" +_SDK_BIN: Final = TOOLS_BIN / "operator-sdk" + +# All file I/O uses this encoding explicitly. +_ENCODING: Final = "utf-8" + +# --------------------------------------------------------------------------- +# URLs +# --------------------------------------------------------------------------- +_URL_OPERATOR_SDK_DOWNLOAD: Final = ( + "https://github.com/operator-framework/operator-sdk" + "/releases/download/{version}/operator-sdk_{goos}_{goarch}" +) + +# k8s.io libraries bumped together (lock-step releases). +_K8S_LIBS: Final = ("k8s.io/api", "k8s.io/apimachinery", "k8s.io/client-go") + +# --------------------------------------------------------------------------- +# Regex patterns +# --------------------------------------------------------------------------- +_PAT_DOCKERFILE_FROM_GOLANG: Final = r"FROM golang:\d+\.\d+" + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +_GREEN: Final = "\033[32m" +_YELLOW: Final = "\033[33m" +_RED: Final = "\033[31m" +_BLUE_BOLD: Final = "\033[1;34m" +_BOLD: Final = "\033[1m" +_RESET: Final = "\033[0m" + + +def log_info(msg: str) -> None: + print(f"{_GREEN}[INFO]{_RESET} {msg}") + + +def log_warn(msg: str) -> None: + print(f"{_YELLOW}[WARN]{_RESET} {msg}", file=sys.stderr) + + +def log_error(msg: str) -> None: + print(f"{_RED}[ERROR]{_RESET} {msg}", file=sys.stderr) + + +def log_step(msg: str) -> None: + print(f"\n{_BLUE_BOLD}==>{_RESET} {_BOLD}{msg}{_RESET}") + + +def die(msg: str) -> NoReturn: + log_error(msg) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Config loading +# --------------------------------------------------------------------------- + + +def load_config(config_dir: str) -> dict[str, Any]: + """Load and validate the operator config from a directory. + + The directory must contain ``config.yaml`` and may contain a + ``patches/`` subdirectory with GNU unified diff files. + + If *config_dir* is a plain name (no path separators), it is resolved + relative to the script's own directory — so ``operator`` finds + ``scripts/upgrade-operator-sdk/operator/``. Otherwise it is used + as-is, allowing absolute or relative paths for other repos. + """ + p = Path(config_dir) + if not p.is_absolute() and os.sep not in config_dir and "/" not in config_dir: + p = Path(__file__).resolve().parent / config_dir + d = p.resolve() + config_file = d / "config.yaml" + if not config_file.exists(): + die(f"Config file not found: {config_file}") + with config_file.open(encoding=_ENCODING) as f: + cfg: dict[str, Any] = yaml.safe_load(f) + + required = ( + "repo", + "domain", + "operator_dir", + "apis", + "operator_sdk_version", + "go_toolchain", + ) + for key in required: + if key not in cfg: + die(f"Missing required key {key!r} in {config_file}") + + cfg["name"] = d.name + cfg.setdefault("backup_paths", []) + cfg.setdefault("extra_commands", []) + cfg.setdefault("k8s_libs", "") + cfg["operator_dir"] = REPO_ROOT / cfg["operator_dir"] + cfg["patches_dir"] = d / "patches" + cfg["backup_dir"] = REPO_ROOT / f"{cfg['operator_dir'].name}.bak" + + tc = cfg["go_toolchain"] + m = re.match(r"go(\d+\.\d+)", tc) + cfg["go_major_minor"] = m.group(1) if m else tc.lstrip("go") + + return cfg + + +def confirm_upgrade(cfg: dict[str, Any]) -> None: + """Print config summary and ask the user to confirm.""" + print() + print(f"{_BOLD}The following upgrade will be performed:{_RESET}") + print() + print(f" operator-sdk {cfg['operator_sdk_version']}") + print(f" Go toolchain {cfg['go_toolchain']}") + if cfg["k8s_libs"]: + print(f" k8s.io libs {cfg['k8s_libs']}") + print() + print(f" Target: {cfg['name']}") + print(f" Directory: {cfg['operator_dir']}") + print() + answer = input(f"{_BOLD}Proceed? [y/N] {_RESET}").strip().lower() + if answer not in ("y", "yes"): + log_info("Aborted by user.") + sys.exit(0) + + +# --------------------------------------------------------------------------- +# Process execution +# --------------------------------------------------------------------------- + + +def _tool_env(cfg: dict[str, Any]) -> dict[str, str]: + return { + "PATH": f"{TOOLS_BIN}:{os.environ.get('PATH', '')}", + "GOTOOLCHAIN": cfg["go_toolchain"], + } + + +def run( + cmd: list[str], + cfg: dict[str, Any], + *, + cwd: Path | None = None, + check: bool = True, + capture: bool = False, +) -> subprocess.CompletedProcess[Any]: + merged_env = {**os.environ, **_tool_env(cfg)} + kwargs: dict[str, Any] = {"cwd": cwd, "env": merged_env} + if capture: + kwargs["capture_output"] = True + kwargs["text"] = True + return subprocess.run(cmd, check=check, **kwargs) + + +# =================================================================== +# Phase 0 — Install tools +# =================================================================== + + +def _check_prerequisites() -> None: + missing = [tool for tool in ("go", "curl", "patch") if shutil.which(tool) is None] + if missing: + die(f"Required tools not found in PATH: {', '.join(missing)}") + + +def install_operator_sdk(cfg: dict[str, Any]) -> None: + version = cfg["operator_sdk_version"] + log_step(f"Installing operator-sdk {version}") + TOOLS_BIN.mkdir(parents=True, exist_ok=True) + + if _SDK_BIN.exists(): + result = run([str(_SDK_BIN), "version"], cfg, capture=True, check=False) + if version.lstrip("v") in result.stdout: + log_info("Already installed") + return + + goos = run(["go", "env", "GOOS"], cfg, capture=True).stdout.strip() + goarch = run(["go", "env", "GOARCH"], cfg, capture=True).stdout.strip() + url = _URL_OPERATOR_SDK_DOWNLOAD.format(version=version, goos=goos, goarch=goarch) + log_info(f"Downloading for {goos}/{goarch}...") + run(["curl", "-sSLo", str(_SDK_BIN), url], cfg) + _SDK_BIN.chmod(0o755) + ver = ( + run([str(_SDK_BIN), "version"], cfg, capture=True).stdout.strip().split("\n")[0] + ) + log_info(f"Installed: {ver}") + + +# =================================================================== +# Phase 1 — Backup +# =================================================================== + + +def backup_operator(cfg: dict[str, Any]) -> None: + op_dir: Path = cfg["operator_dir"] + bak: Path = cfg["backup_dir"] + log_step(f"Phase 1: Backing up {cfg['name']}") + + if bak.exists(): + log_warn(f"Removing existing backup {bak}") + shutil.rmtree(bak) + if not op_dir.exists(): + die(f"{op_dir} does not exist") + + op_dir.rename(bak) + log_info(f"{op_dir} -> {bak}") + + +# =================================================================== +# Phase 2 — Scaffold fresh project +# =================================================================== + + +def scaffold_project(cfg: dict[str, Any]) -> None: + op_dir: Path = cfg["operator_dir"] + sdk = str(_SDK_BIN) + log_step(f"Phase 2: Scaffolding {cfg['name']}") + + op_dir.mkdir(parents=True, exist_ok=True) + + run( + [ + sdk, + "init", + "--domain", + cfg["domain"], + "--repo", + cfg["repo"], + "--project-name", + cfg["name"], + ], + cfg, + cwd=op_dir, + ) + + for api in cfg["apis"]: + _create_api(op_dir, sdk, api, cfg) + + devcontainer = op_dir / ".devcontainer" + if devcontainer.exists(): + shutil.rmtree(devcontainer) + log_info("Removed .devcontainer/ (not needed)") + + log_info("Scaffold complete") + + +def _create_api( + op_dir: Path, sdk: str, api: dict[str, Any], cfg: dict[str, Any] +) -> None: + group = api.get("group", "") + version = api["version"] + kind = api["kind"] + tail = ["--version", version, "--kind", kind, "--resource", "--controller"] + + if not api.get("namespaced", True): + tail.append("--namespaced=false") + + cmd = [sdk, "create", "api"] + if group: + cmd += ["--group", group] + cmd += tail + + result = run(cmd, cfg, cwd=op_dir, check=False) + if result.returncode != 0: + die(f"Failed to create API {kind} (exit {result.returncode})") + + log_info(f"Created {kind} API (group={group!r})") + + +# =================================================================== +# Phase 3 — Restore custom code from backup +# =================================================================== + + +def restore_backup(cfg: dict[str, Any]) -> None: + op_dir: Path = cfg["operator_dir"] + bak: Path = cfg["backup_dir"] + log_step(f"Phase 3: Restoring custom code for {cfg['name']}") + + count = 0 + conflicts: list[str] = [] + for rel_path in cfg["backup_paths"]: + src = bak / rel_path + dst = op_dir / rel_path + + if not src.exists(): + log_warn(f" {rel_path} not found in backup, skipping") + continue + + is_dir = rel_path.endswith("/") + + if is_dir: + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree( + src, + dst, + ignore=shutil.ignore_patterns("*zz_generated*"), + ) + n = sum(1 for _ in dst.rglob("*") if _.is_file()) + log_info(f" {rel_path} ({n} files)") + count += n + else: + if "zz_generated" in rel_path: + continue + if dst.exists(): + log_error(f" {rel_path} exists in both scaffold and backup") + result = subprocess.run( + ["diff", "-u", str(dst), str(src)], + capture_output=True, + text=True, + ) + if result.stdout: + print(result.stdout) + conflicts.append(rel_path) + continue + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(src, dst) + log_info(f" {rel_path}") + count += 1 + + if conflicts: + log_error(f"{len(conflicts)} file(s) conflict between scaffold and backup:") + for c in conflicts: + log_error(f" - {c}") + log_info( + "Update the conflicting files in the .bak/ directory to match " + "the desired result, then re-run with --skip-backup." + ) + die("Aborting due to backup/scaffold conflicts") + + log_info(f"Custom code restored: {count} file(s)") + + +# =================================================================== +# Phase 4 — Apply patches and version substitutions +# =================================================================== + + +def adapt_project(cfg: dict[str, Any]) -> None: + """Apply patch files and substitute dynamic versions.""" + _apply_patches(cfg) + _substitute_versions(cfg) + + +def _apply_patches(cfg: dict[str, Any]) -> None: + patch_dir: Path = cfg["patches_dir"] + op_dir: Path = cfg["operator_dir"] + + if not patch_dir.is_dir(): + log_warn(f"No patch directory found at {patch_dir}") + return + + for patch_file in sorted(patch_dir.glob("*.patch")): + log_info(f"Applying {patch_file.name}...") + result = subprocess.run( + ["patch", "-p1", "--no-backup-if-mismatch", "-i", str(patch_file)], + cwd=op_dir, + capture_output=True, + text=True, + ) + if result.returncode != 0: + log_warn( + f"{patch_file.name} did not apply cleanly " + f"(exit {result.returncode}); resolve manually:\n" + f" {result.stdout.strip()}" + ) + else: + log_info(f" {patch_file.name} applied") + + +def _substitute_versions(cfg: dict[str, Any]) -> None: + op_dir: Path = cfg["operator_dir"] + + dockerfile = op_dir / "Dockerfile" + if dockerfile.exists(): + text = dockerfile.read_text(encoding=_ENCODING) + text = re.sub( + _PAT_DOCKERFILE_FROM_GOLANG, + f"FROM golang:{cfg['go_major_minor']}", + text, + ) + dockerfile.write_text(text, encoding=_ENCODING) + + makefile = op_dir / "Makefile" + if makefile.exists(): + text = makefile.read_text(encoding=_ENCODING) + text = text.replace("__GOTOOLCHAIN__", cfg["go_toolchain"]) + text = text.replace("__IMAGE__", cfg.get("image_placeholder", "")) + makefile.write_text(text, encoding=_ENCODING) + + log_info("Version substitutions applied") + + +# =================================================================== +# Phase 5 — Generate +# =================================================================== + + +def generate(cfg: dict[str, Any]) -> None: + op_dir: Path = cfg["operator_dir"] + log_step(f"Phase 5: Generate & verify {cfg['name']}") + + bin_dir = op_dir / "bin" + if bin_dir.exists(): + shutil.rmtree(bin_dir) + + if cfg["k8s_libs"]: + k8s_get = [f"{lib}@{cfg['k8s_libs']}" for lib in _K8S_LIBS] + log_info(f"Pinning k8s.io libs to {cfg['k8s_libs']}...") + run(["go", "get", *k8s_get], cfg, cwd=op_dir) + + steps: list[tuple[str, list[str]]] = [ + ("go mod tidy...", ["go", "mod", "tidy"]), + ("make manifests generate...", ["make", "manifests", "generate"]), + ("make fmt vet...", ["make", "fmt", "vet"]), + ] + for msg, cmd in steps: + log_info(msg) + run(cmd, cfg, cwd=op_dir) + + for extra in cfg.get("extra_commands", []): + log_info(f"Running {' '.join(extra)}...") + run(extra, cfg, cwd=op_dir) + + log_info(f"Build succeeded for {cfg['name']}") + + +# =================================================================== +# Cleanup +# =================================================================== + + +def _clean_tools() -> None: + """Remove the script's tool cache (.tmp/bin/).""" + if TOOLS_BIN.exists(): + log_step(f"Cleaning tool cache ({TOOLS_BIN.relative_to(REPO_ROOT)}/)") + shutil.rmtree(TOOLS_BIN) + log_info(f"Removed {TOOLS_BIN}") + else: + log_info("Tool cache already empty") + + +# =================================================================== +# Recovery +# =================================================================== + + +def _log_recovery_hint(cfg: dict[str, Any]) -> None: + op_dir: Path = cfg["operator_dir"] + bak: Path = cfg["backup_dir"] + log_error(f"Processing of '{cfg['name']}' was interrupted or failed") + if bak.exists(): + log_warn(f"Backup preserved at: {bak}") + if op_dir.exists() and bak.exists(): + log_warn("Partial build detected. To restore the original state:") + log_warn(f" rm -rf {op_dir} && mv {bak} {op_dir}") + elif bak.exists(): + log_warn(f"To restore: mv {bak} {op_dir}") + + +# =================================================================== +# Main +# =================================================================== + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Upgrade an operator-sdk project by scaffolding fresh " + "and applying patches from a YAML config.", + ) + parser.add_argument( + "config_dir", + help="Operator config directory name (e.g. 'operator') or full " + "path to a directory containing config.yaml", + ) + parser.add_argument( + "--skip-backup", + action="store_true", + help="Skip backup (assumes .bak exists)", + ) + parser.add_argument( + "--clean-tools", + action="store_true", + help="Remove .tmp/bin/ after upgrade", + ) + parser.add_argument( + "--yes", + "-y", + action="store_true", + help="Skip the confirmation prompt", + ) + args = parser.parse_args() + + _check_prerequisites() + + cfg = load_config(args.config_dir) + + if not args.yes: + confirm_upgrade(cfg) + + log_step(f"Operator SDK Upgrade -> {cfg['operator_sdk_version']}") + + install_operator_sdk(cfg) + + op_dir: Path = cfg["operator_dir"] + bak: Path = cfg["backup_dir"] + + if not args.skip_backup: + backup_operator(cfg) + else: + log_info("Skipping backup (--skip-backup)") + if not bak.exists(): + die( + f"{bak} does not exist; cannot use --skip-backup " + "without an existing backup directory" + ) + if op_dir.exists(): + shutil.rmtree(op_dir) + + try: + scaffold_project(cfg) + restore_backup(cfg) + adapt_project(cfg) + generate(cfg) + except BaseException: + _log_recovery_hint(cfg) + raise + + if args.clean_tools: + _clean_tools() + + log_step("Upgrade complete!") + print() + log_info(f"Backup preserved at: {bak}/") + print() + log_info("Recommended next steps:") + log_info(" 1. git diff Review changes") + log_info(f" 2. cd {cfg['name']} && make test Run tests") + + +if __name__ == "__main__": + main() From 3db697e24d3995d2978993dc043374d1f2ebbc7f Mon Sep 17 00:00:00 2001 From: Alex Rodriguez <131964409+ezekiel-alexrod@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:52:12 +0000 Subject: [PATCH 09/12] scripts: detect latest Go/k8s patches and check operator-sdk release After scaffolding, read the generated go.mod to detect the latest stable Go patch (go.dev) and k8s.io patch (module proxy). Also check the latest operator-sdk release on GitHub. Version reconciliation is CI-friendly (zero interactive input): - No pin in YAML: auto-pin detected version and update the file - Pin matches detected: all good - Pin is older: warn with the newer version, keep pinned value - Pin is newer than detected: warn, use detected Re-adds GOTOOLCHAIN export to Makefile patches (single hunk at end). --- BUMPING.md | 36 +- .../upgrade-operator-sdk/operator/config.yaml | 7 +- .../operator/patches/Makefile.patch | 12 +- .../storage-operator/config.yaml | 7 +- .../storage-operator/patches/Makefile.patch | 12 +- scripts/upgrade-operator-sdk/upgrade.py | 342 ++++++++++++++---- 6 files changed, 317 insertions(+), 99 deletions(-) diff --git a/BUMPING.md b/BUMPING.md index 44bc76fff4..353360d507 100644 --- a/BUMPING.md +++ b/BUMPING.md @@ -132,20 +132,30 @@ This guide is applied for both `metalk8s-operator` and `storage-operator`. - `go`, `curl`, and `patch` in `PATH`. - `pyyaml` Python package: `pip install pyyaml` +- `GITHUB_TOKEN` (optional): raises the GitHub API rate limit from 60 to 5000 + req/hour. Set via `export GITHUB_TOKEN=`. ### Updating the versions -Before running the script, update the target versions in the YAML config files at -`scripts/upgrade-operator-sdk//config.yaml`: +Target versions are pinned in `scripts/upgrade-operator-sdk//config.yaml`: ```yaml operator_sdk_version: v1.42.1 # target operator-sdk release -go_toolchain: go1.25.8 # Go toolchain (for GOTOOLCHAIN + FROM golang:X.Y) -k8s_libs: v0.33.9 # k8s.io/{api,apimachinery,client-go} version +go_toolchain: go1.25.8 # pin Go toolchain (for GOTOOLCHAIN) +k8s_libs: v0.33.9 # pin k8s.io libs version ``` -The script makes **no version-detection API calls**; all versions are read from the -YAML config. +After scaffolding, the script detects the latest available versions (operator-sdk +from GitHub, Go and k8s.io patches from go.dev / module proxy) and compares with +the pinned values: + +- **No pin** in YAML: the detected version is used and auto-pinned in the file. +- **Pin matches detected**: all good, no action. +- **Pin is older** than detected: warning printed with the newer version available. + The pinned value is still used. Update the YAML manually when ready. +- **Pin is newer** than detected (unusual): warning, the detected value is used. + +This is CI-friendly: zero interactive input during reconciliation. ### Running the upgrade @@ -173,9 +183,9 @@ Options: Each operator has a config directory at `scripts/upgrade-operator-sdk//` containing `config.yaml` and a `patches/` subdirectory. The config fields are: -- **Versions**: `operator_sdk_version`, `go_toolchain`, `k8s_libs` +- **Versions**: `operator_sdk_version`, `go_toolchain` (optional pin), `k8s_libs` (optional pin) - **Scaffold**: `repo`, `domain`, `apis` (with `group`, `version`, `kind`, `namespaced`). The operator name is derived from the config directory name. -- **Paths**: `operator_dir`, `patches_dir`, `backup_paths` +- **Paths**: `operator_dir`, `backup_paths` - **Post-processing**: `image_placeholder`, `extra_commands` ### Patch files @@ -187,12 +197,10 @@ look for `.rej` files and resolve manually. Patch files use `__PLACEHOLDER__` tokens for values from the YAML config: -| Placeholder | Replaced with | Source | -| ----------------- | -------------------------------------------- | ---------- | -| `__GOTOOLCHAIN__` | `go_toolchain` from config (e.g. `go1.25.8`) | `Makefile` | -| `__IMAGE__` | `image_placeholder` from config | `Makefile` | - -The `FROM golang:X.Y` in `Dockerfile` is derived from `go_toolchain` in the config. +| Placeholder | Replaced with | Source | +| ----------------- | ------------------------------- | ---------- | +| `__GOTOOLCHAIN__` | Detected/pinned Go toolchain | `Makefile` | +| `__IMAGE__` | `image_placeholder` from config | `Makefile` | New `.patch` files in the patches directory are automatically picked up. diff --git a/scripts/upgrade-operator-sdk/operator/config.yaml b/scripts/upgrade-operator-sdk/operator/config.yaml index e9be059e7e..8d67a947c0 100644 --- a/scripts/upgrade-operator-sdk/operator/config.yaml +++ b/scripts/upgrade-operator-sdk/operator/config.yaml @@ -3,8 +3,11 @@ domain: metalk8s.scality.com operator_dir: operator operator_sdk_version: v1.42.1 -go_toolchain: go1.25.8 -k8s_libs: v0.33.9 + +# Optional: pin versions. If absent or empty, the script detects the +# latest patch from the scaffold's go.mod and offers to update this file. +go_toolchain: go1.24.13 +k8s_libs: v0.33.10 apis: - version: v1alpha1 diff --git a/scripts/upgrade-operator-sdk/operator/patches/Makefile.patch b/scripts/upgrade-operator-sdk/operator/patches/Makefile.patch index 5ea8335c27..a051bb1541 100644 --- a/scripts/upgrade-operator-sdk/operator/patches/Makefile.patch +++ b/scripts/upgrade-operator-sdk/operator/patches/Makefile.patch @@ -1,17 +1,13 @@ --- a/Makefile +++ b/Makefile -@@ -1,2 +1,6 @@ - #ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31) -+ -+# Force Go toolchain version to prevent automatic selection issues -+# See: https://go.dev/doc/toolchain -+export GOTOOLCHAIN = __GOTOOLCHAIN__ - ENVTEST_K8S_VERSION ?= $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}') -@@ -4,3 +8,9 @@ +@@ -3,3 +3,12 @@ .PHONY: catalog-push catalog-push: ## Push a catalog image. $(MAKE) docker-push IMG=$(CATALOG_IMG) + ++# Force Go toolchain version ++export GOTOOLCHAIN = __GOTOOLCHAIN__ ++ +.PHONY: metalk8s +metalk8s: manifests kustomize ## Generate MetalK8s resulting manifests + mkdir -p deploy diff --git a/scripts/upgrade-operator-sdk/storage-operator/config.yaml b/scripts/upgrade-operator-sdk/storage-operator/config.yaml index 3e99315997..dba35b53cd 100644 --- a/scripts/upgrade-operator-sdk/storage-operator/config.yaml +++ b/scripts/upgrade-operator-sdk/storage-operator/config.yaml @@ -3,8 +3,11 @@ domain: metalk8s.scality.com operator_dir: storage-operator operator_sdk_version: v1.42.1 -go_toolchain: go1.25.8 -k8s_libs: v0.33.9 + +# Optional: pin versions. If absent or empty, the script detects the +# latest patch from the scaffold's go.mod and offers to update this file. +go_toolchain: go1.24.13 +k8s_libs: v0.33.10 apis: - group: storage diff --git a/scripts/upgrade-operator-sdk/storage-operator/patches/Makefile.patch b/scripts/upgrade-operator-sdk/storage-operator/patches/Makefile.patch index 5ea8335c27..a051bb1541 100644 --- a/scripts/upgrade-operator-sdk/storage-operator/patches/Makefile.patch +++ b/scripts/upgrade-operator-sdk/storage-operator/patches/Makefile.patch @@ -1,17 +1,13 @@ --- a/Makefile +++ b/Makefile -@@ -1,2 +1,6 @@ - #ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31) -+ -+# Force Go toolchain version to prevent automatic selection issues -+# See: https://go.dev/doc/toolchain -+export GOTOOLCHAIN = __GOTOOLCHAIN__ - ENVTEST_K8S_VERSION ?= $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}') -@@ -4,3 +8,9 @@ +@@ -3,3 +3,12 @@ .PHONY: catalog-push catalog-push: ## Push a catalog image. $(MAKE) docker-push IMG=$(CATALOG_IMG) + ++# Force Go toolchain version ++export GOTOOLCHAIN = __GOTOOLCHAIN__ ++ +.PHONY: metalk8s +metalk8s: manifests kustomize ## Generate MetalK8s resulting manifests + mkdir -p deploy diff --git a/scripts/upgrade-operator-sdk/upgrade.py b/scripts/upgrade-operator-sdk/upgrade.py index 1addf83edf..e99876276d 100755 --- a/scripts/upgrade-operator-sdk/upgrade.py +++ b/scripts/upgrade-operator-sdk/upgrade.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """Automates the upgrade of operator-sdk based projects. -Scaffolds a fresh project, restores custom code from a backup, applies -GNU patch files, and runs the build pipeline. All versions are pinned -in the YAML config file — the script makes no version-detection API calls. +Scaffolds a fresh project, detects the latest Go and k8s.io patch +versions from the scaffold's go.mod, restores custom code from a backup, +applies GNU patch files, and runs the build pipeline. Usage: python3 scripts/upgrade-operator-sdk/upgrade.py [OPTIONS] @@ -21,15 +21,23 @@ --yes, -y Skip the confirmation prompt -h, --help Show this help message +Environment variables: + GITHUB_TOKEN Optional; raises GitHub API rate limit from 60 + to 5000 req/hour for operator-sdk release checks. + Requires: go, curl, patch, pyyaml (pip install pyyaml) """ import argparse +import json import os import re import shutil import subprocess import sys +import time +import urllib.error +import urllib.request from pathlib import Path from typing import Any, Final, NoReturn @@ -46,24 +54,26 @@ TOOLS_BIN: Final = REPO_ROOT / ".tmp" / "bin" _SDK_BIN: Final = TOOLS_BIN / "operator-sdk" -# All file I/O uses this encoding explicitly. _ENCODING: Final = "utf-8" # --------------------------------------------------------------------------- -# URLs +# URLs & HTTP # --------------------------------------------------------------------------- _URL_OPERATOR_SDK_DOWNLOAD: Final = ( "https://github.com/operator-framework/operator-sdk" "/releases/download/{version}/operator-sdk_{goos}_{goarch}" ) +_GITHUB_RELEASES_URL: Final = "https://api.github.com/repos/{repo}/releases/latest" +_GITHUB_REPO_OPERATOR_SDK: Final = "operator-framework/operator-sdk" +_URL_GO_RELEASES: Final = "https://go.dev/dl/?mode=json&include=all" +_URL_GO_MODULE_VERSIONS: Final = "https://proxy.golang.org/{module}/@v/list" + +_HTTP_RETRIES: Final = 3 # k8s.io libraries bumped together (lock-step releases). _K8S_LIBS: Final = ("k8s.io/api", "k8s.io/apimachinery", "k8s.io/client-go") - -# --------------------------------------------------------------------------- -# Regex patterns -# --------------------------------------------------------------------------- -_PAT_DOCKERFILE_FROM_GOLANG: Final = r"FROM golang:\d+\.\d+" +# We only query one module on the proxy — all three share the same version. +_K8S_LIB_MODULE: Final = _K8S_LIBS[0] # --------------------------------------------------------------------------- # Logging @@ -97,6 +107,75 @@ def die(msg: str) -> NoReturn: sys.exit(1) +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + + +def _http_get(url: str, *, headers: dict[str, str] | None = None) -> bytes: + req = urllib.request.Request(url, headers=headers or {}) + for attempt in range(_HTTP_RETRIES): + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data: bytes = resp.read() + return data + except urllib.error.HTTPError: + raise + except urllib.error.URLError as exc: + if attempt < _HTTP_RETRIES - 1: + delay = 2 ** (attempt + 1) + log_warn( + f"Request to {url} failed ({exc.reason}), " + f"retrying in {delay}s..." + ) + time.sleep(delay) + else: + raise + raise RuntimeError("_http_get: unreachable") + + +def _fetch_json(url: str) -> Any: + try: + return json.loads(_http_get(url)) + except urllib.error.HTTPError as e: + die(f"HTTP {e.code} for {url}") + except urllib.error.URLError as e: + die(f"Failed to fetch {url}: {e.reason}") + + +def _fetch_text(url: str) -> str: + try: + return _http_get(url).decode(_ENCODING) + except urllib.error.HTTPError as e: + die(f"HTTP {e.code} for {url}") + except urllib.error.URLError as e: + die(f"Failed to fetch {url}: {e.reason}") + + +def _github_headers() -> dict[str, str]: + """Return GitHub API headers. Uses GITHUB_TOKEN if set (raises rate limit).""" + headers: dict[str, str] = {} + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def _fetch_latest_github_release(repo: str) -> str: + """Return the latest release tag for a GitHub repository.""" + try: + data = json.loads( + _http_get( + _GITHUB_RELEASES_URL.format(repo=repo), + headers=_github_headers(), + ) + ) + return str(data["tag_name"]) + except (urllib.error.HTTPError, urllib.error.URLError) as e: + log_warn(f"Could not fetch latest release for {repo}: {e}") + return "" + + # --------------------------------------------------------------------------- # Config loading # --------------------------------------------------------------------------- @@ -105,14 +184,11 @@ def die(msg: str) -> NoReturn: def load_config(config_dir: str) -> dict[str, Any]: """Load and validate the operator config from a directory. - The directory must contain ``config.yaml`` and may contain a - ``patches/`` subdirectory with GNU unified diff files. - If *config_dir* is a plain name (no path separators), it is resolved - relative to the script's own directory — so ``operator`` finds - ``scripts/upgrade-operator-sdk/operator/``. Otherwise it is used - as-is, allowing absolute or relative paths for other repos. + relative to the script's own directory. """ + # Plain name like "operator" → look next to this script. + # Path like "./foo" or "/abs/path" → use as-is. p = Path(config_dir) if not p.is_absolute() and os.sep not in config_dir and "/" not in config_dir: p = Path(__file__).resolve().parent / config_dir @@ -123,42 +199,171 @@ def load_config(config_dir: str) -> dict[str, Any]: with config_file.open(encoding=_ENCODING) as f: cfg: dict[str, Any] = yaml.safe_load(f) - required = ( - "repo", - "domain", - "operator_dir", - "apis", - "operator_sdk_version", - "go_toolchain", - ) - for key in required: + for key in ("repo", "domain", "operator_dir", "apis", "operator_sdk_version"): if key not in cfg: die(f"Missing required key {key!r} in {config_file}") cfg["name"] = d.name + cfg["config_file"] = config_file cfg.setdefault("backup_paths", []) cfg.setdefault("extra_commands", []) - cfg.setdefault("k8s_libs", "") cfg["operator_dir"] = REPO_ROOT / cfg["operator_dir"] cfg["patches_dir"] = d / "patches" cfg["backup_dir"] = REPO_ROOT / f"{cfg['operator_dir'].name}.bak" - tc = cfg["go_toolchain"] - m = re.match(r"go(\d+\.\d+)", tc) - cfg["go_major_minor"] = m.group(1) if m else tc.lstrip("go") - return cfg +# --------------------------------------------------------------------------- +# Version detection from scaffold go.mod +# --------------------------------------------------------------------------- + + +def _latest_go_patch(go_major_minor: str) -> str: + """Query go.dev for the latest stable patch of *go_major_minor*.""" + releases = _fetch_json(_URL_GO_RELEASES) + prefix = f"go{go_major_minor}." + return next( + ( + r["version"] + for r in releases + if r["version"].startswith(prefix) and r.get("stable") + ), + f"go{go_major_minor}.0", + ) + + +def _latest_k8s_patch(base_version: str) -> str: + """Query Go module proxy for the latest stable k8s.io patch.""" + m = re.match(r"(v\d+\.\d+)\.", base_version) + if not m: + return base_version + prefix = m.group(1) + "." + content = _fetch_text(_URL_GO_MODULE_VERSIONS.format(module=_K8S_LIB_MODULE)) + # Filter stable releases only (skip pre-releases containing "-"). + candidates = [ + v.strip() + for v in content.splitlines() + if v.strip().startswith(prefix) and "-" not in v.strip() + ] + if not candidates: + return base_version + + def _patch_num(v: str) -> int: + try: + return int(v.rsplit(".", 1)[-1]) + except ValueError: + return -1 + + return max(candidates, key=_patch_num) + + +def detect_latest_patches(cfg: dict[str, Any]) -> dict[str, str]: + """Read the scaffold go.mod and resolve latest patch versions. + + Also checks the latest operator-sdk release on GitHub. + """ + log_step("Detecting latest available versions") + detected: dict[str, str] = {} + + # operator-sdk: latest GitHub release + log_info("Querying GitHub for latest operator-sdk release...") + latest_sdk = _fetch_latest_github_release(_GITHUB_REPO_OPERATOR_SDK) + if latest_sdk: + detected["operator_sdk_version"] = latest_sdk + log_info(f" operator-sdk: {latest_sdk}") + + # Go and k8s.io: from scaffold go.mod + gomod_path = cfg["operator_dir"] / "go.mod" + if not gomod_path.exists(): + die(f"go.mod not found at {gomod_path}") + gomod = gomod_path.read_text(encoding=_ENCODING) + + m_go = re.search(r"^go\s+(\d+\.\d+)", gomod, re.MULTILINE) + if not m_go: + die("Failed to parse Go version from scaffold go.mod") + go_major_minor = m_go.group(1) + + log_info(f"Scaffold Go version: {go_major_minor}") + log_info("Querying go.dev for latest patch...") + go_toolchain = _latest_go_patch(go_major_minor) + detected["go_toolchain"] = go_toolchain + log_info(f" Go toolchain: {go_toolchain}") + + m_k8s = re.search(r"k8s\.io/api\s+(v\S+)", gomod) + if m_k8s: + k8s_base = m_k8s.group(1) + log_info(f"Scaffold k8s.io/api: {k8s_base}") + log_info("Querying module proxy for latest patch...") + k8s_libs = _latest_k8s_patch(k8s_base) + detected["k8s_libs"] = k8s_libs + log_info(f" k8s.io libs: {k8s_libs}") + + return detected + + +def reconcile_versions( + cfg: dict[str, Any], + detected: dict[str, str], +) -> None: + """Compare detected versions with YAML pins. + + - No pin in YAML: auto-pin the detected value and update the file. + - Pin < detected: warn (newer available), keep the pinned value. + - Pin == detected: all good. + - Pin > detected: warn (unusual), use the detected value. + + Zero interactive input — safe for CI. + """ + log_step("Reconciling versions") + auto_pins: dict[str, str] = {} + + for key in ("operator_sdk_version", "go_toolchain", "k8s_libs"): + found = detected.get(key, "") + if not found: + continue + pinned = cfg.get(key, "") + + if not pinned: + log_info(f" {key}: {found} (detected, auto-pinning)") + cfg[key] = found + auto_pins[key] = found + elif found == pinned: + log_info(f" {key}: {pinned} (up to date)") + elif found > pinned: # lexicographic, works for semver + log_warn(f" {key}: pinned {pinned}, " f"newer {found} available") + cfg[key] = pinned + else: + log_warn(f" {key}: pinned {pinned} > detected {found}, " "using detected") + cfg[key] = found + + if auto_pins: + _update_yaml(cfg["config_file"], auto_pins) + + +def _update_yaml(config_file: Path, updates: dict[str, str]) -> None: + """Update specific keys in the YAML config file in-place.""" + text = config_file.read_text(encoding=_ENCODING) + for key, value in updates.items(): + pattern = rf"^{re.escape(key)}:.*$" + replacement = f"{key}: {value}" + new_text = re.sub(pattern, replacement, text, flags=re.MULTILINE) + if new_text == text: + if not text.endswith("\n"): + text += "\n" + text += f"{key}: {value}\n" + else: + text = new_text + config_file.write_text(text, encoding=_ENCODING) + log_info(f"Updated {config_file.name}: {', '.join(updates)}") + + def confirm_upgrade(cfg: dict[str, Any]) -> None: """Print config summary and ask the user to confirm.""" print() print(f"{_BOLD}The following upgrade will be performed:{_RESET}") print() print(f" operator-sdk {cfg['operator_sdk_version']}") - print(f" Go toolchain {cfg['go_toolchain']}") - if cfg["k8s_libs"]: - print(f" k8s.io libs {cfg['k8s_libs']}") print() print(f" Target: {cfg['name']}") print(f" Directory: {cfg['operator_dir']}") @@ -175,10 +380,14 @@ def confirm_upgrade(cfg: dict[str, Any]) -> None: def _tool_env(cfg: dict[str, Any]) -> dict[str, str]: - return { + env: dict[str, str] = { "PATH": f"{TOOLS_BIN}:{os.environ.get('PATH', '')}", - "GOTOOLCHAIN": cfg["go_toolchain"], } + # Set after reconcile_versions() resolves the latest patch. + # Before that, cfg may not have go_toolchain yet. + if cfg.get("go_toolchain"): + env["GOTOOLCHAIN"] = cfg["go_toolchain"] + return env def run( @@ -335,6 +544,9 @@ def restore_backup(cfg: dict[str, Any]) -> None: is_dir = rel_path.endswith("/") if is_dir: + # Replace scaffold directory entirely with our custom code. + # zz_generated files are excluded — they are regenerated by + # `make generate` in Phase 5. if dst.exists(): shutil.rmtree(dst) shutil.copytree( @@ -365,12 +577,13 @@ def restore_backup(cfg: dict[str, Any]) -> None: count += 1 if conflicts: - log_error(f"{len(conflicts)} file(s) conflict between scaffold and backup:") + log_error(f"{len(conflicts)} file(s) conflict between scaffold " "and backup:") for c in conflicts: log_error(f" - {c}") log_info( - "Update the conflicting files in the .bak/ directory to match " - "the desired result, then re-run with --skip-backup." + "Update the conflicting files in the .bak/ directory " + "to match the desired result, then re-run with " + "--skip-backup." ) die("Aborting due to backup/scaffold conflicts") @@ -378,14 +591,14 @@ def restore_backup(cfg: dict[str, Any]) -> None: # =================================================================== -# Phase 4 — Apply patches and version substitutions +# Phase 4 — Apply patches and placeholder substitutions # =================================================================== def adapt_project(cfg: dict[str, Any]) -> None: - """Apply patch files and substitute dynamic versions.""" + """Apply patch files and substitute placeholders.""" _apply_patches(cfg) - _substitute_versions(cfg) + _substitute_placeholders(cfg) def _apply_patches(cfg: dict[str, Any]) -> None: @@ -414,27 +627,18 @@ def _apply_patches(cfg: dict[str, Any]) -> None: log_info(f" {patch_file.name} applied") -def _substitute_versions(cfg: dict[str, Any]) -> None: +def _substitute_placeholders(cfg: dict[str, Any]) -> None: op_dir: Path = cfg["operator_dir"] - dockerfile = op_dir / "Dockerfile" - if dockerfile.exists(): - text = dockerfile.read_text(encoding=_ENCODING) - text = re.sub( - _PAT_DOCKERFILE_FROM_GOLANG, - f"FROM golang:{cfg['go_major_minor']}", - text, - ) - dockerfile.write_text(text, encoding=_ENCODING) - makefile = op_dir / "Makefile" if makefile.exists(): text = makefile.read_text(encoding=_ENCODING) - text = text.replace("__GOTOOLCHAIN__", cfg["go_toolchain"]) + if cfg.get("go_toolchain"): + text = text.replace("__GOTOOLCHAIN__", cfg["go_toolchain"]) text = text.replace("__IMAGE__", cfg.get("image_placeholder", "")) makefile.write_text(text, encoding=_ENCODING) - log_info("Version substitutions applied") + log_info("Placeholders substituted") # =================================================================== @@ -446,11 +650,13 @@ def generate(cfg: dict[str, Any]) -> None: op_dir: Path = cfg["operator_dir"] log_step(f"Phase 5: Generate & verify {cfg['name']}") + # Remove scaffold-generated bin/ to force fresh tool downloads + # via the Makefile (kustomize, controller-gen, etc.). bin_dir = op_dir / "bin" if bin_dir.exists(): shutil.rmtree(bin_dir) - if cfg["k8s_libs"]: + if cfg.get("k8s_libs"): k8s_get = [f"{lib}@{cfg['k8s_libs']}" for lib in _K8S_LIBS] log_info(f"Pinning k8s.io libs to {cfg['k8s_libs']}...") run(["go", "get", *k8s_get], cfg, cwd=op_dir) @@ -498,7 +704,7 @@ def _log_recovery_hint(cfg: dict[str, Any]) -> None: if bak.exists(): log_warn(f"Backup preserved at: {bak}") if op_dir.exists() and bak.exists(): - log_warn("Partial build detected. To restore the original state:") + log_warn("To restore the original state:") log_warn(f" rm -rf {op_dir} && mv {bak} {op_dir}") elif bak.exists(): log_warn(f"To restore: mv {bak} {op_dir}") @@ -511,13 +717,13 @@ def _log_recovery_hint(cfg: dict[str, Any]) -> None: def main() -> None: parser = argparse.ArgumentParser( - description="Upgrade an operator-sdk project by scaffolding fresh " - "and applying patches from a YAML config.", + description="Upgrade an operator-sdk project by scaffolding " + "fresh and applying patches from a YAML config.", ) parser.add_argument( "config_dir", - help="Operator config directory name (e.g. 'operator') or full " - "path to a directory containing config.yaml", + help="Operator config directory name (e.g. 'operator') or " + "full path to a directory containing config.yaml", ) parser.add_argument( "--skip-backup", @@ -533,7 +739,7 @@ def main() -> None: "--yes", "-y", action="store_true", - help="Skip the confirmation prompt", + help="Skip all confirmation prompts", ) args = parser.parse_args() @@ -564,10 +770,16 @@ def main() -> None: shutil.rmtree(op_dir) try: - scaffold_project(cfg) - restore_backup(cfg) - adapt_project(cfg) - generate(cfg) + scaffold_project(cfg) # Phase 2 + detected = detect_latest_patches(cfg) # Phase 2b + reconcile_versions(cfg, detected) # compare & pin + log_info( + f"Using: Go {cfg.get('go_toolchain', 'scaffold')}" + f", k8s.io {cfg.get('k8s_libs', 'scaffold')}" + ) + restore_backup(cfg) # Phase 3 + adapt_project(cfg) # Phase 4 + generate(cfg) # Phase 5 except BaseException: _log_recovery_hint(cfg) raise From 05260c01c13965de780cb9877e45094a368ec757 Mon Sep 17 00:00:00 2001 From: Alex Rodriguez <131964409+ezekiel-alexrod@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:30:22 +0000 Subject: [PATCH 10/12] scripts: address review round 4 -- operator_dir as CLI arg, raw_copy, new patches - Move operator_dir from YAML config to --operator-dir CLI argument - Remove REPO_ROOT and __file__-based path deduction (KISS) - Rename backup_paths to raw_copy, reduce to purely custom dirs (pkg/, version/, config/metalk8s/, salt/) - Generate patch files for CRD types and controllers (previously raw-copied from backup, now scaffold + patch) - Remove Dockerfile modification (scaffold FROM golang is kept as-is) - Fail on missing raw_copy paths instead of warning - Use Path.is_dir() instead of endswith("/") convention - Remove zz_generated filtering (no longer needed) - Neutralize scaffold-generated controller tests via patches (incompatible with our delegation pattern, never used) - Add --forward flag to patch command to avoid false "reversed patch" --- BUMPING.md | 40 +- .../upgrade-operator-sdk/operator/config.yaml | 22 +- .../patches/clusterconfig_controller.patch | 73 ++ .../clusterconfig_controller_test.patch | 87 ++ .../patches/clusterconfig_types.patch | 182 +++ .../patches/virtualippool_controller.patch | 75 ++ .../virtualippool_controller_test.patch | 87 ++ .../patches/virtualippool_types.patch | 169 +++ .../storage-operator/config.yaml | 17 +- .../patches/volume_controller.patch | 1146 +++++++++++++++++ .../patches/volume_controller_test.patch | 120 ++ .../patches/volume_types.patch | 344 +++++ scripts/upgrade-operator-sdk/upgrade.py | 144 +-- 13 files changed, 2394 insertions(+), 112 deletions(-) create mode 100644 scripts/upgrade-operator-sdk/operator/patches/clusterconfig_controller.patch create mode 100644 scripts/upgrade-operator-sdk/operator/patches/clusterconfig_controller_test.patch create mode 100644 scripts/upgrade-operator-sdk/operator/patches/clusterconfig_types.patch create mode 100644 scripts/upgrade-operator-sdk/operator/patches/virtualippool_controller.patch create mode 100644 scripts/upgrade-operator-sdk/operator/patches/virtualippool_controller_test.patch create mode 100644 scripts/upgrade-operator-sdk/operator/patches/virtualippool_types.patch create mode 100644 scripts/upgrade-operator-sdk/storage-operator/patches/volume_controller.patch create mode 100644 scripts/upgrade-operator-sdk/storage-operator/patches/volume_controller_test.patch create mode 100644 scripts/upgrade-operator-sdk/storage-operator/patches/volume_types.patch diff --git a/BUMPING.md b/BUMPING.md index 353360d507..1eada9b846 100644 --- a/BUMPING.md +++ b/BUMPING.md @@ -141,8 +141,8 @@ Target versions are pinned in `scripts/upgrade-operator-sdk//config.yaml`: ```yaml operator_sdk_version: v1.42.1 # target operator-sdk release -go_toolchain: go1.25.8 # pin Go toolchain (for GOTOOLCHAIN) -k8s_libs: v0.33.9 # pin k8s.io libs version +go_toolchain: go1.24.13 # pin Go toolchain (for GOTOOLCHAIN) +k8s_libs: v0.33.10 # pin k8s.io libs version ``` After scaffolding, the script detects the latest available versions (operator-sdk @@ -159,22 +159,24 @@ This is CI-friendly: zero interactive input during reconciliation. ### Running the upgrade -The script processes one operator at a time. Run it once per operator: +The script processes one operator at a time: ```bash -python3 scripts/upgrade-operator-sdk/upgrade.py operator -python3 scripts/upgrade-operator-sdk/upgrade.py storage-operator -``` +python3 scripts/upgrade-operator-sdk/upgrade.py \ + --operator-dir operator \ + scripts/upgrade-operator-sdk/operator -The argument is the name of the config directory next to the script -(i.e. `scripts/upgrade-operator-sdk//`). A full path can also be -given for configs stored elsewhere. +python3 scripts/upgrade-operator-sdk/upgrade.py \ + --operator-dir storage-operator \ + scripts/upgrade-operator-sdk/storage-operator +``` Options: ``` +--operator-dir Path to the operator project directory (required) --skip-backup Reuse an existing .bak directory (no new backup) ---clean-tools Delete .tmp/bin/ after the upgrade +--clean-tools Remove tool cache after upgrade --yes, -y Skip the confirmation prompt ``` @@ -185,17 +187,23 @@ Each operator has a config directory at `scripts/upgrade-operator-sdk//` c - **Versions**: `operator_sdk_version`, `go_toolchain` (optional pin), `k8s_libs` (optional pin) - **Scaffold**: `repo`, `domain`, `apis` (with `group`, `version`, `kind`, `namespaced`). The operator name is derived from the config directory name. -- **Paths**: `operator_dir`, `backup_paths` +- **Raw copy**: `raw_copy` -- directories or files copied as-is from backup (purely custom code with no scaffold equivalent: `pkg/`, `version/`, `config/metalk8s/`, `salt/`, individual test/helper files) - **Post-processing**: `image_placeholder`, `extra_commands` ### Patch files -MetalK8s-specific customizations to scaffold-generated files (`Dockerfile`, `Makefile`) -are stored as GNU unified diff files in the `patches/` subdirectory next to `config.yaml`. The script -applies them with `patch -p1` after scaffolding. If a patch does not apply cleanly, -look for `.rej` files and resolve manually. +All customizations to scaffold-generated files are stored as GNU unified diff +files in the `patches/` subdirectory. This includes: + +- **Dockerfile** and **Makefile** customizations +- **CRD type definitions** (`*_types.go`) +- **Controller implementations** (`*_controller.go`) +- **Scaffold test stubs** (`*_controller_test.go`) -- neutralized when incompatible with the delegation pattern + +The script applies them with `patch -p1` after scaffolding. If a patch does not +apply cleanly, look for `.rej` files and resolve manually. -Patch files use `__PLACEHOLDER__` tokens for values from the YAML config: +Patch files use `__PLACEHOLDER__` tokens for runtime values: | Placeholder | Replaced with | Source | | ----------------- | ------------------------------- | ---------- | diff --git a/scripts/upgrade-operator-sdk/operator/config.yaml b/scripts/upgrade-operator-sdk/operator/config.yaml index 8d67a947c0..53ab6eed95 100644 --- a/scripts/upgrade-operator-sdk/operator/config.yaml +++ b/scripts/upgrade-operator-sdk/operator/config.yaml @@ -1,11 +1,10 @@ repo: github.com/scality/metalk8s/operator domain: metalk8s.scality.com -operator_dir: operator operator_sdk_version: v1.42.1 -# Optional: pin versions. If absent or empty, the script detects the -# latest patch from the scaffold's go.mod and offers to update this file. +# Optional: pin versions. If absent, the script detects the latest +# patch from the scaffold's go.mod and auto-pins them here. go_toolchain: go1.24.13 k8s_libs: v0.33.10 @@ -17,13 +16,16 @@ apis: kind: VirtualIPPool namespaced: true -backup_paths: - - pkg/ - - version/ - - config/metalk8s/ - - api/ - - hack/ - - internal/controller/ +# Directories/files copied as-is from backup (purely custom, no scaffold equivalent). +raw_copy: + - pkg + - version + - config/metalk8s + - api/v1alpha1/conditions.go + - api/v1alpha1/conditions_test.go + - api/v1alpha1/clusterconfig_types_test.go + - api/v1alpha1/virtualippool_types_test.go + - api/v1alpha1/v1alpha1_suite_test.go image_placeholder: '{{ build_image_name("metalk8s-operator") }}' diff --git a/scripts/upgrade-operator-sdk/operator/patches/clusterconfig_controller.patch b/scripts/upgrade-operator-sdk/operator/patches/clusterconfig_controller.patch new file mode 100644 index 0000000000..91e601efe5 --- /dev/null +++ b/scripts/upgrade-operator-sdk/operator/patches/clusterconfig_controller.patch @@ -0,0 +1,73 @@ +--- a/internal/controller/clusterconfig_controller.go ++++ b/internal/controller/clusterconfig_controller.go +@@ -1,5 +1,5 @@ + /* +-Copyright 2026. ++Copyright 2022. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. +@@ -17,14 +17,10 @@ + package controller + + import ( +- "context" +- ++ "github.com/scality/metalk8s/operator/pkg/controller/clusterconfig" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +- logf "sigs.k8s.io/controller-runtime/pkg/log" +- +- metalk8sscalitycomv1alpha1 "github.com/scality/metalk8s/operator/api/v1alpha1" + ) + + // ClusterConfigReconciler reconciles a ClusterConfig object +@@ -33,9 +29,13 @@ + Scheme *runtime.Scheme + } + +-// +kubebuilder:rbac:groups=metalk8s.scality.com,resources=clusterconfigs,verbs=get;list;watch;create;update;patch;delete +-// +kubebuilder:rbac:groups=metalk8s.scality.com,resources=clusterconfigs/status,verbs=get;update;patch +-// +kubebuilder:rbac:groups=metalk8s.scality.com,resources=clusterconfigs/finalizers,verbs=update ++//+kubebuilder:rbac:groups=metalk8s.scality.com,resources=clusterconfigs,verbs=get;list;watch;create;update;patch;delete ++//+kubebuilder:rbac:groups=metalk8s.scality.com,resources=clusterconfigs/status,verbs=get;update;patch ++//+kubebuilder:rbac:groups=metalk8s.scality.com,resources=clusterconfigs/finalizers,verbs=update ++ ++//+kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;create;update;patch;delete ++//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch ++//+kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools,verbs=get;list;watch;create;update;patch;delete + + // Reconcile is part of the main kubernetes reconciliation loop which aims to + // move the current state of the cluster closer to the desired state. +@@ -45,19 +45,18 @@ + // the user. + // + // For more details, check Reconcile and its Result here: +-// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/reconcile +-func (r *ClusterConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { +- _ = logf.FromContext(ctx) +- +- // TODO(user): your logic here +- +- return ctrl.Result{}, nil +-} ++// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.12.1/pkg/reconcile ++//func (r *ClusterConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { ++// _ = log.FromContext(ctx) ++// ++// return ctrl.Result{}, nil ++//} + + // SetupWithManager sets up the controller with the Manager. + func (r *ClusterConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { +- return ctrl.NewControllerManagedBy(mgr). +- For(&metalk8sscalitycomv1alpha1.ClusterConfig{}). +- Named("clusterconfig"). +- Complete(r) ++ return clusterconfig.Add(mgr) ++ ++ //return ctrl.NewControllerManagedBy(mgr). ++ // For(&metalk8sscalitycomv1alpha1.ClusterConfig{}). ++ // Complete(r) + } diff --git a/scripts/upgrade-operator-sdk/operator/patches/clusterconfig_controller_test.patch b/scripts/upgrade-operator-sdk/operator/patches/clusterconfig_controller_test.patch new file mode 100644 index 0000000000..a95cd2da5e --- /dev/null +++ b/scripts/upgrade-operator-sdk/operator/patches/clusterconfig_controller_test.patch @@ -0,0 +1,87 @@ +--- a/internal/controller/clusterconfig_controller_test.go ++++ b/internal/controller/clusterconfig_controller_test.go +@@ -1,84 +1 @@ +-/* +-Copyright 2026. +- +-Licensed under the Apache License, Version 2.0 (the "License"); +-you may not use this file except in compliance with the License. +-You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +-Unless required by applicable law or agreed to in writing, software +-distributed under the License is distributed on an "AS IS" BASIS, +-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-See the License for the specific language governing permissions and +-limitations under the License. +-*/ +- + package controller +- +-import ( +- "context" +- +- . "github.com/onsi/ginkgo/v2" +- . "github.com/onsi/gomega" +- "k8s.io/apimachinery/pkg/api/errors" +- "k8s.io/apimachinery/pkg/types" +- "sigs.k8s.io/controller-runtime/pkg/reconcile" +- +- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +- +- metalk8sscalitycomv1alpha1 "github.com/scality/metalk8s/operator/api/v1alpha1" +-) +- +-var _ = Describe("ClusterConfig Controller", func() { +- Context("When reconciling a resource", func() { +- const resourceName = "test-resource" +- +- ctx := context.Background() +- +- typeNamespacedName := types.NamespacedName{ +- Name: resourceName, +- Namespace: "default", // TODO(user):Modify as needed +- } +- clusterconfig := &metalk8sscalitycomv1alpha1.ClusterConfig{} +- +- BeforeEach(func() { +- By("creating the custom resource for the Kind ClusterConfig") +- err := k8sClient.Get(ctx, typeNamespacedName, clusterconfig) +- if err != nil && errors.IsNotFound(err) { +- resource := &metalk8sscalitycomv1alpha1.ClusterConfig{ +- ObjectMeta: metav1.ObjectMeta{ +- Name: resourceName, +- Namespace: "default", +- }, +- // TODO(user): Specify other spec details if needed. +- } +- Expect(k8sClient.Create(ctx, resource)).To(Succeed()) +- } +- }) +- +- AfterEach(func() { +- // TODO(user): Cleanup logic after each test, like removing the resource instance. +- resource := &metalk8sscalitycomv1alpha1.ClusterConfig{} +- err := k8sClient.Get(ctx, typeNamespacedName, resource) +- Expect(err).NotTo(HaveOccurred()) +- +- By("Cleanup the specific resource instance ClusterConfig") +- Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) +- }) +- It("should successfully reconcile the resource", func() { +- By("Reconciling the created resource") +- controllerReconciler := &ClusterConfigReconciler{ +- Client: k8sClient, +- Scheme: k8sClient.Scheme(), +- } +- +- _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ +- NamespacedName: typeNamespacedName, +- }) +- Expect(err).NotTo(HaveOccurred()) +- // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. +- // Example: If you expect a certain status condition after reconciliation, verify it here. +- }) +- }) +-}) diff --git a/scripts/upgrade-operator-sdk/operator/patches/clusterconfig_types.patch b/scripts/upgrade-operator-sdk/operator/patches/clusterconfig_types.patch new file mode 100644 index 0000000000..71f085a58d --- /dev/null +++ b/scripts/upgrade-operator-sdk/operator/patches/clusterconfig_types.patch @@ -0,0 +1,182 @@ +--- a/api/v1alpha1/clusterconfig_types.go ++++ b/api/v1alpha1/clusterconfig_types.go +@@ -1,5 +1,5 @@ + /* +-Copyright 2026. ++Copyright 2022. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. +@@ -17,32 +17,102 @@ + package v1alpha1 + + import ( ++ "sync" ++ + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ) + ++const ( ++ wPVIPConfiguredConditionName = "WorkloadPlaneVirtualIPPool" + configuredConditionName ++ wPVIPReadyConditionName = "WorkloadPlaneVirtualIPPool" + readyConditionName ++ ++ cPIngressConfiguredConditionName = "ControlPlaneIngress" + configuredConditionName ++ cPIngressVIPConfiguredConditionName = "ControlPlaneIngressVirtualIP" + configuredConditionName ++ cPIngressVIPReadyConditionName = "ControlPlaneIngressVirtualIP" + readyConditionName ++) ++ + // 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. + +-// ClusterConfigSpec defines the desired state of ClusterConfig. ++type ManagedVirtualIPSource struct { ++ // and will be used to reach the Ingress ++ // A Virtual IP address that will be managed by the Operator ++ Address IPAddress `json:"address"` ++} ++ ++type ExternalIPSource struct { ++ // The IP address used to reach the Ingress ++ Address IPAddress `json:"address"` ++} ++ ++type ControlPlaneIngressSource struct { ++ ManagedVirtualIP *ManagedVirtualIPSource `json:"managedVirtualIP,omitempty"` ++ ExternalIP *ExternalIPSource `json:"externalIP,omitempty"` ++} ++ ++type ControlPlaneIngressSpec struct { ++ ControlPlaneIngressSource `json:",inline"` ++} ++ ++type ControlPlaneSpec struct { ++ // Information about the Control Plane Ingress ++ Ingress ControlPlaneIngressSpec `json:"ingress,omitempty"` ++} ++ ++type WorkloadPlaneSpec struct { ++ // Information about Virtual IP Pools ++ // +optional ++ VirtualIPPools map[string]VirtualIPPoolSpec `json:"virtualIPPools,omitempty"` ++} ++ ++// ClusterConfigSpec defines the desired state of ClusterConfig + type ClusterConfigSpec struct { + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file + +- // Foo is an example field of ClusterConfig. Edit clusterconfig_types.go to remove/update +- Foo string `json:"foo,omitempty"` ++ // Information about the Control Plane. ++ // +optional ++ ControlPlane ControlPlaneSpec `json:"controlPlane,omitempty"` ++ ++ // Information about the Workload Plane. ++ // +optional ++ WorkloadPlane WorkloadPlaneSpec `json:"workloadPlane,omitempty"` ++} ++ ++type ControlPlaneIngressStatus struct { ++ // The IP address where the Ingress is exposed ++ IP IPAddress `json:"ip,omitempty"` ++ // The full endpoint URL to reach the Ingress ++ Endpoint string `json:"endpoint,omitempty"` ++} ++ ++type ControlPlaneStatus struct { ++ // Information about the Control Plane Ingress ++ Ingress ControlPlaneIngressStatus `json:"ingress,omitempty"` + } + +-// ClusterConfigStatus defines the observed state of ClusterConfig. ++// ClusterConfigStatus defines the observed state of ClusterConfig + type ClusterConfigStatus struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + // Important: Run "make" to regenerate code after modifying this file ++ ++ // List of conditions for the ClusterConfig ++ // +patchMergeKey=type ++ // +patchStrategy=merge ++ // +listType=map ++ // +listMapKey=type ++ Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` ++ ++ // Control Plane Information ++ ControlPlane ControlPlaneStatus `json:"controlPlane,omitempty"` + } + +-// +kubebuilder:object:root=true +-// +kubebuilder:subresource:status +-// +kubebuilder:resource:scope=Cluster ++//+kubebuilder:object:root=true ++//+kubebuilder:subresource:status ++//+kubebuilder:resource:scope=Cluster,shortName=cc ++//+kubebuilder:printcolumn:name="Control-Plane-Url",type="string",JSONPath=".status.controlPlane.ingress.endpoint",description="The URL to reach the Control Plane Ingress" + +-// ClusterConfig is the Schema for the clusterconfigs API. ++// ClusterConfig is the Schema for the clusterconfigs API + type ClusterConfig struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` +@@ -51,9 +121,59 @@ + Status ClusterConfigStatus `json:"status,omitempty"` + } + +-// +kubebuilder:object:root=true ++// The ClusterConfig is managed by several SubReconciler in parallel ++// that may Set and Get conditions, so ensure there is always only one ++// accessing it at once ++// NOTE: We can have only one ClusterConfig so keep the mutex has a simple variable here ++var mu sync.Mutex ++ ++// Set a condition on ClusterConfig ++func (v *ClusterConfig) SetCondition(kind string, status metav1.ConditionStatus, reason string, message string) { ++ mu.Lock() ++ defer mu.Unlock() ++ setCondition(v.Generation, &v.Status.Conditions, kind, status, reason, message) ++} ++ ++// Get a condition from ClusterConfig ++func (v *ClusterConfig) GetCondition(kind string) *Condition { ++ mu.Lock() ++ defer mu.Unlock() ++ return getCondition(v.Status.Conditions, kind) ++} ++ ++// Set Ready Condition ++func (v *ClusterConfig) SetReadyCondition(status metav1.ConditionStatus, reason string, message string) { ++ v.SetCondition(readyConditionName, status, reason, message) ++} ++ ++// Set WorkloadPlaneVirtualIPPool Configured Condition ++func (v *ClusterConfig) SetWPVIPConfiguredCondition(status metav1.ConditionStatus, reason string, message string) { ++ v.SetCondition(wPVIPConfiguredConditionName, status, reason, message) ++} ++ ++// Set WorkloadPlaneVirtualIPPool Ready Condition ++func (v *ClusterConfig) SetWPVIPReadyCondition(status metav1.ConditionStatus, reason string, message string) { ++ v.SetCondition(wPVIPReadyConditionName, status, reason, message) ++} ++ ++// Set ControlPlaneIngressConfigured Condition ++func (v *ClusterConfig) SetCPIngressConfiguredCondition(status metav1.ConditionStatus, reason string, message string) { ++ v.SetCondition(cPIngressConfiguredConditionName, status, reason, message) ++} ++ ++// Set ControlPlaneIngressVirtualIP Configured Condition ++func (v *ClusterConfig) SetCPIngressVIPConfiguredCondition(status metav1.ConditionStatus, reason string, message string) { ++ v.SetCondition(cPIngressVIPConfiguredConditionName, status, reason, message) ++} ++ ++// Set ControlPlaneIngressVirtualIP Ready Condition ++func (v *ClusterConfig) SetCPIngressVIPReadyCondition(status metav1.ConditionStatus, reason string, message string) { ++ v.SetCondition(cPIngressVIPReadyConditionName, status, reason, message) ++} ++ ++//+kubebuilder:object:root=true + +-// ClusterConfigList contains a list of ClusterConfig. ++// ClusterConfigList contains a list of ClusterConfig + type ClusterConfigList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` diff --git a/scripts/upgrade-operator-sdk/operator/patches/virtualippool_controller.patch b/scripts/upgrade-operator-sdk/operator/patches/virtualippool_controller.patch new file mode 100644 index 0000000000..1648743b12 --- /dev/null +++ b/scripts/upgrade-operator-sdk/operator/patches/virtualippool_controller.patch @@ -0,0 +1,75 @@ +--- a/internal/controller/virtualippool_controller.go ++++ b/internal/controller/virtualippool_controller.go +@@ -1,5 +1,5 @@ + /* +-Copyright 2026. ++Copyright 2022. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. +@@ -17,14 +17,10 @@ + package controller + + import ( +- "context" +- ++ "github.com/scality/metalk8s/operator/pkg/controller/virtualippool" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +- logf "sigs.k8s.io/controller-runtime/pkg/log" +- +- metalk8sscalitycomv1alpha1 "github.com/scality/metalk8s/operator/api/v1alpha1" + ) + + // VirtualIPPoolReconciler reconciles a VirtualIPPool object +@@ -33,9 +29,15 @@ + Scheme *runtime.Scheme + } + +-// +kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools,verbs=get;list;watch;create;update;patch;delete +-// +kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools/status,verbs=get;update;patch +-// +kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools/finalizers,verbs=update ++//+kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools,verbs=get;list;watch;create;update;patch;delete ++//+kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools/status,verbs=get;update;patch ++//+kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools/finalizers,verbs=update ++ ++//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch ++ ++//+kubebuilder:rbac:groups="",resources=nodes,verbs=get;list;watch ++//+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete ++//+kubebuilder:rbac:groups="apps",resources=daemonsets,verbs=get;list;watch;create;update;patch;delete + + // Reconcile is part of the main kubernetes reconciliation loop which aims to + // move the current state of the cluster closer to the desired state. +@@ -45,19 +47,18 @@ + // the user. + // + // For more details, check Reconcile and its Result here: +-// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/reconcile +-func (r *VirtualIPPoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { +- _ = logf.FromContext(ctx) +- +- // TODO(user): your logic here +- +- return ctrl.Result{}, nil +-} ++// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.12.2/pkg/reconcile ++//func (r *VirtualIPPoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { ++// _ = log.FromContext(ctx) ++// ++// return ctrl.Result{}, nil ++//} + + // SetupWithManager sets up the controller with the Manager. + func (r *VirtualIPPoolReconciler) SetupWithManager(mgr ctrl.Manager) error { +- return ctrl.NewControllerManagedBy(mgr). +- For(&metalk8sscalitycomv1alpha1.VirtualIPPool{}). +- Named("virtualippool"). +- Complete(r) ++ return virtualippool.Add(mgr) ++ ++ //return ctrl.NewControllerManagedBy(mgr). ++ // For(&metalk8sscalitycomv1alpha1.VirtualIPPool{}). ++ // Complete(r) + } diff --git a/scripts/upgrade-operator-sdk/operator/patches/virtualippool_controller_test.patch b/scripts/upgrade-operator-sdk/operator/patches/virtualippool_controller_test.patch new file mode 100644 index 0000000000..cc7b162a01 --- /dev/null +++ b/scripts/upgrade-operator-sdk/operator/patches/virtualippool_controller_test.patch @@ -0,0 +1,87 @@ +--- a/internal/controller/virtualippool_controller_test.go ++++ b/internal/controller/virtualippool_controller_test.go +@@ -1,84 +1 @@ +-/* +-Copyright 2026. +- +-Licensed under the Apache License, Version 2.0 (the "License"); +-you may not use this file except in compliance with the License. +-You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +-Unless required by applicable law or agreed to in writing, software +-distributed under the License is distributed on an "AS IS" BASIS, +-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-See the License for the specific language governing permissions and +-limitations under the License. +-*/ +- + package controller +- +-import ( +- "context" +- +- . "github.com/onsi/ginkgo/v2" +- . "github.com/onsi/gomega" +- "k8s.io/apimachinery/pkg/api/errors" +- "k8s.io/apimachinery/pkg/types" +- "sigs.k8s.io/controller-runtime/pkg/reconcile" +- +- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +- +- metalk8sscalitycomv1alpha1 "github.com/scality/metalk8s/operator/api/v1alpha1" +-) +- +-var _ = Describe("VirtualIPPool Controller", func() { +- Context("When reconciling a resource", func() { +- const resourceName = "test-resource" +- +- ctx := context.Background() +- +- typeNamespacedName := types.NamespacedName{ +- Name: resourceName, +- Namespace: "default", // TODO(user):Modify as needed +- } +- virtualippool := &metalk8sscalitycomv1alpha1.VirtualIPPool{} +- +- BeforeEach(func() { +- By("creating the custom resource for the Kind VirtualIPPool") +- err := k8sClient.Get(ctx, typeNamespacedName, virtualippool) +- if err != nil && errors.IsNotFound(err) { +- resource := &metalk8sscalitycomv1alpha1.VirtualIPPool{ +- ObjectMeta: metav1.ObjectMeta{ +- Name: resourceName, +- Namespace: "default", +- }, +- // TODO(user): Specify other spec details if needed. +- } +- Expect(k8sClient.Create(ctx, resource)).To(Succeed()) +- } +- }) +- +- AfterEach(func() { +- // TODO(user): Cleanup logic after each test, like removing the resource instance. +- resource := &metalk8sscalitycomv1alpha1.VirtualIPPool{} +- err := k8sClient.Get(ctx, typeNamespacedName, resource) +- Expect(err).NotTo(HaveOccurred()) +- +- By("Cleanup the specific resource instance VirtualIPPool") +- Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) +- }) +- It("should successfully reconcile the resource", func() { +- By("Reconciling the created resource") +- controllerReconciler := &VirtualIPPoolReconciler{ +- Client: k8sClient, +- Scheme: k8sClient.Scheme(), +- } +- +- _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ +- NamespacedName: typeNamespacedName, +- }) +- Expect(err).NotTo(HaveOccurred()) +- // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. +- // Example: If you expect a certain status condition after reconciliation, verify it here. +- }) +- }) +-}) diff --git a/scripts/upgrade-operator-sdk/operator/patches/virtualippool_types.patch b/scripts/upgrade-operator-sdk/operator/patches/virtualippool_types.patch new file mode 100644 index 0000000000..e1eec06bea --- /dev/null +++ b/scripts/upgrade-operator-sdk/operator/patches/virtualippool_types.patch @@ -0,0 +1,169 @@ +--- a/api/v1alpha1/virtualippool_types.go ++++ b/api/v1alpha1/virtualippool_types.go +@@ -1,5 +1,5 @@ + /* +-Copyright 2026. ++Copyright 2022. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. +@@ -17,31 +17,90 @@ + package v1alpha1 + + import ( ++ appsv1 "k8s.io/api/apps/v1" ++ corev1 "k8s.io/api/core/v1" + 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. + +-// VirtualIPPoolSpec defines the desired state of VirtualIPPool. ++type SpreadConstraintSpec struct { ++ // Topology label to use to spread the Virtual IPs ++ TopologyKey string `json:"topologyKey"` ++} ++ ++type HttpGetSpec struct { ++ // The IP to do the HTTP request ++ // (default to keepalived Pod IP) ++ // +optional ++ IP IPAddress `json:"host,omitempty"` ++ // The scheme to use for the HTTP request ++ // (default to HTTPS) ++ // +optional ++ // +kubebuilder:default="HTTPS" ++ // +kubebuilder:validation:Enum={"HTTP", "HTTPS"} ++ Scheme string `json:"scheme"` ++ // The port to do the HTTP request ++ // +optional ++ // +kubebuilder:default=443 ++ Port int `json:"port"` ++ // Path for the HTTP request ++ // +optional ++ Path string `json:"path"` ++} ++ ++type HealthcheckSpec struct { ++ // Simple HTTP Get check ++ HttpGet HttpGetSpec `json:"httpGet,omitempty"` ++} ++ ++// +kubebuilder:validation:Format=ipv4 ++type IPAddress string ++ ++// VirtualIPPoolSpec defines the desired state of VirtualIPPool + type VirtualIPPoolSpec struct { + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file + +- // Foo is an example field of VirtualIPPool. Edit virtualippool_types.go to remove/update +- Foo string `json:"foo,omitempty"` ++ // Node Selector to deploy the Virtual IPs manager ++ // +optional ++ NodeSelector map[string]string `json:"nodeSelector,omitempty"` ++ // Tolerations to deploy the Virtual IPs manager ++ // +optional ++ Tolerations []corev1.Toleration `json:"tolerations,omitempty"` ++ // Spread constraints for the Virtual IPs ++ // NOTE: Not supported yet ++ // // +optional ++ // SpreadConstraints []SpreadConstraintSpec `json:"spreadConstraints,omitempty"` ++ ++ // Virtual IP addresses to use ++ // +kubebuilder:validation:MinItems=1 ++ Addresses []IPAddress `json:"addresses"` ++ ++ // The local health check to run to ensure the Virtual IP can sit on ++ // this specific node ++ Healthcheck *HealthcheckSpec `json:"healthcheck,omitempty"` + } + +-// VirtualIPPoolStatus defines the observed state of VirtualIPPool. ++// VirtualIPPoolStatus defines the observed state of VirtualIPPool + type VirtualIPPoolStatus struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + // Important: Run "make" to regenerate code after modifying this file ++ ++ // List of conditions for the VirtualIPPool ++ // +patchMergeKey=type ++ // +patchStrategy=merge ++ // +listType=map ++ // +listMapKey=type ++ Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + } + +-// +kubebuilder:object:root=true +-// +kubebuilder:subresource:status ++//+kubebuilder:object:root=true ++//+kubebuilder:subresource:status ++//+kubebuilder:resource:shortName=vipp + +-// VirtualIPPool is the Schema for the virtualippools API. ++// VirtualIPPool is the Schema for the virtualippools API + type VirtualIPPool struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` +@@ -50,9 +109,59 @@ + Status VirtualIPPoolStatus `json:"status,omitempty"` + } + +-// +kubebuilder:object:root=true ++// Compute the ConfigMap name for a pool ++func (v *VirtualIPPool) GetConfigMap() *corev1.ConfigMap { ++ return &corev1.ConfigMap{ ++ ObjectMeta: metav1.ObjectMeta{ ++ Name: v.GetName(), ++ Namespace: v.GetNamespace(), ++ }, ++ } ++} ++ ++// Compute the DaemonSet name for a pool ++func (v *VirtualIPPool) GetDaemonSet() *appsv1.DaemonSet { ++ return &appsv1.DaemonSet{ ++ ObjectMeta: metav1.ObjectMeta{ ++ Name: v.GetName(), ++ Namespace: v.GetNamespace(), ++ }, ++ } ++} ++ ++// Set a condition on VirtualIPPool ++func (v *VirtualIPPool) SetCondition(kind string, status metav1.ConditionStatus, reason string, message string) { ++ setCondition(v.Generation, &v.Status.Conditions, kind, status, reason, message) ++} ++ ++// Get a condition from VirtualIPPool ++func (v *VirtualIPPool) GetCondition(kind string) *Condition { ++ return getCondition(v.Status.Conditions, kind) ++} ++ ++// Set Configured Condition ++func (v *VirtualIPPool) SetConfiguredCondition(status metav1.ConditionStatus, reason string, message string) { ++ v.SetCondition(configuredConditionName, status, reason, message) ++} ++ ++// Set Available Condition ++func (v *VirtualIPPool) SetAvailableCondition(status metav1.ConditionStatus, reason string, message string) { ++ v.SetCondition(availableConditionName, status, reason, message) ++} ++ ++// Set Ready Condition ++func (v *VirtualIPPool) SetReadyCondition(status metav1.ConditionStatus, reason string, message string) { ++ v.SetCondition(readyConditionName, status, reason, message) ++} ++ ++// Get Ready Condition ++func (v *VirtualIPPool) GetReadyCondition() *Condition { ++ return v.GetCondition(readyConditionName) ++} ++ ++//+kubebuilder:object:root=true + +-// VirtualIPPoolList contains a list of VirtualIPPool. ++// VirtualIPPoolList contains a list of VirtualIPPool + type VirtualIPPoolList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` diff --git a/scripts/upgrade-operator-sdk/storage-operator/config.yaml b/scripts/upgrade-operator-sdk/storage-operator/config.yaml index dba35b53cd..c7b11e9481 100644 --- a/scripts/upgrade-operator-sdk/storage-operator/config.yaml +++ b/scripts/upgrade-operator-sdk/storage-operator/config.yaml @@ -1,11 +1,10 @@ repo: github.com/scality/metalk8s/storage-operator domain: metalk8s.scality.com -operator_dir: storage-operator operator_sdk_version: v1.42.1 -# Optional: pin versions. If absent or empty, the script detects the -# latest patch from the scaffold's go.mod and offers to update this file. +# Optional: pin versions. If absent, the script detects the latest +# patch from the scaffold's go.mod and auto-pins them here. go_toolchain: go1.24.13 k8s_libs: v0.33.10 @@ -15,12 +14,12 @@ apis: kind: Volume namespaced: false -backup_paths: - - api/ - - hack/ - - internal/controller/ - - config/metalk8s/ - - salt/ +# Directories/files copied as-is from backup (purely custom, no scaffold equivalent). +raw_copy: + - config/metalk8s + - salt + - api/v1alpha1/volume_types_test.go + - internal/controller/slice.go image_placeholder: '{{ build_image_name("storage-operator") }}' diff --git a/scripts/upgrade-operator-sdk/storage-operator/patches/volume_controller.patch b/scripts/upgrade-operator-sdk/storage-operator/patches/volume_controller.patch new file mode 100644 index 0000000000..34565a22d8 --- /dev/null +++ b/scripts/upgrade-operator-sdk/storage-operator/patches/volume_controller.patch @@ -0,0 +1,1146 @@ +--- a/internal/controller/volume_controller.go ++++ b/internal/controller/volume_controller.go +@@ -1,5 +1,5 @@ + /* +-Copyright 2026. ++Copyright 2021. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. +@@ -18,46 +18,1121 @@ + + import ( + "context" ++ "fmt" ++ "io/ioutil" ++ "strconv" ++ "time" + ++ errorsng "github.com/pkg/errors" ++ corev1 "k8s.io/api/core/v1" ++ storagev1 "k8s.io/api/storage/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/runtime" ++ "k8s.io/apimachinery/pkg/types" ++ "k8s.io/client-go/rest" ++ "k8s.io/client-go/tools/record" + 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" ++ "sigs.k8s.io/controller-runtime/pkg/reconcile" + + storagev1alpha1 "github.com/scality/metalk8s/storage-operator/api/v1alpha1" ++ "github.com/scality/metalk8s/storage-operator/salt" + ) + ++/* Explanations/schemas about volume lifecycle/reconciliation workflow {{{ ++ ++=================================== ++= Reconciliation loop (top level) = ++=================================== ++ ++When receiving a request, the first thing we do is to fetch the targeted Volume. ++If it doesn't exist, which happens when a volume is `Terminating` and has no ++finalizer, then we're done: nothing more to do. ++ ++If the volume does exist, we have to check its semantic validity (this task is ++usually done by an Admission Controller but it may not be always up and running, ++so we should have a check here). ++ ++Once pre-checks are done, we will fall in one of four cases: ++- the volume is marked for deletion: we have to try to delete the volume ++ (details are given in the "Finalize Volume" section below). ++- the volume is stuck in an unrecoverable (automatically at least) error state: ++ we can't do anything here: the request is considered done and won't be ++ rescheduled. ++- the volume doesn't have a backing PersistentVolume (e.g: newly created ++ volume): we have to "deploy" the volume (details are given in the "Deploy ++ Volume" section below) ++- the backing PersistentVolume exists: let's check its status to update the ++ volume's status accordingly. ++ ++ ----- ------ ++(START) +-------------------------->( STOP ) ++ ----- | ------ ++ | | ^ ++ | | | ++ | | | ++ | +------------+ | ++ | +------------------------------>|DoNotRequeue|<-------------+ | ++ | | +------------+ | | ++ | |N ^ | | ++ v | |Y |Y | +++-------+ Y +------+ Y +-----------+ N +-------+ N +-----+ Y +---------+ | ++|Exists?|-->|Valid?|-->|Terminating?|-->|Failed?|-->|HasPv|-->|PvHealthy| | +++-------+ +------+ +-----------+ +-------+ +-----+ +---------+ | ++ |N |Y |N |N | ++ | v v | | ++ | +--------------+ +------------+ | | ++ | |FinalizeVolume| |DeployVolume| | | ++ | +--------------+ +------------+ | | ++ | v | ++ | +---------+ +-------+ ++ +---------------------------------------->|SetFailed|->|Requeue| ++ +---------+ +-------+ ++ ++ ++ ++================= ++= Deploy Volume = ++================= ++ ++To "deploy" a volume, we need to prepare its storage (using Salt) and create a ++backing PersistentVolume. ++ ++If we have no value for `Job`, that means nothing has started, thus we set a ++finalizer on ourself and then start the volume preparation using an asynchronous ++Salt call (which gives us a job ID) before rescheduling the request to monitor ++the evolution of the job. ++ ++If we do have a job ID, then something is in progress and we monitor it until ++it's over. ++If it has ended with an error, we move the volume into a failed state. ++ ++Otherwise we make another asynchronous Salt call to get information on the ++backing storage device (the polling is done exactly as described above). ++ ++If we successfully retrieve the device information, we proceed with the ++PersistentVolume creation, taking care of putting a finalizer on the ++PersistentVolume (so that its lifetime is tied to ours) and setting ourself as ++the owner of the PersistentVolume. ++ ++Once we have successfuly created the PersistentVolume, we can move into the ++`Available` state and reschedule the request (the next iteration will check the ++health of the PersistentVolume we just created). ++ ++ +------------------+ +------------------+ +----------+ ++ +-->|SetVolumeFinalizer|-->|SpawnPrepareVolume|-->|SetPending| ++ | +------------------+ +------------------+ +----------+ ++ | NO | ++ | v ++ ----- +----+ DONE +--------+ +------------+ +-------+ ------ ++(START)-->|Job?|------>|CreatePV|-->|SetAvailable|--------->|Requeue|-->( STOP ) ++ ----- +----+ +--------+ +------------+ +-------+ ------ ++ | YES ^ ++ v | ++ +-----------+ Job Failed +---------+ | ++ | |-------------->|SetFailed|------------------>+ ++ | | +---------+ | ++ | | | ++ | | Unknown Job +--------+ | ++ |PollSaltJob|-------------->|UnsetJob|------------------->+ ++ | | +--------+ | ++ | | | ++ | | Job Succeed +--------+ | ++ | |-------------->|Job=DONE|------------------->+ ++ +-----------+ +--------+ | ++ | Job in progress | ++ | | ++ +----------------------------------------------------+ ++ ++================ ++= Steady state = ++================ ++ ++Once the volume is deployed, we update, with a synchronous Salt call, the ++`deviceName` status field at each reconciliation loop iteration. This field ++contains the name of the underlying block device (as found under `/dev`). ++ ++=================== ++= Finalize Volume = ++=================== ++ ++`Pending` volumes cannot be deleted (because we don't know where we are in the ++creation process), so we reschedule the request until the volume becomes either ++`Failed` or `Available`. ++ ++For volumes with no backing PersistentVolume we directly go reclaim the storage ++on the node and upon completion we remove our finalizer to let Kubernetes delete ++us. ++ ++If we do have a backing PersistentVolume, we delete it (if it's not already in a ++terminating state) and watch for the moment when it becomes unused (this is done ++by rescheduling). Once the backing PersistentVolume becomes unused, we go ++reclaim its storage and remove the finalizers to let the object be deleted. ++ ++ ----- ------ ++ (START) ( STOP ) ++ ----- ------ ++ | ^ ++ | | ++ v | +++--------+ YES +-------+ ++|Pending?|------------------------------------------------------->|Requeue| +++--------+ +-------+ ++ | NO ^ ++ v | +++--------+ YES +----------------+ NO +--------+ | ++| HasPv? |----------->|IsPvTerminating?|---->|DeletePV|------------->| +++--------+ +----------------+ +--------+ | ++ | NO | YES | ++ | v | ++ | YES +-----------+ NO | ++ |<-----------------|IsPvUnused?|-------------------------------->| ++ | +-----------+ | ++ | | ++ | +--------------------+ +--------------+ | ++ | +---->|SpawnUnprepareVolume|-->|SetTerminating|---------->| ++ | | +--------------------+ +--------------+ | ++ | | NO | ++ | | | ++ | +----+ DONE +-----------------+ +---------------------+ | ++ +-->|Job?|------>|RemovePvFinalizer|-->|RemoveVolumeFinalizer|-->| ++ +----+ +-----------------+ +---------------------+ | ++ | YES | ++ v | ++ +-----------+ Job Failed +---------+ | ++ | |--------------------->|SetFailed|----------------->| ++ | | +---------+ | ++ | | | ++ | | Unknown Job +--------+ | ++ |PollSaltJob|--------------------->|UnsetJob|------------------>| ++ | | +--------+ | ++ | | | ++ | | Job Succeed +--------+ | ++ | |--------------------->|Job=DONE|------------------>| ++ +-----------+ +--------+ | ++ | Job in progress | ++ | | ++ +----------------------------------------------------------+ ++ ++}}} */ ++ ++const VOLUME_PROTECTION = "storage.metalk8s.scality.com/volume-protection" ++const JOB_DONE_MARKER = "DONE" ++ ++var log = logf.Log.WithName("volume-controller") ++ ++type deviceInfo struct { ++ size int64 // Size of the device (in bytes) ++ path string // Reliable path to the device. ++} ++ + // VolumeReconciler reconciles a Volume object + type VolumeReconciler struct { + client.Client +- Scheme *runtime.Scheme ++ Scheme *runtime.Scheme ++ recorder record.EventRecorder ++ salt *salt.Client ++ devices map[string]deviceInfo ++} ++ ++// Trace a state transition, using logging and Kubernetes events. ++func (self *VolumeReconciler) traceStateTransition( ++ volume *storagev1alpha1.Volume, oldPhase storagev1alpha1.VolumePhase, ++) { ++ newPhase := volume.ComputePhase() ++ ++ // Nothing to trace if there is no transition. ++ if newPhase == oldPhase { ++ return ++ } ++ ++ reqLogger := log.WithValues("Volume.Name", volume.Name) ++ ++ self.recorder.Eventf( ++ volume, corev1.EventTypeNormal, "StateTransition", ++ "volume phase transition from '%s' to '%s'", ++ oldPhase, newPhase, ++ ) ++ reqLogger.Info( ++ "volume phase transition: requeue", ++ "Volume.OldPhase", oldPhase, ++ "Volume.NewPhase", newPhase, ++ ) ++} ++ ++// Commit the Volume Status update. ++func (self *VolumeReconciler) updateVolumeStatus( ++ ctx context.Context, ++ volume *storagev1alpha1.Volume, ++ oldPhase storagev1alpha1.VolumePhase, ++) (reconcile.Result, error) { ++ reqLogger := log.WithValues("Volume.Name", volume.Name) ++ ++ if err := self.Client.Status().Update(ctx, volume); err != nil { ++ reqLogger.Error(err, "cannot update Volume status: requeue") ++ return delayedRequeue(err) ++ } ++ ++ self.traceStateTransition(volume, oldPhase) ++ // Status updated: reschedule to move forward. ++ return requeue(nil) ++} ++ ++// Put the volume into Failed state. ++func (self *VolumeReconciler) setFailedVolumeStatus( ++ ctx context.Context, ++ volume *storagev1alpha1.Volume, ++ pv *corev1.PersistentVolume, ++ reason storagev1alpha1.ConditionReason, ++ format string, ++ args ...interface{}, ++) (reconcile.Result, error) { ++ reqLogger := log.WithValues("Volume.Name", volume.Name) ++ oldPhase := volume.ComputePhase() ++ ++ volume.SetFailedStatus(reason, format, args...) ++ if _, err := self.updateVolumeStatus(ctx, volume, oldPhase); err != nil { ++ return delayedRequeue(err) ++ } ++ // If a PV is provided, move it to Failed state as well. ++ if pv != nil { ++ pv.Status = corev1.PersistentVolumeStatus{ ++ Phase: corev1.VolumeFailed, ++ Message: "the owning volume failed", ++ Reason: "OwnerFailed", ++ } ++ if err := self.Client.Status().Update(ctx, pv); err != nil { ++ reqLogger.Error( ++ err, "cannot update PersistentVolume status: requeue", ++ "PersistentVolume.Name", pv.Name, ++ ) ++ return delayedRequeue(err) ++ } ++ } ++ return requeue(nil) ++} ++ ++// Put the volume into Pending state. ++func (self *VolumeReconciler) setPendingVolumeStatus( ++ ctx context.Context, volume *storagev1alpha1.Volume, job string, ++) (reconcile.Result, error) { ++ oldPhase := volume.ComputePhase() ++ ++ volume.SetPendingStatus(job) ++ if _, err := self.updateVolumeStatus(ctx, volume, oldPhase); err != nil { ++ return delayedRequeue(err) ++ } ++ return requeue(nil) ++} ++ ++// Put the volume into Available state. ++func (self *VolumeReconciler) setAvailableVolumeStatus( ++ ctx context.Context, volume *storagev1alpha1.Volume, ++) (reconcile.Result, error) { ++ oldPhase := volume.ComputePhase() ++ ++ volume.SetAvailableStatus() ++ if _, err := self.updateVolumeStatus(ctx, volume, oldPhase); err != nil { ++ return delayedRequeue(err) ++ } ++ return requeue(nil) ++} ++ ++// Put the volume into Terminating state. ++func (self *VolumeReconciler) setTerminatingVolumeStatus( ++ ctx context.Context, volume *storagev1alpha1.Volume, job string, ++) (reconcile.Result, error) { ++ oldPhase := volume.ComputePhase() ++ ++ volume.SetTerminatingStatus(job) ++ if _, err := self.updateVolumeStatus(ctx, volume, oldPhase); err != nil { ++ return delayedRequeue(err) ++ } ++ return requeue(nil) ++} ++ ++// Add the volume-protection on the volume (if not already present). ++func (self *VolumeReconciler) addVolumeFinalizer( ++ ctx context.Context, volume *storagev1alpha1.Volume, ++) error { ++ finalizers := volume.GetFinalizers() ++ volume.SetFinalizers(SliceAppendUnique(finalizers, VOLUME_PROTECTION)) ++ return self.Client.Update(ctx, volume) ++} ++ ++// Remove the volume-protection on the volume (if not already present). ++func (self *VolumeReconciler) removeVolumeFinalizer( ++ ctx context.Context, volume *storagev1alpha1.Volume, ++) error { ++ finalizers := volume.GetFinalizers() ++ volume.SetFinalizers(SliceRemoveValue(finalizers, VOLUME_PROTECTION)) ++ return self.Client.Update(ctx, volume) ++} ++ ++// Get the PersistentVolume associated to the given volume. ++// ++// Return `nil` if no such volume exists. ++func (self *VolumeReconciler) getPersistentVolume( ++ ctx context.Context, volume *storagev1alpha1.Volume, ++) (*corev1.PersistentVolume, error) { ++ pv := &corev1.PersistentVolume{} ++ key := types.NamespacedName{Namespace: "", Name: volume.Name} ++ ++ if err := self.Client.Get(ctx, key, pv); err != nil { ++ if errors.IsNotFound(err) { ++ return nil, nil ++ } ++ return nil, err ++ } ++ if !metav1.IsControlledBy(pv, volume) { ++ return nil, fmt.Errorf( ++ "name conflict: PersistentVolume %s not owned by Volume %s", ++ pv.Name, volume.Name, ++ ) ++ } ++ ++ return pv, nil ++} ++ ++// Get the storage class identified by the given name. ++func (self *VolumeReconciler) getStorageClass( ++ ctx context.Context, name string, ++) (*storagev1.StorageClass, error) { ++ sc := &storagev1.StorageClass{} ++ key := types.NamespacedName{Namespace: "", Name: name} ++ ++ if err := self.Client.Get(ctx, key, sc); err != nil { ++ return nil, err ++ } ++ return sc, nil ++} ++ ++// Remove the volume-protection on the PV (if not already present). ++func (self *VolumeReconciler) removePvFinalizer( ++ ctx context.Context, pv *corev1.PersistentVolume, ++) error { ++ finalizers := pv.GetFinalizers() ++ pv.SetFinalizers(SliceRemoveValue(finalizers, VOLUME_PROTECTION)) ++ return self.Client.Update(ctx, pv) + } + +-// +kubebuilder:rbac:groups=storage.metalk8s.scality.com,resources=volumes,verbs=get;list;watch;create;update;patch;delete +-// +kubebuilder:rbac:groups=storage.metalk8s.scality.com,resources=volumes/status,verbs=get;update;patch +-// +kubebuilder:rbac:groups=storage.metalk8s.scality.com,resources=volumes/finalizers,verbs=update ++type stateSetter func( ++ context.Context, *storagev1alpha1.Volume, string, ++) (reconcile.Result, error) ++ ++type jobSuccessCallback func(map[string]interface{}) (reconcile.Result, error) ++ ++// Poll a Salt state job. ++func (self *VolumeReconciler) pollSaltJob( ++ ctx context.Context, ++ stepName string, ++ volume *storagev1alpha1.Volume, ++ pv *corev1.PersistentVolume, ++ setState stateSetter, ++ reason storagev1alpha1.ConditionReason, ++ onSuccess jobSuccessCallback, ++) (reconcile.Result, error) { ++ nodeName := string(volume.Spec.NodeName) ++ reqLogger := log.WithValues( ++ "Volume.Name", volume.Name, "Volume.NodeName", nodeName, ++ ) ++ ++ job, err := salt.JobFromString(volume.Status.Job) ++ if err != nil { ++ reqLogger.Error(err, "cannot parse Salt job from Volume status") ++ return self.setFailedVolumeStatus( ++ ctx, volume, pv, reason, "cannot parse Salt job from Volume status", ++ ) ++ } ++ if result, err := self.salt.PollJob(ctx, job, nodeName); err != nil { ++ reqLogger.Error( ++ err, fmt.Sprintf("failed to poll Salt job '%s' status", job.Name), ++ ) ++ // This one is not retryable. ++ if failure, ok := err.(*salt.AsyncJobFailed); ok { ++ self.recorder.Eventf( ++ volume, corev1.EventTypeWarning, "SaltCall", ++ "step '%s' failed", stepName, ++ ) ++ return self.setFailedVolumeStatus( ++ ctx, volume, pv, reason, ++ "Salt job '%s' failed with: %s", job.Name, failure.Error(), ++ ) ++ } ++ // Job salt not found or failed to run, let's retry. ++ job.ID = "" ++ return setState(ctx, volume, job.String()) ++ } else { ++ if result == nil { ++ reqLogger.Info( ++ fmt.Sprintf("Salt job '%s' still in progress", job.Name), ++ ) ++ return delayedRequeue(nil) ++ } ++ self.recorder.Eventf( ++ volume, corev1.EventTypeNormal, "SaltCall", ++ "step '%s' succeeded", stepName, ++ ) ++ return onSuccess(result) ++ } ++} ++ ++// Return the saltenv to use on the given node. ++func (self *VolumeReconciler) fetchSaltEnv( ++ ctx context.Context, nodeName string, ++) (string, error) { ++ node := &corev1.Node{} ++ key := types.NamespacedName{Namespace: "", Name: nodeName} ++ ++ if err := self.Client.Get(ctx, key, node); err != nil { ++ return "", err ++ } ++ versionKey := "metalk8s.scality.com/version" ++ if version, found := node.Labels[versionKey]; found { ++ return fmt.Sprintf("metalk8s-%s", version), nil ++ } ++ return "", fmt.Errorf("label %s not found on node %s", versionKey, nodeName) ++} ++ ++// Controller RBAC settings ++ ++// - Volume custom resources ++//+kubebuilder:rbac:groups=storage.metalk8s.scality.com,resources=volumes,verbs=get;list;watch;create;update;patch;delete ++//+kubebuilder:rbac:groups=storage.metalk8s.scality.com,resources=volumes/status,verbs=get;update;patch ++//+kubebuilder:rbac:groups=storage.metalk8s.scality.com,resources=volumes/finalizers,verbs=update ++ ++// - Transition events ++//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch ++ ++// - Owned PersistentVolumes ++//+kubebuilder:rbac:groups="",resources=persistentvolumes,verbs=create;delete;get;list;patch;update;watch ++ ++// - Read referenced StorageClasses ++//+kubebuilder:rbac:groups=storage.k8s.io,resources=storageclasses,verbs=get;list;watch ++ ++// - Read Node's MetalK8s version ++//+kubebuilder:rbac:groups="",resources=nodes,verbs=get;list;watch + + // Reconcile is part of the main kubernetes reconciliation loop which aims to + // move the current state of the cluster closer to the desired state. +-// TODO(user): Modify the Reconcile function to compare the state specified by +-// the Volume object against the actual cluster state, and then +-// perform operations to make the cluster state reflect the state specified by +-// the user. ++func (self *VolumeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { ++ reqLogger := log.WithValues("Request.Name", req.Name) ++ reqLogger.Info("reconciling volume: START") ++ defer reqLogger.Info("reconciling volume: STOP") ++ ++ // Fetch the requested Volume object. ++ // ++ // The reconciliation request can be triggered by either a Volume or a ++ // PersistentVolume owned by a Volume (we're watching both), but because the ++ // lifetime of a Volume always span over the whole lifetime of the backing ++ // PersistentVolume (and they have the same name) it is safe to always ++ // lookup a Volume here. ++ volume := &storagev1alpha1.Volume{} ++ err := self.Client.Get(ctx, req.NamespacedName, volume) ++ if err != nil { ++ if errors.IsNotFound(err) { ++ // Volume not found: ++ // => all the finalizers have been removed & Volume has been deleted ++ // => there is nothing left to do ++ reqLogger.Info("volume already deleted: nothing to do") ++ return endReconciliation() ++ } ++ reqLogger.Error(err, "cannot read Volume: requeue") ++ return delayedRequeue(err) ++ } ++ if err := volume.IsValid(); err != nil { ++ return self.setFailedVolumeStatus( ++ ctx, volume, nil, storagev1alpha1.ReasonInternalError, ++ "invalid volume: %s", err.Error(), ++ ) ++ } ++ saltenv, err := self.fetchSaltEnv(ctx, string(volume.Spec.NodeName)) ++ if err != nil { ++ reqLogger.Error(err, "cannot compute saltenv") ++ return delayedRequeue(err) ++ } ++ // Check if the volume is marked for deletion (i.e., deletion tstamp is set). ++ if !volume.GetDeletionTimestamp().IsZero() { ++ // Pending volume: can do nothing but wait for stabilization. ++ if volume.ComputePhase() == storagev1alpha1.VolumePending { ++ reqLogger.Info("pending volume cannot be finalized: requeue") ++ // Do not return here! We need to re-enter deployVolume to keep ++ // polling the Salt job and make progress. ++ } else { ++ return self.finalizeVolume(ctx, volume, saltenv) ++ } ++ } ++ // Skip volume stuck waiting for deletion or a manual fix. ++ if condition := volume.IsInUnrecoverableFailedState(); condition != nil { ++ reqLogger.Info( ++ "volume stuck in error state: do nothing", ++ "Error.Code", condition.Reason, ++ "Error.Message", condition.Message, ++ ) ++ return endReconciliation() ++ } ++ // Check if a PV already exists for this volume. ++ pv, err := self.getPersistentVolume(ctx, volume) ++ if err != nil { ++ reqLogger.Error( ++ err, "error while looking for backing PersistentVolume: requeue", ++ "PersistentVolume.Name", volume.Name, ++ ) ++ return delayedRequeue(err) ++ } ++ // PV doesn't exist: deploy the volume to create it. ++ if pv == nil { ++ return self.deployVolume(ctx, volume, saltenv) ++ } ++ // Else, check its health. ++ if pv.Status.Phase == corev1.VolumeFailed { ++ _, err := self.setFailedVolumeStatus( ++ ctx, volume, nil, storagev1alpha1.ReasonUnavailableError, ++ "backing PersistentVolume is in a failed state (%s): %s", ++ pv.Status.Reason, pv.Status.Message, ++ ) ++ return delayedRequeue(err) ++ } ++ if _, err = self.setAvailableVolumeStatus(ctx, volume); err != nil { ++ return delayedRequeue(err) ++ } ++ reqLogger.Info("backing PersistentVolume is healthy") ++ return self.refreshDeviceName(ctx, volume, pv) ++} ++ ++// Deploy a volume (i.e prepare the storage and create a PV). ++func (self *VolumeReconciler) deployVolume( ++ ctx context.Context, ++ volume *storagev1alpha1.Volume, ++ saltenv string, ++) (reconcile.Result, error) { ++ nodeName := string(volume.Spec.NodeName) ++ reqLogger := log.WithValues( ++ "Volume.Name", volume.Name, "Volume.NodeName", nodeName, ++ ) ++ job, err := salt.JobFromString(volume.Status.Job) ++ if err != nil { ++ reqLogger.Error(err, "cannot parse Salt job from Volume status") ++ return requeue(err) ++ } ++ ++ switch job.Name { ++ // Since it's the first step, the name can be unset the very first time. ++ case "", "PrepareVolume": ++ return self.prepareStorage(ctx, volume, saltenv, job) ++ case "GetDeviceInfo": ++ return self.getStorageSize(ctx, volume, job) ++ default: ++ // Shouldn't happen, except if someome somehow tampered our status field… ++ return self.setFailedVolumeStatus( ++ ctx, volume, nil, storagev1alpha1.ReasonCreationError, ++ "Tampered Salt job handle: invalid name (%s)", job.Name, ++ ) ++ } ++} ++ ++// Finalize a volume marked for deletion. ++func (self *VolumeReconciler) finalizeVolume( ++ ctx context.Context, ++ volume *storagev1alpha1.Volume, ++ saltenv string, ++) (reconcile.Result, error) { ++ reqLogger := log.WithValues("Volume.Name", volume.Name) ++ // Check if a PV is associated to the volume. ++ pv, err := self.getPersistentVolume(ctx, volume) ++ if err != nil { ++ reqLogger.Error( ++ err, "error while looking for backing PersistentVolume: requeue", ++ "PersistentVolume.Name", volume.Name, ++ ) ++ return delayedRequeue(err) ++ } ++ ++ // If we have a backing PV we delete it (the finalizer will keep it alive). ++ if pv != nil && pv.GetDeletionTimestamp().IsZero() { ++ if err := self.Client.Delete(ctx, pv); err != nil { ++ reqLogger.Error( ++ err, "cannot delete PersistentVolume: requeue", ++ "PersistentVolume.Name", volume.Name, ++ ) ++ return delayedRequeue(err) ++ } ++ reqLogger.Info( ++ "deleting backing PersistentVolume", ++ "PersistentVolume.Name", pv.Name, ++ ) ++ self.recorder.Event( ++ volume, corev1.EventTypeNormal, "PvDeletion", ++ "backing PersistentVolume deleted", ++ ) ++ return requeue(nil) ++ } ++ ++ // If we don't have a PV or it's only used by us we can reclaim the storage. ++ if pv == nil || isPersistentVolumeUnused(pv) { ++ return self.reclaimStorage(ctx, volume, pv, saltenv) ++ } ++ ++ // PersistentVolume still in use: wait before reclaiming the storage. ++ return delayedRequeue(nil) ++} ++ ++func (self *VolumeReconciler) refreshDeviceName( ++ ctx context.Context, ++ volume *storagev1alpha1.Volume, ++ pv *corev1.PersistentVolume, ++) (reconcile.Result, error) { ++ nodeName := string(volume.Spec.NodeName) ++ reqLogger := log.WithValues( ++ "Volume.Name", volume.Name, "Volume.NodeName", nodeName, ++ ) ++ ++ if pv.Spec.PersistentVolumeSource.Local == nil { ++ reqLogger.Info("skipping volume: not a local storage") ++ return endReconciliation() ++ } ++ path := pv.Spec.PersistentVolumeSource.Local.Path ++ ++ name, err := self.salt.GetDeviceName(ctx, nodeName, volume.Name, path) ++ if err != nil { ++ self.recorder.Event( ++ volume, corev1.EventTypeNormal, "SaltCall", ++ "device path resolution failed", ++ ) ++ reqLogger.Error(err, "cannot get device name from Salt response") ++ return delayedRequeue(err) ++ } ++ if volume.Status.DeviceName != name { ++ volume.Status.DeviceName = name ++ reqLogger.Info("update device name", "Volume.DeviceName", name) ++ return self.setAvailableVolumeStatus(ctx, volume) ++ } ++ ++ return endReconciliation() ++} ++ ++func (self *VolumeReconciler) prepareStorage( ++ ctx context.Context, ++ volume *storagev1alpha1.Volume, ++ saltenv string, ++ job *salt.JobHandle, ++) (reconcile.Result, error) { ++ nodeName := string(volume.Spec.NodeName) ++ reqLogger := log.WithValues( ++ "Volume.Name", volume.Name, "Volume.NodeName", nodeName, ++ ) ++ ++ switch job.ID { ++ case "": // No job in progress: call Salt to prepare the volume. ++ // Set volume-protection finalizer on the volume. ++ if err := self.addVolumeFinalizer(ctx, volume); err != nil { ++ reqLogger.Error(err, "cannot set volume-protection: requeue") ++ return delayedRequeue(err) ++ } ++ job, err := self.salt.PrepareVolume(ctx, nodeName, volume.Name, saltenv) ++ if err != nil { ++ reqLogger.Error(err, "failed to run PrepareVolume") ++ return delayedRequeue(err) ++ } else { ++ reqLogger.Info("start to prepare the volume") ++ self.recorder.Event( ++ volume, corev1.EventTypeNormal, "SaltCall", ++ "volume provisioning step 1/2 started", ++ ) ++ return self.setPendingVolumeStatus(ctx, volume, job.String()) ++ } ++ case JOB_DONE_MARKER: // Storage is ready, let's get its information. ++ job.Name = "GetDeviceInfo" ++ job.ID = "" ++ return self.getStorageSize(ctx, volume, job) ++ default: // PrepareVolume in progress: poll its state. ++ return self.pollSaltJob( ++ ctx, "volume provisioning (1/2)", volume, nil, ++ self.setPendingVolumeStatus, ++ storagev1alpha1.ReasonCreationError, ++ func(_ map[string]interface{}) (reconcile.Result, error) { ++ job.ID = JOB_DONE_MARKER ++ return self.setPendingVolumeStatus(ctx, volume, job.String()) ++ }, ++ ) ++ } ++} ++ ++func (self *VolumeReconciler) getStorageSize( ++ ctx context.Context, ++ volume *storagev1alpha1.Volume, ++ job *salt.JobHandle, ++) (reconcile.Result, error) { ++ nodeName := string(volume.Spec.NodeName) ++ reqLogger := log.WithValues( ++ "Volume.Name", volume.Name, "Volume.NodeName", nodeName, ++ ) ++ ++ switch job.ID { ++ case "": // No job in progress: call Salt to get the volume information. ++ job, err := self.salt.GetDeviceInfo(ctx, nodeName, volume.Name) ++ if err != nil { ++ reqLogger.Error(err, "failed to run GetDeviceInfo") ++ return delayedRequeue(err) ++ } else { ++ reqLogger.Info("try to retrieve the volume information") ++ self.recorder.Event( ++ volume, corev1.EventTypeNormal, "SaltCall", ++ "volume provisioning step 2/2 started", ++ ) ++ return self.setPendingVolumeStatus(ctx, volume, job.String()) ++ } ++ case JOB_DONE_MARKER: // We have everything we need: let's create the PV! ++ return self.createPersistentVolume(ctx, volume) ++ default: // GetDeviceInfo in progress: poll its state. ++ return self.pollSaltJob( ++ ctx, "volume provisioning (2/2)", volume, nil, ++ self.setPendingVolumeStatus, ++ storagev1alpha1.ReasonCreationError, ++ func(result map[string]interface{}) (reconcile.Result, error) { ++ info, err := parseDeviceInfo(result) ++ if err != nil { ++ reqLogger.Error(err, "cannot get device info from Salt response") ++ self.recorder.Event( ++ volume, corev1.EventTypeNormal, "SaltCall", ++ "volume provisioning step 2/2 failed", ++ ) ++ return self.setFailedVolumeStatus( ++ ctx, volume, nil, storagev1alpha1.ReasonCreationError, ++ "Salt job '%s' failed with: %s", job.Name, err.Error(), ++ ) ++ } ++ self.devices[volume.Name] = *info ++ job.ID = JOB_DONE_MARKER ++ return self.setPendingVolumeStatus(ctx, volume, job.String()) ++ }, ++ ) ++ } ++} ++ ++// Create a PersistentVolume in the Kubernetes API server. ++func (self *VolumeReconciler) createPersistentVolume( ++ ctx context.Context, volume *storagev1alpha1.Volume, ++) (reconcile.Result, error) { ++ reqLogger := log.WithValues( ++ "Volume.Name", volume.Name, ++ "Volume.NodeName", string(volume.Spec.NodeName), ++ ) ++ ++ // Fetch referenced storage class. ++ scName := volume.Spec.StorageClassName ++ sc, err := self.getStorageClass(ctx, scName) ++ if err != nil { ++ errmsg := fmt.Sprintf("cannot get StorageClass '%s'", scName) ++ reqLogger.Error(err, errmsg, "StorageClass.Name", scName) ++ return delayedRequeue(err) ++ } ++ deviceInfo, found := self.devices[volume.Name] ++ if !found { ++ reqLogger.Error(err, "no device info") ++ // Reschedule a call to `metalk8s_volumes.device_info`. ++ job := salt.JobHandle{Name: "GetDeviceInfo", ID: ""} ++ return self.setPendingVolumeStatus(ctx, volume, job.String()) ++ } ++ // Create the PersistentVolume object. ++ pv, err := newPersistentVolume(volume, sc, deviceInfo) ++ if err != nil { ++ reqLogger.Error( ++ err, "cannot create the PersistentVolume object: requeue", ++ "PersistentVolume.Name", volume.Name, ++ ) ++ return delayedRequeue(err) ++ } ++ // Set Volume instance as the owner and controller. ++ err = controllerutil.SetControllerReference(volume, pv, self.Scheme) ++ if err != nil { ++ reqLogger.Error( ++ err, "cannot become owner of the PersistentVolume: requeue", ++ "PersistentVolume.Name", volume.Name, ++ ) ++ return delayedRequeue(err) ++ } ++ // Create the PV! ++ if err := self.Client.Create(ctx, pv); err != nil { ++ reqLogger.Error( ++ err, "cannot create PersistentVolume: requeue", ++ "PersistentVolume.Name", volume.Name, ++ ) ++ return delayedRequeue(err) ++ } ++ reqLogger.Info( ++ "creating a new PersistentVolume", "PersistentVolume.Name", pv.Name, ++ ) ++ ++ self.recorder.Event( ++ volume, corev1.EventTypeNormal, "PvCreation", ++ "backing PersistentVolume created", ++ ) ++ return self.setAvailableVolumeStatus(ctx, volume) ++} ++ ++// Build a PersistentVolume from a Volume object. ++// ++// Arguments ++// ++// volume: a Volume object ++// storageClass: a StorageClass object ++// deviceInfo: the device information + // +-// For more details, check Reconcile and its Result here: +-// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/reconcile +-func (r *VolumeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { +- _ = logf.FromContext(ctx) ++// Returns ++// ++// The PersistentVolume representing the given Volume. ++func newPersistentVolume( ++ volume *storagev1alpha1.Volume, ++ storageClass *storagev1.StorageClass, ++ deviceInfo deviceInfo, ++) (*corev1.PersistentVolume, error) { ++ volumeSize := *resource.NewQuantity(deviceInfo.size, resource.BinarySI) ++ // We must have `fsType` as parameter, otherwise we can't create our PV. ++ scName := volume.Spec.StorageClassName ++ fsType, found := storageClass.Parameters["fsType"] ++ if !found { ++ return nil, fmt.Errorf( ++ "missing field 'parameters.fsType' in StorageClass '%s'", scName, ++ ) ++ } ++ ++ pv := corev1.PersistentVolume{ ++ ObjectMeta: volume.Spec.Template.Metadata, ++ Spec: volume.Spec.Template.Spec, ++ } ++ pv.ObjectMeta.Name = volume.Name ++ pv.ObjectMeta.Finalizers = append( ++ pv.ObjectMeta.Finalizers, VOLUME_PROTECTION, ++ ) ++ pv.Spec.AccessModes = []corev1.PersistentVolumeAccessMode{ ++ corev1.ReadWriteOnce, ++ } ++ pv.Spec.Capacity = map[corev1.ResourceName]resource.Quantity{ ++ corev1.ResourceStorage: volumeSize, ++ } ++ pv.Spec.MountOptions = storageClass.MountOptions ++ pv.Spec.VolumeMode = &volume.Spec.Mode ++ pv.Spec.PersistentVolumeSource = corev1.PersistentVolumeSource{ ++ Local: &corev1.LocalVolumeSource{ ++ Path: deviceInfo.path, ++ FSType: &fsType, ++ }, ++ } ++ pv.Spec.PersistentVolumeReclaimPolicy = "Retain" ++ pv.Spec.StorageClassName = volume.Spec.StorageClassName ++ pv.Spec.NodeAffinity = nodeAffinity(volume.Spec.NodeName) ++ ++ return &pv, nil ++} + +- // TODO(user): your logic here ++func nodeAffinity(node types.NodeName) *corev1.VolumeNodeAffinity { ++ selector := corev1.NodeSelector{ ++ NodeSelectorTerms: []corev1.NodeSelectorTerm{ ++ { ++ MatchExpressions: []corev1.NodeSelectorRequirement{ ++ { ++ Key: "kubernetes.io/hostname", ++ Operator: corev1.NodeSelectorOpIn, ++ Values: []string{string(node)}, ++ }, ++ }, ++ }, ++ }, ++ } ++ affinity := corev1.VolumeNodeAffinity{ ++ Required: &selector, ++ } ++ return &affinity ++} ++ ++// Check if a PersistentVolume is only used by us. ++func isPersistentVolumeUnused(pv *corev1.PersistentVolume) bool { ++ reqLogger := log.WithValues("PersistentVolume.Name", pv.Name) + +- return ctrl.Result{}, nil ++ switch pv.Status.Phase { ++ case corev1.VolumeBound: ++ reqLogger.Info( ++ "backing PersistentVolume is bound: cannot delete volume", ++ ) ++ return false ++ case corev1.VolumePending: ++ reqLogger.Info( ++ "backing PersistentVolume is pending: waiting for stabilization", ++ ) ++ return false ++ case corev1.VolumeAvailable, corev1.VolumeReleased, corev1.VolumeFailed: ++ reqLogger.Info("the backing PersistentVolume is in a removable state") ++ finalizers := pv.GetFinalizers() ++ if len(finalizers) == 1 && finalizers[0] == VOLUME_PROTECTION { ++ reqLogger.Info("the backing PersistentVolume is unused") ++ return true ++ } ++ return false ++ default: ++ phase := pv.Status.Phase ++ errmsg := fmt.Sprintf( ++ "unexpected PersistentVolume status (%+v): do nothing", phase, ++ ) ++ reqLogger.Info(errmsg, "PersistentVolume.Status", phase) ++ return false ++ } ++} ++ ++// Destroy the give PersistentVolume. ++func (self *VolumeReconciler) reclaimStorage( ++ ctx context.Context, ++ volume *storagev1alpha1.Volume, ++ pv *corev1.PersistentVolume, ++ saltenv string, ++) (reconcile.Result, error) { ++ nodeName := string(volume.Spec.NodeName) ++ reqLogger := log.WithValues( ++ "Volume.Name", volume.Name, "Volume.NodeName", nodeName, ++ ) ++ job, err := salt.JobFromString(volume.Status.Job) ++ if err != nil { ++ reqLogger.Error(err, "cannot parse Salt job from Volume status") ++ return requeue(err) ++ } ++ jobId := job.ID ++ ++ // Ignore existing Job ID in Failed case (no job are running), JID only here ++ // for debug (which is now useless as we're going to delete the Volume). ++ if volume.ComputePhase() == storagev1alpha1.VolumeFailed { ++ jobId = "" ++ } ++ ++ switch jobId { ++ case "": // No job in progress: call Salt to unprepare the volume. ++ job, err := self.salt.UnprepareVolume( ++ ctx, nodeName, volume.Name, saltenv, ++ ) ++ if err != nil { ++ reqLogger.Error(err, "failed to run UnprepareVolume") ++ return delayedRequeue(err) ++ } else { ++ reqLogger.Info("start to unprepare the volume") ++ self.recorder.Event( ++ volume, corev1.EventTypeNormal, "SaltCall", ++ "volume finalization started", ++ ) ++ return self.setTerminatingVolumeStatus(ctx, volume, job.String()) ++ } ++ case JOB_DONE_MARKER: // Salt job is done, now let's remove the finalizers. ++ if pv != nil { ++ if err := self.removePvFinalizer(ctx, pv); err != nil { ++ reqLogger.Error(err, "cannot remove PersistentVolume finalizer") ++ return delayedRequeue(err) ++ } ++ reqLogger.Info("PersistentVolume finalizer removed") ++ self.recorder.Event( ++ volume, corev1.EventTypeNormal, "VolumeFinalization", ++ "storage reclaimed", ++ ) ++ } ++ if err := self.removeVolumeFinalizer(ctx, volume); err != nil { ++ reqLogger.Error(err, "cannot remove Volume finalizer") ++ return delayedRequeue(err) ++ } ++ reqLogger.Info("volume finalizer removed") ++ return endReconciliation() ++ default: // UnprepareVolume in progress: poll its state. ++ return self.pollSaltJob( ++ ctx, "volume finalization", volume, pv, ++ self.setTerminatingVolumeStatus, ++ storagev1alpha1.ReasonDestructionError, ++ func(_ map[string]interface{}) (reconcile.Result, error) { ++ job.ID = JOB_DONE_MARKER ++ return self.setTerminatingVolumeStatus(ctx, volume, job.String()) ++ }, ++ ) ++ } ++} ++ ++// Trigger a reschedule after a short delay. ++func delayedRequeue(err error) (reconcile.Result, error) { ++ delay := 10 * time.Second ++ return reconcile.Result{Requeue: err == nil, RequeueAfter: delay}, err ++} ++ ++// Trigger a reschedule as soon as possible. ++func requeue(err error) (reconcile.Result, error) { ++ return reconcile.Result{Requeue: err == nil}, err ++} ++ ++// Don't trigger a reschedule, we're done. ++func endReconciliation() (reconcile.Result, error) { ++ return reconcile.Result{}, nil ++} ++ ++// Return the credential to use to authenticate with Salt API. ++func getAuthCredential(config *rest.Config) *salt.Credential { ++ if config.BearerToken == "" { ++ panic("must use a BearerToken for SaltAPI authentication") ++ } ++ log.Info("using ServiceAccount bearer token") ++ return salt.NewCredential( ++ // FIXME: this should depend on the actual SA used ++ "system:serviceaccount:kube-system:storage-operator-controller-manager", ++ config.BearerToken, ++ salt.Bearer, ++ ) ++} ++ ++// Extract the device info from a Salt result. ++func parseDeviceInfo(result map[string]interface{}) (*deviceInfo, error) { ++ size_str, ok := result["size"].(string) ++ if !ok { ++ return nil, fmt.Errorf( ++ "cannot find a string value for key 'size' in %v", result, ++ ) ++ } ++ path, ok := result["path"].(string) ++ if !ok { ++ return nil, fmt.Errorf( ++ "cannot find a string value for key 'path' in %v", result, ++ ) ++ } ++ ++ if size, err := strconv.ParseInt(size_str, 10, 64); err != nil { ++ return nil, errorsng.Wrapf( ++ err, "cannot parse device size (%s)", size_str, ++ ) ++ } else { ++ return &deviceInfo{size, path}, nil ++ } + } + + // SetupWithManager sets up the controller with the Manager. + func (r *VolumeReconciler) SetupWithManager(mgr ctrl.Manager) error { ++ config := mgr.GetConfig() ++ caCertData := config.CAData ++ if len(caCertData) == 0 { ++ log.Info("CAData is empty, fallbacking on CAFile") ++ cert, err := ioutil.ReadFile(config.CAFile) ++ if err != nil { ++ return errorsng.Wrapf( ++ err, "cannot read CA cert file (%s)", config.CAFile, ++ ) ++ } ++ caCertData = cert ++ } ++ saltClient, err := salt.NewClient(getAuthCredential(config), caCertData) ++ if err != nil { ++ return err ++ } ++ ++ r.recorder = mgr.GetEventRecorderFor("volume-controller") ++ r.salt = saltClient ++ r.devices = make(map[string]deviceInfo) ++ + return ctrl.NewControllerManagedBy(mgr). + For(&storagev1alpha1.Volume{}). +- Named("volume"). ++ Owns(&corev1.PersistentVolume{}). + Complete(r) + } diff --git a/scripts/upgrade-operator-sdk/storage-operator/patches/volume_controller_test.patch b/scripts/upgrade-operator-sdk/storage-operator/patches/volume_controller_test.patch new file mode 100644 index 0000000000..596b051424 --- /dev/null +++ b/scripts/upgrade-operator-sdk/storage-operator/patches/volume_controller_test.patch @@ -0,0 +1,120 @@ +--- a/internal/controller/volume_controller_test.go ++++ b/internal/controller/volume_controller_test.go +@@ -1,84 +1,43 @@ +-/* +-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" ++ "testing" + +- . "github.com/onsi/ginkgo/v2" +- . "github.com/onsi/gomega" +- "k8s.io/apimachinery/pkg/api/errors" +- "k8s.io/apimachinery/pkg/types" +- "sigs.k8s.io/controller-runtime/pkg/reconcile" ++ "github.com/stretchr/testify/assert" ++ "k8s.io/client-go/rest" + +- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +- +- storagev1alpha1 "github.com/scality/metalk8s/storage-operator/api/v1alpha1" ++ "github.com/scality/metalk8s/storage-operator/salt" + ) + +-var _ = Describe("Volume Controller", func() { +- Context("When reconciling a resource", func() { +- const resourceName = "test-resource" +- +- ctx := context.Background() +- +- typeNamespacedName := types.NamespacedName{ +- Name: resourceName, +- Namespace: "default", // TODO(user):Modify as needed +- } +- volume := &storagev1alpha1.Volume{} +- +- BeforeEach(func() { +- By("creating the custom resource for the Kind Volume") +- err := k8sClient.Get(ctx, typeNamespacedName, volume) +- if err != nil && errors.IsNotFound(err) { +- resource := &storagev1alpha1.Volume{ +- ObjectMeta: metav1.ObjectMeta{ +- Name: resourceName, +- Namespace: "default", +- }, +- // TODO(user): Specify other spec details if needed. +- } +- Expect(k8sClient.Create(ctx, resource)).To(Succeed()) +- } +- }) ++func TestGetAuthCredential(t *testing.T) { ++ tests := map[string]struct { ++ token string ++ username string ++ password string ++ expected *salt.Credential ++ }{ ++ "ServiceAccount": { ++ token: "foo", ++ expected: salt.NewCredential( ++ "system:serviceaccount:kube-system:storage-operator-controller-manager", ++ "foo", ++ salt.Bearer, ++ ), ++ }, ++ } ++ for name, tc := range tests { ++ t.Run(name, func(t *testing.T) { ++ config := rest.Config{BearerToken: tc.token} ++ creds := getAuthCredential(&config) + +- AfterEach(func() { +- // TODO(user): Cleanup logic after each test, like removing the resource instance. +- resource := &storagev1alpha1.Volume{} +- err := k8sClient.Get(ctx, typeNamespacedName, resource) +- Expect(err).NotTo(HaveOccurred()) +- +- By("Cleanup the specific resource instance Volume") +- Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) +- }) +- It("should successfully reconcile the resource", func() { +- By("Reconciling the created resource") +- controllerReconciler := &VolumeReconciler{ +- Client: k8sClient, +- Scheme: k8sClient.Scheme(), +- } +- +- _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ +- NamespacedName: typeNamespacedName, +- }) +- Expect(err).NotTo(HaveOccurred()) +- // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. +- // Example: If you expect a certain status condition after reconciliation, verify it here. ++ assert.Equal(t, tc.expected, creds) + }) ++ } ++} ++ ++func TestGetAuthCredentialNoToken(t *testing.T) { ++ config := rest.Config{Username: "admin", Password: "admin"} ++ assert.Panics(t, func() { ++ getAuthCredential(&config) + }) +-}) ++} diff --git a/scripts/upgrade-operator-sdk/storage-operator/patches/volume_types.patch b/scripts/upgrade-operator-sdk/storage-operator/patches/volume_types.patch new file mode 100644 index 0000000000..9efe3fa1b3 --- /dev/null +++ b/scripts/upgrade-operator-sdk/storage-operator/patches/volume_types.patch @@ -0,0 +1,344 @@ +--- a/api/v1alpha1/volume_types.go ++++ b/api/v1alpha1/volume_types.go +@@ -1,5 +1,5 @@ + /* +-Copyright 2026. ++Copyright 2021. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. +@@ -17,32 +17,152 @@ + package v1alpha1 + + import ( ++ "errors" ++ "fmt" ++ ++ corev1 "k8s.io/api/core/v1" ++ "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ++ "k8s.io/apimachinery/pkg/types" + ) + + // 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. + +-// VolumeSpec defines the desired state of Volume. ++type SparseLoopDeviceVolumeSource struct { ++ // Size of the generated sparse file backing the PersistentVolume. ++ Size resource.Quantity `json:"size"` ++} ++ ++type RawBlockDeviceVolumeSource struct { ++ // Path of the block device on the node to back the PersistentVolume. ++ DevicePath string `json:"devicePath"` ++} ++ ++type LVMLVSource struct { ++ // Name of the LVM VolumeGroup on the node to create the LVM LogicalVolume to back ++ // the PersistentVolume ++ VGName string `json:"vgName"` ++ // Size of the created LVM LogicalVolume backing the PersistentVolume ++ Size resource.Quantity `json:"size"` ++} ++ ++type VolumeSource struct { ++ SparseLoopDevice *SparseLoopDeviceVolumeSource `json:"sparseLoopDevice,omitempty"` ++ RawBlockDevice *RawBlockDeviceVolumeSource `json:"rawBlockDevice,omitempty"` ++ LVMLogicalVolume *LVMLVSource `json:"lvmLogicalVolume,omitempty"` ++} ++ ++// VolumeSpec defines the desired state of Volume + type VolumeSpec struct { + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file + +- // Foo is an example field of Volume. Edit volume_types.go to remove/update +- Foo string `json:"foo,omitempty"` ++ // Name of the node on which the volume is available. ++ NodeName types.NodeName `json:"nodeName"` ++ ++ // Name of the StorageClass that gets assigned to the volume. Also, any ++ // mount options are copied from the StorageClass to the ++ // PersistentVolume if present. ++ StorageClassName string `json:"storageClassName"` ++ ++ // How the volume is intended to be consumed, either Block or Filesystem ++ // (default is Filesystem). ++ // +optional ++ // +kubebuilder:validation:Enum=Filesystem;Block ++ Mode corev1.PersistentVolumeMode `json:"mode,omitempty"` ++ ++ // Template for the underlying PersistentVolume. ++ // +optional ++ Template PersistentVolumeTemplateSpec `json:"template,omitempty"` ++ ++ VolumeSource `json:",inline"` ++} ++ ++// Describes the PersistentVolume that will be created to back the Volume. ++type PersistentVolumeTemplateSpec struct { ++ // Standard object's metadata. ++ // +optional ++ // +kubebuilder:pruning:PreserveUnknownFields ++ Metadata metav1.ObjectMeta `json:"metadata,omitempty"` ++ // Specification of the Persistent Volume. ++ // +optional ++ Spec corev1.PersistentVolumeSpec `json:"spec,omitempty"` ++} ++ ++type VolumePhase string ++ ++// TODO: kept for temporary compatibility, to be removed. ++// "Enum" representing the phase of a volume. ++const ( ++ VolumeFailed VolumePhase = "Failed" ++ VolumePending VolumePhase = "Pending" ++ VolumeAvailable VolumePhase = "Available" ++ VolumeTerminating VolumePhase = "Terminating" ++) ++ ++type ConditionReason string ++ ++// TODO: replace those by more fine-grained ones. ++// "Enum" representing the error codes of the Failed state. ++const ( ++ ReasonPending ConditionReason = "Pending" ++ ReasonTerminating ConditionReason = "Terminating" ++ ++ ReasonInternalError ConditionReason = "InternalError" ++ ReasonCreationError ConditionReason = "CreationError" ++ ReasonDestructionError ConditionReason = "DestructionError" ++ ReasonUnavailableError ConditionReason = "UnavailableError" ++) ++ ++type VolumeConditionType string ++ ++const ( ++ // VolumeReady means Volume is ready to be used. ++ VolumeReady VolumeConditionType = "Ready" ++) ++ ++type VolumeCondition struct { ++ // Type of volume condition. ++ // +kubebuilder:validation:Enum=Ready ++ Type VolumeConditionType `json:"type"` ++ // Status of the condition, one of True, False, Unknown. ++ // +kubebuilder:validation:Enum=True;False;Unknown ++ Status corev1.ConditionStatus `json:"status"` ++ // Last time the condition was updated (optional). ++ LastUpdateTime metav1.Time `json:"lastUpdateTime,omitempty"` ++ // Last time the condition transited from one status to another (optional). ++ LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"` ++ // Unique, one-word, CamelCase reason for the condition's last transition. ++ // +kubebuilder:validation:Enum=Pending;Terminating;InternalError;CreationError;DestructionError;UnavailableError ++ Reason ConditionReason `json:"reason,omitempty"` ++ // Human readable message indicating details about last transition. ++ Message string `json:"message,omitempty"` + } + +-// VolumeStatus defines the observed state of Volume. ++// VolumeStatus defines the observed state of Volume + type VolumeStatus struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + // Important: Run "make" to regenerate code after modifying this file ++ ++ // List of conditions through which the Volume has or has not passed. ++ // +listType=map ++ // +listMapKey=type ++ Conditions []VolumeCondition `json:"conditions,omitempty"` ++ ++ // Job in progress ++ Job string `json:"job,omitempty"` ++ // Name of the underlying block device. ++ DeviceName string `json:"deviceName,omitempty"` + } + +-// +kubebuilder:object:root=true +-// +kubebuilder:subresource:status +-// +kubebuilder:resource:scope=Cluster ++//+kubebuilder:object:root=true ++//+kubebuilder:subresource:status ++//+kubebuilder:resource:scope=Cluster ++//+kubebuilder:printcolumn:name="Node",type="string",JSONPath=".spec.nodeName",description="The node on which the volume is available" ++//+kubebuilder:printcolumn:name="StorageClass",type="string",JSONPath=".spec.storageClassName",description="The storage class of the volume" + +-// Volume is the Schema for the volumes API. ++// Volume is the Schema for the volumes API + type Volume struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` +@@ -51,9 +171,171 @@ + Status VolumeStatus `json:"status,omitempty"` + } + +-// +kubebuilder:object:root=true ++// Set a condition for the volume. ++// ++// If a condition of this type already exists it is updated, otherwise a new ++// condition is added. ++// ++// Arguments ++// ++// kind: type of condition ++// status: status of the condition ++// reason: one-word, CamelCase reason for the transition (optional) ++// message: details about the transition (optional) ++func (self *Volume) SetCondition( ++ kind VolumeConditionType, ++ status corev1.ConditionStatus, ++ reason ConditionReason, ++ message string, ++) { ++ now := metav1.Now() ++ condition := VolumeCondition{ ++ Type: kind, ++ Status: status, ++ LastUpdateTime: now, ++ LastTransitionTime: now, ++ Reason: reason, ++ Message: message, ++ } ++ ++ for idx, cond := range self.Status.Conditions { ++ if cond.Type == kind { ++ // Don't update timestamps if status hasn't changed. ++ if cond.Status == condition.Status { ++ condition.LastTransitionTime = cond.LastTransitionTime ++ condition.LastUpdateTime = cond.LastUpdateTime ++ } ++ self.Status.Conditions[idx] = condition ++ return ++ } ++ } ++ self.Status.Conditions = append(self.Status.Conditions, condition) ++} ++ ++// Get the condition identified by `kind` for the volume. ++// ++// Return `nil` if no such condition exists on the volume. ++func (self *Volume) GetCondition(kind VolumeConditionType) *VolumeCondition { ++ for _, cond := range self.Status.Conditions { ++ if cond.Type == kind { ++ return &cond ++ } ++ } ++ return nil ++} ++ ++// Return the volume phase, computed from the Ready condition. ++func (self *Volume) ComputePhase() VolumePhase { ++ if ready := self.GetCondition(VolumeReady); ready != nil { ++ switch ready.Status { ++ case corev1.ConditionTrue: ++ return VolumeAvailable ++ case corev1.ConditionFalse: ++ return VolumeFailed ++ case corev1.ConditionUnknown: ++ if ready.Reason == ReasonPending { ++ return VolumePending ++ } ++ return VolumeTerminating ++ } ++ } ++ return "" ++} ++ ++// Update the volume status to Failed phase. ++// ++// Arguments ++// ++// reason: the error code that triggered failure. ++// format: the string format for the error message ++// args: values used in the error message ++func (self *Volume) SetFailedStatus( ++ reason ConditionReason, format string, args ...interface{}, ++) { ++ message := fmt.Sprintf(format, args...) ++ ++ // Don't overwrite `Job`: having JID around can help for debug. ++ self.SetCondition(VolumeReady, corev1.ConditionFalse, reason, message) ++} ++ ++// Update the volume status to Pending phase. ++// ++// Arguments ++// ++// job: job in progress ++func (self *Volume) SetPendingStatus(job string) { ++ self.SetCondition(VolumeReady, corev1.ConditionUnknown, ReasonPending, "") ++ self.Status.Job = job ++} ++ ++// Update the volume status to Available phase. ++func (self *Volume) SetAvailableStatus() { ++ self.SetCondition(VolumeReady, corev1.ConditionTrue, "", "") ++ self.Status.Job = "" ++} ++ ++// Update the volume status to Terminating phase. ++// ++// Arguments ++// ++// job: job in progress ++func (self *Volume) SetTerminatingStatus(job string) { ++ self.SetCondition(VolumeReady, corev1.ConditionUnknown, ReasonTerminating, "") ++ self.Status.Job = job ++} ++ ++// Check if a volume is valid. ++func (self *Volume) IsValid() error { ++ // Check if a type is specified. ++ if self.Spec.SparseLoopDevice == nil && ++ self.Spec.RawBlockDevice == nil && ++ self.Spec.LVMLogicalVolume == nil { ++ return errors.New("volume type not found in Volume Spec") ++ } ++ // Check if the size is strictly positive. ++ if self.Spec.SparseLoopDevice != nil { ++ if self.Spec.SparseLoopDevice.Size.Sign() <= 0 { ++ return fmt.Errorf( ++ "invalid SparseLoopDevice size (should be greater than 0): %s", ++ self.Spec.SparseLoopDevice.Size.String(), ++ ) ++ } ++ } else if self.Spec.LVMLogicalVolume != nil { ++ if self.Spec.LVMLogicalVolume.Size.Sign() <= 0 { ++ return fmt.Errorf( ++ "invalid LVM LogicalVolume size (should be greater than 0): %s", ++ self.Spec.LVMLogicalVolume.Size.String(), ++ ) ++ } ++ } ++ ++ // Default to Filesystem when mode is not specified. ++ if self.Spec.Mode == "" { ++ self.Spec.Mode = corev1.PersistentVolumeFilesystem ++ } ++ ++ return nil ++} ++ ++// Check if a volume is in an unrecoverable state. ++func (self *Volume) IsInUnrecoverableFailedState() *VolumeCondition { ++ // Only `UnavailableError` is recoverable. ++ if ready := self.GetCondition(VolumeReady); ready != nil { ++ if ready.Status == corev1.ConditionFalse && ++ ready.Reason != ReasonUnavailableError { ++ return ready ++ } ++ } ++ return nil ++} ++ ++func (self *Volume) IsFormatted() bool { ++ return self.Spec.Mode == corev1.PersistentVolumeFilesystem ++} ++ ++//+kubebuilder:object:root=true + +-// VolumeList contains a list of Volume. ++// VolumeList contains a list of Volume + type VolumeList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` diff --git a/scripts/upgrade-operator-sdk/upgrade.py b/scripts/upgrade-operator-sdk/upgrade.py index e99876276d..e55dd70d0c 100755 --- a/scripts/upgrade-operator-sdk/upgrade.py +++ b/scripts/upgrade-operator-sdk/upgrade.py @@ -2,22 +2,22 @@ """Automates the upgrade of operator-sdk based projects. Scaffolds a fresh project, detects the latest Go and k8s.io patch -versions from the scaffold's go.mod, restores custom code from a backup, -applies GNU patch files, and runs the build pipeline. +versions, restores custom code from a backup, applies GNU patch files, +and runs the build pipeline. Usage: - python3 scripts/upgrade-operator-sdk/upgrade.py [OPTIONS] + python3 scripts/upgrade-operator-sdk/upgrade.py \\ + --operator-dir [OPTIONS] Examples: - python3 scripts/upgrade-operator-sdk/upgrade.py operator - python3 scripts/upgrade-operator-sdk/upgrade.py storage-operator - -The is resolved relative to the script directory. A full path -can also be given for configs stored elsewhere. + python3 scripts/upgrade-operator-sdk/upgrade.py \\ + --operator-dir operator \\ + scripts/upgrade-operator-sdk/operator Options: + --operator-dir Path to the operator project directory (required) --skip-backup Skip the backup step (assumes .bak already exists) - --clean-tools Remove .tmp/bin/ after the upgrade (forces re-download) + --clean-tools Remove tool cache after the upgrade (forces re-download) --yes, -y Skip the confirmation prompt -h, --help Show this help message @@ -50,8 +50,7 @@ # --------------------------------------------------------------------------- # Paths # --------------------------------------------------------------------------- -REPO_ROOT: Final = Path(__file__).resolve().parent.parent.parent -TOOLS_BIN: Final = REPO_ROOT / ".tmp" / "bin" +TOOLS_BIN: Final = Path.home() / ".cache" / "upgrade-operator-sdk" / "bin" _SDK_BIN: Final = TOOLS_BIN / "operator-sdk" _ENCODING: Final = "utf-8" @@ -162,7 +161,7 @@ def _github_headers() -> dict[str, str]: def _fetch_latest_github_release(repo: str) -> str: - """Return the latest release tag for a GitHub repository.""" + """Return the latest release tag, or empty string on failure.""" try: data = json.loads( _http_get( @@ -181,35 +180,27 @@ def _fetch_latest_github_release(repo: str) -> str: # --------------------------------------------------------------------------- -def load_config(config_dir: str) -> dict[str, Any]: - """Load and validate the operator config from a directory. - - If *config_dir* is a plain name (no path separators), it is resolved - relative to the script's own directory. - """ - # Plain name like "operator" → look next to this script. - # Path like "./foo" or "/abs/path" → use as-is. - p = Path(config_dir) - if not p.is_absolute() and os.sep not in config_dir and "/" not in config_dir: - p = Path(__file__).resolve().parent / config_dir - d = p.resolve() +def load_config(config_dir: str, operator_dir: str) -> dict[str, Any]: + """Load and validate the operator config from a directory.""" + d = Path(config_dir).resolve() config_file = d / "config.yaml" if not config_file.exists(): die(f"Config file not found: {config_file}") with config_file.open(encoding=_ENCODING) as f: cfg: dict[str, Any] = yaml.safe_load(f) - for key in ("repo", "domain", "operator_dir", "apis", "operator_sdk_version"): + for key in ("repo", "domain", "apis", "operator_sdk_version"): if key not in cfg: die(f"Missing required key {key!r} in {config_file}") + op = Path(operator_dir).resolve() cfg["name"] = d.name cfg["config_file"] = config_file - cfg.setdefault("backup_paths", []) - cfg.setdefault("extra_commands", []) - cfg["operator_dir"] = REPO_ROOT / cfg["operator_dir"] + cfg["operator_dir"] = op cfg["patches_dir"] = d / "patches" - cfg["backup_dir"] = REPO_ROOT / f"{cfg['operator_dir'].name}.bak" + cfg["backup_dir"] = op.parent / f"{op.name}.bak" + cfg.setdefault("raw_copy", []) + cfg.setdefault("extra_commands", []) return cfg @@ -308,12 +299,12 @@ def reconcile_versions( ) -> None: """Compare detected versions with YAML pins. - - No pin in YAML: auto-pin the detected value and update the file. - - Pin < detected: warn (newer available), keep the pinned value. + - No pin: auto-pin and update the YAML file. + - Pin < detected: warn (newer available), keep pinned. - Pin == detected: all good. - - Pin > detected: warn (unusual), use the detected value. + - Pin > detected: warn (unusual), use detected. - Zero interactive input — safe for CI. + Zero interactive input — CI-safe. """ log_step("Reconciling versions") auto_pins: dict[str, str] = {} @@ -365,8 +356,8 @@ def confirm_upgrade(cfg: dict[str, Any]) -> None: print() print(f" operator-sdk {cfg['operator_sdk_version']}") print() - print(f" Target: {cfg['name']}") - print(f" Directory: {cfg['operator_dir']}") + print(f" Target: {cfg['name']}") + print(f" Operator dir: {cfg['operator_dir']}") print() answer = input(f"{_BOLD}Proceed? [y/N] {_RESET}").strip().lower() if answer not in ("y", "yes"): @@ -384,7 +375,6 @@ def _tool_env(cfg: dict[str, Any]) -> dict[str, str]: "PATH": f"{TOOLS_BIN}:{os.environ.get('PATH', '')}", } # Set after reconcile_versions() resolves the latest patch. - # Before that, cfg may not have go_toolchain yet. if cfg.get("go_toolchain"): env["GOTOOLCHAIN"] = cfg["go_toolchain"] return env @@ -527,39 +517,37 @@ def _create_api( def restore_backup(cfg: dict[str, Any]) -> None: + """Copy raw_copy entries from backup into the scaffold output. + + Entries are directories or files that are purely custom (not + generated by operator-sdk). The scaffold version is replaced + entirely for directories. + + For files: if the file already exists in the scaffold, an error is + raised with a diff. For directories: the scaffold directory is + replaced entirely. + """ op_dir: Path = cfg["operator_dir"] bak: Path = cfg["backup_dir"] log_step(f"Phase 3: Restoring custom code for {cfg['name']}") count = 0 - conflicts: list[str] = [] - for rel_path in cfg["backup_paths"]: + for rel_path in cfg["raw_copy"]: src = bak / rel_path dst = op_dir / rel_path if not src.exists(): - log_warn(f" {rel_path} not found in backup, skipping") - continue + die(f" {rel_path} not found in backup at {src}") - is_dir = rel_path.endswith("/") - - if is_dir: - # Replace scaffold directory entirely with our custom code. - # zz_generated files are excluded — they are regenerated by - # `make generate` in Phase 5. + if src.is_dir(): if dst.exists(): + log_warn(f" {rel_path} exists in scaffold, replacing") shutil.rmtree(dst) - shutil.copytree( - src, - dst, - ignore=shutil.ignore_patterns("*zz_generated*"), - ) + shutil.copytree(src, dst) n = sum(1 for _ in dst.rglob("*") if _.is_file()) log_info(f" {rel_path} ({n} files)") count += n else: - if "zz_generated" in rel_path: - continue if dst.exists(): log_error(f" {rel_path} exists in both scaffold and backup") result = subprocess.run( @@ -569,24 +557,15 @@ def restore_backup(cfg: dict[str, Any]) -> None: ) if result.stdout: print(result.stdout) - conflicts.append(rel_path) - continue + die( + f"Conflict: {rel_path}. Update the file in .bak/ " + "then re-run with --skip-backup." + ) dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy(src, dst) log_info(f" {rel_path}") count += 1 - if conflicts: - log_error(f"{len(conflicts)} file(s) conflict between scaffold " "and backup:") - for c in conflicts: - log_error(f" - {c}") - log_info( - "Update the conflicting files in the .bak/ directory " - "to match the desired result, then re-run with " - "--skip-backup." - ) - die("Aborting due to backup/scaffold conflicts") - log_info(f"Custom code restored: {count} file(s)") @@ -612,7 +591,14 @@ def _apply_patches(cfg: dict[str, Any]) -> None: for patch_file in sorted(patch_dir.glob("*.patch")): log_info(f"Applying {patch_file.name}...") result = subprocess.run( - ["patch", "-p1", "--no-backup-if-mismatch", "-i", str(patch_file)], + [ + "patch", + "-p1", + "--forward", + "--no-backup-if-mismatch", + "-i", + str(patch_file), + ], cwd=op_dir, capture_output=True, text=True, @@ -683,9 +669,9 @@ def generate(cfg: dict[str, Any]) -> None: def _clean_tools() -> None: - """Remove the script's tool cache (.tmp/bin/).""" + """Remove the tool cache.""" if TOOLS_BIN.exists(): - log_step(f"Cleaning tool cache ({TOOLS_BIN.relative_to(REPO_ROOT)}/)") + log_step(f"Cleaning tool cache ({TOOLS_BIN})") shutil.rmtree(TOOLS_BIN) log_info(f"Removed {TOOLS_BIN}") else: @@ -718,12 +704,16 @@ def _log_recovery_hint(cfg: dict[str, Any]) -> None: def main() -> None: parser = argparse.ArgumentParser( description="Upgrade an operator-sdk project by scaffolding " - "fresh and applying patches from a YAML config.", + "fresh and applying patches from a config directory.", ) parser.add_argument( "config_dir", - help="Operator config directory name (e.g. 'operator') or " - "full path to a directory containing config.yaml", + help="Path to config directory containing config.yaml " "and patches/", + ) + parser.add_argument( + "--operator-dir", + required=True, + help="Path to the operator project directory", ) parser.add_argument( "--skip-backup", @@ -733,19 +723,19 @@ def main() -> None: parser.add_argument( "--clean-tools", action="store_true", - help="Remove .tmp/bin/ after upgrade", + help="Remove tool cache after upgrade", ) parser.add_argument( "--yes", "-y", action="store_true", - help="Skip all confirmation prompts", + help="Skip the confirmation prompt", ) args = parser.parse_args() _check_prerequisites() - cfg = load_config(args.config_dir) + cfg = load_config(args.config_dir, args.operator_dir) if not args.yes: confirm_upgrade(cfg) @@ -792,8 +782,8 @@ def main() -> None: log_info(f"Backup preserved at: {bak}/") print() log_info("Recommended next steps:") - log_info(" 1. git diff Review changes") - log_info(f" 2. cd {cfg['name']} && make test Run tests") + log_info(" 1. git diff") + log_info(f" 2. cd {cfg['name']} && make test") if __name__ == "__main__": From 432380bcf93b84fb812b54ed170ddfdc2fc75c1a Mon Sep 17 00:00:00 2001 From: Alex Rodriguez <131964409+ezekiel-alexrod@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:12:44 +0000 Subject: [PATCH 11/12] scripts,tools: move upgrade-operator-sdk to tools/ directory --- {scripts => tools}/upgrade-operator-sdk/operator/config.yaml | 0 .../upgrade-operator-sdk/operator/patches/Dockerfile.patch | 0 .../upgrade-operator-sdk/operator/patches/Makefile.patch | 0 .../operator/patches/clusterconfig_controller.patch | 0 .../operator/patches/clusterconfig_controller_test.patch | 0 .../operator/patches/clusterconfig_types.patch | 0 .../operator/patches/virtualippool_controller.patch | 0 .../operator/patches/virtualippool_controller_test.patch | 0 .../operator/patches/virtualippool_types.patch | 0 {scripts => tools}/upgrade-operator-sdk/pyproject.toml | 0 .../upgrade-operator-sdk/storage-operator/config.yaml | 0 .../storage-operator/patches/Dockerfile.patch | 0 .../upgrade-operator-sdk/storage-operator/patches/Makefile.patch | 0 .../storage-operator/patches/volume_controller.patch | 0 .../storage-operator/patches/volume_controller_test.patch | 0 .../storage-operator/patches/volume_types.patch | 0 {scripts => tools}/upgrade-operator-sdk/upgrade.py | 0 17 files changed, 0 insertions(+), 0 deletions(-) rename {scripts => tools}/upgrade-operator-sdk/operator/config.yaml (100%) rename {scripts => tools}/upgrade-operator-sdk/operator/patches/Dockerfile.patch (100%) rename {scripts => tools}/upgrade-operator-sdk/operator/patches/Makefile.patch (100%) rename {scripts => tools}/upgrade-operator-sdk/operator/patches/clusterconfig_controller.patch (100%) rename {scripts => tools}/upgrade-operator-sdk/operator/patches/clusterconfig_controller_test.patch (100%) rename {scripts => tools}/upgrade-operator-sdk/operator/patches/clusterconfig_types.patch (100%) rename {scripts => tools}/upgrade-operator-sdk/operator/patches/virtualippool_controller.patch (100%) rename {scripts => tools}/upgrade-operator-sdk/operator/patches/virtualippool_controller_test.patch (100%) rename {scripts => tools}/upgrade-operator-sdk/operator/patches/virtualippool_types.patch (100%) rename {scripts => tools}/upgrade-operator-sdk/pyproject.toml (100%) rename {scripts => tools}/upgrade-operator-sdk/storage-operator/config.yaml (100%) rename {scripts => tools}/upgrade-operator-sdk/storage-operator/patches/Dockerfile.patch (100%) rename {scripts => tools}/upgrade-operator-sdk/storage-operator/patches/Makefile.patch (100%) rename {scripts => tools}/upgrade-operator-sdk/storage-operator/patches/volume_controller.patch (100%) rename {scripts => tools}/upgrade-operator-sdk/storage-operator/patches/volume_controller_test.patch (100%) rename {scripts => tools}/upgrade-operator-sdk/storage-operator/patches/volume_types.patch (100%) rename {scripts => tools}/upgrade-operator-sdk/upgrade.py (100%) diff --git a/scripts/upgrade-operator-sdk/operator/config.yaml b/tools/upgrade-operator-sdk/operator/config.yaml similarity index 100% rename from scripts/upgrade-operator-sdk/operator/config.yaml rename to tools/upgrade-operator-sdk/operator/config.yaml diff --git a/scripts/upgrade-operator-sdk/operator/patches/Dockerfile.patch b/tools/upgrade-operator-sdk/operator/patches/Dockerfile.patch similarity index 100% rename from scripts/upgrade-operator-sdk/operator/patches/Dockerfile.patch rename to tools/upgrade-operator-sdk/operator/patches/Dockerfile.patch diff --git a/scripts/upgrade-operator-sdk/operator/patches/Makefile.patch b/tools/upgrade-operator-sdk/operator/patches/Makefile.patch similarity index 100% rename from scripts/upgrade-operator-sdk/operator/patches/Makefile.patch rename to tools/upgrade-operator-sdk/operator/patches/Makefile.patch diff --git a/scripts/upgrade-operator-sdk/operator/patches/clusterconfig_controller.patch b/tools/upgrade-operator-sdk/operator/patches/clusterconfig_controller.patch similarity index 100% rename from scripts/upgrade-operator-sdk/operator/patches/clusterconfig_controller.patch rename to tools/upgrade-operator-sdk/operator/patches/clusterconfig_controller.patch diff --git a/scripts/upgrade-operator-sdk/operator/patches/clusterconfig_controller_test.patch b/tools/upgrade-operator-sdk/operator/patches/clusterconfig_controller_test.patch similarity index 100% rename from scripts/upgrade-operator-sdk/operator/patches/clusterconfig_controller_test.patch rename to tools/upgrade-operator-sdk/operator/patches/clusterconfig_controller_test.patch diff --git a/scripts/upgrade-operator-sdk/operator/patches/clusterconfig_types.patch b/tools/upgrade-operator-sdk/operator/patches/clusterconfig_types.patch similarity index 100% rename from scripts/upgrade-operator-sdk/operator/patches/clusterconfig_types.patch rename to tools/upgrade-operator-sdk/operator/patches/clusterconfig_types.patch diff --git a/scripts/upgrade-operator-sdk/operator/patches/virtualippool_controller.patch b/tools/upgrade-operator-sdk/operator/patches/virtualippool_controller.patch similarity index 100% rename from scripts/upgrade-operator-sdk/operator/patches/virtualippool_controller.patch rename to tools/upgrade-operator-sdk/operator/patches/virtualippool_controller.patch diff --git a/scripts/upgrade-operator-sdk/operator/patches/virtualippool_controller_test.patch b/tools/upgrade-operator-sdk/operator/patches/virtualippool_controller_test.patch similarity index 100% rename from scripts/upgrade-operator-sdk/operator/patches/virtualippool_controller_test.patch rename to tools/upgrade-operator-sdk/operator/patches/virtualippool_controller_test.patch diff --git a/scripts/upgrade-operator-sdk/operator/patches/virtualippool_types.patch b/tools/upgrade-operator-sdk/operator/patches/virtualippool_types.patch similarity index 100% rename from scripts/upgrade-operator-sdk/operator/patches/virtualippool_types.patch rename to tools/upgrade-operator-sdk/operator/patches/virtualippool_types.patch diff --git a/scripts/upgrade-operator-sdk/pyproject.toml b/tools/upgrade-operator-sdk/pyproject.toml similarity index 100% rename from scripts/upgrade-operator-sdk/pyproject.toml rename to tools/upgrade-operator-sdk/pyproject.toml diff --git a/scripts/upgrade-operator-sdk/storage-operator/config.yaml b/tools/upgrade-operator-sdk/storage-operator/config.yaml similarity index 100% rename from scripts/upgrade-operator-sdk/storage-operator/config.yaml rename to tools/upgrade-operator-sdk/storage-operator/config.yaml diff --git a/scripts/upgrade-operator-sdk/storage-operator/patches/Dockerfile.patch b/tools/upgrade-operator-sdk/storage-operator/patches/Dockerfile.patch similarity index 100% rename from scripts/upgrade-operator-sdk/storage-operator/patches/Dockerfile.patch rename to tools/upgrade-operator-sdk/storage-operator/patches/Dockerfile.patch diff --git a/scripts/upgrade-operator-sdk/storage-operator/patches/Makefile.patch b/tools/upgrade-operator-sdk/storage-operator/patches/Makefile.patch similarity index 100% rename from scripts/upgrade-operator-sdk/storage-operator/patches/Makefile.patch rename to tools/upgrade-operator-sdk/storage-operator/patches/Makefile.patch diff --git a/scripts/upgrade-operator-sdk/storage-operator/patches/volume_controller.patch b/tools/upgrade-operator-sdk/storage-operator/patches/volume_controller.patch similarity index 100% rename from scripts/upgrade-operator-sdk/storage-operator/patches/volume_controller.patch rename to tools/upgrade-operator-sdk/storage-operator/patches/volume_controller.patch diff --git a/scripts/upgrade-operator-sdk/storage-operator/patches/volume_controller_test.patch b/tools/upgrade-operator-sdk/storage-operator/patches/volume_controller_test.patch similarity index 100% rename from scripts/upgrade-operator-sdk/storage-operator/patches/volume_controller_test.patch rename to tools/upgrade-operator-sdk/storage-operator/patches/volume_controller_test.patch diff --git a/scripts/upgrade-operator-sdk/storage-operator/patches/volume_types.patch b/tools/upgrade-operator-sdk/storage-operator/patches/volume_types.patch similarity index 100% rename from scripts/upgrade-operator-sdk/storage-operator/patches/volume_types.patch rename to tools/upgrade-operator-sdk/storage-operator/patches/volume_types.patch diff --git a/scripts/upgrade-operator-sdk/upgrade.py b/tools/upgrade-operator-sdk/upgrade.py similarity index 100% rename from scripts/upgrade-operator-sdk/upgrade.py rename to tools/upgrade-operator-sdk/upgrade.py From ecda27e89e14b1f06f1ec463166bf5af1bbf89ec Mon Sep 17 00:00:00 2001 From: Alex Rodriguez <131964409+ezekiel-alexrod@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:36:53 +0000 Subject: [PATCH 12/12] scripts: address review round 5 - Hardcode Jinja build_image_name() directly in Makefile patches, remove __IMAGE__ placeholder and image_placeholder config field - Add 'delete' config field to remove scaffold files (replaces hardcoded .devcontainer removal and controller_test patches) - Simplify controller patches: keep scaffold kubebuilder marker format, remove Copyright changes and commented-out code - Improve raw_copy directory handling: diff and skip if identical, error with diff if different (idempotent re-runs) - Remove auto-pin of YAML config (never modify the config file) - Remove _update_yaml() function (no longer needed) - Fix f-string formatting per review suggestions - Sync BUMPING.md: remove stale image_placeholder references --- BUMPING.md | 9 +- .../upgrade-operator-sdk/operator/config.yaml | 7 +- .../operator/patches/Makefile.patch | 2 +- .../patches/clusterconfig_controller.patch | 51 ++------ .../clusterconfig_controller_test.patch | 87 ------------- .../patches/virtualippool_controller.patch | 55 +++----- .../virtualippool_controller_test.patch | 87 ------------- .../storage-operator/config.yaml | 7 +- .../storage-operator/patches/Makefile.patch | 2 +- .../patches/volume_controller_test.patch | 120 ------------------ tools/upgrade-operator-sdk/upgrade.py | 75 ++++++----- 11 files changed, 89 insertions(+), 413 deletions(-) delete mode 100644 tools/upgrade-operator-sdk/operator/patches/clusterconfig_controller_test.patch delete mode 100644 tools/upgrade-operator-sdk/operator/patches/virtualippool_controller_test.patch delete mode 100644 tools/upgrade-operator-sdk/storage-operator/patches/volume_controller_test.patch diff --git a/BUMPING.md b/BUMPING.md index 1eada9b846..ea1abf1d5d 100644 --- a/BUMPING.md +++ b/BUMPING.md @@ -188,7 +188,7 @@ Each operator has a config directory at `scripts/upgrade-operator-sdk//` c - **Versions**: `operator_sdk_version`, `go_toolchain` (optional pin), `k8s_libs` (optional pin) - **Scaffold**: `repo`, `domain`, `apis` (with `group`, `version`, `kind`, `namespaced`). The operator name is derived from the config directory name. - **Raw copy**: `raw_copy` -- directories or files copied as-is from backup (purely custom code with no scaffold equivalent: `pkg/`, `version/`, `config/metalk8s/`, `salt/`, individual test/helper files) -- **Post-processing**: `image_placeholder`, `extra_commands` +- **Post-processing**: `extra_commands` ### Patch files @@ -205,10 +205,9 @@ apply cleanly, look for `.rej` files and resolve manually. Patch files use `__PLACEHOLDER__` tokens for runtime values: -| Placeholder | Replaced with | Source | -| ----------------- | ------------------------------- | ---------- | -| `__GOTOOLCHAIN__` | Detected/pinned Go toolchain | `Makefile` | -| `__IMAGE__` | `image_placeholder` from config | `Makefile` | +| Placeholder | Replaced with | Source | +| ----------------- | ---------------------------- | ---------- | +| `__GOTOOLCHAIN__` | Detected/pinned Go toolchain | `Makefile` | New `.patch` files in the patches directory are automatically picked up. diff --git a/tools/upgrade-operator-sdk/operator/config.yaml b/tools/upgrade-operator-sdk/operator/config.yaml index 53ab6eed95..d684c4f8a5 100644 --- a/tools/upgrade-operator-sdk/operator/config.yaml +++ b/tools/upgrade-operator-sdk/operator/config.yaml @@ -27,7 +27,12 @@ raw_copy: - api/v1alpha1/virtualippool_types_test.go - api/v1alpha1/v1alpha1_suite_test.go -image_placeholder: '{{ build_image_name("metalk8s-operator") }}' +# Files/dirs to delete from the scaffold (not needed or incompatible). +delete: + - .devcontainer + - .github + - internal/controller/clusterconfig_controller_test.go + - internal/controller/virtualippool_controller_test.go extra_commands: - ["make", "metalk8s"] diff --git a/tools/upgrade-operator-sdk/operator/patches/Makefile.patch b/tools/upgrade-operator-sdk/operator/patches/Makefile.patch index a051bb1541..6d2abfdbec 100644 --- a/tools/upgrade-operator-sdk/operator/patches/Makefile.patch +++ b/tools/upgrade-operator-sdk/operator/patches/Makefile.patch @@ -12,4 +12,4 @@ +metalk8s: manifests kustomize ## Generate MetalK8s resulting manifests + mkdir -p deploy + $(KUSTOMIZE) build config/metalk8s | \ -+ sed 's/BUILD_IMAGE_CLUSTER_OPERATOR:latest/__IMAGE__/' > deploy/manifests.yaml ++ sed 's/BUILD_IMAGE_CLUSTER_OPERATOR:latest/{{ build_image_name("metalk8s-operator") }}/' > deploy/manifests.yaml diff --git a/tools/upgrade-operator-sdk/operator/patches/clusterconfig_controller.patch b/tools/upgrade-operator-sdk/operator/patches/clusterconfig_controller.patch index 91e601efe5..8776986650 100644 --- a/tools/upgrade-operator-sdk/operator/patches/clusterconfig_controller.patch +++ b/tools/upgrade-operator-sdk/operator/patches/clusterconfig_controller.patch @@ -1,12 +1,5 @@ --- a/internal/controller/clusterconfig_controller.go +++ b/internal/controller/clusterconfig_controller.go -@@ -1,5 +1,5 @@ - /* --Copyright 2026. -+Copyright 2022. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. @@ -17,14 +17,10 @@ package controller @@ -23,27 +16,18 @@ ) // ClusterConfigReconciler reconciles a ClusterConfig object -@@ -33,9 +29,13 @@ - Scheme *runtime.Scheme - } - --// +kubebuilder:rbac:groups=metalk8s.scality.com,resources=clusterconfigs,verbs=get;list;watch;create;update;patch;delete --// +kubebuilder:rbac:groups=metalk8s.scality.com,resources=clusterconfigs/status,verbs=get;update;patch --// +kubebuilder:rbac:groups=metalk8s.scality.com,resources=clusterconfigs/finalizers,verbs=update -+//+kubebuilder:rbac:groups=metalk8s.scality.com,resources=clusterconfigs,verbs=get;list;watch;create;update;patch;delete -+//+kubebuilder:rbac:groups=metalk8s.scality.com,resources=clusterconfigs/status,verbs=get;update;patch -+//+kubebuilder:rbac:groups=metalk8s.scality.com,resources=clusterconfigs/finalizers,verbs=update -+ -+//+kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;create;update;patch;delete -+//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch -+//+kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools,verbs=get;list;watch;create;update;patch;delete +@@ -37,27 +33,11 @@ + // +kubebuilder:rbac:groups=metalk8s.scality.com,resources=clusterconfigs/status,verbs=get;update;patch + // +kubebuilder:rbac:groups=metalk8s.scality.com,resources=clusterconfigs/finalizers,verbs=update - // Reconcile is part of the main kubernetes reconciliation loop which aims to - // move the current state of the cluster closer to the desired state. -@@ -45,19 +45,18 @@ - // the user. - // - // For more details, check Reconcile and its Result here: +-// Reconcile is part of the main kubernetes reconciliation loop which aims to +-// move the current state of the cluster closer to the desired state. +-// TODO(user): Modify the Reconcile function to compare the state specified by +-// the ClusterConfig object against the actual cluster state, and then +-// perform operations to make the cluster state reflect the state specified by +-// the user. +-// +-// For more details, check Reconcile and its Result here: -// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/reconcile -func (r *ClusterConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = logf.FromContext(ctx) @@ -52,12 +36,9 @@ - - return ctrl.Result{}, nil -} -+// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.12.1/pkg/reconcile -+//func (r *ClusterConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { -+// _ = log.FromContext(ctx) -+// -+// return ctrl.Result{}, nil -+//} ++// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;create;update;patch;delete ++// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch ++// +kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools,verbs=get;list;watch;create;update;patch;delete // SetupWithManager sets up the controller with the Manager. func (r *ClusterConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { @@ -66,8 +47,4 @@ - Named("clusterconfig"). - Complete(r) + return clusterconfig.Add(mgr) -+ -+ //return ctrl.NewControllerManagedBy(mgr). -+ // For(&metalk8sscalitycomv1alpha1.ClusterConfig{}). -+ // Complete(r) } diff --git a/tools/upgrade-operator-sdk/operator/patches/clusterconfig_controller_test.patch b/tools/upgrade-operator-sdk/operator/patches/clusterconfig_controller_test.patch deleted file mode 100644 index a95cd2da5e..0000000000 --- a/tools/upgrade-operator-sdk/operator/patches/clusterconfig_controller_test.patch +++ /dev/null @@ -1,87 +0,0 @@ ---- a/internal/controller/clusterconfig_controller_test.go -+++ b/internal/controller/clusterconfig_controller_test.go -@@ -1,84 +1 @@ --/* --Copyright 2026. -- --Licensed under the Apache License, Version 2.0 (the "License"); --you may not use this file except in compliance with the License. --You may obtain a copy of the License at -- -- http://www.apache.org/licenses/LICENSE-2.0 -- --Unless required by applicable law or agreed to in writing, software --distributed under the License is distributed on an "AS IS" BASIS, --WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --See the License for the specific language governing permissions and --limitations under the License. --*/ -- - package controller -- --import ( -- "context" -- -- . "github.com/onsi/ginkgo/v2" -- . "github.com/onsi/gomega" -- "k8s.io/apimachinery/pkg/api/errors" -- "k8s.io/apimachinery/pkg/types" -- "sigs.k8s.io/controller-runtime/pkg/reconcile" -- -- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -- -- metalk8sscalitycomv1alpha1 "github.com/scality/metalk8s/operator/api/v1alpha1" --) -- --var _ = Describe("ClusterConfig Controller", func() { -- Context("When reconciling a resource", func() { -- const resourceName = "test-resource" -- -- ctx := context.Background() -- -- typeNamespacedName := types.NamespacedName{ -- Name: resourceName, -- Namespace: "default", // TODO(user):Modify as needed -- } -- clusterconfig := &metalk8sscalitycomv1alpha1.ClusterConfig{} -- -- BeforeEach(func() { -- By("creating the custom resource for the Kind ClusterConfig") -- err := k8sClient.Get(ctx, typeNamespacedName, clusterconfig) -- if err != nil && errors.IsNotFound(err) { -- resource := &metalk8sscalitycomv1alpha1.ClusterConfig{ -- ObjectMeta: metav1.ObjectMeta{ -- Name: resourceName, -- Namespace: "default", -- }, -- // TODO(user): Specify other spec details if needed. -- } -- Expect(k8sClient.Create(ctx, resource)).To(Succeed()) -- } -- }) -- -- AfterEach(func() { -- // TODO(user): Cleanup logic after each test, like removing the resource instance. -- resource := &metalk8sscalitycomv1alpha1.ClusterConfig{} -- err := k8sClient.Get(ctx, typeNamespacedName, resource) -- Expect(err).NotTo(HaveOccurred()) -- -- By("Cleanup the specific resource instance ClusterConfig") -- Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) -- }) -- It("should successfully reconcile the resource", func() { -- By("Reconciling the created resource") -- controllerReconciler := &ClusterConfigReconciler{ -- Client: k8sClient, -- Scheme: k8sClient.Scheme(), -- } -- -- _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ -- NamespacedName: typeNamespacedName, -- }) -- Expect(err).NotTo(HaveOccurred()) -- // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. -- // Example: If you expect a certain status condition after reconciliation, verify it here. -- }) -- }) --}) diff --git a/tools/upgrade-operator-sdk/operator/patches/virtualippool_controller.patch b/tools/upgrade-operator-sdk/operator/patches/virtualippool_controller.patch index 1648743b12..b251e1c1ee 100644 --- a/tools/upgrade-operator-sdk/operator/patches/virtualippool_controller.patch +++ b/tools/upgrade-operator-sdk/operator/patches/virtualippool_controller.patch @@ -1,12 +1,5 @@ --- a/internal/controller/virtualippool_controller.go +++ b/internal/controller/virtualippool_controller.go -@@ -1,5 +1,5 @@ - /* --Copyright 2026. -+Copyright 2022. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. @@ -17,14 +17,10 @@ package controller @@ -23,29 +16,23 @@ ) // VirtualIPPoolReconciler reconciles a VirtualIPPool object -@@ -33,9 +29,15 @@ - Scheme *runtime.Scheme - } +@@ -37,27 +33,12 @@ + // +kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools/status,verbs=get;update;patch + // +kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools/finalizers,verbs=update --// +kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools,verbs=get;list;watch;create;update;patch;delete --// +kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools/status,verbs=get;update;patch --// +kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools/finalizers,verbs=update -+//+kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools,verbs=get;list;watch;create;update;patch;delete -+//+kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools/status,verbs=get;update;patch -+//+kubebuilder:rbac:groups=metalk8s.scality.com,resources=virtualippools/finalizers,verbs=update ++// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch ++// +kubebuilder:rbac:groups="",resources=nodes,verbs=get;list;watch ++// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete ++// +kubebuilder:rbac:groups=apps,resources=daemonsets,verbs=get;list;watch;create;update;patch;delete + -+//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch -+ -+//+kubebuilder:rbac:groups="",resources=nodes,verbs=get;list;watch -+//+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete -+//+kubebuilder:rbac:groups="apps",resources=daemonsets,verbs=get;list;watch;create;update;patch;delete - - // Reconcile is part of the main kubernetes reconciliation loop which aims to - // move the current state of the cluster closer to the desired state. -@@ -45,19 +47,18 @@ - // the user. - // - // For more details, check Reconcile and its Result here: +-// Reconcile is part of the main kubernetes reconciliation loop which aims to +-// move the current state of the cluster closer to the desired state. +-// TODO(user): Modify the Reconcile function to compare the state specified by +-// the VirtualIPPool object against the actual cluster state, and then +-// perform operations to make the cluster state reflect the state specified by +-// the user. +-// +-// For more details, check Reconcile and its Result here: -// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/reconcile -func (r *VirtualIPPoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = logf.FromContext(ctx) @@ -54,13 +41,7 @@ - - return ctrl.Result{}, nil -} -+// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.12.2/pkg/reconcile -+//func (r *VirtualIPPoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { -+// _ = log.FromContext(ctx) -+// -+// return ctrl.Result{}, nil -+//} - +- // SetupWithManager sets up the controller with the Manager. func (r *VirtualIPPoolReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). @@ -68,8 +49,4 @@ - Named("virtualippool"). - Complete(r) + return virtualippool.Add(mgr) -+ -+ //return ctrl.NewControllerManagedBy(mgr). -+ // For(&metalk8sscalitycomv1alpha1.VirtualIPPool{}). -+ // Complete(r) } diff --git a/tools/upgrade-operator-sdk/operator/patches/virtualippool_controller_test.patch b/tools/upgrade-operator-sdk/operator/patches/virtualippool_controller_test.patch deleted file mode 100644 index cc7b162a01..0000000000 --- a/tools/upgrade-operator-sdk/operator/patches/virtualippool_controller_test.patch +++ /dev/null @@ -1,87 +0,0 @@ ---- a/internal/controller/virtualippool_controller_test.go -+++ b/internal/controller/virtualippool_controller_test.go -@@ -1,84 +1 @@ --/* --Copyright 2026. -- --Licensed under the Apache License, Version 2.0 (the "License"); --you may not use this file except in compliance with the License. --You may obtain a copy of the License at -- -- http://www.apache.org/licenses/LICENSE-2.0 -- --Unless required by applicable law or agreed to in writing, software --distributed under the License is distributed on an "AS IS" BASIS, --WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --See the License for the specific language governing permissions and --limitations under the License. --*/ -- - package controller -- --import ( -- "context" -- -- . "github.com/onsi/ginkgo/v2" -- . "github.com/onsi/gomega" -- "k8s.io/apimachinery/pkg/api/errors" -- "k8s.io/apimachinery/pkg/types" -- "sigs.k8s.io/controller-runtime/pkg/reconcile" -- -- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -- -- metalk8sscalitycomv1alpha1 "github.com/scality/metalk8s/operator/api/v1alpha1" --) -- --var _ = Describe("VirtualIPPool Controller", func() { -- Context("When reconciling a resource", func() { -- const resourceName = "test-resource" -- -- ctx := context.Background() -- -- typeNamespacedName := types.NamespacedName{ -- Name: resourceName, -- Namespace: "default", // TODO(user):Modify as needed -- } -- virtualippool := &metalk8sscalitycomv1alpha1.VirtualIPPool{} -- -- BeforeEach(func() { -- By("creating the custom resource for the Kind VirtualIPPool") -- err := k8sClient.Get(ctx, typeNamespacedName, virtualippool) -- if err != nil && errors.IsNotFound(err) { -- resource := &metalk8sscalitycomv1alpha1.VirtualIPPool{ -- ObjectMeta: metav1.ObjectMeta{ -- Name: resourceName, -- Namespace: "default", -- }, -- // TODO(user): Specify other spec details if needed. -- } -- Expect(k8sClient.Create(ctx, resource)).To(Succeed()) -- } -- }) -- -- AfterEach(func() { -- // TODO(user): Cleanup logic after each test, like removing the resource instance. -- resource := &metalk8sscalitycomv1alpha1.VirtualIPPool{} -- err := k8sClient.Get(ctx, typeNamespacedName, resource) -- Expect(err).NotTo(HaveOccurred()) -- -- By("Cleanup the specific resource instance VirtualIPPool") -- Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) -- }) -- It("should successfully reconcile the resource", func() { -- By("Reconciling the created resource") -- controllerReconciler := &VirtualIPPoolReconciler{ -- Client: k8sClient, -- Scheme: k8sClient.Scheme(), -- } -- -- _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ -- NamespacedName: typeNamespacedName, -- }) -- Expect(err).NotTo(HaveOccurred()) -- // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. -- // Example: If you expect a certain status condition after reconciliation, verify it here. -- }) -- }) --}) diff --git a/tools/upgrade-operator-sdk/storage-operator/config.yaml b/tools/upgrade-operator-sdk/storage-operator/config.yaml index c7b11e9481..537cd84199 100644 --- a/tools/upgrade-operator-sdk/storage-operator/config.yaml +++ b/tools/upgrade-operator-sdk/storage-operator/config.yaml @@ -20,8 +20,13 @@ raw_copy: - salt - api/v1alpha1/volume_types_test.go - internal/controller/slice.go + - internal/controller/volume_controller_test.go -image_placeholder: '{{ build_image_name("storage-operator") }}' +# Files/dirs to delete from the scaffold (not needed or incompatible). +delete: + - .devcontainer + - .github + - internal/controller/volume_controller_test.go extra_commands: - ["make", "metalk8s"] diff --git a/tools/upgrade-operator-sdk/storage-operator/patches/Makefile.patch b/tools/upgrade-operator-sdk/storage-operator/patches/Makefile.patch index a051bb1541..f98333af31 100644 --- a/tools/upgrade-operator-sdk/storage-operator/patches/Makefile.patch +++ b/tools/upgrade-operator-sdk/storage-operator/patches/Makefile.patch @@ -12,4 +12,4 @@ +metalk8s: manifests kustomize ## Generate MetalK8s resulting manifests + mkdir -p deploy + $(KUSTOMIZE) build config/metalk8s | \ -+ sed 's/BUILD_IMAGE_CLUSTER_OPERATOR:latest/__IMAGE__/' > deploy/manifests.yaml ++ sed 's/BUILD_IMAGE_CLUSTER_OPERATOR:latest/{{ build_image_name("storage-operator") }}/' > deploy/manifests.yaml diff --git a/tools/upgrade-operator-sdk/storage-operator/patches/volume_controller_test.patch b/tools/upgrade-operator-sdk/storage-operator/patches/volume_controller_test.patch deleted file mode 100644 index 596b051424..0000000000 --- a/tools/upgrade-operator-sdk/storage-operator/patches/volume_controller_test.patch +++ /dev/null @@ -1,120 +0,0 @@ ---- a/internal/controller/volume_controller_test.go -+++ b/internal/controller/volume_controller_test.go -@@ -1,84 +1,43 @@ --/* --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" -+ "testing" - -- . "github.com/onsi/ginkgo/v2" -- . "github.com/onsi/gomega" -- "k8s.io/apimachinery/pkg/api/errors" -- "k8s.io/apimachinery/pkg/types" -- "sigs.k8s.io/controller-runtime/pkg/reconcile" -+ "github.com/stretchr/testify/assert" -+ "k8s.io/client-go/rest" - -- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -- -- storagev1alpha1 "github.com/scality/metalk8s/storage-operator/api/v1alpha1" -+ "github.com/scality/metalk8s/storage-operator/salt" - ) - --var _ = Describe("Volume Controller", func() { -- Context("When reconciling a resource", func() { -- const resourceName = "test-resource" -- -- ctx := context.Background() -- -- typeNamespacedName := types.NamespacedName{ -- Name: resourceName, -- Namespace: "default", // TODO(user):Modify as needed -- } -- volume := &storagev1alpha1.Volume{} -- -- BeforeEach(func() { -- By("creating the custom resource for the Kind Volume") -- err := k8sClient.Get(ctx, typeNamespacedName, volume) -- if err != nil && errors.IsNotFound(err) { -- resource := &storagev1alpha1.Volume{ -- ObjectMeta: metav1.ObjectMeta{ -- Name: resourceName, -- Namespace: "default", -- }, -- // TODO(user): Specify other spec details if needed. -- } -- Expect(k8sClient.Create(ctx, resource)).To(Succeed()) -- } -- }) -+func TestGetAuthCredential(t *testing.T) { -+ tests := map[string]struct { -+ token string -+ username string -+ password string -+ expected *salt.Credential -+ }{ -+ "ServiceAccount": { -+ token: "foo", -+ expected: salt.NewCredential( -+ "system:serviceaccount:kube-system:storage-operator-controller-manager", -+ "foo", -+ salt.Bearer, -+ ), -+ }, -+ } -+ for name, tc := range tests { -+ t.Run(name, func(t *testing.T) { -+ config := rest.Config{BearerToken: tc.token} -+ creds := getAuthCredential(&config) - -- AfterEach(func() { -- // TODO(user): Cleanup logic after each test, like removing the resource instance. -- resource := &storagev1alpha1.Volume{} -- err := k8sClient.Get(ctx, typeNamespacedName, resource) -- Expect(err).NotTo(HaveOccurred()) -- -- By("Cleanup the specific resource instance Volume") -- Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) -- }) -- It("should successfully reconcile the resource", func() { -- By("Reconciling the created resource") -- controllerReconciler := &VolumeReconciler{ -- Client: k8sClient, -- Scheme: k8sClient.Scheme(), -- } -- -- _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ -- NamespacedName: typeNamespacedName, -- }) -- Expect(err).NotTo(HaveOccurred()) -- // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. -- // Example: If you expect a certain status condition after reconciliation, verify it here. -+ assert.Equal(t, tc.expected, creds) - }) -+ } -+} -+ -+func TestGetAuthCredentialNoToken(t *testing.T) { -+ config := rest.Config{Username: "admin", Password: "admin"} -+ assert.Panics(t, func() { -+ getAuthCredential(&config) - }) --}) -+} diff --git a/tools/upgrade-operator-sdk/upgrade.py b/tools/upgrade-operator-sdk/upgrade.py index e55dd70d0c..ec2801e419 100755 --- a/tools/upgrade-operator-sdk/upgrade.py +++ b/tools/upgrade-operator-sdk/upgrade.py @@ -200,6 +200,7 @@ def load_config(config_dir: str, operator_dir: str) -> dict[str, Any]: cfg["patches_dir"] = d / "patches" cfg["backup_dir"] = op.parent / f"{op.name}.bak" cfg.setdefault("raw_copy", []) + cfg.setdefault("delete", []) cfg.setdefault("extra_commands", []) return cfg @@ -299,15 +300,14 @@ def reconcile_versions( ) -> None: """Compare detected versions with YAML pins. - - No pin: auto-pin and update the YAML file. + - No pin: use detected, log info. - Pin < detected: warn (newer available), keep pinned. - Pin == detected: all good. - Pin > detected: warn (unusual), use detected. - Zero interactive input — CI-safe. + Never modifies the YAML file. Zero interactive input -- CI-safe. """ log_step("Reconciling versions") - auto_pins: dict[str, str] = {} for key in ("operator_sdk_version", "go_toolchain", "k8s_libs"): found = detected.get(key, "") @@ -316,38 +316,17 @@ def reconcile_versions( pinned = cfg.get(key, "") if not pinned: - log_info(f" {key}: {found} (detected, auto-pinning)") + log_info(f" {key}: {found} (detected, not pinned)") cfg[key] = found - auto_pins[key] = found elif found == pinned: log_info(f" {key}: {pinned} (up to date)") elif found > pinned: # lexicographic, works for semver - log_warn(f" {key}: pinned {pinned}, " f"newer {found} available") + log_warn(f" {key}: pinned {pinned}, newer {found} available") cfg[key] = pinned else: - log_warn(f" {key}: pinned {pinned} > detected {found}, " "using detected") + log_warn(f" {key}: pinned {pinned} > detected {found}, using detected") cfg[key] = found - if auto_pins: - _update_yaml(cfg["config_file"], auto_pins) - - -def _update_yaml(config_file: Path, updates: dict[str, str]) -> None: - """Update specific keys in the YAML config file in-place.""" - text = config_file.read_text(encoding=_ENCODING) - for key, value in updates.items(): - pattern = rf"^{re.escape(key)}:.*$" - replacement = f"{key}: {value}" - new_text = re.sub(pattern, replacement, text, flags=re.MULTILINE) - if new_text == text: - if not text.endswith("\n"): - text += "\n" - text += f"{key}: {value}\n" - else: - text = new_text - config_file.write_text(text, encoding=_ENCODING) - log_info(f"Updated {config_file.name}: {', '.join(updates)}") - def confirm_upgrade(cfg: dict[str, Any]) -> None: """Print config summary and ask the user to confirm.""" @@ -480,14 +459,25 @@ def scaffold_project(cfg: dict[str, Any]) -> None: for api in cfg["apis"]: _create_api(op_dir, sdk, api, cfg) - devcontainer = op_dir / ".devcontainer" - if devcontainer.exists(): - shutil.rmtree(devcontainer) - log_info("Removed .devcontainer/ (not needed)") + _delete_scaffold_files(cfg) log_info("Scaffold complete") +def _delete_scaffold_files(cfg: dict[str, Any]) -> None: + """Remove files/dirs listed in the 'delete' config field.""" + op_dir: Path = cfg["operator_dir"] + for rel_path in cfg.get("delete", []): + target = op_dir / rel_path + if not target.exists(): + continue + if target.is_dir(): + shutil.rmtree(target) + else: + target.unlink() + log_info(f" Deleted {rel_path}") + + def _create_api( op_dir: Path, sdk: str, api: dict[str, Any], cfg: dict[str, Any] ) -> None: @@ -541,8 +531,26 @@ def restore_backup(cfg: dict[str, Any]) -> None: if src.is_dir(): if dst.exists(): - log_warn(f" {rel_path} exists in scaffold, replacing") - shutil.rmtree(dst) + # Compare scaffold vs backup; skip if identical, error if different. + result = subprocess.run( + ["diff", "-rq", str(dst), str(src)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + n = sum(1 for _ in dst.rglob("*") if _.is_file()) + log_info(f" {rel_path} ({n} files, identical to scaffold)") + count += n + continue + log_error(f" {rel_path} exists in scaffold with different content") + subprocess.run( + ["diff", "-ru", str(dst), str(src)], + capture_output=False, + ) + die( + f"Conflict in {rel_path}. Update the directory in " + ".bak/ then re-run with --skip-backup." + ) shutil.copytree(src, dst) n = sum(1 for _ in dst.rglob("*") if _.is_file()) log_info(f" {rel_path} ({n} files)") @@ -621,7 +629,6 @@ def _substitute_placeholders(cfg: dict[str, Any]) -> None: text = makefile.read_text(encoding=_ENCODING) if cfg.get("go_toolchain"): text = text.replace("__GOTOOLCHAIN__", cfg["go_toolchain"]) - text = text.replace("__IMAGE__", cfg.get("image_placeholder", "")) makefile.write_text(text, encoding=_ENCODING) log_info("Placeholders substituted")