diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml index 0db7dfa..df21195 100644 --- a/.github/workflows/on-pr.yaml +++ b/.github/workflows/on-pr.yaml @@ -12,10 +12,12 @@ on: paths: - ".github/workflows/on-pr.yaml" - ".gitops/**" + - "Makefile" - "apis/**" - "examples/**" - "tests/**" - "functions/**" + - "scripts/**" - "upbound.yaml" permissions: @@ -27,31 +29,51 @@ permissions: jobs: validate: - uses: unbounded-tech/workflows-crossplane/.github/workflows/validate.yaml@v3.0.0 - with: - examples: | - [ - { "example": "examples/crossplanestacks/minimal.yaml", "api_path": "apis/crossplanestacks" }, - { "example": "examples/crossplanestacks/standard.yaml", "api_path": "apis/crossplanestacks" }, - { "example": "examples/crossplanestacks/full.yaml", "api_path": "apis/crossplanestacks" }, - { "example": "examples/awsproviderstacks/minimal.yaml", "api_path": "apis/awsproviderstacks" }, - { "example": "examples/awsproviderstacks/full.yaml", "api_path": "apis/awsproviderstacks" }, - { "example": "examples/functionsstacks/minimal.yaml", "api_path": "apis/functionsstacks" }, - { "example": "examples/functionsstacks/full.yaml", "api_path": "apis/functionsstacks" }, - { "example": "examples/githubproviderstacks/minimal.yaml", "api_path": "apis/githubproviderstacks" }, - { "example": "examples/githubproviderstacks/full.yaml", "api_path": "apis/githubproviderstacks" }, - { "example": "examples/helmproviderstacks/minimal.yaml", "api_path": "apis/helmproviderstacks" }, - { "example": "examples/helmproviderstacks/full.yaml", "api_path": "apis/helmproviderstacks" }, - { "example": "examples/kubernetesproviderstacks/minimal.yaml", "api_path": "apis/kubernetesproviderstacks" }, - { "example": "examples/kubernetesproviderstacks/full.yaml", "api_path": "apis/kubernetesproviderstacks" }, - { "example": "examples/listmonkproviderstacks/minimal.yaml", "api_path": "apis/listmonkproviderstacks" }, - { "example": "examples/listmonkproviderstacks/full.yaml", "api_path": "apis/listmonkproviderstacks" }, - { "example": "examples/openpanelproviderstacks/minimal.yaml", "api_path": "apis/openpanelproviderstacks" }, - { "example": "examples/openpanelproviderstacks/full.yaml", "api_path": "apis/openpanelproviderstacks" }, - { "example": "examples/zitadelproviderstacks/minimal.yaml", "api_path": "apis/zitadelproviderstacks" }, - { "example": "examples/zitadelproviderstacks/full.yaml", "api_path": "apis/zitadelproviderstacks" } - ] - error_on_missing_schemas: true + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Cache crossplane + uses: actions/cache@v5 + with: + path: | + ~/.crossplane/cache + ~/.up/cache + ~/.up/build-cache + key: ${{ runner.os }}-crossplane-${{ hashFiles('**/upbound.yaml','**/definition.yaml') }}- + + - name: Install up + env: + UP_VERSION: v0.46.0 + run: | + set -euo pipefail + curl --fail --location --retry 5 --retry-all-errors --retry-delay 5 \ + "https://cli.upbound.io/stable/${UP_VERSION}/bin/linux_amd64/up" \ + --output up + chmod +x up + sudo mv up /usr/local/bin/up + up version + + - name: Install Crossplane CLI + run: | + curl -sL https://raw.githubusercontent.com/crossplane/crossplane/master/install.sh \ + | XP_VERSION=v2.0.2 sh + + - name: Docker login to GHCR + uses: docker/login-action@v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Build with up + uses: upbound/action-up-project@v1.1.2 + with: + skip-login-check: true + + - name: Validate all examples + run: scripts/ci-validate-all.sh test: uses: unbounded-tech/workflows-crossplane/.github/workflows/test.yaml@v3.0.0 diff --git a/.github/workflows/on-push-main.yaml b/.github/workflows/on-push-main.yaml index 15f25b3..ca3cd76 100644 --- a/.github/workflows/on-push-main.yaml +++ b/.github/workflows/on-push-main.yaml @@ -8,9 +8,11 @@ on: - ".github/workflows/on-push-main.yaml" - ".github/workflows/on-version-tagged.yaml" - ".gitops/**" + - "Makefile" - "apis/**" - "examples/**" - "functions/**" + - "scripts/**" - "tests/**" - "upbound.yaml" @@ -23,31 +25,51 @@ permissions: jobs: validate: - uses: unbounded-tech/workflows-crossplane/.github/workflows/validate.yaml@v3.0.0 - with: - examples: | - [ - { "example": "examples/crossplanestacks/minimal.yaml", "api_path": "apis/crossplanestacks" }, - { "example": "examples/crossplanestacks/standard.yaml", "api_path": "apis/crossplanestacks" }, - { "example": "examples/crossplanestacks/full.yaml", "api_path": "apis/crossplanestacks" }, - { "example": "examples/awsproviderstacks/minimal.yaml", "api_path": "apis/awsproviderstacks" }, - { "example": "examples/awsproviderstacks/full.yaml", "api_path": "apis/awsproviderstacks" }, - { "example": "examples/functionsstacks/minimal.yaml", "api_path": "apis/functionsstacks" }, - { "example": "examples/functionsstacks/full.yaml", "api_path": "apis/functionsstacks" }, - { "example": "examples/githubproviderstacks/minimal.yaml", "api_path": "apis/githubproviderstacks" }, - { "example": "examples/githubproviderstacks/full.yaml", "api_path": "apis/githubproviderstacks" }, - { "example": "examples/helmproviderstacks/minimal.yaml", "api_path": "apis/helmproviderstacks" }, - { "example": "examples/helmproviderstacks/full.yaml", "api_path": "apis/helmproviderstacks" }, - { "example": "examples/kubernetesproviderstacks/minimal.yaml", "api_path": "apis/kubernetesproviderstacks" }, - { "example": "examples/kubernetesproviderstacks/full.yaml", "api_path": "apis/kubernetesproviderstacks" }, - { "example": "examples/listmonkproviderstacks/minimal.yaml", "api_path": "apis/listmonkproviderstacks" }, - { "example": "examples/listmonkproviderstacks/full.yaml", "api_path": "apis/listmonkproviderstacks" }, - { "example": "examples/openpanelproviderstacks/minimal.yaml", "api_path": "apis/openpanelproviderstacks" }, - { "example": "examples/openpanelproviderstacks/full.yaml", "api_path": "apis/openpanelproviderstacks" }, - { "example": "examples/zitadelproviderstacks/minimal.yaml", "api_path": "apis/zitadelproviderstacks" }, - { "example": "examples/zitadelproviderstacks/full.yaml", "api_path": "apis/zitadelproviderstacks" } - ] - error_on_missing_schemas: true + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Cache crossplane + uses: actions/cache@v5 + with: + path: | + ~/.crossplane/cache + ~/.up/cache + ~/.up/build-cache + key: ${{ runner.os }}-crossplane-${{ hashFiles('**/upbound.yaml','**/definition.yaml') }}- + + - name: Install up + env: + UP_VERSION: v0.46.0 + run: | + set -euo pipefail + curl --fail --location --retry 5 --retry-all-errors --retry-delay 5 \ + "https://cli.upbound.io/stable/${UP_VERSION}/bin/linux_amd64/up" \ + --output up + chmod +x up + sudo mv up /usr/local/bin/up + up version + + - name: Install Crossplane CLI + run: | + curl -sL https://raw.githubusercontent.com/crossplane/crossplane/master/install.sh \ + | XP_VERSION=v2.0.2 sh + + - name: Docker login to GHCR + uses: docker/login-action@v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Build with up + uses: upbound/action-up-project@v1.1.2 + with: + skip-login-check: true + + - name: Validate all examples + run: scripts/ci-validate-all.sh test: uses: unbounded-tech/workflows-crossplane/.github/workflows/test.yaml@v3.0.0 diff --git a/Makefile b/Makefile index 89fbeb5..3b84783 100644 --- a/Makefile +++ b/Makefile @@ -89,9 +89,11 @@ validate\:all: generate-configuration done # Shorthand aliases -.PHONY: render validate generate-configuration +.PHONY: render validate generate-configuration print-examples render: ; @$(MAKE) 'render:all' validate: ; @$(MAKE) generate-configuration 'validate:all' +print-examples: + @printf '%s\n' $(EXAMPLES) # Single example targets render\:%: diff --git a/scripts/ci-validate-all.sh b/scripts/ci-validate-all.sh new file mode 100755 index 0000000..79c949e --- /dev/null +++ b/scripts/ci-validate-all.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +set -euo pipefail + +crossplane_bin="${CROSSPLANE_BIN:-./crossplane}" +if [ ! -x "$crossplane_bin" ]; then + crossplane_bin="$(command -v crossplane)" +fi + +configuration_from_upbound() { + local api_dir="$1" + local output_file="${api_dir}/configuration.yaml" + + mkdir -p "$api_dir" + + python3 - upbound.yaml "$output_file" <<'PY' +import re +import sys +import tempfile + +try: + import yaml +except Exception: + target = f"{tempfile.gettempdir()}/aws-crossplane-stack-pyyaml" + sys.path.insert(0, target) + try: + import yaml + except Exception: + import subprocess + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--quiet", "--target", target, "PyYAML"] + ) + import yaml + +in_file, out_file = sys.argv[1], sys.argv[2] + +with open(in_file, "r", encoding="utf-8") as f: + project = yaml.safe_load(f) or {} + +metadata = project.get("metadata") or {} +spec = project.get("spec") or {} + +name = metadata.get("name") +if not name: + raise SystemExit("upbound.yaml is missing metadata.name") + +maintainer = spec.get("maintainer", "") +if isinstance(maintainer, str): + maintainer = re.sub(r"\s*<[^>]+>\s*$", "", maintainer).strip() + +annotations = { + "meta.crossplane.io/maintainer": maintainer, + "meta.crossplane.io/source": spec.get("source", ""), + "meta.crossplane.io/description": spec.get("description", ""), +} + +depends_on = [] +for dep in spec.get("dependsOn") or []: + if not isinstance(dep, dict): + continue + + kind = str(dep.get("kind", "")).strip().lower() + package = dep.get("package") + version = dep.get("version") + if not package: + continue + + if kind == "provider": + item = {"provider": package} + elif kind == "function": + item = {"function": package} + elif kind == "configuration": + item = {"configuration": package} + else: + continue + + if version is not None: + item["version"] = version + depends_on.append(item) + +output = { + "apiVersion": "meta.pkg.crossplane.io/v1alpha1", + "kind": "Configuration", + "metadata": { + "name": name, + "annotations": {k: v for k, v in annotations.items() if v}, + }, + "spec": { + "dependsOn": depends_on, + }, +} + +with open(out_file, "w", encoding="utf-8") as f: + yaml.safe_dump(output, f, sort_keys=False) + +print(f"Wrote {out_file}") +PY +} + +render_with_retry() { + local attempt=1 + local max_attempts="${RENDER_RETRIES:-3}" + local retry_delay="${RENDER_RETRY_DELAY_SECONDS:-5}" + local status=0 + local output + + output="$(mktemp)" + while true; do + if up composition render "$@" >"$output"; then + cat "$output" + rm -f "$output" + return 0 + fi + + status=$? + rm -f "$output" + if [ "$attempt" -ge "$max_attempts" ]; then + return "$status" + fi + + echo "Render attempt ${attempt}/${max_attempts} failed; retrying in ${retry_delay}s..." >&2 + sleep "$retry_delay" + attempt=$((attempt + 1)) + output="$(mktemp)" + done +} + +while IFS= read -r entry; do + [ -n "$entry" ] || continue + + example="${entry%%::*}" + observed="${entry#*::}" + api_dir="$(awk -F/ '{print "apis/" $2}' <<<"$example")" + composition="${api_dir}/composition.yaml" + definition="${api_dir}/definition.yaml" + render_args=(--xrd="$definition" "$composition" "$example") + + if [ -n "$observed" ]; then + render_args+=(--observed-resources="$observed") + fi + + echo "=== Validating ${example} (api=${api_dir}) ===" + configuration_from_upbound "$api_dir" + + render_with_retry "${render_args[@]}" --quiet >/dev/null + + "$crossplane_bin" beta validate "$example" "$api_dir" + + render_with_retry \ + "${render_args[@]}" \ + --include-full-xr \ + --quiet \ + | "$crossplane_bin" beta validate "$api_dir" --error-on-missing-schemas - + + echo "" +done < <(make --no-print-directory print-examples)