|
| 1 | +#!/usr/bin/env bash |
| 2 | +# |
| 3 | +# Mirror the images declared in images.yaml into ghcr.io/openprojectx |
| 4 | +# using skopeo. |
| 5 | +# |
| 6 | +# Environment variables: |
| 7 | +# GHCR_NAMESPACE Destination namespace (default: ghcr.io/openprojectx) |
| 8 | +# IMAGES_FILE Path to the image list (default: images.yaml) |
| 9 | +# DRY_RUN If "true", print actions instead of running skopeo |
| 10 | +# GHCR_USERNAME Username for skopeo login (optional; login skipped if unset) |
| 11 | +# GHCR_TOKEN Token/password for login (optional; login skipped if unset) |
| 12 | +# |
| 13 | +set -euo pipefail |
| 14 | + |
| 15 | +GHCR_NAMESPACE="${GHCR_NAMESPACE:-ghcr.io/openprojectx}" |
| 16 | +IMAGES_FILE="${IMAGES_FILE:-images.yaml}" |
| 17 | +DRY_RUN="${DRY_RUN:-false}" |
| 18 | + |
| 19 | +log() { printf '\033[0;34m==>\033[0m %s\n' "$*"; } |
| 20 | +warn() { printf '\033[0;33mwarn:\033[0m %s\n' "$*" >&2; } |
| 21 | +die() { printf '\033[0;31merror:\033[0m %s\n' "$*" >&2; exit 1; } |
| 22 | + |
| 23 | +command -v skopeo >/dev/null 2>&1 || die "skopeo is not installed" |
| 24 | +command -v yq >/dev/null 2>&1 || die "yq is not installed" |
| 25 | +[[ -f "$IMAGES_FILE" ]] || die "images file not found: $IMAGES_FILE" |
| 26 | + |
| 27 | +# Optionally authenticate to GHCR so we can push. |
| 28 | +if [[ -n "${GHCR_USERNAME:-}" && -n "${GHCR_TOKEN:-}" ]]; then |
| 29 | + log "Logging in to ghcr.io as ${GHCR_USERNAME}" |
| 30 | + echo "$GHCR_TOKEN" | skopeo login ghcr.io -u "$GHCR_USERNAME" --password-stdin |
| 31 | +else |
| 32 | + warn "GHCR_USERNAME/GHCR_TOKEN not set; assuming skopeo is already authenticated" |
| 33 | +fi |
| 34 | + |
| 35 | +# Map a source registry host to a short, friendly namespace so that images |
| 36 | +# with the same name from different registries don't collide in GHCR. |
| 37 | +# Unknown hosts fall back to the host itself (with any port colon sanitized). |
| 38 | +alias_for() { |
| 39 | + case "$1" in |
| 40 | + docker.io|index.docker.io|registry-1.docker.io) echo "dockerhub" ;; |
| 41 | + registry.k8s.io|k8s.gcr.io) echo "k8s" ;; |
| 42 | + quay.io) echo "quay" ;; |
| 43 | + gcr.io) echo "gcr" ;; |
| 44 | + ghcr.io) echo "ghcr" ;; |
| 45 | + mcr.microsoft.com) echo "mcr" ;; |
| 46 | + public.ecr.aws) echo "ecr" ;; |
| 47 | + *) echo "${1//:/_}" ;; |
| 48 | + esac |
| 49 | +} |
| 50 | + |
| 51 | +# Derive a default target ("alias/repo:tag") from a source reference when the |
| 52 | +# config does not specify one explicitly. The source registry is encoded as a |
| 53 | +# leading namespace and the full upstream repo path is preserved, e.g. |
| 54 | +# docker.io/library/busybox:1.36 -> dockerhub/library/busybox:1.36 |
| 55 | +# registry.k8s.io/pause:3.10 -> k8s/pause:3.10 |
| 56 | +default_target() { |
| 57 | + local source="$1" host rest first repo tag lastseg |
| 58 | + |
| 59 | + # Split off the registry host. A leading component is a host only if it |
| 60 | + # looks like one (contains a dot or port colon, or is localhost); otherwise |
| 61 | + # the reference is a Docker Hub shorthand. |
| 62 | + if [[ "$source" == */* ]]; then |
| 63 | + first="${source%%/*}" |
| 64 | + if [[ "$first" == *.* || "$first" == *:* || "$first" == "localhost" ]]; then |
| 65 | + host="$first" |
| 66 | + rest="${source#*/}" |
| 67 | + else |
| 68 | + host="docker.io" |
| 69 | + rest="$source" |
| 70 | + fi |
| 71 | + else |
| 72 | + host="docker.io" |
| 73 | + rest="library/$source" # e.g. "nginx" -> "library/nginx" |
| 74 | + fi |
| 75 | + |
| 76 | + # Separate the tag/digest from the repo path (the tag is the part after the |
| 77 | + # last colon, but only when that colon follows the last path separator). |
| 78 | + if [[ "$rest" == *@* ]]; then |
| 79 | + repo="${rest%@*}" |
| 80 | + tag="latest" # digest pulls have no tag; override 'target' to set one |
| 81 | + else |
| 82 | + lastseg="${rest##*/}" |
| 83 | + if [[ "$lastseg" == *:* ]]; then |
| 84 | + tag="${lastseg##*:}" |
| 85 | + repo="${rest%:*}" |
| 86 | + else |
| 87 | + repo="$rest" |
| 88 | + tag="latest" |
| 89 | + fi |
| 90 | + fi |
| 91 | + |
| 92 | + printf '%s/%s:%s' "$(alias_for "$host")" "$repo" "$tag" |
| 93 | +} |
| 94 | + |
| 95 | +count="$(yq '.images | length' "$IMAGES_FILE")" |
| 96 | +[[ "$count" =~ ^[0-9]+$ && "$count" -gt 0 ]] || die "no images found in $IMAGES_FILE" |
| 97 | +log "Found $count image(s) to mirror into $GHCR_NAMESPACE" |
| 98 | + |
| 99 | +failures=0 |
| 100 | +for i in $(seq 0 $((count - 1))); do |
| 101 | + source="$(yq -r ".images[$i].source" "$IMAGES_FILE")" |
| 102 | + target="$(yq -r ".images[$i].target // \"\"" "$IMAGES_FILE")" |
| 103 | + all="$(yq -r ".images[$i].all // \"true\"" "$IMAGES_FILE")" |
| 104 | + |
| 105 | + [[ -n "$source" && "$source" != "null" ]] || die "images[$i] is missing 'source'" |
| 106 | + [[ -n "$target" ]] || target="$(default_target "$source")" |
| 107 | + |
| 108 | + dest="${GHCR_NAMESPACE}/${target}" |
| 109 | + |
| 110 | + copy_args=(--retry-times 3) |
| 111 | + [[ "$all" == "true" ]] && copy_args+=(--all) |
| 112 | + |
| 113 | + log "[$((i + 1))/$count] $source -> $dest" |
| 114 | + if [[ "$DRY_RUN" == "true" ]]; then |
| 115 | + echo " DRY_RUN: skopeo copy ${copy_args[*]} docker://$source docker://$dest" |
| 116 | + continue |
| 117 | + fi |
| 118 | + |
| 119 | + if ! skopeo copy "${copy_args[@]}" "docker://$source" "docker://$dest"; then |
| 120 | + warn "failed to copy $source" |
| 121 | + failures=$((failures + 1)) |
| 122 | + fi |
| 123 | +done |
| 124 | + |
| 125 | +if [[ "$failures" -gt 0 ]]; then |
| 126 | + die "$failures image(s) failed to sync" |
| 127 | +fi |
| 128 | +log "All images synced successfully" |
0 commit comments