Skip to content

Commit d607ea3

Browse files
williamdesDeepSeek
andcommitted
Mirror the official images into the legacy namespace
Add a daily workflow that copies the tags published by this repository from docker.io/library/phpmyadmin to docker.io/phpmyadmin/phpmyadmin, so users of the legacy namespace get base image security updates without waiting for a release. The script plans first and exposes the plan through a CLI (plan, plan --json), then mirrors only the tags that need it. Tags absent upstream are skipped, matching manifest digests are skipped, and a destination whose version is newer is never overwritten. Copies keep every architecture and preserve digests. See #363 Fixes #469 Co-Authored-By: DeepSeek <noreply@deepseek.com> Signed-off-by: William Desportes <williamdes@wdes.fr>
1 parent 0b073f4 commit d607ea3

2 files changed

Lines changed: 432 additions & 0 deletions

File tree

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
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

Comments
 (0)