Last active
July 11, 2025 21:59
-
-
Save groboclown/071a925d138cc83e30ab12a1e508aae2 to your computer and use it in GitHub Desktop.
Shell Script to generate OpenTofu Provider OCI Image
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # SPDX-License-Identifier: Apache-2.0 | |
| # (uses bash for array and string parsing support) | |
| # This uses oras to turn the zip files into an OCI image using the highly precise | |
| # and involved instructions at: | |
| # https://opentofu.org/docs/main/cli/oci_registries/provider-mirror/#assembling-and-pushing-provider-manifests | |
| # It requires the `oras` CLI tool to be installed: | |
| # https://github.com/oras-project/oras | |
| VERSION="" | |
| ARTIFACT_FILE_BASE="" | |
| OCI_OUT_DIR="" | |
| OS_ARCH_LIST=() | |
| ORAS="${ORAS:-oras}" | |
| GREP="${GREP:-grep}" | |
| SED="${SED:-sed}" | |
| # Also uses: echo, cat, cut, mktemp, mkdir, rm, cp | |
| for arg in "$@"; do | |
| case "${arg}" in | |
| --version=*) | |
| VERSION="${arg#*=}" | |
| ;; | |
| --artifact-file-base=*) | |
| ARTIFACT_FILE_BASE="${arg#*=}" | |
| ;; | |
| --oci-out-dir=*) | |
| OCI_OUT_DIR="${arg#*=}" | |
| ;; | |
| --os-arch=*) | |
| OS_ARCH_LIST+=(${arg#*=}) | |
| ;; | |
| --oras=*) | |
| ORAS="${arg#*=}" | |
| ;; | |
| --help|-h) | |
| echo "Usage: $0 --version=<version> --artifact-file-base=<artifact_file_base> --oci-out-dir=<output_dir> --os-arch=<OS-ARCH> [--oras=<oras>]" | |
| echo " --version Specify the version of the provider. This must align with the version you will push into the OCI repository." | |
| echo " --artifact-file-base Specify the base name for the artifact files." | |
| echo " This is the base name of the provider zip file." | |
| echo " The script will look for files named '<artifact_file_base>_<version>_<os>-<arch>.zip'." | |
| echo " The zip file must contain the provider executable at the root level, with the file name" | |
| echo " matching the file name part of the artifact_file_base. For example, if you have " | |
| echo " '--artifact-file-base=out/terraform-provider-mytool --os-arch=windows-amd64 --os-arch=linux-arm7'," | |
| echo " then the script expects to find files named:" | |
| echo " 'out/terraform-provider-mytool_<version>_windows-amd64.zip'" | |
| echo " The zip file's contents must include, at a minimum, 'terraform-provider-mytool.exe'." | |
| echo " 'out/terraform-provider-mytool_<version>_linux-arm7.zip'" | |
| echo " The zip file's contents must include, at a minimum, 'terraform-provider-mytool'." | |
| echo " --oci-out-dir Specify the output directory for the OCI image layout." | |
| echo " --os-arch Add a supported OS and architecture; you may specify multiple of these. For example, '--os-arch=linux-amd64 --os-arch=darwin-arm64'." | |
| echo " --oras Specify the oras CLI tool path. Defaults to '${ORAS}'." | |
| exit 0 | |
| ;; | |
| esac | |
| done | |
| error=0 | |
| if [ ! -f "${ORAS}" ] && ! which "${ORAS}" >/dev/null 2>&1 ; then | |
| 2>&1 echo "Error: oras CLI tool not found. Please install it to use this script." | |
| 2>&1 echo " You can install it from https://github.com/oras-project/oras" | |
| error=1 | |
| fi | |
| if [ -z "${VERSION}" ]; then | |
| 2>&1 echo "Error: --version is required." | |
| error=1 | |
| fi | |
| if [ -z "${ARTIFACT_FILE_BASE}" ]; then | |
| 2>&1 echo "Error: --artifact-file-base is required." | |
| error=1 | |
| fi | |
| if [ -z "${OCI_OUT_DIR}" ]; then | |
| 2>&1 echo "Error: --oci-out-dir is required." | |
| error=1 | |
| fi | |
| if [ -e "${OCI_OUT_DIR}" ]; then | |
| 2>&1 echo "Error: --oci-out-dir references ${OCI_OUT_DIR}, which exists." | |
| 2>&1 echo " This script will not remove or overwrite existing directories." | |
| error=1 | |
| fi | |
| if [ ${#OS_ARCH_LIST[@]} -eq 0 ]; then | |
| 2>&1 echo "Error: --os-arch is required. You must specify at least one OS and architecture combination." | |
| error=1 | |
| else | |
| for OS_ARCH in "${OS_ARCH_LIST[@]}" ; do | |
| if [[ ! "${OS_ARCH}" =~ ^[a-z0-9_]+-[a-z0-9_]+$ ]]; then | |
| 2>&1 echo "Error: Invalid OS_ARCH format '${OS_ARCH}'. Expected format is 'os-arch'." | |
| error=1 | |
| fi | |
| done | |
| fi | |
| if [ ${error} -ne 0 ]; then | |
| exit 1 | |
| fi | |
| # From the OpenTofu documentation: | |
| # OpenTofu expects the version tagged image to refer to an "image index" manifest, | |
| # which lists a separate manifest for each OS/arch combination. | |
| # The index manifest must have its artifactType property set to | |
| # `application/vnd.opentofu.provider` so that OpenTofu can recognize it as | |
| # representing a provider release. | |
| # Practically speaking, the image index has a manifest, which the image's | |
| # index.json points to. It's the referenced manifest file that must | |
| # have the correct artifactType and mediaType values. The image's index.json | |
| # file does not need to have these properties, but, if it does, they | |
| # must also match. | |
| # OpenTofu then searches the manifests array for a manifest descriptor whose | |
| # artifactType is `application/vnd.opentofu.provider-target` and whose | |
| # platform property matches the operating system and architecture that OpenTofu | |
| # itself was compiled for. | |
| # The selected descriptor is then used to retrieve an Image Manifest | |
| # representing the files and metadata needed to install the selected provider version | |
| # for the current platform. This manifest must again have its artifactType set to | |
| # `application/vnd.opentofu.provider-target`, to match the descriptor used to retrieve it. | |
| # The image manifest's layers array must include exactly one descriptor whose mediaType | |
| # is `archive/zip`, referring to a blob containing exactly the same .zip | |
| # package that would be returned for the same version of the provider on the same | |
| # platform if installed from the provider's origin registry. | |
| # OpenTofu than finally retrieves the indicated blob and extracts the .zip archive | |
| # into the provider cache directory so that it's available for use by subsequent workflow | |
| # commands like tofu apply. | |
| if ! mkdir -p "${OCI_OUT_DIR}" 2>/dev/null ; then | |
| 2>&1 echo "Error: Could not create output directory ${OCI_OUT_DIR}." | |
| exit 1 | |
| fi | |
| tmpdir="$(mktemp -d)" || exit 1 | |
| trap 'rm -rf "${tmpdir}" >/dev/null 2>&1 || true' EXIT | |
| for OS_ARCH in "${OS_ARCH_LIST[@]}" ; do | |
| OS="${OS_ARCH%-*}" | |
| ARCH="${OS_ARCH#*-}" | |
| zip_file="${ARTIFACT_FILE_BASE}_${VERSION}_${OS}-${ARCH}.zip" | |
| if [ ! -f "${zip_file}" ] ; then | |
| 2>&1 echo "Could not find ${zip_file}" | |
| 2>&1 echo "Using base name ${ARTIFACT_FILE_BASE}, version ${VERSION}, os ${OS}, architecture ${ARCH}." | |
| 2>&1 echo "Aborting." | |
| exit 1 | |
| fi | |
| # Add each zip artifact into the local OCI layout. | |
| if ! "${ORAS}" push --no-tty \ | |
| --disable-path-validation \ | |
| --artifact-type application/vnd.opentofu.provider-target \ | |
| --oci-layout "${OCI_OUT_DIR}:${OS}_${ARCH}" \ | |
| "${zip_file}" \ | |
| > "${tmpdir}/${OS}_${ARCH}.log" \ | |
| ; then | |
| 2>&1 echo "Error: Failed to push ${zip_file} to OCI layout." | |
| cat "${tmpdir}/${OS}_${ARCH}.log" | |
| exit 1 | |
| fi | |
| "${GREP}" -E '^Digest: ' "${tmpdir}/${OS}_${ARCH}.log" \ | |
| | cut -f 3 -d ':' \ | |
| > "${tmpdir}/${OS}_${ARCH}.digest" \ | |
| || exit 1 | |
| done | |
| # Create the index manifest for the local OCI layout. | |
| if ! cp "${OCI_OUT_DIR}/index.json" "${tmpdir}/oci-index.json" 2>/dev/null ; then | |
| 2>&1 echo "Error: Failed copying index file." | |
| exit 1 | |
| fi | |
| # This needs the artifact type and media type, | |
| # at the same level as the schemaVersion property. | |
| "${SED}" -i \ | |
| 's|"schemaVersion":|"mediaType":"application/vnd.oci.image.index.v1+json","artifactType":"application/vnd.opentofu.provider","schemaVersion":|' \ | |
| "${tmpdir}/oci-index.json" \ | |
| # Add the platform object to each descriptor in the index. | |
| for OS_ARCH in "${OS_ARCH_LIST[@]}" ; do | |
| OS="${OS_ARCH%-*}" | |
| ARCH="${OS_ARCH#*-}" | |
| # Use the digest to easily spot the place to perform the surgery. | |
| digest="$( cat "${tmpdir}/${OS}_${ARCH}.digest" )" || exit 1 | |
| "${SED}" -i \ | |
| 's|:'"${digest}"'"|:'"${digest}"'","platform":{"os":"'"${OS}"'","architecture":"'"${ARCH}"'"}|' \ | |
| "${tmpdir}/oci-index.json" \ | |
| || exit 1 | |
| done | |
| # Put that index manifest into the local OCI layout. | |
| if ! "${ORAS}" manifest push \ | |
| --oci-layout \ | |
| --media-type application/vnd.oci.image.index.v1+json \ | |
| "${OCI_OUT_DIR}:${VERSION}" \ | |
| "${tmpdir}/oci-index.json" \ | |
| ; then | |
| 2>&1 echo "Error: Failed to push index manifest to OCI layout." | |
| exit 1 | |
| fi | |
| # The documentation says that the index manifest must have its artifactType, | |
| # and claims that it should be in the file just pushed. However, that | |
| # only exists once the manifest push happens. So, this must manipulate the | |
| # index manifest file directly to add the artifactType property. | |
| # If the `manifest push` ever adds in the `--artifact-type` option, this | |
| # can be removed. | |
| "${SED}" -i \ | |
| 's|"application/vnd.oci.image.index.v1+json",|"application/vnd.oci.image.index.v1+json","artifactType":"application/vnd.opentofu.provider",|' \ | |
| "${OCI_OUT_DIR}/index.json" \ | |
| || exit 1 | |
| echo "Completed." | |
| echo "You can now push the OCI image to your registry using the following command:" | |
| echo " ${ORAS} cp --from-oci-layout \"${OCI_OUT_DIR}:${VERSION}\" \"(registry host)/(repository name):${VERSION}\"" | |
| echo "This requires authenticating ${ORAS} with your ECR registry first." | |
| # That command will push each manifest listed in the index.json file as a separate "image". |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment