feat(harbor): add OrbStack Docker Hub mirror runbook
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
# OrbStack -> Harbor 镜像通道 Runbook
|
||||
|
||||
## 目标
|
||||
|
||||
本机制只解决 macOS OrbStack 下的镜像拉取稳定性:
|
||||
|
||||
- Docker CLI 使用标准镜像名,例如 `docker pull testcontainers/ryuk:0.12.0`。
|
||||
- OrbStack k3s 使用标准镜像名,例如 Pod 中 `image: testcontainers/ryuk:0.11.0`。
|
||||
- 本机所有 Docker Hub 拉取先走本地 Harbor proxy-cache。
|
||||
- 业务仓库、Helm、Kubernetes YAML、Dockerfile 中不写 Harbor 域名。
|
||||
|
||||
## 机制
|
||||
|
||||
Docker CLI 和 k3s/containerd 是两条链路:
|
||||
|
||||
```text
|
||||
Docker CLI
|
||||
docker.io/library/redis:7-alpine
|
||||
-> OrbStack Docker registry-mirrors
|
||||
-> http://127.0.0.1:5002
|
||||
-> dockerhub-mirror-auth-proxy
|
||||
-> https://$HARBOR_HOST/v2/dockerhub/library/redis/...
|
||||
|
||||
k3s/containerd
|
||||
docker.io/testcontainers/ryuk:0.11.0
|
||||
-> /etc/rancher/k3s/registries.yaml rewrite
|
||||
-> https://$HARBOR_HOST/v2/dockerhub/testcontainers/ryuk/...
|
||||
```
|
||||
|
||||
为什么 Docker CLI 多了一层本地代理:
|
||||
|
||||
- Docker 的 registry mirror 协议期望 mirror 自身可以匿名响应。
|
||||
- Harbor proxy-cache 即使项目是 public,也可能要求先走 `/service/token`。
|
||||
- 本地代理只做 GET/HEAD,获取 Harbor bearer token 后转发,避免 Docker daemon 直接撞 Harbor token 兼容问题。
|
||||
|
||||
## 文件
|
||||
|
||||
- `scripts/dockerhub-mirror-auth-proxy.py`
|
||||
本地 pull-only auth proxy。
|
||||
- `scripts/orbstack-harbor-mirrorctl.sh`
|
||||
安装、验证、日志和 LaunchAgent 入口。
|
||||
- `~/.orbstack/local/harbor-mirror.env`
|
||||
本机私有配置,不提交。
|
||||
- `~/.orbstack/local/dockerhub_mirror_auth_proxy.py`
|
||||
安装后的运行副本。
|
||||
|
||||
## 首次安装
|
||||
|
||||
```bash
|
||||
cd ~/work/codes/tools/macos/harbor
|
||||
|
||||
./scripts/orbstack-harbor-mirrorctl.sh init-env
|
||||
$EDITOR ~/.orbstack/local/harbor-mirror.env
|
||||
|
||||
./scripts/orbstack-harbor-mirrorctl.sh install
|
||||
```
|
||||
|
||||
`harbor-mirror.env` 最少需要:
|
||||
|
||||
```bash
|
||||
HARBOR_HOST=<your-harbor-host>
|
||||
HARBOR_PROJECT=dockerhub
|
||||
HARBOR_VERIFY_TLS=false
|
||||
HARBOR_NETWORK=harbor_core
|
||||
HARBOR_MIRROR_PORT=5002
|
||||
```
|
||||
|
||||
## 稳定运行
|
||||
|
||||
代理容器用 Docker restart policy 托管:
|
||||
|
||||
```text
|
||||
--restart unless-stopped
|
||||
--network harbor_core
|
||||
127.0.0.1:5002 -> container:5001
|
||||
```
|
||||
|
||||
推荐再安装一个用户级 LaunchAgent,只做轻量守护:
|
||||
|
||||
```bash
|
||||
cd ~/work/codes/tools/macos/harbor
|
||||
./scripts/orbstack-harbor-mirrorctl.sh install-launchd
|
||||
```
|
||||
|
||||
它每 5 分钟运行一次:
|
||||
|
||||
```bash
|
||||
./scripts/orbstack-harbor-mirrorctl.sh ensure
|
||||
```
|
||||
|
||||
`ensure` 的职责很窄:
|
||||
|
||||
- 代理脚本同步到 `~/.orbstack/local/`。
|
||||
- 代理容器不在时重建。
|
||||
- Docker mirror 配置缺失时只告警,不自动重启 OrbStack。
|
||||
|
||||
这种方式避免 LaunchAgent 在后台频繁重启 OrbStack,减少基础设施噪声。
|
||||
|
||||
## 日常命令
|
||||
|
||||
```bash
|
||||
# 看当前状态
|
||||
./scripts/orbstack-harbor-mirrorctl.sh status
|
||||
|
||||
# 验证 Docker CLI 标准路径
|
||||
./scripts/orbstack-harbor-mirrorctl.sh verify-docker
|
||||
|
||||
# 验证 k3s 标准路径
|
||||
./scripts/orbstack-harbor-mirrorctl.sh verify-k3s
|
||||
|
||||
# 一次性巡检
|
||||
./scripts/orbstack-harbor-mirrorctl.sh doctor
|
||||
|
||||
# 看证据日志
|
||||
./scripts/orbstack-harbor-mirrorctl.sh logs
|
||||
```
|
||||
|
||||
## 预期证据
|
||||
|
||||
Docker CLI 成功时,代理日志里应看到:
|
||||
|
||||
```text
|
||||
HEAD /v2/testcontainers/ryuk/manifests/0.12.0 -> 200 repo=testcontainers/ryuk
|
||||
```
|
||||
|
||||
k3s 成功时,Harbor nginx 日志里应看到:
|
||||
|
||||
```text
|
||||
HEAD /v2/dockerhub/testcontainers/ryuk/manifests/0.11.0?ns=docker.io ... "containerd/v..."
|
||||
GET /service/token?scope=repository%3Adockerhub%2Ftestcontainers%2Fryuk%3Apull ... 200
|
||||
HEAD /v2/dockerhub/testcontainers/ryuk/manifests/0.11.0?ns=docker.io ... 200
|
||||
```
|
||||
|
||||
这比只看 `kubectl wait` 更可靠,因为它证明 k3s/containerd 真正命中了 Harbor。
|
||||
|
||||
## 故障处理
|
||||
|
||||
Docker CLI 拉取失败:
|
||||
|
||||
```bash
|
||||
./scripts/orbstack-harbor-mirrorctl.sh status
|
||||
./scripts/orbstack-harbor-mirrorctl.sh logs
|
||||
./scripts/orbstack-harbor-mirrorctl.sh start-proxy
|
||||
./scripts/orbstack-harbor-mirrorctl.sh verify-docker
|
||||
```
|
||||
|
||||
`docker info` 里没有 `http://127.0.0.1:5002/`:
|
||||
|
||||
```bash
|
||||
./scripts/orbstack-harbor-mirrorctl.sh configure-docker
|
||||
./scripts/orbstack-harbor-mirrorctl.sh restart-docker
|
||||
```
|
||||
|
||||
k3s 拉取失败:
|
||||
|
||||
```bash
|
||||
./scripts/orbstack-harbor-mirrorctl.sh verify-k3s
|
||||
kubectl describe pod <pod>
|
||||
docker logs --since 10m nginx | grep dockerhub/
|
||||
```
|
||||
|
||||
如果 Harbor 自身不可用,先修 Harbor,不要改业务镜像路径:
|
||||
|
||||
```bash
|
||||
docker ps
|
||||
docker logs --tail 100 nginx
|
||||
docker logs --tail 100 registry
|
||||
docker logs --tail 100 harbor-core
|
||||
```
|
||||
|
||||
## 不做什么
|
||||
|
||||
- 不在业务代码、PR、Helm values、Kubernetes YAML 中写 Harbor 域名。
|
||||
- 不把本机 `harbor-mirror.env` 提交。
|
||||
- 不依赖公共 mirror。
|
||||
- 不让 Docker CLI 直连 Docker Hub 作为主要路径。
|
||||
- 不让 LaunchAgent 自动重启 OrbStack Docker。
|
||||
|
||||
## 回滚
|
||||
|
||||
```bash
|
||||
docker rm -f dockerhub-mirror-auth
|
||||
|
||||
# 从 ~/.orbstack/config/docker.json* 或 ~/.orbstack/docker* 的 .bak.harbor-mirror.* 恢复
|
||||
~/.orbstack/bin/orbctl restart docker
|
||||
|
||||
launchctl unload ~/Library/LaunchAgents/com.orbstack.harbor-mirror.ensure.plist
|
||||
rm -f ~/Library/LaunchAgents/com.orbstack.harbor-mirror.ensure.plist
|
||||
```
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pull-only Docker Hub mirror adapter for Harbor proxy-cache projects.
|
||||
|
||||
Docker's registry mirror client expects an unauthenticated registry mirror. A
|
||||
Harbor proxy-cache project can require a Harbor bearer token even when the
|
||||
project itself is public. This small local adapter terminates Docker's mirror
|
||||
request, obtains the Harbor token, and forwards the request to:
|
||||
|
||||
https://$HARBOR_HOST/v2/$HARBOR_PROJECT/<repo>/...
|
||||
|
||||
It intentionally supports GET/HEAD only.
|
||||
"""
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
HARBOR_HOST = os.environ.get("HARBOR_HOST")
|
||||
HARBOR_PROJECT = os.environ.get("HARBOR_PROJECT", "dockerhub")
|
||||
LISTEN = os.environ.get("LISTEN", "0.0.0.0")
|
||||
PORT = int(os.environ.get("PORT", "5001"))
|
||||
TOKEN_TTL_SKEW = 120
|
||||
|
||||
if not HARBOR_HOST:
|
||||
raise SystemExit("HARBOR_HOST is required")
|
||||
|
||||
VERIFY_TLS = os.environ.get("HARBOR_VERIFY_TLS", "false").lower() in ("1", "true", "yes")
|
||||
SSL_CTX = ssl.create_default_context() if VERIFY_TLS else ssl._create_unverified_context()
|
||||
TOKEN_CACHE = {}
|
||||
|
||||
|
||||
def log(message):
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
def token_for(repo):
|
||||
harbor_repo = f"{HARBOR_PROJECT}/{repo}"
|
||||
cached = TOKEN_CACHE.get(harbor_repo)
|
||||
now = time.time()
|
||||
if cached and cached[1] > now + TOKEN_TTL_SKEW:
|
||||
return cached[0]
|
||||
|
||||
qs = urllib.parse.urlencode(
|
||||
{
|
||||
"service": "harbor-registry",
|
||||
"scope": f"repository:{harbor_repo}:pull",
|
||||
}
|
||||
)
|
||||
url = f"https://{HARBOR_HOST}/service/token?{qs}"
|
||||
with urllib.request.urlopen(url, context=SSL_CTX, timeout=30) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
token = payload.get("token") or payload.get("access_token")
|
||||
if not token:
|
||||
raise RuntimeError("Harbor token response did not include token")
|
||||
|
||||
expires_in = int(payload.get("expires_in") or 1800)
|
||||
TOKEN_CACHE[harbor_repo] = (token, now + expires_in)
|
||||
return token
|
||||
|
||||
|
||||
def split_repo(path):
|
||||
if not path.startswith("/v2/"):
|
||||
return None, None
|
||||
rest = path[len("/v2/") :]
|
||||
for marker in ("/manifests/", "/blobs/"):
|
||||
idx = rest.find(marker)
|
||||
if idx >= 0:
|
||||
return rest[:idx], rest
|
||||
return None, rest
|
||||
|
||||
|
||||
def filtered_headers(headers):
|
||||
keep = {
|
||||
"accept-ranges",
|
||||
"cache-control",
|
||||
"content-length",
|
||||
"content-range",
|
||||
"content-type",
|
||||
"docker-content-digest",
|
||||
"docker-distribution-api-version",
|
||||
"etag",
|
||||
"last-modified",
|
||||
"location",
|
||||
}
|
||||
return [(k, v) for k, v in headers.items() if k.lower() in keep]
|
||||
|
||||
|
||||
class ProxyHandler(BaseHTTPRequestHandler):
|
||||
server_version = "dockerhub-harbor-auth-proxy/0.2"
|
||||
|
||||
def do_GET(self):
|
||||
self.forward()
|
||||
|
||||
def do_HEAD(self):
|
||||
self.forward()
|
||||
|
||||
def do_POST(self):
|
||||
self.send_error(405, "pull-only proxy")
|
||||
|
||||
def do_PUT(self):
|
||||
self.send_error(405, "pull-only proxy")
|
||||
|
||||
def do_PATCH(self):
|
||||
self.send_error(405, "pull-only proxy")
|
||||
|
||||
def do_DELETE(self):
|
||||
self.send_error(405, "pull-only proxy")
|
||||
|
||||
def forward(self):
|
||||
parsed = urllib.parse.urlsplit(self.path)
|
||||
|
||||
if parsed.path in ("/healthz", "/readyz"):
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(b"ok\n")
|
||||
return
|
||||
|
||||
if parsed.path in ("/v2", "/v2/"):
|
||||
self.send_response(200)
|
||||
self.send_header("Docker-Distribution-Api-Version", "registry/2.0")
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
repo, rest = split_repo(parsed.path)
|
||||
if not repo:
|
||||
self.send_error(404, "unsupported registry path")
|
||||
return
|
||||
|
||||
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
|
||||
if not any(k == "ns" for k, _ in query):
|
||||
query.append(("ns", "docker.io"))
|
||||
qs = urllib.parse.urlencode(query)
|
||||
|
||||
upstream = f"https://{HARBOR_HOST}/v2/{HARBOR_PROJECT}/{rest}"
|
||||
if qs:
|
||||
upstream += f"?{qs}"
|
||||
|
||||
try:
|
||||
token = token_for(repo)
|
||||
req = urllib.request.Request(upstream, method=self.command)
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
for name in ("Accept", "Range", "If-None-Match", "If-Modified-Since"):
|
||||
value = self.headers.get(name)
|
||||
if value:
|
||||
req.add_header(name, value)
|
||||
|
||||
with urllib.request.urlopen(req, context=SSL_CTX, timeout=120) as resp:
|
||||
self.send_response(resp.status)
|
||||
for k, v in filtered_headers(resp.headers):
|
||||
self.send_header(k, v)
|
||||
self.send_header("Docker-Distribution-Api-Version", "registry/2.0")
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
while True:
|
||||
chunk = resp.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
log(f"{self.command} {parsed.path} -> {resp.status} repo={repo}")
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()
|
||||
self.send_response(e.code)
|
||||
for k, v in filtered_headers(e.headers):
|
||||
self.send_header(k, v)
|
||||
self.send_header("Docker-Distribution-Api-Version", "registry/2.0")
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
log(f"{self.command} {parsed.path} -> {e.code} repo={repo}")
|
||||
except Exception as e:
|
||||
log(f"{self.command} {parsed.path} -> 502 {type(e).__name__}: {e}")
|
||||
self.send_error(502, str(e))
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
log("%s - %s" % (self.address_string(), fmt % args))
|
||||
|
||||
|
||||
def main():
|
||||
server = ThreadingHTTPServer((LISTEN, PORT), ProxyHandler)
|
||||
log(f"listening on {LISTEN}:{PORT}, upstream=https://{HARBOR_HOST}/{HARBOR_PROJECT}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
Executable
+342
@@ -0,0 +1,342 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
LOCAL_DIR="${ORBSTACK_LOCAL_DIR:-$HOME/.orbstack/local}"
|
||||
ENV_FILE="${HARBOR_MIRROR_ENV_FILE:-$LOCAL_DIR/harbor-mirror.env}"
|
||||
PROXY_SRC="$ROOT_DIR/scripts/dockerhub-mirror-auth-proxy.py"
|
||||
PROXY_DST="$LOCAL_DIR/dockerhub_mirror_auth_proxy.py"
|
||||
PROXY_CONTAINER="${HARBOR_MIRROR_CONTAINER:-dockerhub-mirror-auth}"
|
||||
PROXY_IMAGE="${HARBOR_MIRROR_PROXY_IMAGE:-python:3.11-alpine}"
|
||||
PROXY_PORT="${HARBOR_MIRROR_PORT:-5002}"
|
||||
PROXY_CONTAINER_PORT="${HARBOR_MIRROR_CONTAINER_PORT:-5001}"
|
||||
HARBOR_NETWORK="${HARBOR_NETWORK:-harbor_core}"
|
||||
MIRROR_URL="http://127.0.0.1:${PROXY_PORT}"
|
||||
DOCKER_CONFIG_FILES=("$HOME/.orbstack/config/docker.json" "$HOME/.orbstack/docker")
|
||||
|
||||
log() { printf '[%s] %s\n' "$1" "$2"; }
|
||||
info() { log INFO "$1"; }
|
||||
ok() { log OK "$1"; }
|
||||
warn() { log WARN "$1"; }
|
||||
fail() { log ERROR "$1"; exit 1; }
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") <command>
|
||||
|
||||
Commands:
|
||||
init-env Create $ENV_FILE template
|
||||
install Copy proxy, start container, configure OrbStack Docker
|
||||
ensure Keep proxy container running; warn if Docker mirror is missing
|
||||
start-proxy Recreate the local auth proxy container
|
||||
configure-docker Add $MIRROR_URL to OrbStack Docker config files
|
||||
restart-docker Restart OrbStack Docker
|
||||
verify-docker Pull a standard Docker Hub image and show proxy evidence
|
||||
verify-k3s Pull a standard Docker Hub image from k3s and show Harbor evidence
|
||||
doctor Run status + Docker verification, then k3s verification when kubectl exists
|
||||
status Show proxy, Docker mirror, and k3s registry state
|
||||
logs Show proxy + Harbor nginx/registry evidence logs
|
||||
install-launchd Install a user LaunchAgent that runs "ensure" every 5 minutes
|
||||
EOF
|
||||
}
|
||||
|
||||
load_env() {
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [[ -z "${HARBOR_HOST:-}" ]]; then
|
||||
HARBOR_HOST="$(docker inspect "$PROXY_CONTAINER" \
|
||||
--format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \
|
||||
| sed -n 's/^HARBOR_HOST=//p' | head -1 || true)"
|
||||
fi
|
||||
|
||||
HARBOR_PROJECT="${HARBOR_PROJECT:-dockerhub}"
|
||||
HARBOR_VERIFY_TLS="${HARBOR_VERIFY_TLS:-false}"
|
||||
HARBOR_NETWORK="${HARBOR_NETWORK:-harbor_core}"
|
||||
PROXY_PORT="${HARBOR_MIRROR_PORT:-$PROXY_PORT}"
|
||||
MIRROR_URL="http://127.0.0.1:${PROXY_PORT}"
|
||||
|
||||
if [[ -z "${HARBOR_HOST:-}" ]]; then
|
||||
fail "HARBOR_HOST is not set. Run: $0 init-env, then edit $ENV_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
init_env() {
|
||||
mkdir -p "$LOCAL_DIR"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
warn "env file already exists: $ENV_FILE"
|
||||
return 0
|
||||
fi
|
||||
local detected_host
|
||||
detected_host="$(docker inspect "$PROXY_CONTAINER" \
|
||||
--format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \
|
||||
| sed -n 's/^HARBOR_HOST=//p' | head -1 || true)"
|
||||
detected_host="${detected_host:-REPLACE_WITH_HARBOR_HOST}"
|
||||
|
||||
cat >"$ENV_FILE" <<'EOF'
|
||||
# Local-only settings for OrbStack -> Harbor mirror.
|
||||
# Do not commit this file.
|
||||
EOF
|
||||
{
|
||||
printf 'HARBOR_HOST=%s\n' "$detected_host"
|
||||
cat <<'EOF'
|
||||
HARBOR_PROJECT=dockerhub
|
||||
HARBOR_VERIFY_TLS=false
|
||||
HARBOR_NETWORK=harbor_core
|
||||
HARBOR_MIRROR_PORT=5002
|
||||
EOF
|
||||
} >>"$ENV_FILE"
|
||||
ok "created $ENV_FILE"
|
||||
}
|
||||
|
||||
copy_proxy() {
|
||||
mkdir -p "$LOCAL_DIR"
|
||||
install -m 0644 "$PROXY_SRC" "$PROXY_DST"
|
||||
ok "proxy script installed: $PROXY_DST"
|
||||
}
|
||||
|
||||
start_proxy() {
|
||||
load_env
|
||||
copy_proxy
|
||||
|
||||
docker network inspect "$HARBOR_NETWORK" >/dev/null 2>&1 \
|
||||
|| fail "Docker network not found: $HARBOR_NETWORK"
|
||||
|
||||
if docker ps -a --format '{{.Names}}' | grep -qx "$PROXY_CONTAINER"; then
|
||||
docker rm -f "$PROXY_CONTAINER" >/dev/null
|
||||
fi
|
||||
|
||||
docker run -d \
|
||||
--name "$PROXY_CONTAINER" \
|
||||
--restart unless-stopped \
|
||||
--network "$HARBOR_NETWORK" \
|
||||
-p "127.0.0.1:${PROXY_PORT}:${PROXY_CONTAINER_PORT}" \
|
||||
-e "HARBOR_HOST=${HARBOR_HOST}" \
|
||||
-e "HARBOR_PROJECT=${HARBOR_PROJECT}" \
|
||||
-e "HARBOR_VERIFY_TLS=${HARBOR_VERIFY_TLS}" \
|
||||
-e "LISTEN=0.0.0.0" \
|
||||
-e "PORT=${PROXY_CONTAINER_PORT}" \
|
||||
-v "${PROXY_DST}:/app/proxy.py:ro" \
|
||||
"$PROXY_IMAGE" python /app/proxy.py >/dev/null
|
||||
|
||||
ok "proxy container running: ${PROXY_CONTAINER} on ${MIRROR_URL}"
|
||||
}
|
||||
|
||||
merge_docker_config() {
|
||||
local file="$1"
|
||||
local mirror="$2"
|
||||
mkdir -p "$(dirname "$file")"
|
||||
|
||||
if [[ -f "$file" ]]; then
|
||||
cp "$file" "${file}.bak.harbor-mirror.$(date +%Y%m%d%H%M%S)"
|
||||
fi
|
||||
|
||||
python3 - "$file" "$mirror" "$PROXY_PORT" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
path, mirror, port = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
data = {}
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SystemExit(f"{path}: invalid JSON: {exc}")
|
||||
|
||||
def prepend_unique(values, item):
|
||||
values = list(values or [])
|
||||
return [item] + [v for v in values if v != item]
|
||||
|
||||
data["registry-mirrors"] = prepend_unique(data.get("registry-mirrors"), mirror)
|
||||
data["insecure-registries"] = prepend_unique(data.get("insecure-registries"), f"127.0.0.1:{port}")
|
||||
data["insecure-registries"] = prepend_unique(data.get("insecure-registries"), f"localhost:{port}")
|
||||
|
||||
tmp = f"{path}.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, indent=2, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
os.replace(tmp, path)
|
||||
PY
|
||||
ok "updated Docker config: $file"
|
||||
}
|
||||
|
||||
configure_docker() {
|
||||
for file in "${DOCKER_CONFIG_FILES[@]}"; do
|
||||
merge_docker_config "$file" "$MIRROR_URL"
|
||||
done
|
||||
warn "restart OrbStack Docker to apply the mirror: $0 restart-docker"
|
||||
}
|
||||
|
||||
restart_docker() {
|
||||
if [[ -x "$HOME/.orbstack/bin/orbctl" ]]; then
|
||||
"$HOME/.orbstack/bin/orbctl" restart docker
|
||||
else
|
||||
fail "orbctl not found at $HOME/.orbstack/bin/orbctl"
|
||||
fi
|
||||
}
|
||||
|
||||
docker_mirror_configured() {
|
||||
local docker_info
|
||||
docker_info="$(docker info 2>/dev/null || true)"
|
||||
printf '%s\n' "$docker_info" | grep -Fq "$MIRROR_URL/" \
|
||||
|| printf '%s\n' "$docker_info" | grep -Fq "$MIRROR_URL"
|
||||
}
|
||||
|
||||
verify_docker() {
|
||||
load_env
|
||||
docker ps --format '{{.Names}}' | grep -qx "$PROXY_CONTAINER" \
|
||||
|| fail "proxy container is not running"
|
||||
docker_mirror_configured || fail "Docker registry mirror is not configured: $MIRROR_URL"
|
||||
|
||||
local image="${1:-testcontainers/ryuk:0.12.0}"
|
||||
info "pulling standard Docker Hub image through Docker mirror: $image"
|
||||
docker pull "$image"
|
||||
ok "Docker pull succeeded: $image"
|
||||
docker logs --tail 80 "$PROXY_CONTAINER" 2>&1 | grep -E 'manifests|blobs' | tail -10 || true
|
||||
}
|
||||
|
||||
verify_k3s() {
|
||||
load_env
|
||||
command -v kubectl >/dev/null || fail "kubectl not found"
|
||||
|
||||
local image="${1:-testcontainers/ryuk:0.11.0}"
|
||||
local pod="k3s-harbor-pull-test-$(date +%s)"
|
||||
trap 'kubectl delete pod "$pod" --ignore-not-found --wait=false >/dev/null 2>&1 || true' RETURN
|
||||
|
||||
info "pulling standard image from k3s: $image"
|
||||
kubectl delete pod "$pod" --ignore-not-found >/dev/null 2>&1 || true
|
||||
kubectl run "$pod" \
|
||||
--image="$image" \
|
||||
--image-pull-policy=Always \
|
||||
--restart=Never \
|
||||
--command -- /bin/sh -c 'sleep 30' >/dev/null
|
||||
kubectl wait --for=condition=Ready "pod/$pod" --timeout=120s
|
||||
kubectl describe pod "$pod" | sed -n '/Events:/,$p'
|
||||
|
||||
local repo="${image%%:*}"
|
||||
if [[ "$repo" != */* ]]; then
|
||||
repo="library/$repo"
|
||||
fi
|
||||
local encoded_repo="${repo//\//%2F}"
|
||||
|
||||
info "Harbor nginx evidence:"
|
||||
docker logs --since 5m nginx 2>&1 \
|
||||
| grep -E "dockerhub/${repo}|repository%3Adockerhub%2F${encoded_repo}%3Apull" \
|
||||
| tail -20 || true
|
||||
}
|
||||
|
||||
status() {
|
||||
info "proxy container"
|
||||
docker ps -a --filter "name=^/${PROXY_CONTAINER}$" \
|
||||
--format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' || true
|
||||
echo
|
||||
info "Docker registry mirrors"
|
||||
docker info 2>/dev/null | sed -n '/Registry Mirrors:/,/Live Restore Enabled:/p' || true
|
||||
echo
|
||||
info "k3s registries.yaml evidence"
|
||||
kubectl get node >/dev/null 2>&1 && kubectl get node -o wide || true
|
||||
}
|
||||
|
||||
logs() {
|
||||
load_env
|
||||
info "proxy logs"
|
||||
docker logs --tail 80 "$PROXY_CONTAINER" 2>&1 || true
|
||||
echo
|
||||
info "Harbor nginx Docker Hub evidence"
|
||||
docker logs --since 15m nginx 2>&1 | grep -E 'dockerhub/|containerd/v|Docker-Client' | tail -80 || true
|
||||
echo
|
||||
info "Harbor registry Docker Hub evidence"
|
||||
docker logs --since 15m registry 2>&1 | grep -E 'dockerhub/' | tail -80 || true
|
||||
}
|
||||
|
||||
ensure() {
|
||||
load_env
|
||||
copy_proxy
|
||||
if ! docker ps --format '{{.Names}}' | grep -qx "$PROXY_CONTAINER"; then
|
||||
warn "proxy container is not running; recreating"
|
||||
start_proxy
|
||||
fi
|
||||
if ! docker_mirror_configured; then
|
||||
warn "Docker mirror is missing from OrbStack config: $MIRROR_URL"
|
||||
fi
|
||||
ok "ensure completed"
|
||||
}
|
||||
|
||||
doctor() {
|
||||
status
|
||||
verify_docker
|
||||
if command -v kubectl >/dev/null && kubectl get node >/dev/null 2>&1; then
|
||||
verify_k3s
|
||||
else
|
||||
warn "kubectl/k3s unavailable; skipped k3s verification"
|
||||
fi
|
||||
}
|
||||
|
||||
install_launchd() {
|
||||
local plist="$HOME/Library/LaunchAgents/com.orbstack.harbor-mirror.ensure.plist"
|
||||
mkdir -p "$HOME/Library/LaunchAgents" "$ROOT_DIR/logs"
|
||||
cat >"$plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.orbstack.harbor-mirror.ensure</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<string>${ROOT_DIR}/scripts/orbstack-harbor-mirrorctl.sh</string>
|
||||
<string>ensure</string>
|
||||
</array>
|
||||
<key>StartInterval</key>
|
||||
<integer>300</integer>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${ROOT_DIR}/logs/orbstack-harbor-mirror-stdout.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${ROOT_DIR}/logs/orbstack-harbor-mirror-stderr.log</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
launchctl unload "$plist" >/dev/null 2>&1 || true
|
||||
launchctl load "$plist"
|
||||
ok "LaunchAgent installed: $plist"
|
||||
}
|
||||
|
||||
install_all() {
|
||||
load_env
|
||||
start_proxy
|
||||
configure_docker
|
||||
restart_docker
|
||||
verify_docker
|
||||
}
|
||||
|
||||
cmd="${1:-}"
|
||||
case "$cmd" in
|
||||
init-env) init_env ;;
|
||||
install) install_all ;;
|
||||
ensure) ensure ;;
|
||||
start-proxy) start_proxy ;;
|
||||
configure-docker) configure_docker ;;
|
||||
restart-docker) restart_docker ;;
|
||||
verify-docker) shift; verify_docker "$@" ;;
|
||||
verify-k3s) shift; verify_k3s "$@" ;;
|
||||
doctor) doctor ;;
|
||||
status) status ;;
|
||||
logs) logs ;;
|
||||
install-launchd) install_launchd ;;
|
||||
-h|--help|help|"") usage ;;
|
||||
*) usage; exit 2 ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user