|
| 1 | +#!/usr/bin/env bash |
| 2 | +set -Eeuo pipefail |
| 3 | + |
| 4 | +# Mirror tags of the official phpMyAdmin image into the legacy namespace. |
| 5 | +# See https://github.com/phpmyadmin/docker/issues/363 |
| 6 | +# |
| 7 | +# Usage: |
| 8 | +# mirror-official-images.sh plan [-j|--json] # show what would be synced |
| 9 | +# mirror-official-images.sh list-tags # JSON array of the tags this repo publishes |
| 10 | +# mirror-official-images.sh login # authenticate to the registry |
| 11 | +# mirror-official-images.sh mirror <tag> # mirror one tag |
| 12 | +# |
| 13 | +# `plan` prints the actions and, in CI, writes the matrix of tags to sync to |
| 14 | +# $GITHUB_OUTPUT. It needs the same tools as the workflow: bashbrew, git and |
| 15 | +# curl (used by generate-stackbrew-library.sh), plus jq and skopeo. |
| 16 | + |
| 17 | +SOURCE_REPO="${SOURCE_REPO:-docker.io/library/phpmyadmin}" |
| 18 | +TARGET_REPO="${TARGET_REPO:-docker.io/phpmyadmin/phpmyadmin}" |
| 19 | + |
| 20 | +error_log="$(mktemp)" |
| 21 | +trap 'rm -f "${error_log}"' EXIT |
| 22 | + |
| 23 | +# The tags this repository publishes, as a compact JSON array |
| 24 | +generate_tags() { |
| 25 | + local tags |
| 26 | + # The explicit `|| return` matters: this function is called from an OR |
| 27 | + # list, where set -e is disabled, so a failing generator would otherwise |
| 28 | + # be ignored and a partial tag list used |
| 29 | + tags="$( |
| 30 | + cd "$(dirname "$(readlink -f "$0")")/../.." |
| 31 | + ./generate-stackbrew-library.sh \ |
| 32 | + | awk '/^Tags:/ { sub(/^Tags:[[:space:]]*/, ""); print }' \ |
| 33 | + | tr ',' '\n' \ |
| 34 | + | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \ |
| 35 | + | sort -u \ |
| 36 | + | jq -R -s -c 'split("\n") | map(select(length > 0))' |
| 37 | + )" || { |
| 38 | + echo 'Failed to generate the tag list from generate-stackbrew-library.sh' >&2 |
| 39 | + return 1 |
| 40 | + } |
| 41 | + |
| 42 | + if [ "${tags}" = '[]' ]; then |
| 43 | + echo 'No tags found in generate-stackbrew-library.sh output' >&2 |
| 44 | + return 1 |
| 45 | + fi |
| 46 | + |
| 47 | + printf '%s\n' "${tags}" |
| 48 | +} |
| 49 | + |
| 50 | +list_tags() { |
| 51 | + local tags |
| 52 | + tags="$(generate_tags)" || return |
| 53 | + |
| 54 | + printf '%s\n' "${tags}" |
| 55 | + |
| 56 | + if [ -n "${GITHUB_OUTPUT:-}" ]; then |
| 57 | + printf 'tags=%s\n' "${tags}" >>"${GITHUB_OUTPUT}" |
| 58 | + fi |
| 59 | +} |
| 60 | + |
| 61 | +login() { |
| 62 | + local registry="${TARGET_REPO%%/*}" |
| 63 | + : "${DOCKERHUB_USERNAME:?DOCKERHUB_USERNAME is not set}" |
| 64 | + : "${DOCKERHUB_TOKEN:?DOCKERHUB_TOKEN is not set}" |
| 65 | + |
| 66 | + printf '%s' "${DOCKERHUB_TOKEN}" \ |
| 67 | + | skopeo login --username "${DOCKERHUB_USERNAME}" --password-stdin "${registry}" |
| 68 | +} |
| 69 | + |
| 70 | +# Only a missing tag is expected; an unknown repository ("name unknown") or a |
| 71 | +# rejected credential has to fail the run |
| 72 | +is_missing() { |
| 73 | + grep -qiE 'manifest unknown' "${error_log}" |
| 74 | +} |
| 75 | + |
| 76 | +# Digest, version and build date of the linux/amd64 image behind a tag, |
| 77 | +# tab separated so that empty or blank values cannot shift the fields |
| 78 | +inspect_meta() { |
| 79 | + skopeo inspect --no-tags --retry-times 3 \ |
| 80 | + --override-os linux --override-arch amd64 "$1" 2>"${error_log}" \ |
| 81 | + | jq -r 'def nz: if . == null or . == "" then "unknown" else tostring end; |
| 82 | + [(.Digest | nz), (.Labels["org.opencontainers.image.version"] | nz), (.Created | nz)] |
| 83 | + | @tsv' 2>>"${error_log}" |
| 84 | +} |
| 85 | + |
| 86 | +# Print a tag's "digest<TAB>version<TAB>created", failing on anything but a |
| 87 | +# missing tag |
| 88 | +tag_meta() { |
| 89 | + local rc=0 meta |
| 90 | + meta="$(inspect_meta "$1")" || rc=$? |
| 91 | + if [ "${rc}" -ne 0 ]; then |
| 92 | + if ! is_missing; then |
| 93 | + echo "Failed to inspect $1:" >&2 |
| 94 | + cat "${error_log}" >&2 |
| 95 | + return "${rc}" |
| 96 | + fi |
| 97 | + return 0 |
| 98 | + fi |
| 99 | + printf '%s\n' "${meta}" |
| 100 | +} |
| 101 | + |
| 102 | +# Fill the result_* variables describing what should happen to a tag. |
| 103 | +# This is the single place where the sync rules live, shared by `plan` |
| 104 | +# and `mirror` so that the two cannot disagree. |
| 105 | +decide() { |
| 106 | + local tag="$1" |
| 107 | + local src_meta dst_meta |
| 108 | + |
| 109 | + result_src="${SOURCE_REPO}:${tag}" |
| 110 | + result_dst="${TARGET_REPO}:${tag}" |
| 111 | + result_action="" |
| 112 | + result_reason="" |
| 113 | + result_src_digest="" |
| 114 | + result_src_version="unknown" |
| 115 | + result_src_created="unknown" |
| 116 | + result_dst_digest="" |
| 117 | + result_dst_version="" |
| 118 | + result_dst_created="" |
| 119 | + |
| 120 | + src_meta="$(tag_meta "docker://${result_src}")" || return |
| 121 | + if [ -z "${src_meta}" ]; then |
| 122 | + result_action="skip" |
| 123 | + result_reason="not in the official image" |
| 124 | + return 0 |
| 125 | + fi |
| 126 | + IFS=$'\t' read -r result_src_digest result_src_version result_src_created <<<"${src_meta}" |
| 127 | + if [ "${result_src_digest}" = "unknown" ]; then |
| 128 | + echo "Could not read the manifest digest of docker://${result_src}" >&2 |
| 129 | + return 1 |
| 130 | + fi |
| 131 | + |
| 132 | + dst_meta="$(tag_meta "docker://${result_dst}")" || return |
| 133 | + if [ -n "${dst_meta}" ]; then |
| 134 | + IFS=$'\t' read -r result_dst_digest result_dst_version result_dst_created <<<"${dst_meta}" |
| 135 | + fi |
| 136 | + |
| 137 | + if [ -z "${result_dst_digest}" ]; then |
| 138 | + result_action="copy" |
| 139 | + result_reason="missing from the target" |
| 140 | + return 0 |
| 141 | + fi |
| 142 | + |
| 143 | + # A newer target must never be overwritten, so an unreadable source |
| 144 | + # version means we cannot tell: keep what is there |
| 145 | + if [ "${result_dst_version}" != "unknown" ] && [ "${result_src_version}" = "unknown" ]; then |
| 146 | + result_action="skip" |
| 147 | + result_reason="source version unreadable, keeping ${result_dst_version}" |
| 148 | + return 0 |
| 149 | + fi |
| 150 | + |
| 151 | + if [ "${result_src_version}" != "unknown" ] && [ "${result_dst_version}" != "unknown" ] \ |
| 152 | + && [ "${result_src_version}" != "${result_dst_version}" ] \ |
| 153 | + && [ "$(printf '%s\n%s\n' "${result_src_version}" "${result_dst_version}" | sort -V | tail -n 1)" = "${result_dst_version}" ]; then |
| 154 | + result_action="skip" |
| 155 | + result_reason="target is newer (${result_dst_version} > ${result_src_version})" |
| 156 | + return 0 |
| 157 | + fi |
| 158 | + |
| 159 | + if [ "${result_dst_digest}" = "${result_src_digest}" ]; then |
| 160 | + result_action="skip" |
| 161 | + result_reason="already up to date" |
| 162 | + return 0 |
| 163 | + fi |
| 164 | + |
| 165 | + result_action="copy" |
| 166 | + result_reason="manifest differs" |
| 167 | + return 0 |
| 168 | +} |
| 169 | + |
| 170 | +# "version built" of an image, or "absent" when the tag does not exist |
| 171 | +describe_image() { |
| 172 | + local digest="$1" version="$2" created="$3" |
| 173 | + if [ -z "${digest}" ]; then |
| 174 | + printf 'absent' |
| 175 | + return |
| 176 | + fi |
| 177 | + if [ "${version}" = "unknown" ] && [ "${created}" = "unknown" ]; then |
| 178 | + printf 'version unknown' |
| 179 | + return |
| 180 | + fi |
| 181 | + printf '%s built %s' "${version}" "${created}" |
| 182 | +} |
| 183 | + |
| 184 | +# Print the plan and write the matrix of tags to sync to $GITHUB_OUTPUT |
| 185 | +plan() { |
| 186 | + local json=0 |
| 187 | + case "${1:-}" in |
| 188 | + '') ;; |
| 189 | + -j | --json) json=1 ;; |
| 190 | + *) |
| 191 | + echo "usage: $0 plan [-j|--json]" >&2 |
| 192 | + return 1 |
| 193 | + ;; |
| 194 | + esac |
| 195 | + if [ "$#" -gt 1 ]; then |
| 196 | + echo "usage: $0 plan [-j|--json]" >&2 |
| 197 | + return 1 |
| 198 | + fi |
| 199 | + |
| 200 | + local tags tag entries=() to_copy=0 to_skip=0 matrix |
| 201 | + |
| 202 | + tags="$(generate_tags)" || return |
| 203 | + |
| 204 | + jq -e 'type == "array" and length > 0' >/dev/null <<<"${tags}" || { |
| 205 | + echo 'Malformed tag list from generate-stackbrew-library.sh' >&2 |
| 206 | + return 1 |
| 207 | + } |
| 208 | + |
| 209 | + if [ "${json}" -eq 0 ]; then |
| 210 | + printf '%-24s %-6s %-44s %-44s %s\n' 'TAG' 'ACTION' 'SOURCE' 'TARGET' 'REASON' |
| 211 | + fi |
| 212 | + |
| 213 | + # Not mapfile: it needs bash 4, and this script is meant to run locally |
| 214 | + # on whatever bash the user has |
| 215 | + while IFS= read -r tag; do |
| 216 | + [ -n "${tag}" ] || continue |
| 217 | + decide "${tag}" || return |
| 218 | + |
| 219 | + case "${result_action}" in |
| 220 | + copy) to_copy=$((to_copy + 1)) ;; |
| 221 | + *) to_skip=$((to_skip + 1)) ;; |
| 222 | + esac |
| 223 | + |
| 224 | + if [ "${json}" -eq 0 ]; then |
| 225 | + printf '%-24s %-6s %-44s %-44s %s\n' \ |
| 226 | + "${tag}" "${result_action}" \ |
| 227 | + "$(describe_image "${result_src_digest}" "${result_src_version}" "${result_src_created}")" \ |
| 228 | + "$(describe_image "${result_dst_digest}" "${result_dst_version}" "${result_dst_created}")" \ |
| 229 | + "${result_reason}" |
| 230 | + fi |
| 231 | + |
| 232 | + entries+=("$(jq -n -c \ |
| 233 | + --arg tag "${tag}" \ |
| 234 | + --arg action "${result_action}" \ |
| 235 | + --arg reason "${result_reason}" \ |
| 236 | + --arg src "${result_src}" \ |
| 237 | + --arg srcDigest "${result_src_digest}" \ |
| 238 | + --arg srcVersion "${result_src_version}" \ |
| 239 | + --arg srcCreated "${result_src_created}" \ |
| 240 | + --arg dst "${result_dst}" \ |
| 241 | + --arg dstDigest "${result_dst_digest}" \ |
| 242 | + --arg dstVersion "${result_dst_version}" \ |
| 243 | + --arg dstCreated "${result_dst_created}" \ |
| 244 | + '{tag: $tag, action: $action, reason: $reason, |
| 245 | + source: {ref: $src, digest: $srcDigest, version: $srcVersion, created: $srcCreated}, |
| 246 | + target: {ref: $dst, digest: $dstDigest, version: $dstVersion, created: $dstCreated}}')") |
| 247 | + done < <(jq -r '.[]' <<<"${tags}") |
| 248 | + |
| 249 | + if [ "${to_copy}" -eq 0 ]; then |
| 250 | + matrix='{"include":[]}' |
| 251 | + else |
| 252 | + matrix="$(printf '%s\n' "${entries[@]}" \ |
| 253 | + | jq -s -c '{include: [.[] | select(.action == "copy") | {tag}]}')" |
| 254 | + fi |
| 255 | + |
| 256 | + if [ "${json}" -eq 1 ]; then |
| 257 | + printf '%s\n' "${entries[@]}" | jq -s . |
| 258 | + else |
| 259 | + printf '\n%d to sync, %d skipped\n' "${to_copy}" "${to_skip}" |
| 260 | + fi |
| 261 | + |
| 262 | + if [ -n "${GITHUB_OUTPUT:-}" ]; then |
| 263 | + printf 'matrix=%s\n' "${matrix}" >>"${GITHUB_OUTPUT}" |
| 264 | + printf 'count=%d\n' "${to_copy}" >>"${GITHUB_OUTPUT}" |
| 265 | + fi |
| 266 | +} |
| 267 | + |
| 268 | +mirror() { |
| 269 | + local tag="$1" |
| 270 | + local after_digest="" after_version="unknown" after_created="unknown" |
| 271 | + local after attempt rc current_src delay |
| 272 | + |
| 273 | + decide "${tag}" || return |
| 274 | + |
| 275 | + printf 'Before: %s version=%s built=%s digest=%s\n' \ |
| 276 | + "${result_src}" "${result_src_version}" "${result_src_created}" "${result_src_digest:-none}" |
| 277 | + printf 'Before: %s version=%s built=%s digest=%s\n' \ |
| 278 | + "${result_dst}" "${result_dst_version:-none}" "${result_dst_created:-none}" "${result_dst_digest:-none}" |
| 279 | + |
| 280 | + if [ "${result_action}" != "copy" ]; then |
| 281 | + echo "Tag '${tag}': ${result_reason}; nothing to do" |
| 282 | + return 0 |
| 283 | + fi |
| 284 | + |
| 285 | + echo "Copying '${tag}' (${result_reason}) from ${SOURCE_REPO} to ${TARGET_REPO}" |
| 286 | + skopeo copy --all --preserve-digests --retry-times 3 \ |
| 287 | + "docker://${result_src}" "docker://${result_dst}" |
| 288 | + |
| 289 | + # Give the registry time to serve the new manifest before reporting |
| 290 | + for attempt in 1 2 3 4; do |
| 291 | + rc=0 |
| 292 | + after="$(tag_meta "docker://${result_dst}")" || rc=$? |
| 293 | + if [ "${rc}" -ne 0 ]; then |
| 294 | + echo "Copy reported success but the destination cannot be read" >&2 |
| 295 | + return "${rc}" |
| 296 | + fi |
| 297 | + IFS=$'\t' read -r after_digest after_version after_created <<<"${after}" |
| 298 | + |
| 299 | + if [ "${after_digest}" = "${result_src_digest}" ]; then |
| 300 | + break |
| 301 | + fi |
| 302 | + |
| 303 | + # The upstream tag can move between our inspect and the copy |
| 304 | + rc=0 |
| 305 | + current_src="$(tag_meta "docker://${result_src}")" || rc=$? |
| 306 | + if [ "${rc}" -eq 0 ] && [ -n "${current_src}" ] \ |
| 307 | + && [ "${after_digest}" != "unknown" ] \ |
| 308 | + && [ "${current_src%%$'\t'*}" != "unknown" ] \ |
| 309 | + && [ "${after_digest}" = "${current_src%%$'\t'*}" ]; then |
| 310 | + break |
| 311 | + fi |
| 312 | + |
| 313 | + if [ "${attempt}" -eq 4 ]; then |
| 314 | + if [ -z "${after_digest}" ]; then |
| 315 | + echo "Destination ${result_dst} still does not exist; expected ${result_src_digest}" >&2 |
| 316 | + else |
| 317 | + echo "Destination digest ${after_digest} does not match source ${result_src_digest}" >&2 |
| 318 | + fi |
| 319 | + return 1 |
| 320 | + fi |
| 321 | + |
| 322 | + case "${attempt}" in |
| 323 | + 1) delay=5 ;; |
| 324 | + 2) delay=15 ;; |
| 325 | + *) delay=30 ;; |
| 326 | + esac |
| 327 | + echo "Waiting ${delay}s for the registry to publish the new manifest" |
| 328 | + sleep "${delay}" |
| 329 | + done |
| 330 | + |
| 331 | + printf 'After: %s version=%s built=%s digest=%s\n' \ |
| 332 | + "${result_dst}" "${after_version}" "${after_created}" "${after_digest}" |
| 333 | +} |
| 334 | + |
| 335 | +case "${1:-}" in |
| 336 | +list-tags) |
| 337 | + list_tags |
| 338 | + ;; |
| 339 | +plan) |
| 340 | + shift |
| 341 | + plan "$@" |
| 342 | + ;; |
| 343 | +login) |
| 344 | + login |
| 345 | + ;; |
| 346 | +mirror) |
| 347 | + if [ -z "${2:-}" ] || [ "$#" -gt 2 ]; then |
| 348 | + echo "usage: $0 mirror <tag>" >&2 |
| 349 | + exit 1 |
| 350 | + fi |
| 351 | + mirror "$2" |
| 352 | + ;; |
| 353 | +*) |
| 354 | + echo "usage: $0 {plan [-j|--json]|list-tags|login|mirror <tag>}" >&2 |
| 355 | + exit 1 |
| 356 | + ;; |
| 357 | +esac |
0 commit comments