offline-docker-cli 2.2.2__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.
Files changed (49) hide show
  1. offline_docker/__init__.py +3 -0
  2. offline_docker/__main__.py +4 -0
  3. offline_docker/actions.py +173 -0
  4. offline_docker/build.py +245 -0
  5. offline_docker/cli.py +486 -0
  6. offline_docker/config.py +692 -0
  7. offline_docker/configure.py +282 -0
  8. offline_docker/context.py +111 -0
  9. offline_docker/defaults.py +48 -0
  10. offline_docker/discover.py +139 -0
  11. offline_docker/docker_cmd.py +136 -0
  12. offline_docker/download_debs.py +142 -0
  13. offline_docker/download_images.py +26 -0
  14. offline_docker/download_npm.py +106 -0
  15. offline_docker/download_trivy.py +90 -0
  16. offline_docker/download_wheels.py +363 -0
  17. offline_docker/download_yarn.py +208 -0
  18. offline_docker/ensure_base.py +61 -0
  19. offline_docker/hooks.py +84 -0
  20. offline_docker/init_cmd.py +54 -0
  21. offline_docker/load_images.py +34 -0
  22. offline_docker/oc_discovery.py +206 -0
  23. offline_docker/resolve.py +306 -0
  24. offline_docker/scan.py +496 -0
  25. offline_docker/sources/__init__.py +18 -0
  26. offline_docker/sources/base.py +18 -0
  27. offline_docker/sources/filesystem.py +17 -0
  28. offline_docker/sources/nexus.py +61 -0
  29. offline_docker/sources/nexus_client.py +67 -0
  30. offline_docker/sources/proget.py +49 -0
  31. offline_docker/sources/proget_client.py +48 -0
  32. offline_docker/sources/upstream.py +36 -0
  33. offline_docker/status.py +247 -0
  34. offline_docker/sync_cache.py +125 -0
  35. offline_docker/trivy_scan.py +86 -0
  36. offline_docker/tui/__init__.py +19 -0
  37. offline_docker/tui/app.py +56 -0
  38. offline_docker/tui/runner.py +87 -0
  39. offline_docker/tui/screens.py +333 -0
  40. offline_docker/user_config.py +89 -0
  41. offline_docker/utils.py +73 -0
  42. offline_docker/validate.py +53 -0
  43. offline_docker/wheel_prune.py +141 -0
  44. offline_docker_cli-2.2.2.dist-info/METADATA +417 -0
  45. offline_docker_cli-2.2.2.dist-info/RECORD +49 -0
  46. offline_docker_cli-2.2.2.dist-info/WHEEL +5 -0
  47. offline_docker_cli-2.2.2.dist-info/entry_points.txt +2 -0
  48. offline_docker_cli-2.2.2.dist-info/licenses/LICENSE +21 -0
  49. offline_docker_cli-2.2.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,136 @@
1
+ """Thin wrappers around docker / docker compose CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ _SUBPROCESS_ENCODING = "utf-8"
13
+
14
+
15
+ def _write_stdout(text: str) -> None:
16
+ try:
17
+ sys.stdout.write(text)
18
+ except UnicodeEncodeError:
19
+ sys.stdout.buffer.write(
20
+ text.encode(sys.stdout.encoding or _SUBPROCESS_ENCODING, errors="replace")
21
+ )
22
+ sys.stdout.flush()
23
+
24
+
25
+ class CommandError(RuntimeError):
26
+ def __init__(self, cmd: list[str], returncode: int):
27
+ self.cmd = cmd
28
+ self.returncode = returncode
29
+ super().__init__(f"Command failed ({returncode}): {' '.join(cmd)}")
30
+
31
+
32
+ def require_docker() -> None:
33
+ if shutil.which("docker") is None:
34
+ raise SystemExit("ERROR: docker not found in PATH")
35
+
36
+
37
+ def run(
38
+ cmd: list[str],
39
+ *,
40
+ cwd: Path | None = None,
41
+ env: dict[str, str] | None = None,
42
+ check: bool = True,
43
+ ) -> int:
44
+ print(f"$ {' '.join(cmd)}", flush=True)
45
+ merged = os.environ.copy()
46
+ if env:
47
+ merged.update(env)
48
+ result = subprocess.run(cmd, cwd=cwd, env=merged)
49
+ if check and result.returncode != 0:
50
+ raise CommandError(cmd, result.returncode)
51
+ return result.returncode
52
+
53
+
54
+ def run_capture(
55
+ cmd: list[str],
56
+ *,
57
+ cwd: Path | None = None,
58
+ env: dict[str, str] | None = None,
59
+ ) -> subprocess.CompletedProcess[str]:
60
+ merged = os.environ.copy()
61
+ if env:
62
+ merged.update(env)
63
+ return subprocess.run(
64
+ cmd,
65
+ cwd=cwd,
66
+ env=merged,
67
+ capture_output=True,
68
+ text=True,
69
+ encoding=_SUBPROCESS_ENCODING,
70
+ errors="replace",
71
+ )
72
+
73
+
74
+ def image_exists(image: str) -> bool:
75
+ r = run_capture(["docker", "image", "inspect", image])
76
+ return r.returncode == 0
77
+
78
+
79
+ def run_tee(
80
+ cmd: list[str],
81
+ log_file: Path,
82
+ *,
83
+ cwd: Path | None = None,
84
+ env: dict[str, str] | None = None,
85
+ ) -> None:
86
+ """Run command, stream stdout/stderr to console and log file."""
87
+ log_file.parent.mkdir(parents=True, exist_ok=True)
88
+ print(f"$ {' '.join(cmd)} -> {log_file}", flush=True)
89
+ merged = os.environ.copy()
90
+ if env:
91
+ merged.update(env)
92
+ with log_file.open("w", encoding="utf-8") as log:
93
+ proc = subprocess.Popen(
94
+ cmd,
95
+ cwd=cwd,
96
+ env=merged,
97
+ stdout=subprocess.PIPE,
98
+ stderr=subprocess.STDOUT,
99
+ text=True,
100
+ encoding=_SUBPROCESS_ENCODING,
101
+ errors="replace",
102
+ bufsize=1,
103
+ )
104
+ assert proc.stdout is not None
105
+ for line in proc.stdout:
106
+ _write_stdout(line)
107
+ log.write(line)
108
+ rc = proc.wait()
109
+ if rc != 0:
110
+ raise CommandError(cmd, rc)
111
+
112
+
113
+ def dir_size_human(path: Path) -> str:
114
+ """Human-readable size. Skips broken symlinks (common on Windows yarn caches)."""
115
+ total = 0
116
+ try:
117
+ if path.is_file():
118
+ total = path.stat().st_size
119
+ elif path.is_dir():
120
+ for f in path.rglob("*"):
121
+ try:
122
+ # is_file() follows symlinks and can raise WinError 1920
123
+ if f.is_file():
124
+ total += f.stat().st_size
125
+ except OSError:
126
+ pass
127
+ except OSError:
128
+ pass
129
+ size = float(total)
130
+ for unit in ("B", "K", "M", "G"):
131
+ if size < 1024.0 or unit == "G":
132
+ if unit == "B":
133
+ return f"{int(size)}{unit}"
134
+ return f"{size:.1f}{unit}"
135
+ size /= 1024.0
136
+ return f"{size:.1f}G"
@@ -0,0 +1,142 @@
1
+ """Download .deb packages into offline-cache/debs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from offline_docker import docker_cmd
8
+ from offline_docker.context import ProjectContext
9
+ from offline_docker.utils import docker_mount_path
10
+
11
+
12
+ def _build_debs_guest(ctx: ProjectContext) -> str:
13
+ pkgs_bash = " ".join(f'"{p}"' for p in ctx.config.deb_packages)
14
+ extra_repo_script = ""
15
+ for repo in ctx.config.deb_extra_repos:
16
+ gpg_name = Path(repo.gpg_dest).name
17
+ list_name = Path(repo.list_dest).name
18
+ extra_repo_script += f"""
19
+ echo ">>> Extra repo: {repo.name}"
20
+ curl -fsSL {repo.gpg_url} | gpg --dearmor -o /apt-meta/{gpg_name}
21
+ curl -fsSL {repo.list_url} > /apt-meta/{list_name}
22
+ cp /apt-meta/{gpg_name} /usr/share/keyrings/{gpg_name}
23
+ cp /apt-meta/{list_name} /etc/apt/sources.list.d/{list_name}
24
+ apt-get update
25
+ """
26
+ # Optional apt mirrors from package resolver (ProGet deb / Nexus apt)
27
+ apt_mirror_script = ""
28
+ for i, mirror in enumerate(ctx.config.package_resolver().apt_mirrors()):
29
+ # Best-effort: prepend as extra signed-by-less list (corp mirrors vary)
30
+ apt_mirror_script += f"""
31
+ echo ">>> Apt mirror from sources[{i}]: {mirror}"
32
+ echo "deb [trusted=yes] {mirror} ./" > /etc/apt/sources.list.d/od-mirror-{i}.list
33
+ """
34
+ return f"""
35
+ set -euo pipefail
36
+ export DEBIAN_FRONTEND=noninteractive
37
+
38
+ # Cache debs is bound to the guest distro: Ubuntu/Debian .deb несовместимы, и
39
+ # dpkg -i чужих пакетов ломает базовый образ. Тег образа может переехать
40
+ # (python:3.12-slim bookworm -> trixie) — сбрасываем кэш при смене базы.
41
+ . /etc/os-release
42
+ BASE_OS="$ID-$VERSION_ID"
43
+ if [ -f /debs/.base-os ] && [ "$(cat /debs/.base-os)" != "$BASE_OS" ]; then
44
+ echo ">>> База изменилась: $(cat /debs/.base-os) -> $BASE_OS - очищаю старые .deb"
45
+ find /debs -maxdepth 1 -name '*.deb' -delete
46
+ fi
47
+ printf '%s\\n' "$BASE_OS" > /debs/.base-os
48
+
49
+ apt-get update
50
+ apt-get install -y --no-install-recommends ca-certificates curl gnupg
51
+ {apt_mirror_script}
52
+ {extra_repo_script}
53
+ echo "ok-$(date +%s)" > /apt-meta/.ready
54
+
55
+ PKGS=({pkgs_bash})
56
+
57
+ echo ""
58
+ echo ">>> Сбор транзитивных зависимостей"
59
+ ALL=""
60
+ for pkg in "${{PKGS[@]}}"; do
61
+ deps="$(apt-cache depends --recurse --no-recommends \\
62
+ --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances \\
63
+ "$pkg" 2>/dev/null | grep -E "^[[:alnum:]][[:alnum:]._+-]*$" || true)"
64
+ ALL="$ALL $deps"
65
+ done
66
+ UNIQUE="$(echo "$ALL" | tr " " "\\n" | sort -u | grep -v "^$")"
67
+
68
+ cd /debs
69
+ total="$(echo "$UNIQUE" | wc -l)"
70
+ n=0
71
+ skipped=0
72
+ for pkg in $UNIQUE; do
73
+ n=$((n + 1))
74
+ if compgen -G "${{pkg}}_*.deb" > /dev/null \\
75
+ || compgen -G "${{pkg}}-*.deb" > /dev/null; then
76
+ skipped=$((skipped + 1))
77
+ continue
78
+ fi
79
+ if (( n % 25 == 1 )); then
80
+ echo " [$n/$total] $pkg ..."
81
+ fi
82
+ apt-get download "$pkg" 2>/dev/null || echo " WARN: не скачан $pkg" >&2
83
+ done
84
+
85
+ count="$(find /debs -maxdepth 1 -name "*.deb" | wc -l)"
86
+ echo ""
87
+ echo "=== Готово: ${{count}} .deb в /debs (пропущено уже скачанных: ${{skipped}}) ==="
88
+ du -sh /debs
89
+ rm -rf /var/lib/apt/lists/*
90
+ """
91
+
92
+
93
+ def download_debs(ctx: ProjectContext) -> None:
94
+ ctx.ensure_offline_dirs()
95
+ from offline_docker.sync_cache import maybe_materialize
96
+
97
+ maybe_materialize(ctx)
98
+
99
+ debs_docker = docker_mount_path(ctx.debs_dir)
100
+ apt_meta_docker = docker_mount_path(ctx.apt_meta_dir)
101
+ resolver = ctx.config.package_resolver()
102
+
103
+ print("=== download-debs ===")
104
+ print(f"Корень: {ctx.root}")
105
+ print(f"Offline cache: {ctx.offline_cache}")
106
+ print(f"Ubuntu image: {ctx.config.deb_ubuntu_image}")
107
+ print(f"Пакетов (seed): {len(ctx.config.deb_packages)}")
108
+ mirrors = resolver.apt_mirrors()
109
+ if mirrors:
110
+ print(f"Apt mirrors: {', '.join(mirrors)}")
111
+ print()
112
+
113
+ docker_cmd.run(
114
+ [
115
+ "docker",
116
+ "run",
117
+ "--rm",
118
+ "-v",
119
+ f"{debs_docker}:/debs",
120
+ "-v",
121
+ f"{apt_meta_docker}:/apt-meta",
122
+ ctx.config.deb_ubuntu_image,
123
+ "bash",
124
+ "-ec",
125
+ _build_debs_guest(ctx),
126
+ ],
127
+ env={"DOCKER_BUILDKIT": "0"},
128
+ )
129
+
130
+ if resolver.settings.resolve.fill_local:
131
+ from offline_docker.sync_cache import publish_to_remotes
132
+
133
+ publish_to_remotes(ctx)
134
+
135
+ if not (ctx.apt_meta_dir / ".ready").is_file():
136
+ raise SystemExit(
137
+ "ERROR: apt-meta не попал на хост - проверьте Docker mount path (D:/..., не /d/...)"
138
+ )
139
+
140
+ deb_count = len(list(ctx.debs_dir.glob("*.deb")))
141
+ print()
142
+ print(f"Всего .deb: {deb_count}")
@@ -0,0 +1,26 @@
1
+ """Pull and save Docker images into offline-cache/docker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from offline_docker import docker_cmd
6
+ from offline_docker.context import ProjectContext
7
+
8
+
9
+ def download_docker_images(ctx: ProjectContext) -> None:
10
+ ctx.ensure_offline_dirs()
11
+ print("=== download-docker-images ===")
12
+ print(f"Каталог: {ctx.docker_cache_dir}")
13
+ print()
14
+
15
+ for image in ctx.config.docker_images:
16
+ safe_name = image.replace("/", "_").replace(":", "-")
17
+ tar_path = ctx.docker_cache_dir / f"{safe_name}.tar"
18
+ print(f">>> pull {image}")
19
+ docker_cmd.run(["docker", "pull", image])
20
+ print(f">>> save -> {tar_path}")
21
+ docker_cmd.run(["docker", "save", "-o", str(tar_path), image])
22
+ print(docker_cmd.dir_size_human(tar_path))
23
+ print()
24
+
25
+ tar_count = len(list(ctx.docker_cache_dir.glob("*.tar")))
26
+ print(f"=== Готово: {tar_count} tar-файлов ===")
@@ -0,0 +1,106 @@
1
+ """Download npm cache for frontend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from offline_docker import docker_cmd
6
+ from offline_docker.context import ProjectContext
7
+ from offline_docker.utils import docker_mount_path, file_sha256
8
+
9
+ # POSIX /bin/sh (node:*-alpine has no bash; node ENTRYPOINT would otherwise exec "node bash").
10
+ # Не использовать pipefail: dash (Debian /bin/sh) его не поддерживает.
11
+ NPM_GUEST = r"""
12
+ set -eu
13
+ cp /app-src/package.json /app-src/package-lock.json . 2>/dev/null || cp /app-src/package.json .
14
+ if [ ! -f package-lock.json ]; then
15
+ echo "ERROR: package-lock.json не найден - нужен lockfile для offline npm ci" >&2
16
+ exit 1
17
+ fi
18
+ npm config set cache /npm-cache
19
+ npm config set registry "${NPM_REGISTRY}"
20
+ # HTTP corporate registries (e.g. Nexus without TLS)
21
+ case "${NPM_REGISTRY}" in
22
+ http://*) npm config set strict-ssl false ;;
23
+ esac
24
+ npm ci --no-audit --no-fund
25
+ echo "mount-check-$(date +%s)" > /npm-cache/.mount-check
26
+ """
27
+
28
+
29
+ def download_npm_cache(ctx: ProjectContext) -> None:
30
+ fe = ctx.config.frontend_path()
31
+ lock = ctx.config.frontend_lock_path()
32
+ if fe is None or lock is None:
33
+ print("=== download-npm-cache: SKIP (frontend/package-lock.json не найден) ===")
34
+ return
35
+
36
+ ctx.ensure_offline_dirs()
37
+ hash_file = ctx.npm_cache_dir / ".package-lock.sha256"
38
+ current = file_sha256(lock)
39
+ cacache = ctx.npm_cache_dir / "_cacache"
40
+
41
+ if (
42
+ hash_file.is_file()
43
+ and hash_file.read_text(encoding="utf-8").strip() == current
44
+ and cacache.is_dir()
45
+ ):
46
+ print("=== download-npm-cache: package-lock.json не менялся, cache актуален ===")
47
+ print(docker_cmd.dir_size_human(ctx.npm_cache_dir))
48
+ return
49
+
50
+ from offline_docker.sync_cache import maybe_materialize
51
+
52
+ maybe_materialize(ctx)
53
+
54
+ resolver = ctx.config.package_resolver()
55
+ registry = resolver.primary_npm_registry() or "https://registry.npmjs.org/"
56
+ used_default = resolver.primary_npm_registry() is None
57
+
58
+ frontend_docker = docker_mount_path(fe)
59
+ npm_docker = docker_mount_path(ctx.npm_cache_dir)
60
+
61
+ print("=== download-npm-cache ===")
62
+ print(f"Frontend: {fe}")
63
+ print(f"npm cache dir: {ctx.npm_cache_dir}")
64
+ print(f"Node image: {ctx.config.node_image}")
65
+ print(f"Resolve: {' -> '.join(resolver.settings.resolve.order)}")
66
+ print(f"npm registry: {registry}")
67
+ if used_default and resolver.is_local_only():
68
+ print(
69
+ "WARN: sources.json = local-only; для download используется "
70
+ "https://registry.npmjs.org/"
71
+ )
72
+ print(
73
+ " Явный режим: offdock configure --with-upstream"
74
+ )
75
+
76
+ docker_cmd.run(
77
+ [
78
+ "docker",
79
+ "run",
80
+ "--rm",
81
+ "--entrypoint",
82
+ "/bin/sh",
83
+ "-e",
84
+ f"NPM_REGISTRY={registry}",
85
+ "-v",
86
+ f"{frontend_docker}:/app-src:ro",
87
+ "-v",
88
+ f"{npm_docker}:/npm-cache",
89
+ "-w",
90
+ "/work",
91
+ ctx.config.node_image,
92
+ "-ec",
93
+ NPM_GUEST,
94
+ ],
95
+ env={"DOCKER_BUILDKIT": "0", "MSYS_NO_PATHCONV": "1"},
96
+ )
97
+
98
+ hash_file.write_text(current + "\n", encoding="utf-8")
99
+ print()
100
+ print(f"=== Готово: npm cache в {ctx.npm_cache_dir} ===")
101
+ print(docker_cmd.dir_size_human(ctx.npm_cache_dir))
102
+
103
+ if resolver.settings.resolve.fill_local:
104
+ from offline_docker.sync_cache import publish_to_remotes
105
+
106
+ publish_to_remotes(ctx)
@@ -0,0 +1,90 @@
1
+ """Download Trivy vulnerability DB into offline-cache/trivy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ from offline_docker import docker_cmd
8
+ from offline_docker.context import ProjectContext
9
+ from offline_docker.utils import docker_mount_path
10
+
11
+
12
+ def trivy_db_stale(ctx: ProjectContext) -> bool:
13
+ """True if DB is missing or older than configured max age."""
14
+ cache = ctx.trivy_cache_dir
15
+ ts_file = cache / ".trivy-db.timestamp"
16
+ max_age = ctx.config.trivy.db_max_age_days * 86400
17
+
18
+ if not cache.is_dir():
19
+ return True
20
+
21
+ others = [
22
+ p
23
+ for p in cache.rglob("*")
24
+ if p.is_file() and p.name != ".trivy-db.timestamp"
25
+ ]
26
+ if not others:
27
+ return True
28
+
29
+ if not ts_file.is_file():
30
+ return True
31
+
32
+ try:
33
+ stamp = int(ts_file.read_text(encoding="utf-8").strip())
34
+ except ValueError:
35
+ return True
36
+
37
+ return (time.time() - stamp) > max_age
38
+
39
+
40
+ def ensure_trivy_image(ctx: ProjectContext) -> None:
41
+ image = ctx.config.trivy.scanner_image
42
+ if docker_cmd.image_exists(image):
43
+ return
44
+ from offline_docker.load_images import load_docker_images
45
+
46
+ print(f"Образ {image} не найден — load-docker-images")
47
+ load_docker_images(ctx)
48
+ if not docker_cmd.image_exists(image):
49
+ raise SystemExit(
50
+ f"ERROR: образ {image} недоступен. Выполните offdock cache images"
51
+ )
52
+
53
+
54
+ def download_trivy_db(ctx: ProjectContext) -> None:
55
+ ctx.ensure_offline_dirs()
56
+ if not trivy_db_stale(ctx):
57
+ print(
58
+ f"=== download-trivy-db: БД актуальна (моложе {ctx.config.trivy.db_max_age_days} дн.) ==="
59
+ )
60
+ print(docker_cmd.dir_size_human(ctx.trivy_cache_dir))
61
+ return
62
+
63
+ print("=== download-trivy-db ===")
64
+ print(f"Каталог: {ctx.trivy_cache_dir}")
65
+ print(f"Образ: {ctx.config.trivy.scanner_image}")
66
+ print()
67
+
68
+ ensure_trivy_image(ctx)
69
+ cache_docker = docker_mount_path(ctx.trivy_cache_dir)
70
+
71
+ docker_cmd.run(
72
+ [
73
+ "docker",
74
+ "run",
75
+ "--rm",
76
+ "-v",
77
+ f"{cache_docker}:/root/.cache/trivy",
78
+ ctx.config.trivy.scanner_image,
79
+ "image",
80
+ "--download-db-only",
81
+ ],
82
+ env={"MSYS_NO_PATHCONV": "1"},
83
+ )
84
+
85
+ (ctx.trivy_cache_dir / ".trivy-db.timestamp").write_text(
86
+ str(int(time.time())) + "\n", encoding="utf-8"
87
+ )
88
+ print()
89
+ print(f"=== Готово: vulnerability DB в {ctx.trivy_cache_dir} ===")
90
+ print(docker_cmd.dir_size_human(ctx.trivy_cache_dir))