remote-docker-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
remote_docker/cli.py
ADDED
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
rdocker — Docker-like CLI that proxies pull/save/load to a remote SSH host via skopeo.
|
|
4
|
+
|
|
5
|
+
Architecture:
|
|
6
|
+
Local Machine Remote Machine (SSH)
|
|
7
|
+
┌──────────────────┐ ┌──────────────────────────────┐
|
|
8
|
+
│ docker-compose │ │ /tmp/rdocker/<name>/ │
|
|
9
|
+
│ .yml │ │ ├── nginx_latest.tar │
|
|
10
|
+
└────────┬─────────┘ │ ├── redis_7-alpine.tar │
|
|
11
|
+
│ parse + 补全 latest │ └── ... │
|
|
12
|
+
▼ │ └── <name>.tar.gz ← 产物 │
|
|
13
|
+
│ images list │ ──ssh+skopeo──▶ │ skopeo copy docker://... │
|
|
14
|
+
│ (in memory) │ │ docker-archive:xx.tar │
|
|
15
|
+
└─────────────────┘ └──────────────┬─────────────┘
|
|
16
|
+
│
|
|
17
|
+
│ scp
|
|
18
|
+
▼
|
|
19
|
+
./<name>.tar.gz ← 本地最终产物
|
|
20
|
+
|
|
21
|
+
Config file: ~/.config/rdocker/config.yml
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import getpass
|
|
25
|
+
import os
|
|
26
|
+
import platform
|
|
27
|
+
import re
|
|
28
|
+
import shlex
|
|
29
|
+
import shutil
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import tempfile
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Optional
|
|
35
|
+
from remote_docker import __version__
|
|
36
|
+
|
|
37
|
+
import click
|
|
38
|
+
import yaml
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ─────────────────────────────────────────────
|
|
42
|
+
# 配置层
|
|
43
|
+
# ─────────────────────────────────────────────
|
|
44
|
+
CONFIG_DIR = Path(os.path.expanduser("~/.config/rdocker"))
|
|
45
|
+
CONFIG_FILE = CONFIG_DIR / "config.yml"
|
|
46
|
+
|
|
47
|
+
DEFAULT_REMOTE_DIR = "/tmp/rdocker"
|
|
48
|
+
DEFAULT_SSH_PORT = 22
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── 配置读写 ────────────────────────────────
|
|
52
|
+
|
|
53
|
+
def load_config_file() -> dict:
|
|
54
|
+
"""从 ~/.config/rdocker/config.yml 读取配置,不存在返回空 dict"""
|
|
55
|
+
if not CONFIG_FILE.exists():
|
|
56
|
+
return {}
|
|
57
|
+
try:
|
|
58
|
+
data = yaml.safe_load(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
59
|
+
return data or {}
|
|
60
|
+
except yaml.YAMLError as e:
|
|
61
|
+
click.echo(f"⚠️ Config file parse error: {e}", err=True)
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def save_config_file(config: dict):
|
|
66
|
+
"""写入配置文件"""
|
|
67
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
# 保留注释友好:用 yaml 写,排序 keys
|
|
69
|
+
yaml_text = yaml.dump(
|
|
70
|
+
config,
|
|
71
|
+
default_flow_style=False,
|
|
72
|
+
sort_keys=False,
|
|
73
|
+
allow_unicode=True,
|
|
74
|
+
indent=2,
|
|
75
|
+
)
|
|
76
|
+
CONFIG_FILE.write_text(yaml_text, encoding="utf-8")
|
|
77
|
+
os.chmod(CONFIG_FILE, 0o600)
|
|
78
|
+
click.echo(f"✅ Config saved to {CONFIG_FILE}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_config_value(key: str, default: str = "") -> str:
|
|
82
|
+
"""
|
|
83
|
+
优先级:环境变量 rdocker_<KEY> > 配置文件 > default
|
|
84
|
+
"""
|
|
85
|
+
env_val = os.getenv(f"rdocker_{key.upper()}")
|
|
86
|
+
if env_val:
|
|
87
|
+
return env_val
|
|
88
|
+
cfg = load_config_file()
|
|
89
|
+
return cfg.get(key, default)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ─────────────────────────────────────────────
|
|
93
|
+
# 工具函数
|
|
94
|
+
# ─────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def parse_compose_images(compose_file: Path) -> list[str]:
|
|
97
|
+
"""
|
|
98
|
+
解析 docker-compose.yml,提取所有 service.image,
|
|
99
|
+
没有 tag 的自动补全为 :latest,
|
|
100
|
+
正确区分 registry:port 与 :tag 的情况。
|
|
101
|
+
"""
|
|
102
|
+
data = yaml.safe_load(compose_file.read_text(encoding="utf-8"))
|
|
103
|
+
images: set[str] = set()
|
|
104
|
+
|
|
105
|
+
services = data.get("services") or {}
|
|
106
|
+
for svc_name, svc_cfg in services.items():
|
|
107
|
+
img = (svc_cfg or {}).get("image")
|
|
108
|
+
if not img:
|
|
109
|
+
click.echo(f"⚠️ Service '{svc_name}' has no image field, skipping.", err=True)
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
parts = img.split("/")
|
|
113
|
+
first = parts[0]
|
|
114
|
+
|
|
115
|
+
# registry:port 形式(如 registry:5000/app:v1)
|
|
116
|
+
if ":" in first and re.match(r"^[^:]+:\d+$", first):
|
|
117
|
+
rest = "/".join(parts[1:])
|
|
118
|
+
if ":" in rest and not re.search(r":\d+$", img):
|
|
119
|
+
pass # 已有 tag
|
|
120
|
+
else:
|
|
121
|
+
img = img + ":latest"
|
|
122
|
+
elif re.match(r"^[^:]+:\d+$", img):
|
|
123
|
+
img = img + ":latest"
|
|
124
|
+
elif ":" not in img:
|
|
125
|
+
img = img + ":latest"
|
|
126
|
+
|
|
127
|
+
images.add(img)
|
|
128
|
+
|
|
129
|
+
return sorted(images)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_ssh_cmd(host: str, user: str, key: str, port: int, remote_cmd: str) -> list[str]:
|
|
133
|
+
cmd = [
|
|
134
|
+
"ssh",
|
|
135
|
+
"-o", "StrictHostKeyChecking=no",
|
|
136
|
+
"-o", "ConnectTimeout=30",
|
|
137
|
+
"-o", "ServerAliveInterval=60",
|
|
138
|
+
"-p", str(port),
|
|
139
|
+
]
|
|
140
|
+
if key:
|
|
141
|
+
cmd.extend(["-i", os.path.expanduser(key)])
|
|
142
|
+
cmd += [f"{user}@{host}", remote_cmd]
|
|
143
|
+
return cmd
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_scp_cmd(host: str, user: str, key: str, port: int, src: str, dst: str) -> list[str]:
|
|
147
|
+
cmd = [
|
|
148
|
+
"scp",
|
|
149
|
+
"-o", "StrictHostKeyChecking=no",
|
|
150
|
+
"-o", "ConnectTimeout=30",
|
|
151
|
+
"-P", str(port),
|
|
152
|
+
]
|
|
153
|
+
if key:
|
|
154
|
+
cmd.extend(["-i", os.path.expanduser(key)])
|
|
155
|
+
cmd += [f"{user}@{host}:{src}", dst]
|
|
156
|
+
return cmd
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def run_local(cmd: list[str], check: bool = True, capture: bool = False) -> subprocess.CompletedProcess:
|
|
160
|
+
click.echo(f" $ {' '.join(shlex.quote(c) for c in cmd)}", err=True)
|
|
161
|
+
if capture:
|
|
162
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
163
|
+
else:
|
|
164
|
+
result = subprocess.run(cmd)
|
|
165
|
+
if check and result.returncode != 0:
|
|
166
|
+
err_msg = result.stderr.strip() if capture else ""
|
|
167
|
+
raise click.ClickException(
|
|
168
|
+
f"Command failed (exit {result.returncode}): "
|
|
169
|
+
f"{' '.join(shlex.quote(c) for c in cmd)}"
|
|
170
|
+
+ (f"\n{err_msg}" if err_msg else "")
|
|
171
|
+
)
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def run_ssh(host: str, user: str, key: str, port: int, remote_cmd: str, capture: bool = False) -> subprocess.CompletedProcess:
|
|
176
|
+
cmd = build_ssh_cmd(host, user, key, port, remote_cmd)
|
|
177
|
+
return run_local(cmd, check=True, capture=capture)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def run_scp_download(host: str, user: str, key: str, port: int, remote_src: str, local_dst: str) -> subprocess.CompletedProcess:
|
|
181
|
+
cmd = build_scp_cmd(host, user, key, port, remote_src, local_dst)
|
|
182
|
+
return run_local(cmd, check=True, capture=False)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ─────────────────────────────────────────────
|
|
186
|
+
# 核心动作
|
|
187
|
+
# ─────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
def local_arch() -> str:
|
|
190
|
+
"""Return the Docker/OCI architecture name for the local machine."""
|
|
191
|
+
machine = platform.machine().lower()
|
|
192
|
+
mapping = {
|
|
193
|
+
"x86_64": "amd64",
|
|
194
|
+
"amd64": "amd64",
|
|
195
|
+
"aarch64": "arm64",
|
|
196
|
+
"arm64": "arm64",
|
|
197
|
+
"armv7l": "arm",
|
|
198
|
+
"armv6l": "arm",
|
|
199
|
+
"i386": "386",
|
|
200
|
+
"i686": "386",
|
|
201
|
+
"ppc64le": "ppc64le",
|
|
202
|
+
"s390x": "s390x",
|
|
203
|
+
}
|
|
204
|
+
return mapping.get(machine, machine)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def image_short_name(image: str) -> str:
|
|
208
|
+
"""Return the last path segment of an image reference, with any :tag stripped.
|
|
209
|
+
|
|
210
|
+
Examples:
|
|
211
|
+
nginx -> nginx
|
|
212
|
+
nginx:1.25-alpine -> nginx
|
|
213
|
+
library/nginx -> nginx
|
|
214
|
+
library/nginx:1.25 -> nginx
|
|
215
|
+
registry:5000/app:v1 -> app
|
|
216
|
+
harbor.example.com/team/app -> app
|
|
217
|
+
harbor.example.com/team/app:1.0 -> app
|
|
218
|
+
"""
|
|
219
|
+
last = image.rsplit("/", 1)[-1]
|
|
220
|
+
if ":" in last:
|
|
221
|
+
last = last.split(":", 1)[0]
|
|
222
|
+
return last or image
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def default_bundle_name(mode: str, ref: str = "") -> str:
|
|
226
|
+
"""Default bundle name for commands.
|
|
227
|
+
|
|
228
|
+
mode='compose' → current directory's last short name
|
|
229
|
+
mode='image' → image reference's last segment, tag stripped
|
|
230
|
+
"""
|
|
231
|
+
if mode == "image" and ref:
|
|
232
|
+
return image_short_name(ref)
|
|
233
|
+
return Path.cwd().name or "rdocker"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def do_pull(host, user, key, port, images, name, registry_auth):
|
|
237
|
+
remote_dir = f"{DEFAULT_REMOTE_DIR}/{name}"
|
|
238
|
+
click.echo(f"📁 Creating remote directory: {remote_dir}")
|
|
239
|
+
run_ssh(host, user, key, port, f"mkdir -p {remote_dir}")
|
|
240
|
+
|
|
241
|
+
arch_flag = f"--override-arch {shlex.quote(local_arch())}"
|
|
242
|
+
auth_flag = ""
|
|
243
|
+
if registry_auth:
|
|
244
|
+
auth_flag = f"--src-creds {shlex.quote(registry_auth)}"
|
|
245
|
+
|
|
246
|
+
failed = []
|
|
247
|
+
for img in images:
|
|
248
|
+
safe_name = img.replace("/", "_").replace(":", "_")
|
|
249
|
+
tar_path = f"{remote_dir}/{safe_name}.tar"
|
|
250
|
+
click.echo(f"⬇️ Pulling ({local_arch()}): {img} → {tar_path}")
|
|
251
|
+
|
|
252
|
+
skopeo_cmd = (
|
|
253
|
+
f"skopeo copy {arch_flag} {auth_flag} "
|
|
254
|
+
f"docker://{shlex.quote(img)} "
|
|
255
|
+
f"docker-archive:{shlex.quote(tar_path)}:{shlex.quote(img)}"
|
|
256
|
+
)
|
|
257
|
+
try:
|
|
258
|
+
run_ssh(host, user, key, port, skopeo_cmd)
|
|
259
|
+
except click.ClickException as e:
|
|
260
|
+
click.echo(f" ❌ Failed: {img} — {e}", err=True)
|
|
261
|
+
failed.append(img)
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
click.echo("")
|
|
265
|
+
click.echo(f"✅ Pull complete. ({len(images) - len(failed)}/{len(images)} succeeded)")
|
|
266
|
+
if failed:
|
|
267
|
+
click.echo(f"❌ Failed images:", err=True)
|
|
268
|
+
for f in failed:
|
|
269
|
+
click.echo(f" - {f}", err=True)
|
|
270
|
+
raise click.ClickException(f"{len(failed)} image(s) failed to pull")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def do_save(host, user, key, port, name):
|
|
274
|
+
remote_dir = f"{DEFAULT_REMOTE_DIR}/{name}"
|
|
275
|
+
tarball = f"/tmp/{name}.tar.gz"
|
|
276
|
+
|
|
277
|
+
click.echo(f"📦 Packing remote tars into {tarball} ...")
|
|
278
|
+
check_cmd = f"ls {remote_dir}/*.tar >/dev/null 2>&1 && echo OK || echo EMPTY"
|
|
279
|
+
result = run_ssh(host, user, key, port, check_cmd, capture=True)
|
|
280
|
+
if "EMPTY" in result.stdout:
|
|
281
|
+
raise click.ClickException(f"No tar files found in {remote_dir}")
|
|
282
|
+
|
|
283
|
+
tar_cmd = f"cd {remote_dir} && tar -czf {tarball} ."
|
|
284
|
+
run_ssh(host, user, key, port, tar_cmd)
|
|
285
|
+
click.echo(f"✅ Remote bundle created: {tarball}")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def do_fetch(host, user, key, port, name, output_dir) -> str:
|
|
289
|
+
remote_tarball = f"/tmp/{name}.tar.gz"
|
|
290
|
+
local_path = os.path.join(output_dir, f"{name}.tar.gz")
|
|
291
|
+
click.echo(f"⬇️ Downloading {remote_tarball} → {local_path} ...")
|
|
292
|
+
run_scp_download(host, user, key, port, remote_tarball, local_path)
|
|
293
|
+
click.echo(f"✅ Downloaded: {local_path}")
|
|
294
|
+
return local_path
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def do_load(local_tarball: str):
|
|
298
|
+
tarball = Path(local_tarball)
|
|
299
|
+
if not tarball.exists():
|
|
300
|
+
raise click.ClickException(f"File not found: {tarball}")
|
|
301
|
+
|
|
302
|
+
if not shutil.which("docker"):
|
|
303
|
+
raise click.ClickException("docker command not found on local machine")
|
|
304
|
+
|
|
305
|
+
click.echo(f"📂 Extracting {tarball} ...")
|
|
306
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
307
|
+
subprocess.run(["tar", "-xzf", str(tarball), "-C", tmp], check=True)
|
|
308
|
+
|
|
309
|
+
tar_files = sorted(Path(tmp).glob("*.tar"))
|
|
310
|
+
if not tar_files:
|
|
311
|
+
raise click.ClickException(f"No .tar files found in {tarball}")
|
|
312
|
+
|
|
313
|
+
click.echo(f"🔄 Loading {len(tar_files)} image(s) into local Docker ...")
|
|
314
|
+
for tar in tar_files:
|
|
315
|
+
click.echo(f" Loading {tar.name} ...")
|
|
316
|
+
result = subprocess.run(
|
|
317
|
+
["docker", "load", "-i", str(tar)],
|
|
318
|
+
capture_output=True, text=True,
|
|
319
|
+
)
|
|
320
|
+
if result.returncode != 0:
|
|
321
|
+
click.echo(f" ❌ docker load failed for {tar.name}: {result.stderr}", err=True)
|
|
322
|
+
else:
|
|
323
|
+
click.echo(f" ✅ {result.stdout.strip()}")
|
|
324
|
+
|
|
325
|
+
click.echo("✅ All images loaded into local Docker daemon")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def do_cleanup(host, user, key, port, name):
|
|
329
|
+
remote_dir = f"{DEFAULT_REMOTE_DIR}/{name}"
|
|
330
|
+
remote_tarball = f"/tmp/{name}.tar.gz"
|
|
331
|
+
click.echo(f"🧹 Cleaning up remote files ...")
|
|
332
|
+
cleanup_cmd = f"rm -rf {remote_dir} {remote_tarball}"
|
|
333
|
+
try:
|
|
334
|
+
run_ssh(host, user, key, port, cleanup_cmd)
|
|
335
|
+
click.echo("✅ Remote cleanup done")
|
|
336
|
+
except click.ClickException:
|
|
337
|
+
click.echo("⚠️ Remote cleanup failed (non-fatal)", err=True)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ── 干跑计划(不执行,仅打印) ─────────────────
|
|
341
|
+
|
|
342
|
+
def cmd_to_str(cmd: list[str]) -> str:
|
|
343
|
+
"""Quote a command list as a printable shell string."""
|
|
344
|
+
return " ".join(shlex.quote(c) for c in cmd)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def print_save_plan(host, user, key, port, name):
|
|
348
|
+
"""Print the save command without executing."""
|
|
349
|
+
remote_dir = f"{DEFAULT_REMOTE_DIR}/{name}"
|
|
350
|
+
tarball = f"/tmp/{name}.tar.gz"
|
|
351
|
+
click.echo(f"🔍 Dry-run: no commands will be executed")
|
|
352
|
+
click.echo("")
|
|
353
|
+
check_cmd = build_ssh_cmd(
|
|
354
|
+
host, user, key, port,
|
|
355
|
+
f"ls {remote_dir}/*.tar >/dev/null 2>&1 && echo OK || echo EMPTY",
|
|
356
|
+
)
|
|
357
|
+
click.echo(f" {cmd_to_str(check_cmd)}")
|
|
358
|
+
tar_cmd = build_ssh_cmd(host, user, key, port, f"cd {remote_dir} && tar -czf {tarball} .")
|
|
359
|
+
click.echo(f" {cmd_to_str(tar_cmd)}")
|
|
360
|
+
click.echo("")
|
|
361
|
+
click.echo(f" Remote bundle would be: {tarball}")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def print_fetch_plan(host, user, key, port, name, local_dst):
|
|
365
|
+
"""Print the fetch command without executing."""
|
|
366
|
+
remote_tarball = f"/tmp/{name}.tar.gz"
|
|
367
|
+
click.echo(f"🔍 Dry-run: no commands will be executed")
|
|
368
|
+
click.echo("")
|
|
369
|
+
cmd = build_scp_cmd(host, user, key, port, remote_tarball, local_dst)
|
|
370
|
+
click.echo(f" {cmd_to_str(cmd)}")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def print_load_plan(host, user, key, port, name, file, output_dir="."):
|
|
374
|
+
"""Print what load would execute; if remote fallback applies, print scp too."""
|
|
375
|
+
if file:
|
|
376
|
+
tarball = file
|
|
377
|
+
elif name:
|
|
378
|
+
tarball = f"{name}.tar.gz"
|
|
379
|
+
else:
|
|
380
|
+
raise click.ClickException("Must specify --name or --file")
|
|
381
|
+
|
|
382
|
+
click.echo(f"🔍 Dry-run: no commands will be executed")
|
|
383
|
+
click.echo("")
|
|
384
|
+
|
|
385
|
+
would_fetch = False
|
|
386
|
+
if not Path(tarball).exists() and name and host:
|
|
387
|
+
would_fetch = True
|
|
388
|
+
local_dst = os.path.join(output_dir or ".", f"{name}.tar.gz")
|
|
389
|
+
click.echo(f" # Local '{tarball}' not found, would fetch from remote:")
|
|
390
|
+
cmd = build_scp_cmd(host, user, key, port, f"/tmp/{name}.tar.gz", local_dst)
|
|
391
|
+
click.echo(f" {cmd_to_str(cmd)}")
|
|
392
|
+
click.echo("")
|
|
393
|
+
local_tarball = local_dst
|
|
394
|
+
else:
|
|
395
|
+
local_tarball = tarball
|
|
396
|
+
|
|
397
|
+
click.echo(f" # Extract and load:")
|
|
398
|
+
click.echo(f" tar -xzf {shlex.quote(local_tarball)} -C <tmpdir>")
|
|
399
|
+
click.echo(f" docker load -i <each.tar in tmpdir>")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def print_cleanup_plan(host, user, key, port, name):
|
|
403
|
+
"""Print the cleanup command without executing."""
|
|
404
|
+
remote_dir = f"{DEFAULT_REMOTE_DIR}/{name}"
|
|
405
|
+
remote_tarball = f"/tmp/{name}.tar.gz"
|
|
406
|
+
click.echo(f"🔍 Dry-run: no commands will be executed")
|
|
407
|
+
click.echo("")
|
|
408
|
+
cleanup_cmd = f"rm -rf {remote_dir} {remote_tarball}"
|
|
409
|
+
cmd = build_ssh_cmd(host, user, key, port, cleanup_cmd)
|
|
410
|
+
click.echo(f" {cmd_to_str(cmd)}")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ─────────────────────────────────────────────
|
|
414
|
+
# Click CLI
|
|
415
|
+
# ─────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
@click.group(
|
|
418
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
419
|
+
help=(
|
|
420
|
+
"rdocker — Docker-like CLI that proxies pull/save/load to a remote SSH host via skopeo.\n\n"
|
|
421
|
+
"Config priority: CLI flag > env var > config file > default\n"
|
|
422
|
+
"Config file: ~/.config/rdocker/config.yml"
|
|
423
|
+
),
|
|
424
|
+
)
|
|
425
|
+
@click.option("--host", default=lambda: get_config_value("host", ""), help="Remote SSH host")
|
|
426
|
+
@click.option("--user", default=lambda: get_config_value("user", getpass.getuser()), help="SSH user (default: current system username)")
|
|
427
|
+
@click.option("--port", default=lambda: int(get_config_value("port", str(DEFAULT_SSH_PORT))), type=int, help="SSH port")
|
|
428
|
+
@click.option("--key", default=lambda: get_config_value("key", ""), help="SSH private key path (default: use ssh-agent / ~/.ssh/id_*)")
|
|
429
|
+
@click.pass_context
|
|
430
|
+
def cli(ctx: click.Context, host: str, user: str, port: int, key: str):
|
|
431
|
+
ctx.ensure_object(dict)
|
|
432
|
+
ctx.obj["host"] = host
|
|
433
|
+
ctx.obj["user"] = user
|
|
434
|
+
ctx.obj["port"] = port
|
|
435
|
+
ctx.obj["key"] = key
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _require_host(ctx: click.Context) -> str:
|
|
439
|
+
host = ctx.obj.get("host", "")
|
|
440
|
+
if not host:
|
|
441
|
+
raise click.ClickException(
|
|
442
|
+
"Remote host not specified.\n"
|
|
443
|
+
" → Use --host flag, set rdocker_HOST env var, or run: rdocker config init"
|
|
444
|
+
)
|
|
445
|
+
return host
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# ── config 命令组 ────────────────────────────
|
|
449
|
+
|
|
450
|
+
@cli.group(help="Manage rdocker configuration (~/.config/rdocker/config.yml)")
|
|
451
|
+
def config():
|
|
452
|
+
pass
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@config.command("init", help="Interactively create or overwrite the config file")
|
|
456
|
+
@click.option("--force", "-f", is_flag=True, help="Overwrite existing config without prompting")
|
|
457
|
+
def config_init(force: bool):
|
|
458
|
+
"""
|
|
459
|
+
交互式引导用户填写 SSH 远程主机配置,写入 ~/.config/rdocker/config.yml
|
|
460
|
+
"""
|
|
461
|
+
if CONFIG_FILE.exists() and not force:
|
|
462
|
+
click.confirm(
|
|
463
|
+
f"⚠️ Config file already exists at {CONFIG_FILE}.\n"
|
|
464
|
+
f" Overwrite?",
|
|
465
|
+
abort=True,
|
|
466
|
+
default=False,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
click.echo("")
|
|
470
|
+
click.echo("🔧 rdocker Config Init")
|
|
471
|
+
click.echo("─" * 50)
|
|
472
|
+
click.echo("Enter remote SSH connection details (press Enter to use [default]):")
|
|
473
|
+
click.echo("")
|
|
474
|
+
|
|
475
|
+
# host(必填)
|
|
476
|
+
host = click.prompt(" Remote host (IP or domain)", type=str, default="").strip()
|
|
477
|
+
while not host:
|
|
478
|
+
click.echo(" ❌ Host cannot be empty", err=True)
|
|
479
|
+
host = click.prompt(" Remote host (IP or domain)", type=str, default="").strip()
|
|
480
|
+
|
|
481
|
+
# user
|
|
482
|
+
user = click.prompt(" SSH user", type=str, default=getpass.getuser()).strip()
|
|
483
|
+
if not user:
|
|
484
|
+
user = getpass.getuser()
|
|
485
|
+
|
|
486
|
+
# port
|
|
487
|
+
port_str = click.prompt(" SSH port", type=str, default=str(DEFAULT_SSH_PORT)).strip()
|
|
488
|
+
try:
|
|
489
|
+
port = int(port_str)
|
|
490
|
+
except ValueError:
|
|
491
|
+
click.echo(f" ⚠️ Invalid port '{port_str}', using default {DEFAULT_SSH_PORT}", err=True)
|
|
492
|
+
port = DEFAULT_SSH_PORT
|
|
493
|
+
|
|
494
|
+
# key (empty = use system default: ssh-agent / ~/.ssh/id_*)
|
|
495
|
+
key = click.prompt(" SSH private key path (empty = system default)", type=str, default="").strip()
|
|
496
|
+
|
|
497
|
+
# registry auth(可选)
|
|
498
|
+
registry_auth = click.prompt(
|
|
499
|
+
" Registry auth (user:pass, leave empty if none)",
|
|
500
|
+
type=str, default="",
|
|
501
|
+
).strip()
|
|
502
|
+
|
|
503
|
+
# remote dir
|
|
504
|
+
remote_dir = click.prompt(
|
|
505
|
+
" Remote working directory",
|
|
506
|
+
type=str, default=DEFAULT_REMOTE_DIR,
|
|
507
|
+
).strip()
|
|
508
|
+
if not remote_dir:
|
|
509
|
+
remote_dir = DEFAULT_REMOTE_DIR
|
|
510
|
+
|
|
511
|
+
# 组装配置
|
|
512
|
+
config_data = {
|
|
513
|
+
"host": host,
|
|
514
|
+
"user": user,
|
|
515
|
+
"port": port,
|
|
516
|
+
"key": key,
|
|
517
|
+
"remote_dir": remote_dir,
|
|
518
|
+
}
|
|
519
|
+
if registry_auth:
|
|
520
|
+
config_data["registry_auth"] = registry_auth
|
|
521
|
+
|
|
522
|
+
# 写入
|
|
523
|
+
save_config_file(config_data)
|
|
524
|
+
|
|
525
|
+
# 验证 SSH 连通性
|
|
526
|
+
click.echo("")
|
|
527
|
+
click.echo("🔍 Testing SSH connection ...")
|
|
528
|
+
ssh_key_path = os.path.expanduser(key)
|
|
529
|
+
test_cmd = [
|
|
530
|
+
"ssh",
|
|
531
|
+
"-i", ssh_key_path,
|
|
532
|
+
"-o", "StrictHostKeyChecking=no",
|
|
533
|
+
"-o", "ConnectTimeout=10",
|
|
534
|
+
"-p", str(port),
|
|
535
|
+
f"{user}@{host}",
|
|
536
|
+
"echo 'SSH OK' && which skopeo || echo 'WARN: skopeo not found on remote'",
|
|
537
|
+
]
|
|
538
|
+
try:
|
|
539
|
+
result = subprocess.run(test_cmd, capture_output=True, text=True, timeout=20)
|
|
540
|
+
if result.returncode == 0:
|
|
541
|
+
click.echo(f" ✅ SSH connection successful")
|
|
542
|
+
if "skopeo not found" in result.stdout:
|
|
543
|
+
click.echo(f" ⚠️ skopeo is NOT installed on remote host. Install it first!", err=True)
|
|
544
|
+
else:
|
|
545
|
+
click.echo(f" ✅ skopeo is available on remote")
|
|
546
|
+
else:
|
|
547
|
+
click.echo(f" ❌ SSH connection failed:\n {result.stderr.strip()}", err=True)
|
|
548
|
+
except subprocess.TimeoutExpired:
|
|
549
|
+
click.echo(f" ❌ SSH connection timeout (10s)", err=True)
|
|
550
|
+
except Exception as e:
|
|
551
|
+
click.echo(f" ❌ SSH test error: {e}", err=True)
|
|
552
|
+
|
|
553
|
+
click.echo("")
|
|
554
|
+
click.echo("🎉 Config initialized! You can now run:")
|
|
555
|
+
click.echo(f" rdocker all docker-compose.yml --name myapp")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@config.command("show", help="Display current configuration")
|
|
559
|
+
def config_show():
|
|
560
|
+
"""查看当前配置(脱敏显示密码)"""
|
|
561
|
+
cfg = load_config_file()
|
|
562
|
+
|
|
563
|
+
click.echo("")
|
|
564
|
+
click.echo("📋 rdocker Configuration")
|
|
565
|
+
click.echo("─" * 50)
|
|
566
|
+
|
|
567
|
+
if not cfg:
|
|
568
|
+
click.echo(f" (no config file found at {CONFIG_FILE})")
|
|
569
|
+
click.echo(f" Run: rdocker config init")
|
|
570
|
+
return
|
|
571
|
+
|
|
572
|
+
click.echo(f" Config file: {CONFIG_FILE}")
|
|
573
|
+
click.echo(f" Permissions: {oct(os.stat(CONFIG_FILE).st_mode)[-3:]}")
|
|
574
|
+
click.echo("")
|
|
575
|
+
for key in ("host", "user", "port", "key", "remote_dir", "registry_auth"):
|
|
576
|
+
val = cfg.get(key, "<not set>")
|
|
577
|
+
# 脱敏 registry_auth
|
|
578
|
+
if key == "registry_auth" and val and ":" in str(val):
|
|
579
|
+
parts = str(val).split(":", 1)
|
|
580
|
+
masked = f"{parts[0]}:***"
|
|
581
|
+
click.echo(f" {key:15s} = {masked}")
|
|
582
|
+
else:
|
|
583
|
+
click.echo(f" {key:15s} = {val}")
|
|
584
|
+
|
|
585
|
+
click.echo("")
|
|
586
|
+
click.echo(" Priority: CLI flag > env var (rdocker_*) > config file > default")
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
@config.command("set", help="Set a single config key-value pair")
|
|
590
|
+
@click.argument("key", type=click.Choice([
|
|
591
|
+
"host", "user", "port", "key", "remote_dir", "registry_auth"
|
|
592
|
+
]))
|
|
593
|
+
@click.argument("value")
|
|
594
|
+
def config_set(key: str, value: str):
|
|
595
|
+
"""修改配置中的单个字段"""
|
|
596
|
+
cfg = load_config_file()
|
|
597
|
+
|
|
598
|
+
# 类型转换
|
|
599
|
+
if key == "port":
|
|
600
|
+
try:
|
|
601
|
+
value = int(value)
|
|
602
|
+
except ValueError:
|
|
603
|
+
raise click.ClickException(f"port must be an integer, got '{value}'")
|
|
604
|
+
|
|
605
|
+
cfg[key] = value
|
|
606
|
+
save_config_file(cfg)
|
|
607
|
+
|
|
608
|
+
# 脱敏显示
|
|
609
|
+
display_val = value
|
|
610
|
+
if key == "registry_auth" and ":" in str(value):
|
|
611
|
+
parts = str(value).split(":", 1)
|
|
612
|
+
display_val = f"{parts[0]}:***"
|
|
613
|
+
|
|
614
|
+
click.echo(f" {key} = {display_val}")
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@config.command("unset", help="Remove a config key")
|
|
618
|
+
@click.argument("key", type=click.Choice([
|
|
619
|
+
"host", "user", "port", "key", "remote_dir", "registry_auth"
|
|
620
|
+
]))
|
|
621
|
+
def config_unset(key: str):
|
|
622
|
+
"""删除配置中的某个字段"""
|
|
623
|
+
cfg = load_config_file()
|
|
624
|
+
if key in cfg:
|
|
625
|
+
del cfg[key]
|
|
626
|
+
save_config_file(cfg)
|
|
627
|
+
click.echo(f" removed: {key}")
|
|
628
|
+
else:
|
|
629
|
+
click.echo(f" key '{key}' not in config, nothing to do")
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
@config.command("path", help="Print the config file path")
|
|
633
|
+
def config_path():
|
|
634
|
+
"""打印配置文件路径"""
|
|
635
|
+
click.echo(str(CONFIG_FILE))
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# ── pull ──────────────────────────────────────
|
|
639
|
+
|
|
640
|
+
@cli.command()
|
|
641
|
+
@click.argument("compose_file", type=click.Path(exists=True, dir_okay=False))
|
|
642
|
+
@click.option("--name", "-n", default="", help="Bundle name (default: current directory short name)")
|
|
643
|
+
@click.option("--registry-auth", "-a", default=lambda: get_config_value("registry_auth", ""), help="Registry credentials (user:pass)")
|
|
644
|
+
@click.option("--try", "dry_run", is_flag=True, help="Dry-run: print commands without executing")
|
|
645
|
+
@click.pass_context
|
|
646
|
+
def pull(ctx: click.Context, compose_file: str, name: str, registry_auth: str, dry_run: bool):
|
|
647
|
+
"""Parse docker-compose.yml and pull all images to remote host via skopeo."""
|
|
648
|
+
host = _require_host(ctx)
|
|
649
|
+
user = ctx.obj["user"]
|
|
650
|
+
port = ctx.obj["port"]
|
|
651
|
+
key = ctx.obj["key"]
|
|
652
|
+
|
|
653
|
+
if not name:
|
|
654
|
+
name = default_bundle_name("compose")
|
|
655
|
+
click.echo(f"📦 Bundle name: {name}")
|
|
656
|
+
|
|
657
|
+
compose_path = Path(compose_file)
|
|
658
|
+
click.echo(f"📄 Parsing: {compose_path}")
|
|
659
|
+
images = parse_compose_images(compose_path)
|
|
660
|
+
|
|
661
|
+
if not images:
|
|
662
|
+
raise click.ClickException("No images found in compose file")
|
|
663
|
+
|
|
664
|
+
click.echo(f"📋 Found {len(images)} image(s):")
|
|
665
|
+
for img in images:
|
|
666
|
+
click.echo(f" - {img}")
|
|
667
|
+
|
|
668
|
+
click.echo("")
|
|
669
|
+
auth = registry_auth or None
|
|
670
|
+
if dry_run:
|
|
671
|
+
print_pull_plan(host, user, key, port, images, name, auth)
|
|
672
|
+
return
|
|
673
|
+
do_pull(host, user, key, port, images, name, auth)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def print_pull_plan(host, user, key, port, images, name, registry_auth):
|
|
677
|
+
"""Print the commands that would be executed, without running them."""
|
|
678
|
+
remote_dir = f"{DEFAULT_REMOTE_DIR}/{name}"
|
|
679
|
+
arch_flag = f"--override-arch {shlex.quote(local_arch())} "
|
|
680
|
+
click.echo(f"🔍 Dry-run: no commands will be executed")
|
|
681
|
+
click.echo("")
|
|
682
|
+
click.echo(f" ssh {user}@{host} 'mkdir -p {remote_dir}'")
|
|
683
|
+
|
|
684
|
+
auth_flag = ""
|
|
685
|
+
if registry_auth:
|
|
686
|
+
auth_flag = f"--src-creds {shlex.quote(registry_auth)} "
|
|
687
|
+
|
|
688
|
+
for img in images:
|
|
689
|
+
safe_name = img.replace("/", "_").replace(":", "_")
|
|
690
|
+
tar_path = f"{remote_dir}/{safe_name}.tar"
|
|
691
|
+
skopeo_cmd = (
|
|
692
|
+
f"skopeo copy {arch_flag}{auth_flag}"
|
|
693
|
+
f"docker://{shlex.quote(img)} "
|
|
694
|
+
f"docker-archive:{shlex.quote(tar_path)}:{shlex.quote(img)}"
|
|
695
|
+
)
|
|
696
|
+
click.echo(f" ssh {user}@{host} '{skopeo_cmd}'")
|
|
697
|
+
|
|
698
|
+
click.echo("")
|
|
699
|
+
click.echo(f" ({len(images)} image(s) would be pulled)")
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
# ── spull ─────────────────────────────────────
|
|
703
|
+
|
|
704
|
+
@cli.command()
|
|
705
|
+
@click.argument("image")
|
|
706
|
+
@click.option("--name", "-n", default="", help="Bundle name (default: current directory short name)")
|
|
707
|
+
@click.option("--registry-auth", "-a", default=lambda: get_config_value("registry_auth", ""), help="Registry credentials (user:pass)")
|
|
708
|
+
@click.option("--try", "dry_run", is_flag=True, help="Dry-run: print commands without executing")
|
|
709
|
+
@click.pass_context
|
|
710
|
+
def spull(ctx: click.Context, image: str, name: str, registry_auth: str, dry_run: bool):
|
|
711
|
+
"""Pull a single image to remote host via skopeo (no compose parsing)."""
|
|
712
|
+
host = _require_host(ctx)
|
|
713
|
+
user = ctx.obj["user"]
|
|
714
|
+
port = ctx.obj["port"]
|
|
715
|
+
key = ctx.obj["key"]
|
|
716
|
+
|
|
717
|
+
if not name:
|
|
718
|
+
name = default_bundle_name("image", image)
|
|
719
|
+
click.echo(f"📦 Bundle name: {name}")
|
|
720
|
+
click.echo(f"🐳 Image: {image}")
|
|
721
|
+
|
|
722
|
+
images = [image]
|
|
723
|
+
auth = registry_auth or None
|
|
724
|
+
if dry_run:
|
|
725
|
+
print_pull_plan(host, user, key, port, images, name, auth)
|
|
726
|
+
return
|
|
727
|
+
do_pull(host, user, key, port, images, name, auth)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
# ── save ──────────────────────────────────────
|
|
731
|
+
|
|
732
|
+
@cli.command()
|
|
733
|
+
@click.option("--name", "-n", default="", help="Bundle name (default: current directory short name)")
|
|
734
|
+
@click.option("--try", "dry_run", is_flag=True, help="Dry-run: print commands without executing")
|
|
735
|
+
@click.pass_context
|
|
736
|
+
def save(ctx: click.Context, name: str, dry_run: bool):
|
|
737
|
+
"""Package all remote tar files into a single tar.gz."""
|
|
738
|
+
if not name:
|
|
739
|
+
name = default_bundle_name("compose")
|
|
740
|
+
click.echo(f"📦 Bundle name: {name}")
|
|
741
|
+
host = _require_host(ctx)
|
|
742
|
+
user = ctx.obj["user"]
|
|
743
|
+
port = ctx.obj["port"]
|
|
744
|
+
key = ctx.obj["key"]
|
|
745
|
+
if dry_run:
|
|
746
|
+
print_save_plan(host, user, key, port, name)
|
|
747
|
+
return
|
|
748
|
+
do_save(host, user, key, port, name)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
# ── fetch ─────────────────────────────────────
|
|
752
|
+
|
|
753
|
+
@cli.command()
|
|
754
|
+
@click.option("--name", "-n", default="", help="Bundle name (default: current directory short name)")
|
|
755
|
+
@click.option("--output-dir", "-o", default=".", help="Local output directory (default: current dir)")
|
|
756
|
+
@click.option("--try", "dry_run", is_flag=True, help="Dry-run: print commands without executing")
|
|
757
|
+
@click.pass_context
|
|
758
|
+
def fetch(ctx: click.Context, name: str, output_dir: str, dry_run: bool):
|
|
759
|
+
"""Download the tar.gz bundle from remote host to local."""
|
|
760
|
+
if not name:
|
|
761
|
+
name = default_bundle_name("compose")
|
|
762
|
+
click.echo(f"📦 Bundle name: {name}")
|
|
763
|
+
host = _require_host(ctx)
|
|
764
|
+
user = ctx.obj["user"]
|
|
765
|
+
port = ctx.obj["port"]
|
|
766
|
+
key = ctx.obj["key"]
|
|
767
|
+
|
|
768
|
+
local_dst = os.path.join(output_dir, f"{name}.tar.gz")
|
|
769
|
+
if dry_run:
|
|
770
|
+
print_fetch_plan(host, user, key, port, name, local_dst)
|
|
771
|
+
return
|
|
772
|
+
|
|
773
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
774
|
+
do_fetch(host, user, key, port, name, output_dir)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
# ── load ──────────────────────────────────────
|
|
778
|
+
|
|
779
|
+
@cli.command()
|
|
780
|
+
@click.option("--name", "-n", help="Bundle name (downloads <name>.tar.gz from remote)")
|
|
781
|
+
@click.option("--file", "-f", help="Local tar.gz file to load (alternative to --name)")
|
|
782
|
+
@click.option("--try", "dry_run", is_flag=True, help="Dry-run: print commands without executing")
|
|
783
|
+
@click.pass_context
|
|
784
|
+
def load(ctx: click.Context, name: str, file: Optional[str], dry_run: bool):
|
|
785
|
+
"""Load images from tar.gz into local Docker daemon."""
|
|
786
|
+
host = ctx.obj.get("host", "")
|
|
787
|
+
user = ctx.obj.get("user", "")
|
|
788
|
+
port = ctx.obj.get("port", 22)
|
|
789
|
+
key = ctx.obj.get("key", "")
|
|
790
|
+
|
|
791
|
+
if dry_run:
|
|
792
|
+
print_load_plan(host, user, key, port, name, file)
|
|
793
|
+
return
|
|
794
|
+
|
|
795
|
+
tarball = None
|
|
796
|
+
|
|
797
|
+
if file:
|
|
798
|
+
tarball = file
|
|
799
|
+
elif name:
|
|
800
|
+
tarball = f"{name}.tar.gz"
|
|
801
|
+
else:
|
|
802
|
+
raise click.ClickException("Must specify --name or --file")
|
|
803
|
+
|
|
804
|
+
if not Path(tarball).exists():
|
|
805
|
+
if name and host:
|
|
806
|
+
click.echo(f"📥 Local file not found, fetching from remote ...")
|
|
807
|
+
tarball = do_fetch(host, user, key, port, name, ".")
|
|
808
|
+
else:
|
|
809
|
+
raise click.ClickException(f"File not found: {tarball}")
|
|
810
|
+
|
|
811
|
+
do_load(tarball)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
# ── cleanup ───────────────────────────────────
|
|
815
|
+
|
|
816
|
+
@cli.command()
|
|
817
|
+
@click.option("--name", "-n", default="", help="Bundle name (default: current directory short name)")
|
|
818
|
+
@click.option("--try", "dry_run", is_flag=True, help="Dry-run: print commands without executing")
|
|
819
|
+
@click.pass_context
|
|
820
|
+
def cleanup(ctx: click.Context, name: str, dry_run: bool):
|
|
821
|
+
"""Remove remote temporary files."""
|
|
822
|
+
if not name:
|
|
823
|
+
name = default_bundle_name("compose")
|
|
824
|
+
click.echo(f"📦 Bundle name: {name}")
|
|
825
|
+
host = _require_host(ctx)
|
|
826
|
+
user = ctx.obj["user"]
|
|
827
|
+
port = ctx.obj["port"]
|
|
828
|
+
key = ctx.obj["key"]
|
|
829
|
+
if dry_run:
|
|
830
|
+
print_cleanup_plan(host, user, key, port, name)
|
|
831
|
+
return
|
|
832
|
+
do_cleanup(host, user, key, port, name)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
# ── all ───────────────────────────────────────
|
|
836
|
+
|
|
837
|
+
@cli.command()
|
|
838
|
+
@click.argument("compose_file", type=click.Path(exists=True, dir_okay=False))
|
|
839
|
+
@click.option("--name", "-n", default="", help="Bundle name (default: current directory short name)")
|
|
840
|
+
@click.option("--registry-auth", "-a", default=lambda: get_config_value("registry_auth", ""), help="Registry credentials (user:pass)")
|
|
841
|
+
@click.option("--output-dir", "-o", default=".", help="Local output directory")
|
|
842
|
+
@click.option("--no-cleanup", is_flag=True, help="Do not remove remote temp files")
|
|
843
|
+
@click.option("--no-load", is_flag=True, help="Do not docker load after download")
|
|
844
|
+
@click.option("--try", "dry_run", is_flag=True, help="Dry-run: print commands without executing")
|
|
845
|
+
@click.pass_context
|
|
846
|
+
def all_cmd(
|
|
847
|
+
ctx: click.Context,
|
|
848
|
+
compose_file: str,
|
|
849
|
+
name: str,
|
|
850
|
+
registry_auth: str,
|
|
851
|
+
output_dir: str,
|
|
852
|
+
no_cleanup: bool,
|
|
853
|
+
no_load: bool,
|
|
854
|
+
dry_run: bool,
|
|
855
|
+
):
|
|
856
|
+
"""
|
|
857
|
+
One-shot: parse compose → remote pull → remote pack → download → local load.
|
|
858
|
+
|
|
859
|
+
Equivalent to running: pull → save → fetch → load
|
|
860
|
+
"""
|
|
861
|
+
if not name:
|
|
862
|
+
name = default_bundle_name("compose")
|
|
863
|
+
click.echo(f"📦 Bundle name: {name}")
|
|
864
|
+
|
|
865
|
+
host = ctx.obj["host"]
|
|
866
|
+
user = ctx.obj["user"]
|
|
867
|
+
port = ctx.obj["port"]
|
|
868
|
+
key = ctx.obj["key"]
|
|
869
|
+
|
|
870
|
+
if dry_run:
|
|
871
|
+
click.echo("🔍 Dry-run: no commands will be executed")
|
|
872
|
+
click.echo("")
|
|
873
|
+
|
|
874
|
+
# Step 1
|
|
875
|
+
click.echo("=" * 60)
|
|
876
|
+
click.echo("STEP 1/4: Parse compose & pull images on remote")
|
|
877
|
+
click.echo("=" * 60)
|
|
878
|
+
compose_path = Path(compose_file)
|
|
879
|
+
images = parse_compose_images(compose_path)
|
|
880
|
+
if not images:
|
|
881
|
+
raise click.ClickException("No images found in compose file")
|
|
882
|
+
click.echo(f"📋 Found {len(images)} image(s):")
|
|
883
|
+
for img in images:
|
|
884
|
+
click.echo(f" - {img}")
|
|
885
|
+
click.echo("")
|
|
886
|
+
auth = registry_auth or None
|
|
887
|
+
print_pull_plan(host, user, key, port, images, name, auth)
|
|
888
|
+
|
|
889
|
+
# Step 2
|
|
890
|
+
click.echo("")
|
|
891
|
+
click.echo("=" * 60)
|
|
892
|
+
click.echo("STEP 2/4: Pack tar.gz on remote")
|
|
893
|
+
click.echo("=" * 60)
|
|
894
|
+
print_save_plan(host, user, key, port, name)
|
|
895
|
+
|
|
896
|
+
# Step 3
|
|
897
|
+
click.echo("")
|
|
898
|
+
click.echo("=" * 60)
|
|
899
|
+
click.echo("STEP 3/4: Download bundle to local")
|
|
900
|
+
click.echo("=" * 60)
|
|
901
|
+
local_dst = os.path.join(output_dir, f"{name}.tar.gz")
|
|
902
|
+
print_fetch_plan(host, user, key, port, name, local_dst)
|
|
903
|
+
|
|
904
|
+
# Step 4
|
|
905
|
+
if not no_load:
|
|
906
|
+
click.echo("")
|
|
907
|
+
click.echo("=" * 60)
|
|
908
|
+
click.echo("STEP 4/4: Load images into local Docker")
|
|
909
|
+
click.echo("=" * 60)
|
|
910
|
+
print_load_plan(host, user, key, port, name, None, output_dir=output_dir)
|
|
911
|
+
else:
|
|
912
|
+
click.echo("")
|
|
913
|
+
click.echo("⏭️ Skipping docker load (--no-load)")
|
|
914
|
+
|
|
915
|
+
# Cleanup
|
|
916
|
+
if not no_cleanup:
|
|
917
|
+
click.echo("")
|
|
918
|
+
click.echo("=" * 60)
|
|
919
|
+
click.echo("Cleanup remote temp files")
|
|
920
|
+
click.echo("=" * 60)
|
|
921
|
+
print_cleanup_plan(host, user, key, port, name)
|
|
922
|
+
else:
|
|
923
|
+
click.echo("⏭️ Skipping remote cleanup (--no-cleanup)")
|
|
924
|
+
return
|
|
925
|
+
|
|
926
|
+
# Step 1: Parse + Pull
|
|
927
|
+
click.echo("=" * 60)
|
|
928
|
+
click.echo("STEP 1/4: Parse compose & pull images on remote")
|
|
929
|
+
click.echo("=" * 60)
|
|
930
|
+
compose_path = Path(compose_file)
|
|
931
|
+
images = parse_compose_images(compose_path)
|
|
932
|
+
if not images:
|
|
933
|
+
raise click.ClickException("No images found in compose file")
|
|
934
|
+
click.echo(f"📋 Found {len(images)} image(s):")
|
|
935
|
+
for img in images:
|
|
936
|
+
click.echo(f" - {img}")
|
|
937
|
+
|
|
938
|
+
auth = registry_auth or None
|
|
939
|
+
do_pull(host, user, key, port, images, name, auth)
|
|
940
|
+
|
|
941
|
+
# Step 2: Save
|
|
942
|
+
click.echo("")
|
|
943
|
+
click.echo("=" * 60)
|
|
944
|
+
click.echo("STEP 2/4: Pack tar.gz on remote")
|
|
945
|
+
click.echo("=" * 60)
|
|
946
|
+
do_save(host, user, key, port, name)
|
|
947
|
+
|
|
948
|
+
# Step 3: Fetch
|
|
949
|
+
click.echo("")
|
|
950
|
+
click.echo("=" * 60)
|
|
951
|
+
click.echo("STEP 3/4: Download bundle to local")
|
|
952
|
+
click.echo("=" * 60)
|
|
953
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
954
|
+
local_tarball = do_fetch(host, user, key, port, name, output_dir)
|
|
955
|
+
|
|
956
|
+
# Step 4: Load (optional)
|
|
957
|
+
if not no_load:
|
|
958
|
+
click.echo("")
|
|
959
|
+
click.echo("=" * 60)
|
|
960
|
+
click.echo("STEP 4/4: Load images into local Docker")
|
|
961
|
+
click.echo("=" * 60)
|
|
962
|
+
do_load(local_tarball)
|
|
963
|
+
else:
|
|
964
|
+
click.echo("⏭️ Skipping docker load (--no-load)")
|
|
965
|
+
|
|
966
|
+
# Cleanup
|
|
967
|
+
if not no_cleanup:
|
|
968
|
+
do_cleanup(host, user, key, port, name)
|
|
969
|
+
else:
|
|
970
|
+
click.echo("⏭️ Skipping remote cleanup (--no-cleanup)")
|
|
971
|
+
|
|
972
|
+
click.echo("")
|
|
973
|
+
click.echo("🎉 All done!")
|
|
974
|
+
click.echo(f" Bundle: {local_tarball}")
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
# ─────────────────────────────────────────────
|
|
978
|
+
# 入口
|
|
979
|
+
# ─────────────────────────────────────────────
|
|
980
|
+
def main():
|
|
981
|
+
"""Console script entry point (referenced by pyproject.toml)."""
|
|
982
|
+
cli()
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
if __name__ == "__main__":
|
|
986
|
+
main()
|