feat(harbor): add OrbStack Docker Hub mirror runbook

This commit is contained in:
2026-05-05 06:42:20 +08:00
parent 2204d61ada
commit 7fcb3723e7
3 changed files with 728 additions and 0 deletions
+189
View File
@@ -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
View File
@@ -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)
+342
View File
@@ -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