diff --git a/.github/workflows/update-resource-types.yaml b/.github/workflows/update-resource-types.yaml index 52ae24178f9..85ac05c4667 100644 --- a/.github/workflows/update-resource-types.yaml +++ b/.github/workflows/update-resource-types.yaml @@ -38,7 +38,8 @@ name: Update Resource Types # selects a stable unit release when one exists, otherwise resolves the edge # candidate to an immutable commit SHA, records it in # deploy/manifest/defaults.yaml (`resourceTypes[]` / `recipePacks[]`), and -# re-copies the manifests under deploy/manifest/built-in-providers/. It then +# re-copies the manifests under deploy/manifest/built-in-providers/ and the +# compiled kubernetes recipe pack under deploy/manifest/recipe-packs/. It then # runs `make generate` and commits all resulting generated artifacts. The # event's units advance, as do any consumed edge units whose first stable # release is now available; entries Radius does not register are skipped. @@ -229,6 +230,11 @@ jobs: # PATH from the next step onwards. run: make install-yq + - name: Install Bicep + # Required to compile the kubernetes recipe pack. The sync script + # requires the version pinned in build/tools.yaml. + run: make install-bicep + - name: Prepare cumulative update branch # Rebuild the bot branch from the current main on every dispatch instead # of rebasing its previous commit. make update-resource-types @@ -373,7 +379,7 @@ jobs: sign-commits: true title: "chore(resource-types-contrib): updates" body: | - Automated refresh of resource type manifests under `deploy/manifest/built-in-providers/`. + Automated refresh of resource type manifests under `deploy/manifest/built-in-providers/` and the kubernetes recipe pack under `deploy/manifest/recipe-packs/`. Triggered by [`${{ env.CONTRIB_REPO }}`](https://github.com/${{ env.CONTRIB_REPO }}) on the `${{ steps.contrib.outputs.channel }}` channel at ref `${{ steps.contrib.outputs.ref }}`. Affected: ${{ steps.contrib.outputs.affected }}. diff --git a/.github/workflows/verify-resource-types-manifest.yaml b/.github/workflows/verify-resource-types-manifest.yaml index 82ad04dc56d..def85f8fc87 100644 --- a/.github/workflows/verify-resource-types-manifest.yaml +++ b/.github/workflows/verify-resource-types-manifest.yaml @@ -48,11 +48,13 @@ on: - main - release/* paths: - # Trigger when the default list and per-namespace pins, the copied - # manifests, or the sync logic (Makefile, resource-types.mk, the sync - # script) changes. + # Trigger when the default list and pins, the copied manifests, the + # compiled recipe pack, or the sync logic (Makefile, resource-types.mk, + # the sync script, the Bicep pin) changes. - "deploy/manifest/defaults.yaml" - "deploy/manifest/built-in-providers/**" + - "deploy/manifest/recipe-packs/**" + - "build/tools.yaml" - "Makefile" - "build/resource-types.mk" - "build/scripts/sync-resource-types.sh" @@ -86,11 +88,15 @@ jobs: - name: Install yq run: make install-yq + - name: Install Bicep + run: make install-bicep + - name: Test stable-first pin selection run: make test-sync-resource-types # Re-run the copy step (without bumping the ref) to regenerate the - # manifest files from the ref pinned in deploy/manifest/defaults.yaml. + # manifest files and the compiled recipe pack from the refs pinned in + # deploy/manifest/defaults.yaml. - name: Sync resource types from pinned ref run: make sync-resource-types @@ -99,16 +105,16 @@ jobs: - name: Check for drift run: | changes="$(git status --porcelain --untracked-files=all -- \ - deploy/manifest/built-in-providers/)" + deploy/manifest/built-in-providers/ deploy/manifest/recipe-packs/)" if [ -n "${changes}" ]; then echo "" - echo "ERROR: Resource type manifest copies are out of sync with the pinned" - echo "resource-types-contrib revisions (resourceTypes[].ref) in deploy/manifest/defaults.yaml." + echo "ERROR: Resource type manifest copies or the compiled recipe pack are out of" + echo "sync with the pinned resource-types-contrib revisions in deploy/manifest/defaults.yaml." echo "" printf '%s\n' "${changes}" - git diff -- deploy/manifest/built-in-providers/ + git diff -- deploy/manifest/built-in-providers/ deploy/manifest/recipe-packs/ echo "" - echo "Run 'make sync-resource-types' and commit the updated files." + echo "Run 'make install-bicep' and 'make sync-resource-types', then commit the updated files." exit 1 fi - echo "Resource type manifest copies are in sync." + echo "Resource type manifest copies and the compiled recipe pack are in sync." diff --git a/build/resource-types.mk b/build/resource-types.mk index dcb5c8132a7..4cfe23937ec 100644 --- a/build/resource-types.mk +++ b/build/resource-types.mk @@ -32,9 +32,14 @@ # //.yaml (with the "Radius." # prefix stripped) into every destination directory, and prunes # stale managed files. -# recipePacks per-pack pins for the recipe packs published upstream. Recipe -# packs are not vendored here. Extension workflows resolve -# their immutable sources directly from this catalog. +# recipePacks per-pack pins for the recipe packs published upstream. The +# kubernetes pack is compiled from +# recipe-packs/kubernetes/default.bicep into +# deploy/manifest/recipe-packs/kubernetes/default.json, which +# rad embeds to create the default recipe pack. Compiling +# requires the Bicep CLI pinned in build/tools.yaml +# (make install-bicep). Other packs are not vendored; extension +# workflows resolve their immutable sources from this catalog. # # At startup UCP's RegisterDirectory loads the committed files unchanged; # manifests without a "location" field are routed via DefaultDownstreamEndpoint @@ -49,11 +54,12 @@ # when no stable release exists, then pin the commit # SHA and copy the manifest files. # update-recipe-packs - Apply the same stable-first selection to each -# recipePacks entry. Nothing is copied; only the pins -# are rewritten. -# sync-resource-types - Copy manifest files from the refs already pinned in -# defaults.yaml (no ref bump). Used by CI to verify -# manifest and pin drift. +# recipePacks entry, then recompile the kubernetes +# pack. Resource type manifests are not copied. +# sync-resource-types - Copy manifest files and compile the kubernetes pack +# from the refs already pinned in defaults.yaml (no +# ref bump). Used by CI to verify manifest and pin +# drift. # Path to the file listing default resource types and the upstream pins. DEFAULTS_YAML := deploy/manifest/defaults.yaml @@ -104,7 +110,8 @@ export RECIPE_PACKS_REF RECIPE_PACKS_NAME RECIPE_PACKS_PINS SYNC_RESOURCE_TYPES_ENV := \ DEFAULTS_YAML="$(DEFAULTS_YAML)" \ MANIFEST_DEST_DIRS="$(MANIFEST_DEST_DIRS)" \ - MANUAL_CORE_MANIFESTS="$(MANUAL_CORE_MANIFESTS)" + MANUAL_CORE_MANIFESTS="$(MANUAL_CORE_MANIFESTS)" \ + BICEP_VERSION="$(BICEP_VERSION)" ##@ Resource Types @@ -117,11 +124,11 @@ update-resource-types: ## Pin stable resource type releases (edge only when unre @$(SYNC_RESOURCE_TYPES_ENV) ./build/scripts/sync-resource-types.sh --update .PHONY: update-recipe-packs -update-recipe-packs: ## Pin stable recipe pack releases (edge only when unreleased) +update-recipe-packs: ## Pin stable recipe pack releases (edge only when unreleased) and compile the kubernetes pack @$(SYNC_RESOURCE_TYPES_ENV) ./build/scripts/sync-resource-types.sh --update-recipe-packs .PHONY: sync-resource-types -sync-resource-types: ## Copy manifest files from the per-namespace refs pinned in defaults.yaml +sync-resource-types: ## Copy manifest files and compile the kubernetes recipe pack from the refs pinned in defaults.yaml @$(SYNC_RESOURCE_TYPES_ENV) ./build/scripts/sync-resource-types.sh .PHONY: test-sync-resource-types diff --git a/build/scripts/sync-resource-types.sh b/build/scripts/sync-resource-types.sh index e3f8da5a746..b29c37f8a57 100755 --- a/build/scripts/sync-resource-types.sh +++ b/build/scripts/sync-resource-types.sh @@ -15,18 +15,24 @@ set -euo pipefail # optional SVG icons are copied into every destination # directory and stale managed files are pruned. # recipePacks one entry per recipe pack folder published upstream (azure, -# kubernetes, ...). Recipe packs are not vendored into this -# repo, so they are only pinned - never copied. +# kubernetes, ...). Every pinned pack folder is verified to +# exist. Packs listed in VENDORED_RECIPE_PACKS (kubernetes) are +# also compiled from recipe-packs//default.bicep to +# RECIPE_PACK_DEST_DIR//default.json, which rad embeds +# to create the default recipe pack. Other packs are only +# pinned. # # Path resolution: strip the "Radius." prefix from a defaultRegistration entry, # then //.yaml (e.g. Radius.Compute/containers -> # Compute/containers/containers.yaml) inside the fetched tree. # # Modes: -# (default) Validate all pins and copy resource type manifests. +# (default) Validate all pins, copy resource type manifests and +# compile vendored recipe packs. # --update Re-pin `resourceTypes`, then copy. -# --update-recipe-packs Re-pin `recipePacks` only (nothing is copied). -# --update-all Atomically re-pin both sections, then copy manifests. +# --update-recipe-packs Re-pin `recipePacks`, then compile vendored recipe +# packs (resource type manifests are not copied). +# --update-all Atomically re-pin both sections, then copy. # # Both update modes select what to pin the same way, with the *_PINS variable # winning when set: @@ -54,6 +60,10 @@ set -euo pipefail # RECIPE_PACKS_PINS --update-recipe-packs: JSON [{name, ref}, ...]. # RECIPE_PACKS_REF --update-recipe-packs: ref (default "main"). # RECIPE_PACKS_NAME --update-recipe-packs: limit to one pack. +# RECIPE_PACK_DEST_DIR Destination for compiled vendored recipe packs. +# VENDORED_RECIPE_PACKS Space-separated recipe packs to compile and copy. +# BICEP Bicep CLI used to compile packs (default "bicep"). +# BICEP_VERSION Required Bicep version (default: build/tools.yaml). readonly DEFAULTS_YAML="${DEFAULTS_YAML:-deploy/manifest/defaults.yaml}" readonly MANIFEST_DEST_DIRS="${MANIFEST_DEST_DIRS:-deploy/manifest/built-in-providers/dev deploy/manifest/built-in-providers/self-hosted}" @@ -64,6 +74,16 @@ readonly RESOURCE_TYPES_PINS="${RESOURCE_TYPES_PINS:-}" readonly RECIPE_PACKS_REF="${RECIPE_PACKS_REF:-main}" readonly RECIPE_PACKS_NAME="${RECIPE_PACKS_NAME:-}" readonly RECIPE_PACKS_PINS="${RECIPE_PACKS_PINS:-}" +RECIPE_PACK_DEST_DIR="${RECIPE_PACK_DEST_DIR:-deploy/manifest/recipe-packs}" +readonly VENDORED_RECIPE_PACKS="${VENDORED_RECIPE_PACKS:-kubernetes}" +BICEP="${BICEP:-bicep}" +BICEP_VERSION="${BICEP_VERSION:-}" + +# The vendored pack's entry file and the Radius extension version recorded in +# its compiled imports. The version is fixed so the output does not depend on +# the build channel. +readonly RECIPE_PACK_ENTRY_FILE="default.bicep" +readonly RECIPE_PACK_RADIUS_EXTENSION="br:biceptypes.azurecr.io/radius:latest" # YAML keys of the two pin sections in defaults.yaml. readonly RESOURCE_TYPES_SECTION="resourceTypes" @@ -528,7 +548,7 @@ fetch_ref() { # type belonging to that resourceTypes entry into all destination directories. copy_manifests() { local tmp_root pairs_file i=0 repo ref dir entry ns rel type src src_icon dest - local pack pack_repo pack_ref + local pack tmp_root="$(mktemp -d)" # shellcheck disable=SC2064 trap "rm -rf '${tmp_root}'" EXIT @@ -577,14 +597,106 @@ copy_manifests() { echo " Copied ${entry}" fi done - for pack in $(entry_names "${RECIPE_PACKS_SECTION}"); do - pack_repo="$(pin_field "${RECIPE_PACKS_SECTION}" "${pack}" repo)" - pack_ref="$(pin_field "${RECIPE_PACKS_SECTION}" "${pack}" ref)" - [[ "${pack_repo}|${pack_ref}" == "${repo}|${ref}" ]] || continue - [[ -d "${dir}/recipe-packs/${pack}" ]] || - fail "Recipe pack directory not found: recipe-packs/${pack} at ${repo}@${ref}." + process_recipe_packs "${dir}" "${repo}" "${ref}" + done <"${pairs_file}" +} + +# is_vendored_recipe_pack succeeds when the pack is compiled and copied. +is_vendored_recipe_pack() { + local vendored + for vendored in ${VENDORED_RECIPE_PACKS}; do + [[ "${vendored}" == "$1" ]] && return 0 + done + return 1 +} + +# process_recipe_packs verifies every recipePacks entry +# pinned to (repo, ref) exists in the fetched tree and compiles vendored ones. +process_recipe_packs() { + local dir="$1" repo="$2" ref="$3" pack pack_repo pack_ref + for pack in $(entry_names "${RECIPE_PACKS_SECTION}"); do + pack_repo="$(pin_field "${RECIPE_PACKS_SECTION}" "${pack}" repo)" + pack_ref="$(pin_field "${RECIPE_PACKS_SECTION}" "${pack}" ref)" + [[ "${pack_repo}|${pack_ref}" == "${repo}|${ref}" ]] || continue + [[ -d "${dir}/recipe-packs/${pack}" ]] || + fail "Recipe pack directory not found: recipe-packs/${pack} at ${repo}@${ref}." + if is_vendored_recipe_pack "${pack}"; then + vendor_recipe_pack "${dir}/recipe-packs/${pack}" "${pack}" "${repo}" "${ref}" + echo " Compiled recipe pack ${pack}" + else echo " Verified recipe pack ${pack}" - done + fi + done +} + +# require_bicep fails unless BICEP is the version pinned in build/tools.yaml, so +# local and CI runs compile identical JSON. +require_bicep() { + local actual + command -v "${BICEP}" >/dev/null 2>&1 || + fail "Bicep CLI '${BICEP}' not found. Install it via: make install-bicep" + if [[ -z "${BICEP_VERSION}" ]]; then + BICEP_VERSION="$(yq -r '.tools[] | select(.name == "bicep") | .version' build/tools.yaml)" + fi + actual="$("${BICEP}" --version | sed -n 's/^Bicep CLI version \([0-9.]*\).*/\1/p')" + [[ "v${actual}" == "${BICEP_VERSION}" ]] || + fail "Bicep ${BICEP_VERSION} is required but '${BICEP}' is version '${actual}'. Run 'make install-bicep' and put its install dir first on PATH, or set BICEP to that binary." +} + +# vendor_recipe_pack compiles the pack's entry +# file to RECIPE_PACK_DEST_DIR//default.json. +vendor_recipe_pack() { + local pack_dir="$1" pack="$2" repo="$3" ref="$4" src build_dir dest + src="${pack_dir}/${RECIPE_PACK_ENTRY_FILE}" + [[ -f "${src}" ]] || + fail "Recipe pack file not found: recipe-packs/${pack}/${RECIPE_PACK_ENTRY_FILE} at ${repo}@${ref}. Vendored recipe packs must provide this file." + require_bicep + + # Compile a copy so the bicepconfig.json written here is the only one Bicep + # finds, regardless of any config in the fetched tree. + build_dir="$(mktemp -d)" + cp "${src}" "${build_dir}/${RECIPE_PACK_ENTRY_FILE}" + printf '{\n "extensions": {\n "radius": "%s"\n }\n}\n' \ + "${RECIPE_PACK_RADIUS_EXTENSION}" >"${build_dir}/bicepconfig.json" + if ! "${BICEP}" build "${build_dir}/${RECIPE_PACK_ENTRY_FILE}" --outfile "${build_dir}/default.json"; then + rm -rf "${build_dir}" + fail "Failed to compile recipe-packs/${pack}/${RECIPE_PACK_ENTRY_FILE} at ${repo}@${ref}." + fi + + # The generator block records the Bicep version and template hash; drop it + # so a Bicep upgrade alone does not change the vendored file. + dest="${RECIPE_PACK_DEST_DIR}/${pack}" + mkdir -p "${dest}" + yq -p json -o json -I 2 \ + 'del(.metadata._generator) | del(.metadata | select(length == 0))' \ + "${build_dir}/default.json" >"${dest}/default.json" + rm -rf "${build_dir}" +} + +# copy_recipe_packs fetches each distinct recipePacks (repo, ref) once and +# verifies or compiles the packs pinned to it. +copy_recipe_packs() { + local tmp_root pairs_file i=0 repo ref dir pack + tmp_root="$(mktemp -d)" + # shellcheck disable=SC2064 + trap "rm -rf '${tmp_root}'" EXIT + + pairs_file="${tmp_root}/pairs" + : >"${pairs_file}" + for pack in $(entry_names "${RECIPE_PACKS_SECTION}"); do + repo="$(pin_field "${RECIPE_PACKS_SECTION}" "${pack}" repo)" + ref="$(pin_field "${RECIPE_PACKS_SECTION}" "${pack}" ref)" + printf '%s|%s\n' "${repo}" "${ref}" >>"${pairs_file}" + done + sort -u "${pairs_file}" -o "${pairs_file}" + + while IFS='|' read -r repo ref; do + [ -n "${repo}" ] || continue + dir="${tmp_root}/src_${i}" + i=$((i + 1)) + echo " Source: ${repo} @ ${ref}" + fetch_ref "${repo}" "${ref}" "${dir}" + process_recipe_packs "${dir}" "${repo}" "${ref}" done <"${pairs_file}" } @@ -624,6 +736,20 @@ prune_stale() { done } +# update_recipe_packs re-pins recipePacks and recompiles vendored packs. Only +# recipe packs change here, so resource type manifests are not copied. +update_recipe_packs() { + if has_pins "${RECIPE_PACKS_PINS}"; then + apply_pins "${RECIPE_PACKS_SECTION}" "${RECIPE_PACKS_PINS}" RECIPE_PACKS_PINS + else + update_section "${RECIPE_PACKS_SECTION}" "${RECIPE_PACKS_REF}" "${RECIPE_PACKS_NAME}" + fi + promote_edge_pins "${RECIPE_PACKS_SECTION}" + validate_section_pins "${RECIPE_PACKS_SECTION}" + echo "Syncing vendored recipe packs from resource-types-contrib..." + copy_recipe_packs +} + main() { local mode="sync" case "${1:-}" in @@ -646,16 +772,9 @@ main() { return 0 fi - # Recipe packs are pinned but never vendored, so this mode does not copy. if [ "${mode}" = update-recipe-packs ]; then - if has_pins "${RECIPE_PACKS_PINS}"; then - apply_pins "${RECIPE_PACKS_SECTION}" "${RECIPE_PACKS_PINS}" RECIPE_PACKS_PINS - else - update_section "${RECIPE_PACKS_SECTION}" "${RECIPE_PACKS_REF}" "${RECIPE_PACKS_NAME}" - fi - promote_edge_pins "${RECIPE_PACKS_SECTION}" - validate_section_pins "${RECIPE_PACKS_SECTION}" - echo "Done. Review and commit the updated ${DEFAULTS_YAML}." + update_recipe_packs + echo "Done. Review and commit the updated files." return 0 fi diff --git a/build/scripts/test-sync-resource-types.sh b/build/scripts/test-sync-resource-types.sh index 6ef33e30696..afc0c512b7a 100755 --- a/build/scripts/test-sync-resource-types.sh +++ b/build/scripts/test-sync-resource-types.sh @@ -78,6 +78,115 @@ git() { esac } +# fake_bicep stands in for the Bicep CLI. build copies the source's single line +# into a template with a generator block, like the real compiler output. +fake_bicep() { + case "$1" in + --version) echo "Bicep CLI version 0.46.1 (fake)" ;; + build) + [[ "$3" == "--outfile" ]] || return 2 + [[ -f "$(dirname "$2")/bicepconfig.json" ]] || return 3 + printf '{"metadata":{"_generator":{"version":"0.46.1"}},"marker":"%s"}\n' \ + "$(cat "$2")" >"$4" + ;; + *) return 2 ;; + esac +} + +# fake_pack_tree writes the recipe pack folders fetched at ref. +fake_pack_tree() { + mkdir -p "$1/recipe-packs/kubernetes" "$1/recipe-packs/azure" + printf 'kubernetes@%s' "$2" >"$1/recipe-packs/kubernetes/default.bicep" + printf 'azure@%s' "$2" >"$1/recipe-packs/azure/default.bicep" +} + +test_recipe_pack_vendoring() { + local tmp actual_order expected_order + tmp="$(mktemp -d)" + + ( + # shellcheck disable=SC2034 # Read by the sourced sync functions. + BICEP=fake_bicep BICEP_VERSION=v0.46.1 RECIPE_PACK_DEST_DIR="${tmp}/out" + mkdir -p "${tmp}/missing/recipe-packs/kubernetes" + if (vendor_recipe_pack "${tmp}/missing/recipe-packs/kubernetes" kubernetes example.invalid abc) >/dev/null 2>&1; then + fail "vendored recipe pack without default.bicep was accepted." + fi + [[ ! -e "${tmp}/out/kubernetes/default.json" ]] || + fail "missing default.bicep still wrote default.json." + + # shellcheck disable=SC2034 # Read by the sourced sync functions. + BICEP_VERSION=v0.45.0 + fake_pack_tree "${tmp}/v1" v1 + if (vendor_recipe_pack "${tmp}/v1/recipe-packs/kubernetes" kubernetes example.invalid v1) >/dev/null 2>&1; then + fail "Bicep version other than the pinned one was accepted." + fi + ) + + ( + # shellcheck disable=SC2034 # Read by the sourced sync functions. + BICEP=fake_bicep BICEP_VERSION=v0.46.1 RECIPE_PACK_DEST_DIR="${tmp}/out" + # shellcheck disable=SC2329 # Invoked indirectly by copy_recipe_packs. + entry_names() { printf '%s\n' azure kubernetes; } + # shellcheck disable=SC2329 # Invoked indirectly by copy_recipe_packs. + pin_field() { + case "$3" in + repo) printf '%s' example.invalid ;; + ref) cat "${tmp}/ref" ;; + esac + } + # shellcheck disable=SC2329 # Invoked indirectly by copy_recipe_packs. + fetch_ref() { fake_pack_tree "$3" "$2"; } + + printf v1 >"${tmp}/ref" + (copy_recipe_packs) >/dev/null + assert_equal \ + '{"marker":"kubernetes@v1"}' \ + "$(yq -p json -o json -I 0 . "${tmp}/out/kubernetes/default.json")" \ + "compiled kubernetes recipe pack without generator metadata" + [[ ! -e "${tmp}/out/azure" ]] || + fail "non-vendored azure recipe pack was copied." + + printf v2 >"${tmp}/ref" + (copy_recipe_packs) >/dev/null + assert_equal \ + '{"marker":"kubernetes@v2"}' \ + "$(yq -p json -o json -I 0 . "${tmp}/out/kubernetes/default.json")" \ + "recompiled kubernetes recipe pack after a pin move" + ) + + actual_order="$( + ( + # shellcheck disable=SC2329 # Invoked indirectly by update_recipe_packs. + has_pins() { return 1; } + # shellcheck disable=SC2329 # Invoked indirectly by update_recipe_packs. + update_section() { echo "update:$1"; } + # shellcheck disable=SC2329 # Invoked indirectly by update_recipe_packs. + promote_edge_pins() { echo "promote:$1"; } + # shellcheck disable=SC2329 # Invoked indirectly by update_recipe_packs. + validate_section_pins() { echo "validate:$1"; } + # shellcheck disable=SC2329 # Invoked indirectly by update_recipe_packs. + copy_recipe_packs() { echo "copy-recipe-packs"; } + # shellcheck disable=SC2329 # Must not be invoked by update_recipe_packs. + copy_manifests() { echo "copy-manifests"; } + update_recipe_packs | grep -v '^Syncing' + ) + )" + expected_order="$( + cat <<'EOF' +update:recipePacks +promote:recipePacks +validate:recipePacks +copy-recipe-packs +EOF + )" + assert_equal \ + "${expected_order}" \ + "${actual_order}" \ + "update-recipe-packs re-pins then recompiles without copying manifests" + + rm -rf "${tmp}" +} + main() { local edge_sha resolved_sha resolved_tag edge_sha="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" @@ -205,7 +314,9 @@ EOF "${actual_order}" \ "atomic update ordering" - echo "Stable-first resource type pin tests passed." + test_recipe_pack_vendoring + + echo "Stable-first resource type pin and recipe pack vendoring tests passed." } main "$@" diff --git a/deploy/manifest/defaults.yaml b/deploy/manifest/defaults.yaml index 05994bab0ed..49d6a5149c2 100644 --- a/deploy/manifest/defaults.yaml +++ b/deploy/manifest/defaults.yaml @@ -4,9 +4,12 @@ # manifests, which are fetched and copied into # deploy/manifest/built-in-providers/{dev,self-hosted}/. # recipePacks[] per-pack pins for the recipe packs published by -# resource-types-contrib. Recipe packs are not vendored -# into this repo; generated deploy workflows fetch the -# validated upstream revision recorded here. +# resource-types-contrib. The kubernetes pack is compiled +# into deploy/manifest/recipe-packs/kubernetes/default.json, +# which rad embeds to create the default recipe pack. +# Other packs are not vendored; generated deploy +# workflows fetch the validated upstream revision +# recorded here. # defaultRegistration the / entries that ship as # defaults. Each resolves to a file path in # resource-types-contrib by stripping the "Radius." diff --git a/deploy/manifest/embed.go b/deploy/manifest/embed.go index 11566dc791d..e4a1b4dc742 100644 --- a/deploy/manifest/embed.go +++ b/deploy/manifest/embed.go @@ -33,7 +33,7 @@ import "embed" // FS holds the product-shipped assets. Reads return a fresh copy, so callers // cannot corrupt the bytes another consumer will see. // -//go:embed default-icon.svg defaults.yaml built-in-providers/self-hosted/*.svg +//go:embed default-icon.svg defaults.yaml built-in-providers/self-hosted/*.svg recipe-packs/kubernetes/default.json var FS embed.FS const ( @@ -48,4 +48,9 @@ const ( // from resource-types-contrib. A type's icon is at // BuiltInIconsDir + "/.svg". BuiltInIconsDir = "built-in-providers/self-hosted" + + // KubernetesRecipePackPath is the [FS] path of the Kubernetes recipe pack + // compiled from the resource-types-contrib revision pinned under + // `recipePacks` in defaults.yaml. rad creates the default recipe pack from it. + KubernetesRecipePackPath = "recipe-packs/kubernetes/default.json" ) diff --git a/deploy/manifest/recipe-packs/kubernetes/default.json b/deploy/manifest/recipe-packs/kubernetes/default.json new file mode 100644 index 00000000000..825e5fcc14b --- /dev/null +++ b/deploy/manifest/recipe-packs/kubernetes/default.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "imports": { + "radius": { + "provider": "Radius", + "version": "latest" + } + }, + "resources": { + "kubernetesRecipePack": { + "import": "radius", + "type": "Radius.Core/recipePacks@2025-08-01-preview", + "properties": { + "name": "default", + "properties": { + "recipes": { + "Radius.Compute/containers": { + "kind": "bicep", + "source": "ghcr.io/radius-project/kube-recipes/containers:latest" + }, + "Radius.Compute/persistentVolumes": { + "kind": "bicep", + "source": "ghcr.io/radius-project/kube-recipes/persistentvolumes:latest" + }, + "Radius.Compute/routes": { + "kind": "bicep", + "source": "ghcr.io/radius-project/kube-recipes/routes:latest", + "parameters": { + "gatewayName": "radius", + "gatewayNamespace": "radius-system" + } + }, + "Radius.Security/secrets": { + "kind": "bicep", + "source": "ghcr.io/radius-project/kube-recipes/secrets:latest" + }, + "Radius.Data/mySqlDatabases": { + "kind": "bicep", + "source": "ghcr.io/radius-project/kube-recipes/mysqldatabases:latest" + }, + "Radius.Data/postgreSqlDatabases": { + "kind": "bicep", + "source": "ghcr.io/radius-project/kube-recipes/postgresqldatabases:latest" + }, + "Radius.Data/redisCaches": { + "kind": "bicep", + "source": "ghcr.io/radius-project/kube-recipes/rediscaches:latest" + }, + "Radius.Messaging/rabbitMQ": { + "kind": "bicep", + "source": "ghcr.io/radius-project/kube-recipes/rabbitmq:latest" + } + } + } + } + } + } +} diff --git a/docs/contributing/contributing-code/contributing-code-shell-and-make/README.md b/docs/contributing/contributing-code/contributing-code-shell-and-make/README.md index ffe2c23789f..d7700488dfd 100644 --- a/docs/contributing/contributing-code/contributing-code-shell-and-make/README.md +++ b/docs/contributing/contributing-code/contributing-code-shell-and-make/README.md @@ -23,6 +23,20 @@ The canonical versions, release sources, platform assets, and SHA-256 checksums Bicep remains on a manually tested pin (`update: false`) because upgrades must preserve ACR authentication, local registry trust, and extension compatibility. Its `versionFiles` entry synchronizes the version in `pkg/cli/bicep/tools/download_tools.go`, keeping `rad bicep download`, `make install-bicep`, and Bicep container staging on the same release. Local registries require [explicit hostname trust](../contributing-code-tests/running-functional-tests.md#local-bicep-registry-trust); do not disable registry or certificate validation to test an upgrade. +## Updating resource types and recipe packs + +[`deploy/manifest/defaults.yaml`](../../../../deploy/manifest/defaults.yaml) pins the resource-types-contrib commits that Radius ships. `make update-resource-types`, `make update-recipe-packs`, and `make update-resource-types-and-recipe-packs` move the pins; `make sync-resource-types` regenerates the vendored files from the current pins without changing them. + +These targets compile the pinned Kubernetes recipe pack (`recipe-packs/kubernetes/default.bicep`) into [`deploy/manifest/recipe-packs/kubernetes/default.json`](../../../../deploy/manifest/recipe-packs/kubernetes/default.json), which `rad` embeds to create the default recipe pack. Compiling requires the Bicep CLI version pinned in `build/tools.yaml`: + +```bash +make install-bicep # installs into $HOME/.local/bin by default +export PATH="$HOME/.local/bin:$PATH" # or set BICEP=/path/to/bicep +make sync-resource-types +``` + +The script fails if `bicep` is missing or is a different version. Commit the regenerated `default.json` together with the `defaults.yaml` change; CI fails if the committed file does not match a fresh compile of the pinned pack. + ## Linting shell scripts with ShellCheck Every tracked `.sh` script is validated with [ShellCheck](https://github.com/koalaman/shellcheck) as part of the pull request checks, so run it locally before you push. Install the pinned version (into a user-owned bin directory — no `sudo`) and lint every tracked script with: diff --git a/pkg/defaults/defaults.go b/pkg/defaults/defaults.go index 0339d1af811..83dd1fa484c 100644 --- a/pkg/defaults/defaults.go +++ b/pkg/defaults/defaults.go @@ -67,6 +67,15 @@ limitations under the License. // [ResourceTypePin] exposes those entries so release builds can address the // artifacts published from exactly that revision — see `pkg/cli/recipepack`, // which uses the pinned commit SHA as the OCI tag of each core recipe. +// +// # Kubernetes recipe pack +// +// recipe-packs/kubernetes/default.json is compiled by `make sync-resource-types` +// from the resource-types-contrib revision pinned under `recipePacks`. +// [DefaultKubernetesRecipePack] decodes its properties with the +// Radius.Core/recipePacks API models so rad can create the default recipe pack +// without keeping its own copy. Fields the models don't know fail the load +// instead of being dropped. package defaults import ( @@ -135,4 +144,5 @@ func init() { loadIcons(logger, parsed.DefaultRegistration) loadPins(logger, parsed.ResourceTypes) + loadKubernetesRecipePack(logger) } diff --git a/pkg/defaults/recipepack.go b/pkg/defaults/recipepack.go new file mode 100644 index 00000000000..9f303c8792e --- /dev/null +++ b/pkg/defaults/recipepack.go @@ -0,0 +1,308 @@ +/* +Copyright 2023 The Radius 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. +*/ + +package defaults + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "slices" + "strings" + + manifestassets "github.com/radius-project/radius/deploy/manifest" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" +) + +// recipePackResourceType is the resource type and API version the embedded +// pack must use. The pack is decoded with the models for this API version, so a +// pack written for another version fails to load instead of losing fields. +const recipePackResourceType = "Radius.Core/recipePacks@2025-08-01-preview" + +var ( + // kubernetesRecipePackJSON is the validated properties object of the + // embedded Kubernetes recipe pack. Set once at package init; each caller + // decodes its own copy. + kubernetesRecipePackJSON []byte + // errKubernetesRecipePack is the reason the embedded pack failed to load. + errKubernetesRecipePack = errors.New("default Kubernetes recipe pack not loaded") +) + +// loadKubernetesRecipePack validates the embedded Kubernetes recipe pack. It is +// called once from the package init; see that function for the concurrency +// contract. +func loadKubernetesRecipePack(logger *log.Logger) { + raw, err := manifestassets.FS.ReadFile(manifestassets.KubernetesRecipePackPath) + if err == nil { + kubernetesRecipePackJSON, err = parseRecipePack(raw) + } + if err != nil { + errKubernetesRecipePack = fmt.Errorf("load %s: %w", manifestassets.KubernetesRecipePackPath, err) + logger.Printf("%s; default Kubernetes recipe pack disabled", errKubernetesRecipePack) + return + } + errKubernetesRecipePack = nil +} + +// DefaultKubernetesRecipePack returns the properties of the Kubernetes recipe +// pack pinned in defaults.yaml, as authored upstream. Every call returns a new +// copy. It returns an error when the embedded pack is missing or invalid. +func DefaultKubernetesRecipePack() (*corerpv20250801.RecipePackProperties, error) { + if errKubernetesRecipePack != nil { + return nil, errKubernetesRecipePack + } + properties := &corerpv20250801.RecipePackProperties{} + if err := json.Unmarshal(kubernetesRecipePackJSON, properties); err != nil { + return nil, err + } + return properties, nil +} + +// RecipeSourceRepository returns source without its tag. source must be a +// literal OCI reference with a tag and no digest. +func RecipeSourceRepository(source string) (string, error) { + switch { + case source == "": + return "", errors.New("source is empty") + case isExpression(source): + return "", fmt.Errorf("source %q is an expression", source) + case strings.Contains(source, "@"): + return "", fmt.Errorf("source %q must use a tag, not a digest", source) + } + + nameStart := strings.LastIndex(source, "/") + 1 + tagStart := strings.LastIndex(source[nameStart:], ":") + if tagStart <= 0 { + return "", fmt.Errorf("source %q has no tag", source) + } + return source[:nameStart+tagStart], nil +} + +// recipePackTemplate is the subset of a compiled recipe pack template that the +// loader reads. Bicep emits symbolic-name resources (languageVersion 2.0) for +// templates that use extensions, so resources are keyed by symbolic name. +// Everything else in the template, such as extension imports, is ignored. +type recipePackTemplate struct { + Resources map[string]struct { + Type string `json:"type"` + Properties struct { + Properties json.RawMessage `json:"properties"` + } `json:"properties"` + } `json:"resources"` +} + +// parseRecipePack validates the only Radius.Core/recipePacks resource in a +// compiled recipe pack template and returns its properties object. The +// properties must decode into the API models with no unknown or read-only +// fields, every recipe must have a supported kind and a tagged source, and +// every value must be a literal: ARM expressions are not evaluated. Literals +// that Bicep escaped as "[[...]" are returned as authored. +func parseRecipePack(raw []byte) ([]byte, error) { + var template recipePackTemplate + if err := json.Unmarshal(raw, &template); err != nil { + return nil, err + } + + var packs []string + for name, resource := range template.Resources { + if strings.HasPrefix(resource.Type, "Radius.Core/recipePacks@") { + packs = append(packs, name) + } + } + if len(packs) != 1 { + return nil, fmt.Errorf("expected exactly one Radius.Core/recipePacks resource, found %d", len(packs)) + } + pack := template.Resources[packs[0]] + if pack.Type != recipePackResourceType { + return nil, fmt.Errorf("recipe pack type is %s, rad supports %s", pack.Type, recipePackResourceType) + } + if len(pack.Properties.Properties) == 0 { + return nil, errors.New("recipe pack has no properties") + } + + var properties corerpv20250801.RecipePackProperties + if err := decodeKnownFields(pack.Properties.Properties, &properties); err != nil { + return nil, fmt.Errorf("recipe pack: %w", err) + } + if properties.ProvisioningState != nil || properties.ReferencedBy != nil { + return nil, errors.New("recipe pack sets read-only properties") + } + if len(properties.Recipes) == 0 { + return nil, errors.New("recipe pack has no recipes") + } + + var rawRecipes struct { + Recipes map[string]json.RawMessage `json:"recipes"` + } + if err := json.Unmarshal(pack.Properties.Properties, &rawRecipes); err != nil { + return nil, err + } + for resourceType, rawRecipe := range rawRecipes.Recipes { + if err := validateRecipe(rawRecipe); err != nil { + return nil, fmt.Errorf("recipe for %s: %w", resourceType, err) + } + } + + var literal any + if err := json.Unmarshal(pack.Properties.Properties, &literal); err != nil { + return nil, err + } + unescaped, err := json.Marshal(unescapeLiterals(literal)) + if err != nil { + return nil, err + } + properties = corerpv20250801.RecipePackProperties{} + if err := json.Unmarshal(unescaped, &properties); err != nil { + return nil, err + } + return json.Marshal(&properties) +} + +// validateRecipe checks one recipe definition. +func validateRecipe(raw json.RawMessage) error { + var recipe corerpv20250801.RecipeDefinition + if err := decodeKnownFields(raw, &recipe); err != nil { + return err + } + if recipe.Kind == nil || *recipe.Kind == "" { + return errors.New("kind is empty") + } + if !slices.Contains(corerpv20250801.PossibleRecipeKindValues(), *recipe.Kind) { + return fmt.Errorf("kind %q is not supported", *recipe.Kind) + } + if recipe.Source == nil { + return errors.New("source is empty") + } + if _, err := RecipeSourceRepository(*recipe.Source); err != nil { + return err + } + + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return err + } + if path, ok := findExpression(value, ""); ok { + return fmt.Errorf("%s is an expression", path) + } + return nil +} + +// decodeKnownFields decodes raw into out and fails when raw has a top-level +// field that out does not keep. The generated models ignore unknown fields, so +// the check re-encodes out and compares field names. +func decodeKnownFields[T any](raw json.RawMessage, out *T) error { + if err := json.Unmarshal(raw, out); err != nil { + return err + } + + var input, kept map[string]any + if err := json.Unmarshal(raw, &input); err != nil { + return err + } + encoded, err := json.Marshal(out) + if err != nil { + return err + } + if err := json.Unmarshal(encoded, &kept); err != nil { + return err + } + + var unknown []string + for name, value := range input { + if _, ok := kept[name]; !ok && value != nil { + unknown = append(unknown, fmt.Sprintf("%q", name)) + } + } + if len(unknown) > 0 { + slices.Sort(unknown) + return fmt.Errorf("unsupported fields %s", strings.Join(unknown, ", ")) + } + return nil +} + +// findExpression returns the path of the first string in value that is an ARM +// template expression. +func findExpression(value any, path string) (string, bool) { + switch v := value.(type) { + case string: + return path, isExpression(v) + case map[string]any: + names := make([]string, 0, len(v)) + for name := range v { + names = append(names, name) + } + slices.Sort(names) + for _, name := range names { + namePath := strings.TrimPrefix(path+"."+name, ".") + if isExpression(name) { + return namePath, true + } + if p, ok := findExpression(v[name], namePath); ok { + return p, true + } + } + case []any: + for i, item := range v { + if p, ok := findExpression(item, fmt.Sprintf("%s[%d]", path, i)); ok { + return p, true + } + } + } + return "", false +} + +// isExpression reports whether s is an ARM template expression: it starts with +// "[" and ends with "]", and is not escaped with a second leading "[". +func isExpression(s string) bool { + return isBracketed(s) && !strings.HasPrefix(s, "[[") +} + +// unescapeLiteral removes the extra leading "[" that Bicep adds to a literal +// string that starts with "[" and ends with "]", so "[[x]" becomes "[x]". +// Other strings are returned unchanged. +func unescapeLiteral(s string) string { + if isBracketed(s) && strings.HasPrefix(s, "[[") { + return s[1:] + } + return s +} + +func isBracketed(s string) bool { + return strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]") +} + +// unescapeLiterals applies unescapeLiteral to every string and object key in +// value, at any depth. +func unescapeLiterals(value any) any { + switch v := value.(type) { + case string: + return unescapeLiteral(v) + case map[string]any: + out := make(map[string]any, len(v)) + for name, item := range v { + out[unescapeLiteral(name)] = unescapeLiterals(item) + } + return out + case []any: + out := make([]any, len(v)) + for i, item := range v { + out[i] = unescapeLiterals(item) + } + return out + } + return value +} diff --git a/pkg/defaults/recipepack_test.go b/pkg/defaults/recipepack_test.go new file mode 100644 index 00000000000..8f74702ca5c --- /dev/null +++ b/pkg/defaults/recipepack_test.go @@ -0,0 +1,210 @@ +/* +Copyright 2023 The Radius 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. +*/ + +package defaults + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" + + manifestassets "github.com/radius-project/radius/deploy/manifest" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/to" +) + +// TestDefaultKubernetesRecipePack_EmbeddedPack checks the embedded pack without +// listing its types, which come from the pinned upstream revision: it must +// load, and rad must register every type the pack serves. +func TestDefaultKubernetesRecipePack_EmbeddedPack(t *testing.T) { + t.Parallel() + + pack, err := DefaultKubernetesRecipePack() + require.NoError(t, err, "embedded Kubernetes recipe pack must load; run make sync-resource-types") + require.NotEmpty(t, pack.Recipes) + + raw, err := manifestassets.FS.ReadFile(manifestassets.DefaultsYAMLPath) + require.NoError(t, err) + var parsed catalog + require.NoError(t, yaml.Unmarshal(raw, &parsed)) + + for resourceType, recipe := range pack.Recipes { + assert.Contains(t, parsed.DefaultRegistration, resourceType, "recipe pack type must be registered by default") + require.NotNil(t, recipe.Source, resourceType) + _, err := RecipeSourceRepository(*recipe.Source) + assert.NoError(t, err, resourceType) + } +} + +// TestDefaultKubernetesRecipePack_ReturnsCopy guards the read-only contract: a +// caller mutating the result must not change what the next caller sees. +func TestDefaultKubernetesRecipePack_ReturnsCopy(t *testing.T) { + t.Parallel() + + first, err := DefaultKubernetesRecipePack() + require.NoError(t, err) + mutatedParams := 0 + for _, recipe := range first.Recipes { + recipe.Source = to.Ptr("mutated") + for key := range recipe.Parameters { + recipe.Parameters[key] = "mutated" + mutatedParams++ + } + if recipe.Parameters != nil { + recipe.Parameters["mutated"] = true + } + } + require.NotZero(t, mutatedParams, "the embedded pack should have parameters to mutate in place") + first.Recipes["Mutated/type"] = &corerpv20250801.RecipeDefinition{} + + second, err := DefaultKubernetesRecipePack() + require.NoError(t, err) + assert.NotContains(t, second.Recipes, "Mutated/type") + for _, recipe := range second.Recipes { + assert.NotEqual(t, "mutated", *recipe.Source) + assert.NotContains(t, recipe.Parameters, "mutated") + for key, value := range recipe.Parameters { + assert.NotEqual(t, "mutated", value, "parameter %q", key) + } + } +} + +func TestParseRecipePack(t *testing.T) { + t.Parallel() + + template := func(packType, properties string) string { + return `{"imports":{"radius":{"provider":"Radius","version":"latest"}},"resources":{` + + `"other":{"type":"Radius.Core/environments@2025-08-01-preview"},` + + `"pack":{"type":"` + packType + `","properties":{"name":"default","properties":` + properties + `}}}}` + } + pack := func(recipes string) string { + return template(recipePackResourceType, `{"recipes":{`+recipes+`}}`) + } + + tests := []struct { + name string + raw string + want *corerpv20250801.RecipePackProperties + wantErr string + }{ + { + name: "valid", + raw: pack(`"Radius.Data/redisCaches":{"kind":"bicep","source":"ghcr.io/org/recipes/rediscaches:latest"},` + + `"Radius.Compute/routes":{"kind":"terraform","source":"localhost:5000/routes:0.1","plainHttp":true,` + + `"parameters":{"gatewayName":"radius","escaped":"[[literal]","twice":"[[[two]","open":"[open","close":"close]","empty":"[[]",` + + `"[[key]":{"n":"[[nested]"},"list":[1,"a","[[inarr]"]}}`), + want: &corerpv20250801.RecipePackProperties{ + Recipes: map[string]*corerpv20250801.RecipeDefinition{ + "Radius.Data/redisCaches": { + Kind: to.Ptr(corerpv20250801.RecipeKindBicep), + Source: to.Ptr("ghcr.io/org/recipes/rediscaches:latest"), + }, + "Radius.Compute/routes": { + Kind: to.Ptr(corerpv20250801.RecipeKindTerraform), + Source: to.Ptr("localhost:5000/routes:0.1"), + PlainHTTP: to.Ptr(true), + Parameters: map[string]any{ + "gatewayName": "radius", + "escaped": "[literal]", + "twice": "[[two]", + "open": "[open", + "close": "close]", + "empty": "[]", + "[key]": map[string]any{"n": "[nested]"}, + "list": []any{float64(1), "a", "[inarr]"}, + }, + }, + }, + }, + }, + {name: "invalid JSON", raw: `{`, wantErr: "unexpected end of JSON input"}, + {name: "no pack resource", raw: `{"resources":{}}`, wantErr: "expected exactly one Radius.Core/recipePacks resource, found 0"}, + { + name: "two pack resources", + raw: `{"resources":{"a":{"type":"Radius.Core/recipePacks@v1"},"b":{"type":"Radius.Core/recipePacks@v1"}}}`, + wantErr: "found 2", + }, + {name: "other API version", raw: template("Radius.Core/recipePacks@2030-01-01", `{"recipes":{}}`), wantErr: "rad supports " + recipePackResourceType}, + {name: "no properties", raw: `{"resources":{"pack":{"type":"` + recipePackResourceType + `","properties":{"name":"default"}}}}`, wantErr: "recipe pack has no properties"}, + {name: "no recipes", raw: pack(``), wantErr: "recipe pack has no recipes"}, + {name: "unknown pack field", raw: template(recipePackResourceType, `{"recipes":{},"description":"x"}`), wantErr: `unsupported fields "description"`}, + {name: "read-only pack field", raw: template(recipePackResourceType, `{"recipes":{"A/b":{"kind":"bicep","source":"r/b:1"}},"provisioningState":"Succeeded"}`), wantErr: "read-only"}, + {name: "unknown recipe field", raw: pack(`"A/b":{"kind":"bicep","source":"r/b:1","timeout":5,"retries":1}`), wantErr: `recipe for A/b: unsupported fields "retries", "timeout"`}, + {name: "missing kind", raw: pack(`"A/b":{"source":"r/b:1"}`), wantErr: "recipe for A/b: kind is empty"}, + {name: "unsupported kind", raw: pack(`"A/b":{"kind":"helm","source":"r/b:1"}`), wantErr: `kind "helm" is not supported`}, + {name: "missing source", raw: pack(`"A/b":{"kind":"bicep"}`), wantErr: "source is empty"}, + {name: "untagged source", raw: pack(`"A/b":{"kind":"bicep","source":"localhost:5000/b"}`), wantErr: "has no tag"}, + {name: "digest source", raw: pack(`"A/b":{"kind":"bicep","source":"r/b@sha256:abc"}`), wantErr: "not a digest"}, + {name: "expression source", raw: pack(`"A/b":{"kind":"bicep","source":"[format('r/b:{0}', 'x')]"}`), wantErr: "is an expression"}, + { + name: "expression parameter", + raw: pack(`"A/b":{"kind":"bicep","source":"r/b:1","parameters":{"p":{"q":["ok","[parameters('x')]"]}}}`), + wantErr: "parameters.p.q[1] is an expression", + }, + { + name: "expression key", + raw: pack(`"A/b":{"kind":"bicep","source":"r/b:1","parameters":{"[parameters('k')]":"v"}}`), + wantErr: "parameters.[parameters('k')] is an expression", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := parseRecipePack([]byte(tt.raw)) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + var properties corerpv20250801.RecipePackProperties + require.NoError(t, properties.UnmarshalJSON(got)) + assert.Equal(t, tt.want, &properties) + }) + } +} + +func TestRecipeSourceRepository(t *testing.T) { + t.Parallel() + + tests := []struct { + source string + want string + wantErr string + }{ + {source: "ghcr.io/org/recipes/containers:latest", want: "ghcr.io/org/recipes/containers"}, + {source: "localhost:5000/routes:0.1", want: "localhost:5000/routes"}, + {source: "localhost:5000/routes", wantErr: "has no tag"}, + {source: "r/b@sha256:abc", wantErr: "not a digest"}, + {source: "", wantErr: "source is empty"}, + } + for _, tt := range tests { + t.Run(tt.source, func(t *testing.T) { + t.Parallel() + + got, err := RecipeSourceRepository(tt.source) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +}