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()