#!/bin/bash
set -euo pipefail
# -----------------------------
# 配置区
# -----------------------------
REGISTRY="${REGISTRY:-harbor.ops.qianxin-inc.cn}" # Harbor 地址
REPO="${REPO:-qde/qde-grpc-server-oversea}" # 仓库名
TAG="${TAG:-20251103}" # 镜像标签
USERNAME='robot$x'
PASSWORD='x'
TLS_SKIP_VERIFY=true # 自签证书可设 true
MANIFEST_JSON="/tmp/manifest.json"
DIGESTS_FILE="/tmp/layers.digests"
# -----------------------------
# 构建 curl 命令
# -----------------------------
CURL_CMD="curl -sS -u $USERNAME:$PASSWORD"
$TLS_SKIP_VERIFY && CURL_CMD="$CURL_CMD -k"
# -----------------------------
# 拉取 manifest
# -----------------------------
echo "[INFO] 拉取 manifest: $REGISTRY/v2/$REPO/manifests/$TAG"
$CURL_CMD -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
"https://$REGISTRY/v2/$REPO/manifests/$TAG" -o "$MANIFEST_JSON" || {
echo "[ERROR] 拉取 manifest 失败,请检查账号/密码/网络/registry"
exit 1
}
# -----------------------------
# 检查 JSON 是否有效
# -----------------------------
if ! jq . "$MANIFEST_JSON" &>/dev/null; then
echo "[ERROR] manifest.json 无效或为空,请检查 registry 地址/认证/网络"
cat "$MANIFEST_JSON"
exit 1
fi
# -----------------------------
# 判断 schemaVersion 并提取 blob digest + size
# -----------------------------
SCHEMA=$(jq -r '.schemaVersion' "$MANIFEST_JSON")
if [ "$SCHEMA" = "2" ]; then
CONFIG_DIGEST=$(jq -r '.config.digest' "$MANIFEST_JSON")
CONFIG_SIZE=$(jq -r '.config.size' "$MANIFEST_JSON")
jq -r '.layers[].digest' "$MANIFEST_JSON" > "$DIGESTS_FILE"
mapfile -t LAYERS_SIZES < <(jq -r '.layers[].size' "$MANIFEST_JSON")
elif [ "$SCHEMA" = "1" ]; then
CONFIG_DIGEST="N/A"
CONFIG_SIZE="N/A"
jq -r '.fsLayers[].blobSum' "$MANIFEST_JSON" > "$DIGESTS_FILE"
mapfile -t LAYERS_SIZES < <(for _ in $(cat "$DIGESTS_FILE"); do echo "N/A"; done)
else
echo "[ERROR] 不支持的 manifest schemaVersion: $SCHEMA"
exit 1
fi
echo "[INFO] schemaVersion=$SCHEMA"
echo "[INFO] Config digest: $CONFIG_DIGEST"
echo "[INFO] Config size: $CONFIG_SIZE"
echo "[INFO] 共找到 $(wc -l < "$DIGESTS_FILE") 层 blob"
echo
# -----------------------------
# 遍历每个 blob 请求 HEAD,打印重定向 URL(支持 Ceph RGW/S3)及 size,并检查 HTTP 状态
# -----------------------------
i=0
ALL_OK=true
while read -r digest; do
size="${LAYERS_SIZES[i]}"
echo "=== Blob $((i+1)) ==="
echo "Digest: $digest"
echo "Size: $size"
# -I HEAD 请求,-L 跟随重定向
STATUS=$($CURL_CMD -I -L -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
"https://$REGISTRY/v2/$REPO/blobs/$digest" 2>&1 | tee /tmp/blob_head.txt | grep -E "HTTP/|Location:")
echo "$STATUS"
# 如果 HTTP 最终状态不是 2xx,就标记失败
if ! grep -q "HTTP/.* 2[0-9][0-9]" /tmp/blob_head.txt; then
ALL_OK=false
fi
echo
i=$((i+1))
done < "$DIGESTS_FILE"
# -----------------------------
# 输出最终结果
# -----------------------------
if $ALL_OK; then
echo "[INFO] 所有 blob 拉取检查通过,pull 可能成功"
else
echo "[ERROR] 存在 blob 拉取失败,pull 可能失败"
fi
echo "[INFO] 完成"
#!/bin/bash
set -euo pipefail
# -----------------------------
# 配置区
# -----------------------------
REGISTRY="${REGISTRY:-harbor.ops.qianxin-inc.cn}" # Harbor 地址
REPO="${REPO:-qde/qde-grpc-server-oversea}" # 仓库名
TAG="${TAG:-20251103}" # 镜像 tag
USERNAME='robot$x' # 账号
PASSWORD='x' # 密码
TLS_SKIP_VERIFY=true # 自签证书可设 true
LOCAL_IMAGE="${LOCAL_IMAGE:-$REPO:$TAG}" # 本地镜像名
TMPDIR=$(mktemp -d)
echo "[INFO] 临时目录: $TMPDIR"
# -----------------------------
# 构建 curl 命令
# -----------------------------
CURL_CMD="curl -sS -u $USERNAME:$PASSWORD"
$TLS_SKIP_VERIFY && CURL_CMD="$CURL_CMD -k"
# -----------------------------
# 1. docker save 导出镜像 tar
# -----------------------------
IMAGE_TAR="$TMPDIR/image.tar"
echo "[INFO] docker save 导出镜像 $LOCAL_IMAGE -> $IMAGE_TAR"
docker pull "$LOCAL_IMAGE" >/dev/null 2>&1 || true
docker save -o "$IMAGE_TAR" "$LOCAL_IMAGE"
# -----------------------------
# 2. 解压 tar 并解析 manifest.json
# -----------------------------
tar -xf "$IMAGE_TAR" -C "$TMPDIR"
MANIFEST_JSON="$TMPDIR/manifest.json"
if [ ! -f "$MANIFEST_JSON" ]; then
echo "[ERROR] 找不到 manifest.json"
exit 1
fi
CONFIG_FILE=$(jq -r '.[0].Config' "$MANIFEST_JSON")
mapfile -t LAYERS_ARRAY < <(jq -r '.[0].Layers[]' "$MANIFEST_JSON")
echo "[INFO] 解析 manifest.json 成功:"
echo " Config: $CONFIG_FILE"
echo " Layers 数量: ${#LAYERS_ARRAY[@]}"
echo
# -----------------------------
# 工具函数
# -----------------------------
calc_digest_size() {
local file="$1"
local sha
sha=$(sha256sum "$file" | awk '{print $1}')
local size
size=$(wc -c < "$file" | tr -d ' ')
echo "$sha $size"
}
start_upload() {
local name="$1"
$CURL_CMD -i -X POST "https://$REGISTRY/v2/$name/blobs/uploads/" \
| grep -i '^Location:' | sed -E 's/Location: *//I' | tr -d '\r'
}
upload_blob() {
local url="$1"
local file="$2"
local digest="$3"
if [[ "$url" =~ ^/ ]]; then
url="https://$REGISTRY$url"
fi
echo "[INFO] 上传 blob -> $file"
echo " digest=$digest"
echo " size=$(wc -c < "$file")"
echo " url=$url"
# -L 跟随重定向,输出 HTTP 状态和 Location
RESPONSE=$($CURL_CMD -i -X PUT --data-binary @"$file" "$url?digest=$digest" -w "\n%{http_code}")
echo "$RESPONSE" | grep -E "HTTP/|Location:" || true
# 检查 HTTP 状态码
STATUS_CODE=$(echo "$RESPONSE" | tail -n1)
if [[ ! "$STATUS_CODE" =~ ^2[0-9][0-9]$ ]]; then
echo "[ERROR] 上传失败 HTTP status=$STATUS_CODE"
return 1
fi
echo
return 0
}
# -----------------------------
# 3. 上传 config
# -----------------------------
CONFIG_PATH="$TMPDIR/$CONFIG_FILE"
read CONFIG_DIGEST CONFIG_SIZE < <(calc_digest_size "$CONFIG_PATH")
echo "[INFO] Config 信息:"
echo " 路径: $CONFIG_PATH"
echo " 大小: $CONFIG_SIZE"
echo " Digest: sha256:$CONFIG_DIGEST"
echo
CONFIG_LOC=$(start_upload "$REPO")
upload_blob "$CONFIG_LOC" "$CONFIG_PATH" "sha256:$CONFIG_DIGEST" || exit 1
# -----------------------------
# 4. 上传 layers
# -----------------------------
declare -a LAYER_DIGESTS
declare -a LAYER_SIZES
for layer in "${LAYERS_ARRAY[@]}"; do
LAYER_PATH="$TMPDIR/$layer"
read DIGEST SIZE < <(calc_digest_size "$LAYER_PATH")
LAYER_DIGESTS+=("sha256:$DIGEST")
LAYER_SIZES+=("$SIZE")
echo "[INFO] Layer 上传信息:"
echo " 路径: $LAYER_PATH"
echo " 大小: $SIZE"
echo " Digest: sha256:$DIGEST"
echo
UPLOC=$(start_upload "$REPO")
upload_blob "$UPLOC" "$LAYER_PATH" "sha256:$DIGEST" || exit 1
done
# -----------------------------
# 5. 构造 manifest_final.json
# -----------------------------
MANIFEST_FINAL="$TMPDIR/manifest_final.json"
{
echo "{"
echo " \"schemaVersion\": 2,"
echo " \"mediaType\": \"application/vnd.docker.distribution.manifest.v2+json\","
echo " \"config\": {"
echo " \"mediaType\": \"application/vnd.docker.container.image.v1+json\","
echo " \"size\": $CONFIG_SIZE,"
echo " \"digest\": \"sha256:$CONFIG_DIGEST\""
echo " },"
echo " \"layers\": ["
for i in "${!LAYER_DIGESTS[@]}"; do
echo " {"
echo " \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\","
echo " \"size\": ${LAYER_SIZES[i]},"
echo " \"digest\": \"${LAYER_DIGESTS[i]}\""
if [ $i -lt $((${#LAYER_DIGESTS[@]} - 1)) ]; then
echo " },"
else
echo " }"
fi
done
echo " ]"
echo "}"
} > "$MANIFEST_FINAL"
echo "[INFO] 生成 manifest_final.json 内容如下:"
cat "$MANIFEST_FINAL" | jq .
echo
# -----------------------------
# 6. 上传 manifest
# -----------------------------
echo "[INFO] 上传 manifest -> $TAG"
RESPONSE=$($CURL_CMD -i -X PUT "https://$REGISTRY/v2/$REPO/manifests/$TAG" \
-H "Content-Type: application/vnd.docker.distribution.manifest.v2+json" \
--data-binary @"$MANIFEST_FINAL")
echo "$RESPONSE" | grep -E "HTTP/|Location:" || true
HTTP_STATUS=$(echo "$RESPONSE" | grep HTTP | tail -n1 | awk '{print $2}')
if [[ ! "$HTTP_STATUS" =~ ^2[0-9][0-9]$ ]]; then
echo "[ERROR] manifest 上传失败 HTTP status=$HTTP_STATUS"
exit 1
fi
echo
echo "[INFO] 镜像 push 完成!临时目录: $TMPDIR"
echo "[DEBUG] 你可以手动检查:"
echo " manifest.json: $MANIFEST_JSON"
echo " config.json: $CONFIG_PATH"
echo " layers: ${#LAYERS_ARRAY[@]} 个文件"
# rm -rf "$TMPDIR" # 调试完再打开删除