diff --git a/Containerfile b/Containerfile new file mode 100644 index 00000000..4abb6d29 --- /dev/null +++ b/Containerfile @@ -0,0 +1,53 @@ +FROM alpine:3.21 + +LABEL org.opencontainers.image.source="https://github.com/SovereignCloudStack/cluster-stacks" +LABEL org.opencontainers.image.description="Cluster Stack Build Tools" +LABEL org.opencontainers.image.licenses="Apache-2.0" + +# Install system dependencies +RUN apk add --no-cache \ + bash \ + git \ + curl \ + tar \ + gzip \ + gawk \ + python3 \ + py3-yaml \ + jq \ + ca-certificates + +# Install helm +RUN HELM_VERSION=v3.17.3 && \ + curl -fsSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" | \ + tar -xz -C /usr/local/bin --strip-components=1 linux-amd64/helm + +# Install yq (mikefarah) +RUN YQ_VERSION=v4.45.4 && \ + curl -fsSL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" \ + -o /usr/local/bin/yq && chmod +x /usr/local/bin/yq + +# Install oras +RUN ORAS_VERSION=1.2.2 && \ + curl -fsSL "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_amd64.tar.gz" | \ + tar -xz -C /usr/local/bin oras + +# Install just +RUN JUST_VERSION=1.40.0 && \ + curl -fsSL "https://github.com/casey/just/releases/download/${JUST_VERSION}/just-${JUST_VERSION}-x86_64-unknown-linux-musl.tar.gz" | \ + tar -xz -C /usr/local/bin just + +WORKDIR /workspace + +# Verify installations +RUN bash --version | head -1 && \ + helm version --short && \ + yq --version && \ + oras version && \ + just --version && \ + git --version + +# Allow git operations inside mounted volumes +RUN git config --global --add safe.directory /workspace + +CMD ["/bin/bash"] diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..6714924d --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1768886240, + "narHash": "sha256-C2TjvwYZ2VDxYWeqvvJ5XPPp6U7H66zeJlRaErJKoEM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..8db9a34d --- /dev/null +++ b/flake.nix @@ -0,0 +1,88 @@ +{ + description = "Cluster Stacks - Build tools for SCS Kubernetes cluster stacks"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + devShells.default = pkgs.mkShell { + name = "cluster-stacks-dev"; + + buildInputs = with pkgs; [ + # Core tools + bash + git + curl + + # Container tools + docker + podman + + # Kubernetes tools + kubectl + kubernetes-helm + kind + kustomize + + # Build tools + just + python3 + python3Packages.pyyaml + jq + yq-go + + # OCI/Registry tools + oras + ]; + + shellHook = '' + echo "Cluster Stacks development environment" + echo "" + echo "Available tools:" + echo " just - Run 'just --list' to see available commands" + echo " helm - Kubernetes package manager" + echo " kubectl - Kubernetes CLI" + echo " kind - Local Kubernetes clusters" + echo " yq - YAML processor" + echo " oras - OCI registry client" + echo " python3 - With PyYAML" + echo "" + + # Generate shell completions into a cache directory + comp_dir="''${XDG_CACHE_HOME:-$HOME/.cache}/cluster-stacks/completions" + mkdir -p "$comp_dir" + + user_shell=$(getent passwd "$(whoami)" 2>/dev/null | cut -d: -f7) + shell_name=$(basename "''${user_shell:-bash}") + + # Regenerate completions if the directory is empty or tools were updated + if [ ! -f "$comp_dir/.$shell_name-generated" ]; then + kubectl completion "$shell_name" > "$comp_dir/_kubectl" 2>/dev/null || true + helm completion "$shell_name" > "$comp_dir/_helm" 2>/dev/null || true + just --completions "$shell_name" > "$comp_dir/_just" 2>/dev/null || true + kind completion "$shell_name" > "$comp_dir/_kind" 2>/dev/null || true + oras completion "$shell_name" > "$comp_dir/_oras" 2>/dev/null || true + touch "$comp_dir/.$shell_name-generated" + fi + + # Make completions available + export FPATH="$comp_dir:$FPATH" + + # Start user's preferred shell instead of bash. + # nix develop always drops into bash; this detects the user's + # real login shell from /etc/passwd and exec's into it. + if [ -n "$user_shell" ] && [ "$shell_name" != "bash" ] && [ -x "$user_shell" ]; then + exec "$user_shell" + fi + ''; + }; + } + ); +} diff --git a/hack/build.sh b/hack/build.sh new file mode 100755 index 00000000..a4be36e5 --- /dev/null +++ b/hack/build.sh @@ -0,0 +1,382 @@ +#!/usr/bin/env bash +# Build and optionally publish cluster-stack release artifacts. +# +# Usage: +# ./hack/build.sh [options] +# +# Options: +# --version Build for a specific K8s minor version (e.g., 1.34) +# --all Build for all K8s versions in versions.yaml +# --publish Push to OCI registry after building +# --validate Validate addon bundle structure against clusteraddon.yaml +# +# Without --version or --all, builds for the version in csctl.yaml. +# +# Environment: +# OCI_REGISTRY OCI registry (default: ttl.sh) +# OCI_REPOSITORY OCI repository (auto-generated for ttl.sh) +# OCI_USERNAME OCI auth username (optional) +# OCI_PASSWORD OCI auth password (optional) +# OCI_ACCESS_TOKEN OCI auth token (optional, alternative to user/pass) +# OUTPUT_DIR Output directory (default: .release) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# ============================================ +# Argument parsing +# ============================================ + +STACK_DIR="" +TARGET_VERSION="" +BUILD_ALL=false +PUBLISH=false +VALIDATE=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) TARGET_VERSION="$2"; shift 2 ;; + --all) BUILD_ALL=true; shift ;; + --publish) PUBLISH=true; shift ;; + --validate) VALIDATE=true; shift ;; + -*) echo "Unknown option: $1"; exit 1 ;; + *) + if [[ -z "$STACK_DIR" ]]; then + STACK_DIR="$1"; shift + else + echo "Unexpected argument: $1"; exit 1 + fi + ;; + esac +done + +if [[ -z "$STACK_DIR" ]]; then + echo "Usage: $0 [--version X.Y] [--all] [--publish] [--validate]" + exit 1 +fi + +if [[ ! -d "$STACK_DIR" ]]; then + echo "Stack directory not found: $STACK_DIR" + exit 1 +fi + +if [[ ! -f "$STACK_DIR/csctl.yaml" ]]; then + echo "csctl.yaml not found in: $STACK_DIR" + exit 1 +fi + +# ============================================ +# Read stack configuration +# ============================================ + +PROVIDER=$(yq '.config.provider.type' "$STACK_DIR/csctl.yaml") +STACK_NAME=$(yq '.config.clusterStackName' "$STACK_DIR/csctl.yaml") +OUTPUT_DIR="${OUTPUT_DIR:-.release}" + +# ============================================ +# Determine which K8s versions to build +# ============================================ + +collect_versions() { + if [[ "$BUILD_ALL" == "true" && -f "$STACK_DIR/versions.yaml" ]]; then + yq -r '.[].kubernetes' "$STACK_DIR/versions.yaml" + elif [[ -n "$TARGET_VERSION" && -f "$STACK_DIR/versions.yaml" ]]; then + yq -r ".[].kubernetes | select(test(\"^${TARGET_VERSION}\"))" "$STACK_DIR/versions.yaml" + else + # Fall back to csctl.yaml (strip leading 'v') + yq -r '.config.kubernetesVersion' "$STACK_DIR/csctl.yaml" | sed 's/^v//' + fi +} + +VERSIONS=$(collect_versions) + +if [[ -z "$VERSIONS" ]]; then + echo "No matching K8s versions found" + if [[ -f "$STACK_DIR/versions.yaml" ]]; then + echo "Available versions:" + yq -r '.[].kubernetes' "$STACK_DIR/versions.yaml" | sed 's/^/ - /' + fi + exit 1 +fi + +# ============================================ +# OCI registry setup +# ============================================ + +setup_oci() { + if [[ -z "${OCI_REGISTRY:-}" ]]; then + if ! command -v git >/dev/null 2>&1; then + echo "git not found — required for auto-generating ttl.sh repository names" + exit 1 + fi + DATE_YYYYMMDD="${OCI_DATE:-$(date +%Y%m%d)}" + export OCI_REGISTRY="ttl.sh" + export OCI_REPOSITORY="clusterstacks-${DATE_YYYYMMDD}" + echo "Auto-configured ttl.sh: $OCI_REGISTRY/$OCI_REPOSITORY (expires in 24h)" + fi +} + +# Determine release version for a given K8s minor version. +# Stable: queries OCI for highest vN tag, returns v(N+1). Dev: returns v0-. +get_release_version() { + local k8s_short="$1" + local k8s_dash="${k8s_short//./-}" + local tag_prefix="${PROVIDER}-${STACK_NAME}-${k8s_dash}" + + if [[ "${OCI_REGISTRY:-}" == "ttl.sh" ]] || [[ -z "${OCI_REPOSITORY:-}" ]]; then + # Dev version + local timestamp + timestamp=$(date +%s) + echo "v0-ttl.${timestamp}" + return + fi + + # Query OCI for existing stable versions + local latest=0 + if command -v oras >/dev/null 2>&1; then + local tags + tags=$(oras repo tags "${OCI_REGISTRY}/${OCI_REPOSITORY}" 2>/dev/null || echo "") + if [[ -n "$tags" ]]; then + latest=$(echo "$tags" | grep -oP "^${tag_prefix}-v\K[0-9]+" | sort -n | tail -1 || echo "0") + latest="${latest:-0}" + fi + fi + + echo "v$((latest + 1))" +} + +# ============================================ +# Build one K8s version +# ============================================ + +build_version() { + local k8s_version="$1" + local k8s_short + k8s_short=$(echo "$k8s_version" | grep -oP '^\d+\.\d+') + local k8s_dash="${k8s_short//./-}" + + echo "" + echo "Building ${PROVIDER}-${STACK_NAME} for K8s ${k8s_version}" + echo "---" + + # Get release version + if [[ "$PUBLISH" == "true" ]]; then + setup_oci + fi + local release_version + release_version=$(get_release_version "$k8s_short") + local release_dir="${OUTPUT_DIR}/${PROVIDER}-${STACK_NAME}-${k8s_dash}-${release_version}" + + mkdir -p "$release_dir" + + # ---- Prepare working copy ---- + local work_dir + work_dir=$(mktemp -d) + trap "rm -rf $work_dir" RETURN + + cp -r "$STACK_DIR/cluster-class" "$work_dir/" + cp -r "$STACK_DIR/cluster-addon" "$work_dir/" + + # Patch cluster-class Chart.yaml: set name and version + local class_chart="$work_dir/cluster-class/Chart.yaml" + yq -i ".name = \"${PROVIDER}-${STACK_NAME}-${k8s_dash}-cluster-class\"" "$class_chart" + yq -i ".version = \"${release_version}\"" "$class_chart" + + # Patch csctl.yaml kubernetes version + if [[ -f "$STACK_DIR/csctl.yaml" ]]; then + cp "$STACK_DIR/csctl.yaml" "$work_dir/csctl.yaml" + yq -i ".config.kubernetesVersion = \"v${k8s_version}\"" "$work_dir/csctl.yaml" + fi + + # Patch cluster-class values.yaml image names (if the field exists) + local class_values="$work_dir/cluster-class/values.yaml" + if [[ -f "$class_values" ]] && yq -e '.images.controlPlane.name' "$class_values" >/dev/null 2>&1; then + yq -i ".images.controlPlane.name = \"ubuntu-capi-image-v${k8s_version}\"" "$class_values" + yq -i ".images.worker.name = \"ubuntu-capi-image-v${k8s_version}\"" "$class_values" + fi + + # ---- Patch addon versions from versions.yaml ---- + if [[ -f "$STACK_DIR/versions.yaml" ]]; then + # Get the entry matching this K8s version + local version_entry + version_entry=$(yq -r ".[] | select(.kubernetes | test(\"^${k8s_short}\"))" "$STACK_DIR/versions.yaml") + + if [[ -n "$version_entry" ]]; then + # Get all keys except metadata fields — these are addon overrides + local addon_keys + addon_keys=$(echo "$version_entry" | yq -r 'keys | .[] | select(test("^(kubernetes|ubuntu)$") | not)') + + for addon_key in $addon_keys; do + local addon_version + addon_version=$(echo "$version_entry" | yq -r ".\"${addon_key}\"") + + # Find Chart.yaml files containing this dependency and patch + for chart_file in "$work_dir"/cluster-addon/*/Chart.yaml; do + [[ -f "$chart_file" ]] || continue + local has_dep + has_dep=$(yq ".dependencies[] | select(.name == \"${addon_key}\") | .name" "$chart_file" 2>/dev/null || echo "") + if [[ -n "$has_dep" ]]; then + yq -i "(.dependencies[] | select(.name == \"${addon_key}\")).version = \"${addon_version}\"" "$chart_file" + echo " Patched ${addon_key} -> ${addon_version}" + fi + done + done + fi + fi + + # ---- Package cluster-class ---- + echo " Packaging cluster-class..." + rm -rf "$work_dir/cluster-class/charts" + helm package "$work_dir/cluster-class" -d "$release_dir/" > /dev/null + echo " cluster-class packaged" + + # ---- Package cluster-addon bundle ---- + echo " Packaging cluster-addon..." + local addon_temp + addon_temp=$(mktemp -d) + local addon_count=0 + + for addon_dir in "$work_dir"/cluster-addon/*/; do + [[ -d "$addon_dir" ]] || continue + local addon_name + addon_name=$(basename "$addon_dir") + cp -r "$addon_dir" "$addon_temp/$addon_name" + rm -rf "$addon_temp/$addon_name/charts" + addon_count=$((addon_count + 1)) + done + + if [[ $addon_count -eq 0 ]]; then + echo " No addon subdirectories found" + rm -rf "$addon_temp" + exit 1 + fi + + local addon_tgz="${PROVIDER}-${STACK_NAME}-${k8s_dash}-cluster-addon-${release_version}.tgz" + (cd "$addon_temp" && tar -czf "$(cd "$REPO_ROOT" && pwd)/$release_dir/$addon_tgz" */) + rm -rf "$addon_temp" + echo " cluster-addon packaged ($addon_count addons)" + + # ---- Validate addon bundle ---- + if [[ "$VALIDATE" == "true" && -f "$STACK_DIR/clusteraddon.yaml" ]]; then + echo " Validating addon bundle..." + local validate_dir + validate_dir=$(mktemp -d) + tar -xzf "$release_dir/$addon_tgz" -C "$validate_dir" + + local expected_addons + expected_addons=$(yq '.addonStages | to_entries | .[].value[].name' "$STACK_DIR/clusteraddon.yaml" 2>/dev/null | sort -u) + + local failed=false + for addon in $expected_addons; do + if [[ ! -d "$validate_dir/$addon" ]]; then + echo " Missing addon: $addon (referenced in clusteraddon.yaml)" + failed=true + fi + done + rm -rf "$validate_dir" + + if [[ "$failed" == "true" ]]; then + exit 1 + fi + echo " Validation passed" + fi + + # ---- Copy clusteraddon.yaml ---- + if [[ -f "$STACK_DIR/clusteraddon.yaml" ]]; then + cp "$STACK_DIR/clusteraddon.yaml" "$release_dir/" + elif [[ -f "$STACK_DIR/cluster-addon-values.yaml" ]]; then + cp "$STACK_DIR/cluster-addon-values.yaml" "$release_dir/" + fi + + # ---- Generate metadata.yaml ---- + local git_hash + git_hash=$(git rev-parse HEAD 2>/dev/null || echo "unknown") + + cat > "$release_dir/metadata.yaml" < "$release_dir/hashes.json" </dev/null 2>&1; then + echo " oras not found — install from https://oras.land/docs/installation" + exit 1 + fi + + echo "" + echo " Publishing to $OCI_REGISTRY/$OCI_REPOSITORY:$oci_tag" + + local oras_opts=() + if [[ -n "${OCI_USERNAME:-}" && -n "${OCI_PASSWORD:-}" ]]; then + oras_opts+=(--username "$OCI_USERNAME" --password "$OCI_PASSWORD") + elif [[ -n "${OCI_ACCESS_TOKEN:-}" ]]; then + oras_opts+=(--password "$OCI_ACCESS_TOKEN") + fi + + local files=() + for f in "$release_dir"/*; do + [[ -f "$f" ]] && files+=("$(basename "$f")") + done + + (cd "$release_dir" && oras push \ + "$OCI_REGISTRY/$OCI_REPOSITORY:$oci_tag" \ + --artifact-type application/vnd.clusterstack.release \ + "${oras_opts[@]}" \ + "${files[@]}") + + echo " Published: $OCI_REGISTRY/$OCI_REPOSITORY:$oci_tag" + echo " Pull: oras pull $OCI_REGISTRY/$OCI_REPOSITORY:$oci_tag" +} + +# ============================================ +# Main +# ============================================ + +echo "Cluster Stack: ${PROVIDER}/${STACK_NAME}" +echo "K8s versions: $(echo "$VERSIONS" | tr '\n' ' ')" +echo "" + +for version in $VERSIONS; do + build_version "$version" +done + +echo "" +echo "Done." diff --git a/hack/docugen.py b/hack/docugen.py index 1f58fdd7..b3c45051 100755 --- a/hack/docugen.py +++ b/hack/docugen.py @@ -1,149 +1,149 @@ #!/usr/bin/env python3 """ -Generate markdown table from cluster-class variable definitions. +Generate markdown documentation from ClusterClass variable definitions. -This script temporarily renders the helm template of the cluster-class. -Parses the `Cluster.spec.topology.variables` definitions and -generates a markdown table for documentation purposes. +Renders the cluster-class Helm template, parses the topology variables +(openAPIV3Schema), and outputs a markdown table of all configurable options. + +Usage: + ./hack/docugen.py + ./hack/docugen.py --output docs/configuration.md + ./hack/docugen.py --template hack/config-template.md + ./hack/docugen.py --dry-run """ import argparse import subprocess -from pathlib import Path import sys +from pathlib import Path import yaml -BASE_PATH = Path(__file__).parent.parent -TEMPLATE_PATH = BASE_PATH.joinpath("providers", "openstack", "scs", "cluster-class") -DOCS_TMPL_PATH = BASE_PATH.joinpath("hack", "config-template.md") -DOCS_OUT_PATH = BASE_PATH.joinpath("docs", "providers", "openstack", "configuration.md") - - -def generate_row(content: list): - """Generate string of markdown table row.""" - row_tmpl = "|{content}|" - row_str = "|".join(content) - - return row_tmpl.format(content=row_str) - - -def parse_variable(tmpl: dict) -> list: - """ - Parse schema of simple cluster-stack variable type. (String, Boolean, Integer, Array) - Parameters: - tmpl (dict): Dictionary of variable schema, - parsed from cluster-class.yaml via yaml.safe_load(). +def generate_row(columns: list) -> str: + """Generate a markdown table row from a list of column values.""" + return "|" + "|".join(str(c) for c in columns) + "|" - Returns: List of variable schema properties. - Each entry represents a column in the final markdown table row. - """ - var_name = tmpl["name"] - var_required = tmpl["required"] - var_schema = tmpl["schema"]["openAPIV3Schema"] - var_type = var_schema["type"] - var_default = var_schema.get("default", "") - var_example = var_schema.get("example", "") - var_desc = var_schema.get("description", "TODO") +def parse_variable(var: dict) -> list: + """Parse a simple variable (string, boolean, integer, array) into table columns.""" + name = var["name"] + required = var["required"] + schema = var["schema"]["openAPIV3Schema"] - row = [] - row.append(f"`{var_name}`") - row.append(var_type) + var_type = schema["type"] + default = schema.get("default", "") + example = schema.get("example", "") + description = schema.get("description", "TODO").replace("\n", "
") - # Make sure that example and default values are quoted in the table if var_type == "string": - row.append(f'"{var_default}"') - row.append(f'"{var_example}"') - - # Cast any non-string type example / default to string - if var_type in ["array", "integer", "boolean"]: - row.append(str(var_default)) - row.append(str(var_example)) - - row.append(var_desc.replace("\n", "
")) - row.append(str(var_required)) # Convert boolean variable to string - - return row - - -def parse_object(tmpl: dict) -> list: - """ - Parse cluster-stack variable schema of type object. - Separated due to nesting in YAML format. - Parameters: - tmpl (dict): Dictionary of object schema, - parsed from cluster-class.yaml via yaml.safe_load(). - - Returns: - List of object properties. - Each entry represents a row in the final markdown table as a list, - consisting of the rows column values. - """ - var_name = tmpl["name"] - props = tmpl["schema"]["openAPIV3Schema"]["properties"] - - object_list = [] - for prop in props: - row = [] - row.append(f"`{var_name}.{prop}`") - row.append(props[prop]["type"]) - row.append(props[prop].get("default", "")) - row.append(props[prop].get("example", "")) - row.append(props[prop].get("description", "TODO")) - # append dummy row for required field - row.append("") - - object_list.append(row) - return object_list + default = f'"{default}"' + example = f'"{example}"' + else: + default = str(default) + example = str(example) + + return [f"`{name}`", var_type, default, example, description, str(required)] + + +def parse_object(var: dict) -> list: + """Parse an object variable into multiple table rows (one per property).""" + name = var["name"] + props = var["schema"]["openAPIV3Schema"]["properties"] + rows = [] + + for prop_name, prop_schema in props.items(): + rows.append([ + f"`{name}.{prop_name}`", + prop_schema["type"], + prop_schema.get("default", ""), + prop_schema.get("example", ""), + prop_schema.get("description", "TODO"), + "", + ]) + + return rows + + +def render_cluster_class(stack_dir: Path) -> dict: + """Render the cluster-class Helm template and return parsed YAML.""" + cluster_class_dir = stack_dir / "cluster-class" + if not cluster_class_dir.exists(): + print(f"cluster-class directory not found: {cluster_class_dir}", file=sys.stderr) + sys.exit(1) + + result = subprocess.run( + ["helm", "template", "docugen", str(cluster_class_dir), + "-s", "templates/cluster-class.yaml"], + capture_output=True, check=False, + ) + if result.returncode != 0: + print(f"helm template failed:\n{result.stderr.decode()}", file=sys.stderr) + sys.exit(1) -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument( - "--dry-run", action="store_true", help="Only print result to stdout." - ) + return yaml.safe_load(result.stdout.decode("utf-8")) - args = parser.parse_args() - cmd = [ - "helm", - "template", - "docugen", - TEMPLATE_PATH, - "-s", - "templates/cluster-class.yaml", +def generate_table(template: dict) -> str: + """Generate a markdown table from ClusterClass topology variables.""" + rows = [ + "|Name|Type|Default|Example|Description|Required|", + "|----|----|-------|-------|-----------|--------|", ] - with open(DOCS_TMPL_PATH, "r") as f: - tmpl = f.read() + for var in template["spec"]["variables"]: + var_type = var["schema"]["openAPIV3Schema"]["type"] + if var_type == "object": + for row in parse_object(var): + rows.append(generate_row(row)) + else: + rows.append(generate_row(parse_variable(var))) - cmdout = subprocess.run(cmd, capture_output=True, check=False) - rendered_template = cmdout.stdout.decode("utf-8") + return "\n".join(rows) - template = yaml.safe_load(rendered_template) - result_table = [] - result_table.append("|Name|Type|Default|Example|Description|Required|") - result_table.append("|----|----|-------|-------|-----------|--------|") +def main(): + parser = argparse.ArgumentParser( + description="Generate docs from ClusterClass variables", + ) + parser.add_argument( + "stack_dir", type=Path, + help="Path to the cluster stack directory", + ) + parser.add_argument( + "--template", type=Path, default=None, + help="Markdown template file with !!table!! placeholder", + ) + parser.add_argument( + "--output", "-o", type=Path, default=None, + help="Output file path (default: stdout)", + ) + parser.add_argument( + "--dry-run", action="store_true", + help="Print to stdout even if --output is set", + ) + args = parser.parse_args() - for var in template["spec"]["variables"]: - if var["schema"]["openAPIV3Schema"]["type"] in ["object"]: - parsed = parse_object(var) - for object_property in parsed: - result_table.append(generate_row(object_property)) - else: - parsed = parse_variable(var) - result_table.append(generate_row(parsed)) + # Render and parse + template = render_cluster_class(args.stack_dir) + table = generate_table(template) - output = tmpl.replace("!!table!!", "\n".join(result_table)) + # Apply template if provided + if args.template and args.template.exists(): + output = args.template.read_text().replace("!!table!!", table) + else: + output = table - if args.dry_run: + # Output + if args.dry_run or args.output is None: print(output) - sys.exit() + else: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(output) + print(f"Written: {args.output}", file=sys.stderr) + - print(f"Writing output to file {DOCS_OUT_PATH}") - with open(DOCS_OUT_PATH, "w") as f: - f.write(output) +if __name__ == "__main__": + main() diff --git a/hack/ensure-connected-to-mgt-cluster.sh b/hack/ensure-connected-to-mgt-cluster.sh deleted file mode 100755 index 5eda91e7..00000000 --- a/hack/ensure-connected-to-mgt-cluster.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/bin/bash -# Copyright 2023 The Kubernetes Authors. -# -# 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. - -set -euo pipefail - -context="$(kubectl config current-context 2>/dev/null || true)" - -if [ "$context" = "kind-scs-cluster-stacks" ]; then - exit 0 -fi - -if [ "$context" = "" ]; then - echo "No context set" - exit 1 -fi - - -echo "You are connected to $context. Please set KUBECONFIG to .mgt-cluster-kubeconfig.yaml" -exit 1 - -if [ "$#" -lt 1 ]; then - echo "Usage: $0 VAR1 VAR2 ..." - exit 1 -fi - -missing_vars=() -for varname in "$@"; do - eval varvalue="\$$varname" - if [ -z "$varvalue" ]; then - missing_vars+=("$varname") - fi -done - -if [ ${#missing_vars[@]} -gt 0 ]; then - echo "Missing or empty environment variables: ${missing_vars[*]}" - exit 1 -fi diff --git a/hack/ensure-env-variables.sh b/hack/ensure-env-variables.sh deleted file mode 100755 index 8dc027e4..00000000 --- a/hack/ensure-env-variables.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash -# Copyright 2023 The Kubernetes Authors. -# -# 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. - -if [ "$#" -lt 1 ]; then - echo "Usage: $0 VAR1 VAR2 ..." - exit 1 -fi - -missing_vars=() -for varname in "$@"; do - eval varvalue="\$$varname" - if [ -z "$varvalue" ]; then - missing_vars+=("$varname") - fi -done - -if [ ${#missing_vars[@]} -gt 0 ]; then - echo "Missing or empty environment variables: ${missing_vars[*]}" - exit 1 -fi diff --git a/hack/generate-image-manifests.sh b/hack/generate-image-manifests.sh new file mode 100755 index 00000000..149db832 --- /dev/null +++ b/hack/generate-image-manifests.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# Generate OpenStack Image CRD manifests for Kubernetes CAPI images. +# +# Reads versions.yaml to get K8s versions and Ubuntu releases, constructs +# image URLs, fetches SHA256 checksums, and outputs Image CRD YAML. +# +# The Ubuntu release is read from the "ubuntu" field in versions.yaml. +# Entries without an "ubuntu" field are skipped (e.g., docker provider stacks). +# +# Usage: +# ./hack/generate-image-manifests.sh # All versions +# ./hack/generate-image-manifests.sh --version 1.34 # Specific version +# ./hack/generate-image-manifests.sh --output-dir manifests/ # Write to files +# ./hack/generate-image-manifests.sh --skip-checksum # Skip checksum fetch +# +# Environment: +# IMAGE_BASE_URL Base URL for images (default: https://nbg1.your-objectstorage.com/osism/openstack-k8s-capi-images) +# CLOUD_NAME CloudCredentialsRef cloud name (default: openstack) +# SECRET_NAME CloudCredentialsRef secret name (default: openstack) + +set -euo pipefail + +# Defaults +BASE_URL="${IMAGE_BASE_URL:-https://nbg1.your-objectstorage.com/osism/openstack-k8s-capi-images}" +CLOUD_NAME="${CLOUD_NAME:-openstack}" +SECRET_NAME="${SECRET_NAME:-openstack}" + +# ============================================ +# Argument parsing +# ============================================ + +STACK_DIR="" +TARGET_VERSION="" +OUTPUT_DIR="" +SKIP_CHECKSUM=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) TARGET_VERSION="$2"; shift 2 ;; + --output-dir) OUTPUT_DIR="$2"; shift 2 ;; + --skip-checksum) SKIP_CHECKSUM=true; shift ;; + -*) echo "Unknown option: $1" >&2; exit 1 ;; + *) + if [[ -z "$STACK_DIR" ]]; then + STACK_DIR="$1"; shift + else + echo "Unexpected argument: $1" >&2; exit 1 + fi + ;; + esac +done + +if [[ -z "$STACK_DIR" ]]; then + echo "Usage: $0 [--version X.Y] [--output-dir dir] [--skip-checksum]" >&2 + exit 1 +fi + +if [[ ! -f "$STACK_DIR/versions.yaml" ]]; then + echo "versions.yaml not found in: $STACK_DIR" >&2 + exit 1 +fi + +# ============================================ +# Check for ubuntu field +# ============================================ + +HAS_UBUNTU=$(yq -r '.[0] | has("ubuntu")' "$STACK_DIR/versions.yaml" 2>/dev/null || echo "false") +if [[ "$HAS_UBUNTU" != "true" ]]; then + echo "No 'ubuntu' field in versions.yaml — this stack has no node images to generate." >&2 + echo "Image manifests are only relevant for OpenStack-based stacks." >&2 + exit 0 +fi + +# ============================================ +# Collect entries +# ============================================ + +ENTRY_COUNT=$(yq '. | length' "$STACK_DIR/versions.yaml") + +[[ -n "$OUTPUT_DIR" ]] && mkdir -p "$OUTPUT_DIR" + +# ============================================ +# Generate manifests +# ============================================ + +GENERATED=0 +FAIL_COUNT=0 + +for ((i=0; i&2 + echo "Use --skip-checksum to generate without hash validation" >&2 + FAIL_COUNT=$((FAIL_COUNT + 1)) + continue + fi + HASH_BLOCK=" + hash: + algorithm: sha256 + value: ${CHECKSUM}" + fi + + MANIFEST="--- +apiVersion: openstack.k-orc.cloud/v1alpha1 +kind: Image +metadata: + name: ubuntu-capi-image-v${K8S_VERSION} +spec: + cloudCredentialsRef: + cloudName: ${CLOUD_NAME} + secretName: ${SECRET_NAME} + managementPolicy: managed + resource: + visibility: private + properties: + hardware: + diskBus: scsi + scsiModel: virtio-scsi + vifModel: virtio + qemuGuestAgent: true + rngModel: virtio + architecture: x86_64 + minDiskGB: 20 + minMemoryMB: 2048 + operatingSystem: + distro: ubuntu + version: \"${UBUNTU:0:2}.${UBUNTU:2:2}\" + content: + diskFormat: qcow2 + download: + url: ${IMAGE_URL}${HASH_BLOCK}" + + if [[ -n "$OUTPUT_DIR" ]]; then + OUTFILE="${OUTPUT_DIR}/${IMAGE_NAME}.yaml" + echo "$MANIFEST" > "$OUTFILE" + echo "Written: $OUTFILE" >&2 + else + echo "$MANIFEST" + fi + + GENERATED=$((GENERATED + 1)) +done + +# ============================================ +# Summary +# ============================================ + +if [[ $GENERATED -eq 0 && $FAIL_COUNT -eq 0 ]]; then + echo "No matching versions found" >&2 + exit 1 +fi + +if [[ $FAIL_COUNT -gt 0 ]]; then + echo "Generated $GENERATED manifest(s), failed $FAIL_COUNT" >&2 + exit 1 +fi + +echo "Generated $GENERATED manifest(s)" >&2 diff --git a/hack/generate-resources.sh b/hack/generate-resources.sh new file mode 100755 index 00000000..a49dca22 --- /dev/null +++ b/hack/generate-resources.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Generate ClusterStack and Cluster YAML resources for testing. +# +# Usage: +# ./hack/generate-resources.sh --version 1.34 [options] +# +# Options: +# --version K8s minor version (required) +# --namespace Namespace (default: cluster) +# --cluster-name Workload cluster name (default: cs-cluster) +# --cluster-only Only generate the Cluster resource +# --clusterstack-only Only generate the ClusterStack resource +# +# Output goes to stdout. Pipe to kubectl apply -f - or redirect to a file. + +set -euo pipefail + +# ============================================ +# Argument parsing +# ============================================ + +STACK_DIR="" +K8S_VERSION="" +NAMESPACE="cluster" +CLUSTER_NAME="cs-cluster" +CLUSTER_ONLY=false +CLUSTERSTACK_ONLY=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) K8S_VERSION="$2"; shift 2 ;; + --namespace) NAMESPACE="$2"; shift 2 ;; + --cluster-name) CLUSTER_NAME="$2"; shift 2 ;; + --cluster-only) CLUSTER_ONLY=true; shift ;; + --clusterstack-only) CLUSTERSTACK_ONLY=true; shift ;; + -*) echo "Unknown option: $1" >&2; exit 1 ;; + *) + if [[ -z "$STACK_DIR" ]]; then + STACK_DIR="$1"; shift + else + echo "Unexpected argument: $1" >&2; exit 1 + fi + ;; + esac +done + +if [[ -z "$STACK_DIR" || -z "$K8S_VERSION" ]]; then + echo "Usage: $0 --version X.Y [--namespace ns] [--cluster-name name]" >&2 + exit 1 +fi + +if [[ ! -f "$STACK_DIR/csctl.yaml" ]]; then + echo "csctl.yaml not found in: $STACK_DIR" >&2 + exit 1 +fi + +# ============================================ +# Read stack configuration +# ============================================ + +PROVIDER=$(yq '.config.provider.type' "$STACK_DIR/csctl.yaml") +STACK_NAME=$(yq '.config.clusterStackName' "$STACK_DIR/csctl.yaml") +K8S_DASH="${K8S_VERSION//./-}" + +# Resolve full K8s version from versions.yaml if available +K8S_FULL="$K8S_VERSION" +if [[ -f "$STACK_DIR/versions.yaml" ]]; then + K8S_FULL=$(yq -r ".[] | select(.kubernetes | test(\"^${K8S_VERSION}\")) | .kubernetes" "$STACK_DIR/versions.yaml" | head -1) + K8S_FULL="${K8S_FULL:-$K8S_VERSION}" +fi + +# Try to find the latest CS version from OCI registry +CS_VERSION="v1" +if [[ -n "${OCI_REGISTRY:-}" && -n "${OCI_REPOSITORY:-}" ]] && command -v oras >/dev/null 2>&1; then + TAG_PREFIX="${PROVIDER}-${STACK_NAME}-${K8S_DASH}" + LATEST=$(oras repo tags "${OCI_REGISTRY}/${OCI_REPOSITORY}" 2>/dev/null | \ + grep -oP "^${TAG_PREFIX}-v\K[0-9]+" | sort -n | tail -1 || echo "") + [[ -n "$LATEST" ]] && CS_VERSION="v${LATEST}" +fi + +# ============================================ +# Generate ClusterStack resource +# ============================================ + +if [[ "$CLUSTER_ONLY" != "true" ]]; then + cat < list: - """ - Read supported versions from file for output or further usage inside this script. - - Parameters: - None - - Returns: - List of supported versions - """ - logger.info("Loading supported versions.") - version_file = SOURCE_PATH.joinpath("versions.yaml") - with open(version_file, encoding="utf-8") as stream: - try: - result = yaml.safe_load(stream) - except yaml.YAMLError as exc: - print(exc) - - return result - - -def get_dash_version(version: str) -> str: - """ - Helper function to convert version from dotted to separated by hyphen. (1.27.14 -> 1-27) - Parameters: - version (string): String containing the full semver version. - - Returns: - String with shortened version separated by a hypgen. - """ - return "-".join(version.split(".")[0:2]) - - -def create_output_dir(version: str) -> PosixPath: - """ - Prepare output directory by creating it and copying the source files over. - This overwrites files existing inside the output directory, as those - should not be edited manually. - - Parameters: - version (string): Semver version string as read from the supported versions list. - - Returns: - PosixPath object of the created directory. - """ - out = get_dash_version(version) - out_dir = DEFAULT_TARGET_PATH.joinpath(out) - - logger.info("Creating output directory at %s", out_dir) - - # TODO: how to handle FileExistsError? - # as the output is being generated, it *should* be safe to overwrite - if out_dir.exists(): - shutil.rmtree(str(out_dir)) - - # Copy whole tree from src dir and remove "versions.yaml" file - shutil.copytree(SOURCE_PATH, out_dir) - out_dir.joinpath("versions.yaml").unlink() - for file in out_dir.joinpath("cluster-addon").rglob("Chart.lock"): - file.unlink() - for folder in out_dir.joinpath("cluster-addon").rglob("charts"): - shutil.rmtree(folder) - - return out_dir - - -def readfile(path: PosixPath): - """ - Helper function to read yaml configuration files. - - Parameters: - path (PosixPath): pathlib object of the file to open. - - Returns: - Content of the yaml configuration file. - """ - # TODO: yaml.safe_load either returns a list or dict, - # depending on the structure of the yaml file. This can be improved / refactored. - with open(path, encoding="utf-8") as stream: - try: - content = yaml.safe_load(stream) - except yaml.YAMLError as exc: - print(exc) - - return content - - -def writefile(path: PosixPath, content): - """ - Helper function to write content to a yaml configuration file. - - Parameters: - path (PosixPath): pathlib object of the target file path. - content: yaml data to be written. This can either be of type list or dict. - - Returns: - None - """ - with open(path, "w", encoding="utf-8") as stream: - yaml.safe_dump(content, stream) - - -def update_cluster_addon( - target: PosixPath, build: bool, build_verbose: bool, **versions -): - """ - Update relevant files inside the cluster-stacks//cluster-addon subdirectory - - Parameters: - target (PosixPath): pathlib object of the relevant file. - build (boolean): Toggle to control if helm dependencies should be build, - build_verbose (boolean): Toggle to control if build output should be printed. - versions (kwargs): Dictionary of version information. - - Returns: - None - """ - logger.info("Updating %s", target) - content = readfile(target) - - for dep in content["dependencies"]: - if dep["name"] == "openstack-cinder-csi": - dep["version"] = versions["cinder_csi"] - - if dep["name"] == "openstack-cloud-controller-manager": - dep["version"] = versions["occm"] - - content["name"] = ( - f"openstack-scs-{get_dash_version(versions['kubernetes'])}-cluster-addon" - ) - - writefile(target, content) - - if build: - logger.info("Building helm dependencies") - cmd = ["helm", "dependency", "build"] - subprocess.run( - cmd, - cwd=str(target).replace("Chart.yaml", ""), - capture_output=build_verbose, - check=False, - ) - - -def update_csctl_conf(target: PosixPath, **versions): - """ - Function to update csctl configuration file. - - Parameters: - target (PosixPath): pathlib object of the relevant file. - versions (kwargs): Dictionary of version information. - - Returns: - None - """ - logger.info("Updating %s", target) - content = readfile(target) - - content["config"]["kubernetesVersion"] = f"v{versions['kubernetes']}" - - writefile(target, content) - - -def update_cluster_class(target: PosixPath, **kwargs): - """ - Update relevant files inside the cluster-stacks//cluster-class subdirectory. - - Parameters: - target (PosixPath): pathlib object of the relevant file. - versions (kwargs): Dictionary of version information. - - Returns: - None - """ - chart_file = target.joinpath("Chart.yaml") - values_file = target.joinpath("values.yaml") - - logger.info("Updating %s", chart_file) - content = readfile(chart_file) - version = get_dash_version(kwargs["kubernetes"]) - content["name"] = f"openstack-scs-{version}-cluster-class" - - writefile(chart_file, content) - - logger.info("Updating %s", values_file) - content = readfile(values_file) - - content["images"]["controlPlane"][ - "name" - ] = f"ubuntu-capi-image-v{kwargs['kubernetes']}" - content["images"]["worker"]["name"] = f"ubuntu-capi-image-v{kwargs['kubernetes']}" - - writefile(values_file, content) - - -def update_node_images(target: PosixPath, **kwargs): - """ - Update relevant files inside the cluster-stacks//node-images subdirectory. - - Parameters: - target (PosixPath): pathlib object of the relevant file. - versions (kwargs): Dictionary of version information. - - Returns: - None - """ - logger.info("Updating %s", target) - content = readfile(target) - - # TODO: can this magic URL be 'removed'? - # pylint: disable=locally-disabled, line-too-long - url = f"https://swift.services.a.regiocloud.tech/swift/v1/AUTH_b182637428444b9aa302bb8d5a5a418c/openstack-k8s-capi-images/ubuntu-2204-kube-v{kwargs['kubernetes'][0:4]}/ubuntu-2204-kube-v{kwargs['kubernetes']}.qcow2" - content["spec"]["resource"]["content"]["download"]["url"] = url - - checksum_url = f"{url}.CHECKSUM" - response = requests.get(checksum_url, timeout=30) - response.raise_for_status() - - checksum_line = response.text.strip() - checksum = checksum_line.split()[0] - - content["spec"]["resource"]["content"]["download"]["hash"]["value"] = checksum - logger.info("Updated checksum: %s", checksum) - - writefile(target, content) - - -if __name__ == "__main__": - LOGFORMAT = "%(asctime)s - %(levelname)s: %(message)s" - logging.basicConfig(level=logging.INFO, encoding="utf-8", format=LOGFORMAT) - # Initialize arg parser - parser = argparse.ArgumentParser() - parser.add_argument( - "-t", - "--target-version", - type=str, - help="Generate files for version specified like 1.XX. See '-l' to list supported versions.", - ) - parser.add_argument( - "-l", "--list", action="store_true", help="List supported versions and exit." - ) - parser.add_argument("--build", action="store_true", help="Build helm dependencies.") - parser.add_argument( - "--build-verbose", action="store_false", help="Show output of helm build" - ) - args = parser.parse_args() - - # Load supported target versions - sup_versions = load_supported_versions() - - if args.list: - print("Supported Kubernetes Versions:") - for v in sup_versions: - print(f"{'.'.join(v['kubernetes'].split('.')[0:2])}") - print("Usage: generate_version.py --target-version VERSION") - sys.exit() - - # filter versions to generate - if args.target_version: - target_versions = [ - v for v in sup_versions if v["kubernetes"].startswith(args.target_version) - ] - else: - target_versions = sup_versions - - for tv in target_versions: - output_dir = create_output_dir(tv["kubernetes"]) - for chart_yaml in output_dir.joinpath("cluster-addon").rglob("Chart.yaml"): - update_cluster_addon( - chart_yaml, - args.build, - args.build_verbose, - **tv, - ) - update_csctl_conf(output_dir.joinpath("csctl.yaml"), **tv) - update_cluster_class(output_dir.joinpath("cluster-class"), **tv) - update_node_images( - output_dir.joinpath("cluster-class", "templates", "image.yaml"), **tv - ) diff --git a/hack/kind-dev.sh b/hack/kind-dev.sh deleted file mode 100755 index 70c2d99e..00000000 --- a/hack/kind-dev.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash - -# Copyright 2023 The Kubernetes Authors. -# -# 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. - -set -o errexit -set -o pipefail -set -x - -K8S_VERSION=v1.27.2 - -REPO_ROOT=$(git rev-parse --show-toplevel) -cd "${REPO_ROOT}" || exit 1 - -# Creates a kind cluster with the ctlptl tool https://github.com/tilt-dev/ctlptl -function ctlptl_kind-cluster() { - - local CLUSTER_NAME=$1 - local CLUSTER_VERSION=$2 - - cat < +# +# Environment: +# OCI_REGISTRY OCI registry to query for CS versions (optional) +# OCI_REPOSITORY OCI repository to query for CS versions (optional) + +set -euo pipefail + +STACK_DIR="${1:?Usage: $0 }" + +if [[ ! -f "$STACK_DIR/csctl.yaml" ]]; then + echo "csctl.yaml not found in: $STACK_DIR" >&2 + exit 1 +fi + +PROVIDER=$(yq '.config.provider.type' "$STACK_DIR/csctl.yaml") +STACK_NAME=$(yq '.config.clusterStackName' "$STACK_DIR/csctl.yaml") + +# ============================================ +# Collect universal addon versions (from Chart.yaml) +# ============================================ + +declare -A UNIVERSAL_ADDONS + +for chart_file in "$STACK_DIR"/cluster-addon/*/Chart.yaml; do + [[ -f "$chart_file" ]] || continue + addon_name=$(basename "$(dirname "$chart_file")") + num_deps=$(yq '.dependencies | length' "$chart_file" 2>/dev/null || echo "0") + + for ((i=0; i/dev/null || echo "") + +# Collect all addon names for header +ALL_ADDONS=() +for dep_name in $(echo "${!UNIVERSAL_ADDONS[@]}" | tr ' ' '\n' | sort); do + # Skip addons that are in versions.yaml (they'll be shown per-version) + if echo "$VERSIONED_KEYS" | grep -qx "$dep_name" 2>/dev/null; then + continue + fi + ALL_ADDONS+=("$dep_name") +done +for key in $VERSIONED_KEYS; do + ALL_ADDONS+=("$key") +done + +# Print header +HEADER="K8s Version | CS Version" +SEPARATOR="--------------- | ----------" +for addon in "${ALL_ADDONS[@]}"; do + # Truncate long names for display + short=$(echo "$addon" | sed 's/openstack-/os-/') + HEADER+=" | $short" + SEPARATOR+=" | $(printf '%*s' ${#short} '' | tr ' ' '-')" +done +echo "$HEADER" +echo "$SEPARATOR" + +# Print rows +ENTRY_COUNT=$(yq '. | length' "$STACK_DIR/versions.yaml") + +for ((i=0; i/dev/null 2>&1; then + TAG_PREFIX="${PROVIDER}-${STACK_NAME}-${K8S_DASH}" + LATEST=$(oras repo tags "${OCI_REGISTRY}/${OCI_REPOSITORY}" 2>/dev/null | \ + grep -oP "^${TAG_PREFIX}-v\K[0-9]+" | sort -n | tail -1 || echo "") + [[ -n "$LATEST" ]] && CS_VERSION="v${LATEST}" + fi + + ROW=$(printf "%-15s | %-10s" "$K8S_VERSION" "$CS_VERSION") + + for addon in "${ALL_ADDONS[@]}"; do + # Check versions.yaml first (for K8s-tied addons) + ver=$(yq -r ".[$i].\"${addon}\" // \"\"" "$STACK_DIR/versions.yaml" 2>/dev/null || echo "") + if [[ -z "$ver" || "$ver" == "null" ]]; then + # Fall back to universal version from Chart.yaml + ver="${UNIVERSAL_ADDONS[$addon]:-?}" + fi + short=$(echo "$addon" | sed 's/openstack-/os-/') + ROW+=" | $(printf "%-${#short}s" "$ver")" + done + + echo "$ROW" +done diff --git a/hack/tools/release/notes.go b/hack/tools/release/notes.go deleted file mode 100644 index 50852632..00000000 --- a/hack/tools/release/notes.go +++ /dev/null @@ -1,209 +0,0 @@ -// +build tools - -/* -Copyright 2022 The Kubernetes Authors. - -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. -*/ - -//go:build tools -package main - -import ( - "bytes" - "flag" - "fmt" - "os" - "os/exec" - "strings" -) - -/* -This tool prints all the titles of all PRs from previous release to HEAD. -This needs to be run *before* a tag is created. - -Use these as the base of your release notes. -*/ - -const ( - features = ":sparkles: New Features" - bugs = ":bug: Bug Fixes" - documentation = ":book: Documentation" - proposals = ":memo: Proposals" - warning = ":warning: Breaking Changes" - other = ":seedling: Others" - unknown = ":question: Sort these by hand" -) - -var ( - outputOrder = []string{ - proposals, - warning, - features, - bugs, - other, - documentation, - unknown, - } - - fromTag = flag.String("from", "", "The tag or commit to start from.") -) - -func main() { - flag.Parse() - os.Exit(run()) -} - -func lastTag() string { - if fromTag != nil && *fromTag != "" { - return *fromTag - } - cmd := exec.Command("git", "describe", "--tags", "--abbrev=0") - out, err := cmd.Output() - if err != nil { - return firstCommit() - } - return string(bytes.TrimSpace(out)) -} - -func firstCommit() string { - cmd := exec.Command("git", "rev-list", "--max-parents=0", "HEAD") - out, err := cmd.Output() - if err != nil { - return "UNKNOWN" - } - return string(bytes.TrimSpace(out)) -} - -func run() int { - lastTag := lastTag() - cmd := exec.Command("git", "rev-list", lastTag+"..HEAD", "--merges", "--pretty=format:%B") //nolint:gosec - - merges := map[string][]string{ - features: {}, - bugs: {}, - documentation: {}, - warning: {}, - other: {}, - unknown: {}, - } - out, err := cmd.CombinedOutput() - if err != nil { - fmt.Println("Error") - fmt.Println(string(out)) - return 1 - } - - commits := []*commit{} - outLines := strings.Split(string(out), "\n") - for _, line := range outLines { - line = strings.TrimSpace(line) - last := len(commits) - 1 - switch { - case strings.HasPrefix(line, "commit"): - commits = append(commits, &commit{}) - case strings.HasPrefix(line, "Merge"): - commits[last].merge = line - continue - case line == "": - default: - commits[last].body = line - } - } - - for _, c := range commits { - body := strings.TrimSpace(c.body) - var key, prNumber, fork string - switch { - case strings.HasPrefix(body, ":sparkles:"), strings.HasPrefix(body, "✨"): - key = features - body = strings.TrimPrefix(body, ":sparkles:") - body = strings.TrimPrefix(body, "✨") - case strings.HasPrefix(body, ":bug:"), strings.HasPrefix(body, "🐛"): - key = bugs - body = strings.TrimPrefix(body, ":bug:") - body = strings.TrimPrefix(body, "🐛") - case strings.HasPrefix(body, ":book:"), strings.HasPrefix(body, "📖"): - key = documentation - body = strings.TrimPrefix(body, ":book:") - body = strings.TrimPrefix(body, "📖") - if strings.Contains(body, "CAEP") || strings.Contains(body, "proposal") { - key = proposals - } - case strings.HasPrefix(body, ":seedling:"), strings.HasPrefix(body, "🌱"): - key = other - body = strings.TrimPrefix(body, ":seedling:") - body = strings.TrimPrefix(body, "🌱") - case strings.HasPrefix(body, ":warning:"), strings.HasPrefix(body, "⚠️"): - key = warning - body = strings.TrimPrefix(body, ":warning:") - body = strings.TrimPrefix(body, "⚠️") - default: - key = unknown - } - - body = strings.TrimSpace(body) - if body == "" { - continue - } - body = fmt.Sprintf("- %s", body) - _, _ = fmt.Sscanf(c.merge, "Merge pull request %s from %s", &prNumber, &fork) - if key == documentation { - merges[key] = append(merges[key], prNumber) - continue - } - merges[key] = append(merges[key], formatMerge(body, prNumber)) - } - - // TODO Turn this into a link (requires knowing the project name + organization) - fmt.Printf("Changes since %v\n---\n", lastTag) - - for _, key := range outputOrder { - mergeslice := merges[key] - if len(mergeslice) == 0 { - continue - } - - switch key { - case documentation: - fmt.Printf( - ":book: Additionally, there have been %d contributions to our documentation and book. (%s) \n\n", - len(mergeslice), - strings.Join(mergeslice, ", "), - ) - default: - fmt.Println("## " + key) - for _, merge := range mergeslice { - fmt.Println(merge) - } - fmt.Println() - } - } - - fmt.Println("") - fmt.Println("_Thanks to all our contributors!_ 😊") - - return 0 -} - -type commit struct { - merge string - body string -} - -func formatMerge(line, prNumber string) string { - if prNumber == "" { - return line - } - return fmt.Sprintf("%s (%s)", line, prNumber) -} diff --git a/hack/update-addons.sh b/hack/update-addons.sh new file mode 100755 index 00000000..53b510bf --- /dev/null +++ b/hack/update-addons.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +# Helm chart dependency updater. +# Queries upstream repos for latest versions and asks before updating. +# Updates both Chart.yaml files and versions.yaml (for K8s-tied addons). +# +# Fully generic: reads repository URLs from Chart.yaml dependencies and derives +# K8s-tied addon list from versions.yaml keys. Works for any provider/stack. +# +# Usage: +# ./hack/update-addons.sh +# ./hack/update-addons.sh --k8s-version 1.34 +# ./hack/update-addons.sh --yes # auto-approve all updates +# +# Examples: +# ./hack/update-addons.sh providers/openstack/scs2 +# ./hack/update-addons.sh providers/openstack/scs2 --yes +# ./hack/update-addons.sh providers/docker/scs + +set -euo pipefail + +# ============================================ +# Argument parsing +# ============================================ + +STACK_DIR="" +K8S_VERSION="" +AUTO_APPROVE=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --k8s-version) K8S_VERSION="$2"; shift 2 ;; + --yes|-y) AUTO_APPROVE=true; shift ;; + -*) echo "Unknown option: $1"; exit 1 ;; + *) + if [[ -z "$STACK_DIR" ]]; then + STACK_DIR="$1"; shift + else + echo "Unexpected argument: $1"; exit 1 + fi + ;; + esac +done + +if [[ -z "$STACK_DIR" ]]; then + echo "Usage: $0 [--k8s-version X.Y] [--yes]" + exit 1 +fi + +if [[ ! -d "$STACK_DIR/cluster-addon" ]]; then + echo "cluster-addon directory not found in: $STACK_DIR" + exit 1 +fi + +# Auto-detect K8s version from versions.yaml (use highest) +if [[ -z "$K8S_VERSION" && -f "$STACK_DIR/versions.yaml" ]]; then + K8S_VERSION=$(yq -r '.[-1].kubernetes' "$STACK_DIR/versions.yaml" | grep -oP '^\d+\.\d+' || echo "") +fi + +echo "Checking addon updates for $STACK_DIR" +if [[ -n "$K8S_VERSION" ]]; then + echo "K8s version: $K8S_VERSION (used for version-tied chart filtering)" +fi +echo "" + +# ============================================ +# Derive K8s-tied chart names from versions.yaml +# ============================================ +# Any key in versions.yaml that is not a metadata field is a K8s-version-tied addon. +# Metadata fields (kubernetes, ubuntu) are excluded. +# This makes the script fully generic — no hardcoded chart names needed. + +METADATA_KEYS="kubernetes|ubuntu" +declare -A K8S_TIED_CHARTS=() + +if [[ -f "$STACK_DIR/versions.yaml" ]]; then + while IFS= read -r key; do + K8S_TIED_CHARTS["$key"]=1 + done < <(yq -r '.[0] | keys | .[] | select(test("^('"$METADATA_KEYS"')$") | not)' "$STACK_DIR/versions.yaml" 2>/dev/null) + + if [[ ${#K8S_TIED_CHARTS[@]} -gt 0 ]]; then + echo "K8s-tied addons (from versions.yaml): ${!K8S_TIED_CHARTS[*]}" + fi +fi + +# ============================================ +# Helm repository setup (from Chart.yaml) +# ============================================ +# Scan all addon Chart.yaml files and collect unique repository URLs. +# Each chart name gets its repo added dynamically — no hardcoded map needed. + +declare -A ADDED_REPOS=() + +echo "Adding Helm repositories..." +for addon_dir in "$STACK_DIR/cluster-addon"/*/; do + [[ -f "$addon_dir/Chart.yaml" ]] || continue + + num_deps=$(yq '.dependencies | length' "$addon_dir/Chart.yaml") + [[ "$num_deps" == "0" || "$num_deps" == "null" ]] && continue + + for ((i=0; i/dev/null || true + ADDED_REPOS["$dep_name"]="$dep_repo" + fi + done +done + +if [[ ${#ADDED_REPOS[@]} -gt 0 ]]; then + helm repo update > /dev/null 2>&1 +fi +echo "" + +# ============================================ +# Helper functions +# ============================================ + +is_k8s_tied() { + [[ -n "${K8S_TIED_CHARTS[$1]+_}" ]] +} + +get_latest_version() { + local chart="$1" + + # Repo must have been added in the setup phase + [[ -z "${ADDED_REPOS[$chart]+_}" ]] && return + + if is_k8s_tied "$chart" && [[ -n "$K8S_VERSION" ]]; then + local k8s_minor="${K8S_VERSION#*.}" + helm search repo "$chart/$chart" --versions -o json 2>/dev/null | \ + jq -r --arg minor "$k8s_minor" \ + '[.[] | select(.version | startswith("2." + $minor + "."))] | .[0].version // empty' 2>/dev/null || echo "" + else + helm search repo "$chart/$chart" -o json 2>/dev/null | \ + jq -r '.[0].version // empty' 2>/dev/null || echo "" + fi +} + +# Update versions.yaml when a K8s-tied addon is updated +update_versions_yaml() { + local chart_name="$1" + local new_version="$2" + + [[ ! -f "$STACK_DIR/versions.yaml" ]] && return + + # Check if this chart has entries in versions.yaml + local has_key + has_key=$(yq -r ".[0] | has(\"${chart_name}\")" "$STACK_DIR/versions.yaml" 2>/dev/null || echo "false") + [[ "$has_key" != "true" ]] && return + + # Update all entries in versions.yaml for this chart, matching by K8s minor version + local entry_count + entry_count=$(yq '. | length' "$STACK_DIR/versions.yaml") + + for ((j=0; j/dev/null | \ + jq -r --arg minor "$entry_minor" \ + '[.[] | select(.version | startswith("2." + $minor + "."))] | .[0].version // empty' 2>/dev/null || echo "") + if [[ -n "$tied_version" ]]; then + yq -i ".[${j}].\"${chart_name}\" = \"${tied_version}\"" "$STACK_DIR/versions.yaml" + echo " Updated versions.yaml: K8s ${entry_k8s} -> ${chart_name} ${tied_version}" + fi + else + yq -i ".[${j}].\"${chart_name}\" = \"${new_version}\"" "$STACK_DIR/versions.yaml" + echo " Updated versions.yaml: ${chart_name} ${new_version}" + fi + done +} + +# ============================================ +# Process each addon +# ============================================ + +UPDATES_COUNT=0 + +for addon_dir in "$STACK_DIR/cluster-addon"/*/; do + [[ -f "$addon_dir/Chart.yaml" ]] || continue + + addon_name=$(basename "$addon_dir") + chart_file="$addon_dir/Chart.yaml" + + echo "--- $addon_name ---" + + num_deps=$(yq '.dependencies | length' "$chart_file") + if [[ "$num_deps" == "0" || "$num_deps" == "null" ]]; then + echo " No dependencies" + continue + fi + + for ((i=0; i [--check|--apply] [--supported-minors N] +# +# Options: +# --check Show available updates without modifying files (default) +# --apply Apply updates to versions.yaml +# --supported-minors N Keep the N most recent K8s minor versions (default: 4) +# +# Environment: +# GITHUB_TOKEN Optional. GitHub personal access token for higher API rate limits. +# Without token: 60 requests/hour. With token: 5000 requests/hour. +# +# Examples: +# ./hack/update-versions.sh providers/openstack/scs2 --check +# ./hack/update-versions.sh providers/openstack/scs2 --apply +# GITHUB_TOKEN=ghp_xxx ./hack/update-versions.sh providers/openstack/scs2 --apply + +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Defaults +MODE="check" +SUPPORTED_MINORS=4 + +# --- Ubuntu version mapping --- +# Update this function when new Ubuntu LTS versions become available for CAPI images. +# Currently: +# K8s 1.32 and earlier → Ubuntu 22.04 (2204) +# K8s 1.33 and later → Ubuntu 24.04 (2404) +ubuntu_for_minor() { + local minor="$1" + if [[ "$minor" -le 32 ]]; then + echo "2204" + else + echo "2404" + fi +} + +# --- Helm repo URLs for K8s-tied addons --- +# Maps addon chart names to their Helm repo index URL. +# Only addons whose version tracks the K8s minor version belong here. +declare -A ADDON_HELM_REPOS=( + ["openstack-cloud-controller-manager"]="https://kubernetes.github.io/cloud-provider-openstack" + ["openstack-cinder-csi"]="https://kubernetes.github.io/cloud-provider-openstack" +) + +# Maps legacy addon key names (from versions.yaml) to their actual Helm chart name. +# This allows the script to work with both old and new key naming conventions. +declare -A ADDON_KEY_TO_CHART=( + ["occm"]="openstack-cloud-controller-manager" + ["cinder_csi"]="openstack-cinder-csi" + ["openstack-cloud-controller-manager"]="openstack-cloud-controller-manager" + ["openstack-cinder-csi"]="openstack-cinder-csi" +) + +# --- Helper functions --- + +usage() { + sed -n '3,/^$/s/^# \?//p' "$0" + exit 1 +} + +info() { echo " $*"; } +ok() { echo " ✓ $*"; } +warn() { echo " ! $*"; } +update(){ echo " → $*"; } + +github_curl() { + local url="$1" + local -a headers=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + headers=(-H "Authorization: token $GITHUB_TOKEN") + fi + curl -sfL "${headers[@]+"${headers[@]}"}" "$url" +} + +# Fetch all stable K8s release tags from GitHub. +# Returns lines like: 1.34.4 +fetch_k8s_versions() { + local page=1 + local all_tags="" + + while true; do + local tags + tags=$(github_curl "https://api.github.com/repos/kubernetes/kubernetes/tags?per_page=100&page=$page" \ + | jq -r '.[].name // empty') || { echo "Error fetching K8s tags from GitHub" >&2; return 1; } + + [[ -z "$tags" ]] && break + all_tags+="$tags"$'\n' + page=$((page + 1)) + + # Stop once we've gone past the versions we care about + if echo "$tags" | grep -qE '^v1\.2[0-9]\.'; then + break + fi + done + + # Filter to stable releases only (no alpha/beta/rc), strip 'v' prefix + echo "$all_tags" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sed 's/^v//' | sort -V +} + +# Get the highest patch version for a given minor from a list of versions. +# Usage: echo "$versions" | highest_patch 34 +highest_patch() { + local minor="$1" + grep -E "^1\.${minor}\.[0-9]+$" | sort -V | tail -1 +} + +# Get the N most recent K8s minor version numbers from a list of versions. +# Usage: echo "$versions" | recent_minors 4 +# Returns: lines like 32, 33, 34, 35 +recent_minors() { + local count="$1" + grep -oE '^1\.[0-9]+' | sort -t. -k2 -n -u | tail -"$count" | sed 's/^1\.//' +} + +# Fetch the Helm repo index and find the highest stable version matching a pattern. +# Usage: helm_latest_version "https://repo-url" "chart-name" "2.34" +helm_latest_version() { + local repo_url="$1" + local chart_name="$2" + local version_prefix="$3" + + curl -sfL "${repo_url}/index.yaml" \ + | yq ".entries.\"${chart_name}\"[].version" \ + | grep -E "^${version_prefix}\.[0-9]+$" \ + | sort -V \ + | tail -1 +} + +# Read the current versions.yaml as a list of K8s versions. +# Returns: lines like 1.32.8, 1.33.7, etc. +current_k8s_versions() { + local versions_file="$1" + yq -r '.[].kubernetes' "$versions_file" 2>/dev/null +} + +# Get a field from a versions.yaml entry by K8s version. +# Usage: get_version_field versions.yaml 1.34.3 openstack-cinder-csi +get_version_field() { + local versions_file="$1" + local k8s_version="$2" + local field="$3" + yq -r ".[] | select(.kubernetes == \"$k8s_version\") | .\"$field\" // \"\"" "$versions_file" +} + +# Detect which addon keys exist in versions.yaml (excluding kubernetes and ubuntu). +detect_addons() { + local versions_file="$1" + yq -r '.[0] | keys[] | select(test("^(kubernetes|ubuntu)$") | not)' "$versions_file" 2>/dev/null +} + +# --- Main logic --- + +parse_args() { + if [[ $# -lt 1 ]]; then + usage + fi + + STACK_DIR="$1" + shift + + # Resolve relative path + if [[ ! "$STACK_DIR" = /* ]]; then + STACK_DIR="$REPO_ROOT/$STACK_DIR" + fi + + while [[ $# -gt 0 ]]; do + case "$1" in + --check) MODE="check" ;; + --apply) MODE="apply" ;; + --supported-minors) + shift + SUPPORTED_MINORS="$1" + ;; + -h|--help) usage ;; + *) echo "Unknown option: $1" >&2; usage ;; + esac + shift + done +} + +main() { + parse_args "$@" + + local versions_file="$STACK_DIR/versions.yaml" + if [[ ! -f "$versions_file" ]]; then + echo "Error: versions.yaml not found in $STACK_DIR" >&2 + exit 1 + fi + + echo "Checking $STACK_DIR..." + echo "" + + # --- Step 1: Fetch all available K8s versions --- + echo "Fetching Kubernetes releases from GitHub..." + local all_k8s_versions + all_k8s_versions=$(fetch_k8s_versions) + + # Determine which minors to support + local supported + supported=$(echo "$all_k8s_versions" | recent_minors "$SUPPORTED_MINORS") + + echo "Supported minor versions: $(echo $supported | tr '\n' ' ' | sed 's/1\.//g; s/ $//; s/ /, 1./g; s/^/1./')" + echo "" + + # --- Step 2: Detect addons in versions.yaml --- + local addon_keys + addon_keys=$(detect_addons "$versions_file") || true + local has_addons=false + if [[ -n "$addon_keys" ]]; then + has_addons=true + fi + + # Check if stack has ubuntu mapping + local has_ubuntu=false + if [[ "$(yq '.[0].ubuntu' "$versions_file")" != "null" ]]; then + has_ubuntu=true + fi + + # --- Step 3: Fetch Helm repo indexes for K8s-tied addons --- + # Cache the index files to avoid redundant downloads + declare -A helm_index_cache + if [[ "$has_addons" == true ]]; then + echo "Fetching Helm repo indexes for addons..." + for addon_key in $addon_keys; do + local chart_name="${ADDON_KEY_TO_CHART[$addon_key]:-}" + if [[ -z "$chart_name" ]]; then + warn "Unknown addon key: $addon_key (not in ADDON_KEY_TO_CHART mapping)" + continue + fi + local repo_url="${ADDON_HELM_REPOS[$chart_name]:-}" + if [[ -z "$repo_url" ]]; then + warn "No Helm repo URL configured for chart: $chart_name" + continue + fi + if [[ -z "${helm_index_cache[$repo_url]:-}" ]]; then + helm_index_cache[$repo_url]=$(curl -sfL "${repo_url}/index.yaml") || { + warn "Failed to fetch Helm repo index: $repo_url" + continue + } + fi + done + echo "" + fi + + # --- Step 4: Build the new versions list --- + local current_versions + current_versions=$(current_k8s_versions "$versions_file") + local current_minors + current_minors=$(echo "$current_versions" | grep -oE '^1\.[0-9]+' | sed 's/^1\.//' | sort -n -u) + + # Track changes for reporting + local changes=0 + local new_entries="" + + echo "Kubernetes versions:" + for minor in $supported; do + local latest_patch + latest_patch=$(echo "$all_k8s_versions" | highest_patch "$minor") + + if [[ -z "$latest_patch" ]]; then + warn "1.$minor: no stable release found" + continue + fi + + # Check if this minor exists in current versions.yaml + local current_patch + current_patch=$(echo "$current_versions" | highest_patch "$minor") || true + + if [[ -z "$current_patch" ]]; then + update "1.$minor: NEW → $latest_patch" + changes=$((changes + 1)) + elif [[ "$current_patch" != "$latest_patch" ]]; then + update "1.$minor: $current_patch → $latest_patch" + changes=$((changes + 1)) + else + ok "1.$minor: $latest_patch (up to date)" + fi + done + + # Check for versions to remove (not in supported minors) + for current_minor in $current_minors; do + if ! echo "$supported" | grep -qx "$current_minor"; then + update "1.$current_minor: REMOVE (no longer supported)" + changes=$((changes + 1)) + fi + done + echo "" + + # --- Step 5: Check addon versions --- + if [[ "$has_addons" == true ]]; then + echo "K8s-tied addon versions:" + for addon_key in $addon_keys; do + local chart_name="${ADDON_KEY_TO_CHART[$addon_key]:-}" + [[ -z "$chart_name" ]] && continue + local repo_url="${ADDON_HELM_REPOS[$chart_name]:-}" + [[ -z "$repo_url" ]] && continue + + for minor in $supported; do + local addon_prefix="2.$minor" + local latest_addon + latest_addon=$(echo "${helm_index_cache[$repo_url]}" \ + | yq ".entries.\"${chart_name}\"[].version" \ + | grep -E "^${addon_prefix}\.[0-9]+$" \ + | sort -V \ + | tail -1) || true + + if [[ -z "$latest_addon" ]]; then + warn "$addon_key 1.$minor: no version found matching $addon_prefix.*" + continue + fi + + # Get current version from versions.yaml for this minor + local current_k8s + current_k8s=$(echo "$current_versions" | highest_patch "$minor") || true + local current_addon="" + if [[ -n "$current_k8s" ]]; then + current_addon=$(get_version_field "$versions_file" "$current_k8s" "$addon_key") + fi + + if [[ -z "$current_addon" ]]; then + update "$addon_key 1.$minor: NEW → $latest_addon" + changes=$((changes + 1)) + elif [[ "$current_addon" != "$latest_addon" ]]; then + update "$addon_key 1.$minor: $current_addon → $latest_addon" + changes=$((changes + 1)) + else + ok "$addon_key 1.$minor: $latest_addon (up to date)" + fi + done + done + echo "" + fi + + # --- Step 6: Apply changes --- + if [[ "$changes" -eq 0 ]]; then + echo "Everything is up to date." + return 0 + fi + + echo "$changes update(s) available." + + if [[ "$MODE" == "check" ]]; then + echo "" + echo "Run with --apply to update versions.yaml" + return 0 + fi + + echo "" + echo "Applying updates to $versions_file..." + + # Build new versions.yaml using a temp file to accumulate entries + local tmpfile + tmpfile=$(mktemp) + echo "[]" > "$tmpfile" + + for minor in $supported; do + local latest_patch + latest_patch=$(echo "$all_k8s_versions" | highest_patch "$minor") + [[ -z "$latest_patch" ]] && continue + + # Build the entry as a temp YAML file + local entry_file + entry_file=$(mktemp) + echo "kubernetes: \"$latest_patch\"" > "$entry_file" + + # Add ubuntu mapping if the stack uses it + if [[ "$has_ubuntu" == true ]]; then + local ubuntu_ver + ubuntu_ver=$(ubuntu_for_minor "$minor") + echo "ubuntu: \"$ubuntu_ver\"" >> "$entry_file" + fi + + # Add addon versions + if [[ "$has_addons" == true ]]; then + for addon_key in $addon_keys; do + local chart_name="${ADDON_KEY_TO_CHART[$addon_key]:-}" + local repo_url="" + local addon_prefix="2.$minor" + local latest_addon="" + + if [[ -n "$chart_name" ]]; then + repo_url="${ADDON_HELM_REPOS[$chart_name]:-}" + fi + + if [[ -n "$repo_url" ]] && [[ -n "${helm_index_cache[$repo_url]:-}" ]]; then + latest_addon=$(echo "${helm_index_cache[$repo_url]}" \ + | yq ".entries.\"${chart_name}\"[].version" \ + | grep -E "^${addon_prefix}\.[0-9]+$" \ + | sort -V \ + | tail -1) || true + fi + + if [[ -n "$latest_addon" ]]; then + echo "$addon_key: \"$latest_addon\"" >> "$entry_file" + else + # Keep the current version if we can't find a newer one + local current_k8s + current_k8s=$(echo "$current_versions" | highest_patch "$minor") || true + if [[ -n "$current_k8s" ]]; then + local current_addon + current_addon=$(get_version_field "$versions_file" "$current_k8s" "$addon_key") + if [[ -n "$current_addon" ]]; then + echo "$addon_key: \"$current_addon\"" >> "$entry_file" + fi + fi + fi + done + fi + + # Append entry to the array + yq -i ". += [load(\"$entry_file\")]" "$tmpfile" + rm -f "$entry_file" + done + + # Write the final versions.yaml + yq -P '.' "$tmpfile" > "$versions_file" + rm -f "$tmpfile" + + echo "Updated: $versions_file" + echo "" + echo "New content:" + cat "$versions_file" +} + +main "$@" diff --git a/justfile b/justfile new file mode 100644 index 00000000..9a298019 --- /dev/null +++ b/justfile @@ -0,0 +1,148 @@ +# Cluster Stacks build system +# Usage: just [args...] +# Config: set PROVIDER and CLUSTER_STACK env vars or in .env (default: openstack/scs2) +# +# All hack/ scripts derive the stack directory from $PROVIDER and $CLUSTER_STACK +# automatically. You can also pass a stack-dir as first argument to override. + +set dotenv-load +set positional-arguments + +export PROVIDER := env("PROVIDER", "openstack") +export CLUSTER_STACK := env("CLUSTER_STACK", "scs2") +CONTAINER_IMAGE := "cluster-stack-tools" + +# Show available recipes +default: + @just --list + +# ============================================ +# Build & Publish +# ============================================ + +# Build cluster-stack for a K8s version (e.g., just build 1.34) +build version: + ./hack/build.sh --version {{version}} + +# Build cluster-stack for all K8s versions +build-all: + ./hack/build.sh --all + +# Build and publish for a K8s version (e.g., just publish 1.34) +publish version: + ./hack/build.sh --version {{version}} --publish + +# Build and publish all K8s versions +publish-all: + ./hack/build.sh --all --publish + +# Clean build artifacts +clean: + rm -rf .release providers/*/out + @echo "Cleaned .release and provider output directories" + +# ============================================ +# Addon Management +# ============================================ + +# Check upstream Helm repos for newer addon versions (pass --yes to auto-approve) +update-addons *FLAGS: + ./hack/update-addons.sh {{FLAGS}} + +# Check upstream addon versions for ALL stacks (pass --yes to auto-approve) +update-addons-all *FLAGS: + #!/usr/bin/env bash + set -euo pipefail + for stack_dir in providers/*/*/; do + [[ -d "$stack_dir/cluster-addon" ]] || continue + echo "========== $stack_dir ==========" + ./hack/update-addons.sh "$stack_dir" {{FLAGS}} + echo "" + done + +# Check for K8s patch updates, new minors, and addon version bumps +update-versions *FLAGS: + ./hack/update-versions.sh {{FLAGS}} + +# Check for version updates across ALL stacks +update-versions-all *FLAGS: + #!/usr/bin/env bash + set -euo pipefail + for stack_dir in providers/*/*/; do + [[ -f "$stack_dir/versions.yaml" ]] || continue + echo "========== $stack_dir ==========" + ./hack/update-versions.sh "$stack_dir" {{FLAGS}} + echo "" + done + +# ============================================ +# Resource Generation +# ============================================ + +# Generate both ClusterStack + Cluster YAML (e.g., just generate-resources 1.34) +generate-resources version *FLAGS: + ./hack/generate-resources.sh --version {{version}} {{FLAGS}} + +# Generate only the ClusterStack resource (e.g., just generate-clusterstack 1.34) +generate-clusterstack version *FLAGS: + ./hack/generate-resources.sh --version {{version}} --clusterstack-only {{FLAGS}} + +# Generate only the Cluster resource (e.g., just generate-cluster 1.34) +generate-cluster version *FLAGS: + ./hack/generate-resources.sh --version {{version}} --cluster-only {{FLAGS}} + +# Generate OpenStack Image CRD manifests +generate-image-manifests: + ./hack/generate-image-manifests.sh + +# ============================================ +# Info +# ============================================ + +# Show version matrix for all K8s versions and addons +matrix: + ./hack/show-matrix.sh + +# Generate configuration docs from ClusterClass variables +generate-docs: + ./hack/docugen.py --template hack/config-template.md + +# ============================================ +# Container +# ============================================ + +# Detect container runtime (podman preferred, fallback to docker) +[private] +container-runtime: + #!/usr/bin/env bash + if command -v podman &>/dev/null; then echo "podman" + elif command -v docker &>/dev/null; then echo "docker" + else echo "ERROR: neither podman nor docker found" >&2; exit 1 + fi + +# Build the tools container image +container-build: + #!/usr/bin/env bash + set -euo pipefail + runtime=$(just container-runtime) + echo "Building {{CONTAINER_IMAGE}} with $runtime..." + $runtime build -t {{CONTAINER_IMAGE}} -f Containerfile . + +# Run any just recipe inside the container (e.g., just container-run build-all) +container-run *ARGS: + #!/usr/bin/env bash + set -euo pipefail + runtime=$(just container-runtime) + # Build image if it doesn't exist + if ! $runtime image exists {{CONTAINER_IMAGE}} 2>/dev/null && \ + ! $runtime images --format '{{{{.Repository}}' | grep -qx '{{CONTAINER_IMAGE}}'; then + echo "Image {{CONTAINER_IMAGE}} not found, building..." + just container-build + fi + $runtime run --rm -it \ + -v "$(pwd):/workspace" \ + -w /workspace \ + -e PROVIDER="$PROVIDER" \ + -e CLUSTER_STACK="$CLUSTER_STACK" \ + {{CONTAINER_IMAGE}} \ + just {{ARGS}}