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,282 @@
1
+ """Interactive / non-interactive repository source configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import Any
8
+
9
+ from offline_docker.user_config import (
10
+ local_only_dict,
11
+ online_fill_dict,
12
+ save_user_sources,
13
+ user_sources_path,
14
+ )
15
+
16
+ # Helpful placeholders for operators (shown in prompts; Enter keeps empty).
17
+ _HINT_PROGET_URL = "https://proget.vitebsk.energo.net"
18
+ _HINT_PROGET_PYPI = "ve_pypi"
19
+ _HINT_PROGET_NPM = "npm-ve"
20
+ _HINT_PROGET_DEB = "local-deb"
21
+ _HINT_NEXUS_URL = "http://repos.vitebsk.energo.by:8081"
22
+ _HINT_NEXUS_PYPI = "pypi-group"
23
+ _HINT_NEXUS_NPM = "npm-group"
24
+
25
+
26
+ def _prompt(label: str, *, hint: str | None = None) -> str:
27
+ suffix = f" [{hint}]" if hint else ""
28
+ try:
29
+ value = input(f"{label}{suffix}: ").strip()
30
+ except EOFError:
31
+ return ""
32
+ return value
33
+
34
+
35
+ def _build_sources_dict(
36
+ *,
37
+ proget_url: str,
38
+ proget_pypi: str,
39
+ proget_npm: str,
40
+ proget_deb: str,
41
+ nexus_url: str,
42
+ nexus_pypi: str,
43
+ nexus_npm: str,
44
+ nexus_raw: str,
45
+ order: list[str] | None = None,
46
+ ) -> dict[str, Any]:
47
+ sources: list[dict[str, Any]] = [
48
+ {"name": "local", "type": "filesystem", "enabled": True},
49
+ ]
50
+ resolve_order = ["local"]
51
+
52
+ if proget_url:
53
+ feeds: dict[str, str] = {}
54
+ if proget_pypi:
55
+ feeds["pypi"] = proget_pypi
56
+ if proget_npm:
57
+ feeds["npm"] = proget_npm
58
+ if proget_deb:
59
+ feeds["deb"] = proget_deb
60
+ sources.append(
61
+ {
62
+ "name": "proget",
63
+ "type": "proget",
64
+ "url": proget_url.rstrip("/"),
65
+ "feeds": feeds,
66
+ "auth_env": "PROGET_API_KEY",
67
+ "enabled": True,
68
+ "publish": False,
69
+ }
70
+ )
71
+ resolve_order.append("proget")
72
+ else:
73
+ sources.append(
74
+ {
75
+ "name": "proget",
76
+ "type": "proget",
77
+ "url": "",
78
+ "feeds": {},
79
+ "auth_env": "PROGET_API_KEY",
80
+ "enabled": False,
81
+ "publish": False,
82
+ }
83
+ )
84
+
85
+ if nexus_url:
86
+ repos: dict[str, str] = {}
87
+ if nexus_pypi:
88
+ repos["pypi"] = nexus_pypi
89
+ if nexus_npm:
90
+ repos["npm"] = nexus_npm
91
+ if nexus_raw:
92
+ repos["raw"] = nexus_raw
93
+ sources.append(
94
+ {
95
+ "name": "nexus",
96
+ "type": "nexus",
97
+ "url": nexus_url.rstrip("/"),
98
+ "repos": repos,
99
+ "auth_env": "NEXUS_AUTH",
100
+ "enabled": True,
101
+ "publish": False,
102
+ }
103
+ )
104
+ resolve_order.append("nexus")
105
+ else:
106
+ sources.append(
107
+ {
108
+ "name": "nexus",
109
+ "type": "nexus",
110
+ "url": "",
111
+ "repos": {},
112
+ "auth_env": "NEXUS_AUTH",
113
+ "enabled": False,
114
+ "publish": False,
115
+ }
116
+ )
117
+
118
+ if order:
119
+ resolve_order = order
120
+
121
+ return {
122
+ "sources": sources,
123
+ "resolve": {
124
+ "order": resolve_order,
125
+ "fill_local": True,
126
+ "on_miss": "fail",
127
+ },
128
+ }
129
+
130
+
131
+ def run_configure(args: argparse.Namespace) -> None:
132
+ if getattr(args, "local_only", False) and getattr(args, "with_upstream", False):
133
+ print("ERROR: укажите либо --local-only, либо --with-upstream", file=sys.stderr)
134
+ raise SystemExit(2)
135
+
136
+ if getattr(args, "local_only", False):
137
+ path = save_user_sources(local_only_dict())
138
+ print(f"Сохранено (local-only): {path}")
139
+ print("Режим для офлайн/корп VM: cache без явного upstream")
140
+ print(" (CLI всё ещё может временно добавить PyPI как fallback — см. WARN).")
141
+ print("Для заполнения кэша с интернета: offdock configure --with-upstream")
142
+ return
143
+
144
+ if getattr(args, "with_upstream", False):
145
+ path = save_user_sources(online_fill_dict())
146
+ print(f"Сохранено (local + upstream): {path}")
147
+ print("Resolve order: local, upstream")
148
+ print("PyPI: https://pypi.org/simple | npm: https://registry.npmjs.org/")
149
+ return
150
+
151
+ interactive = sys.stdin.isatty() and not getattr(args, "non_interactive", False)
152
+
153
+ proget_url = (getattr(args, "proget_url", None) or "").strip()
154
+ nexus_url = (getattr(args, "nexus_url", None) or "").strip()
155
+ proget_pypi = (getattr(args, "proget_pypi", None) or "").strip()
156
+ proget_npm = (getattr(args, "proget_npm", None) or "").strip()
157
+ proget_deb = (getattr(args, "proget_deb", None) or "").strip()
158
+ nexus_pypi = (getattr(args, "nexus_pypi", None) or "").strip()
159
+ nexus_npm = (getattr(args, "nexus_npm", None) or "").strip()
160
+ nexus_raw = (getattr(args, "nexus_raw", None) or "").strip()
161
+ order_raw = (getattr(args, "order", None) or "").strip()
162
+
163
+ flags_given = any(
164
+ getattr(args, name) is not None
165
+ for name in (
166
+ "proget_url",
167
+ "nexus_url",
168
+ "proget_pypi",
169
+ "proget_npm",
170
+ "proget_deb",
171
+ "nexus_pypi",
172
+ "nexus_npm",
173
+ "nexus_raw",
174
+ "order",
175
+ )
176
+ )
177
+
178
+ if interactive and not flags_given:
179
+ print("Настройка источников пакетов (Enter = пропустить / пусто).")
180
+ print(f"User config будет записан в: {user_sources_path()}")
181
+ print()
182
+ print("— ProGet —")
183
+ proget_url = _prompt("ProGet base URL", hint=_HINT_PROGET_URL)
184
+ if proget_url:
185
+ proget_pypi = _prompt("ProGet PyPI feed", hint=_HINT_PROGET_PYPI) or _HINT_PROGET_PYPI
186
+ proget_npm = _prompt("ProGet npm feed", hint=_HINT_PROGET_NPM) or _HINT_PROGET_NPM
187
+ proget_deb = _prompt("ProGet deb feed", hint=_HINT_PROGET_DEB) or _HINT_PROGET_DEB
188
+ print()
189
+ print("— Nexus —")
190
+ nexus_url = _prompt("Nexus base URL", hint=_HINT_NEXUS_URL)
191
+ if nexus_url:
192
+ nexus_pypi = _prompt("Nexus PyPI repo", hint=_HINT_NEXUS_PYPI) or _HINT_NEXUS_PYPI
193
+ nexus_npm = _prompt("Nexus npm repo", hint=_HINT_NEXUS_NPM) or _HINT_NEXUS_NPM
194
+ nexus_raw = _prompt("Nexus raw repo (optional)", hint="offline-cache-raw")
195
+ print()
196
+ order_raw = _prompt(
197
+ "Resolve order (comma-separated)",
198
+ hint="local,proget,nexus",
199
+ )
200
+
201
+ # Non-interactive defaults for feed names when URL given without feed flags
202
+ if proget_url:
203
+ proget_pypi = proget_pypi or _HINT_PROGET_PYPI
204
+ proget_npm = proget_npm or _HINT_PROGET_NPM
205
+ proget_deb = proget_deb or _HINT_PROGET_DEB
206
+ if nexus_url:
207
+ nexus_pypi = nexus_pypi or _HINT_NEXUS_PYPI
208
+ nexus_npm = nexus_npm or _HINT_NEXUS_NPM
209
+
210
+ order: list[str] | None = None
211
+ if order_raw:
212
+ order = [p.strip() for p in order_raw.split(",") if p.strip()]
213
+
214
+ if not proget_url and not nexus_url:
215
+ # Без корп. реестров на машине с сетью удобнее local+upstream, чем «тихий» local-only.
216
+ data = online_fill_dict()
217
+ path = save_user_sources(data)
218
+ print(f"URL не заданы — сохранён local + upstream (PyPI/npmjs): {path}")
219
+ print("Только offline-cache: offdock configure --local-only")
220
+ return
221
+
222
+ data = _build_sources_dict(
223
+ proget_url=proget_url,
224
+ proget_pypi=proget_pypi,
225
+ proget_npm=proget_npm,
226
+ proget_deb=proget_deb,
227
+ nexus_url=nexus_url,
228
+ nexus_pypi=nexus_pypi,
229
+ nexus_npm=nexus_npm,
230
+ nexus_raw=nexus_raw,
231
+ order=order,
232
+ )
233
+ path = save_user_sources(data)
234
+ print(f"Сохранено: {path}")
235
+ print(f"Resolve order: {', '.join(data['resolve']['order'])}")
236
+ print("Auth (опционально): PROGET_API_KEY, NEXUS_AUTH")
237
+
238
+
239
+ def add_configure_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
240
+ p = sub.add_parser(
241
+ "configure",
242
+ help="Настроить ProGet/Nexus (user-level sources.json)",
243
+ description=(
244
+ "Интерактивная или флаги-настройка источников пакетов. "
245
+ "--with-upstream = local + PyPI/npmjs; --local-only = только offline-cache; "
246
+ "пустые ProGet/Nexus URL в интерактиве = --with-upstream. "
247
+ "Не требует --project."
248
+ ),
249
+ formatter_class=argparse.RawDescriptionHelpFormatter,
250
+ epilog=(
251
+ "примеры:\n"
252
+ " offdock configure\n"
253
+ " offdock configure --with-upstream # fill cache с PyPI/npmjs\n"
254
+ " offdock configure --local-only # только offline-cache\n"
255
+ " offdock configure --proget-url https://proget.example.com \\\n"
256
+ " --nexus-url http://nexus.example.com:8081\n"
257
+ ),
258
+ )
259
+ p.add_argument(
260
+ "--local-only",
261
+ action="store_true",
262
+ help="Только offline-cache (офлайн/корп VM)",
263
+ )
264
+ p.add_argument(
265
+ "--with-upstream",
266
+ action="store_true",
267
+ help="local + PyPI/npmjs upstream (машина с сетью для cache)",
268
+ )
269
+ p.add_argument("--non-interactive", action="store_true", help="Без prompt (только флаги)")
270
+ p.add_argument("--proget-url", default=None, help="ProGet base URL")
271
+ p.add_argument("--proget-pypi", default=None, help=f"ProGet PyPI feed (hint: {_HINT_PROGET_PYPI})")
272
+ p.add_argument("--proget-npm", default=None, help=f"ProGet npm feed (hint: {_HINT_PROGET_NPM})")
273
+ p.add_argument("--proget-deb", default=None, help=f"ProGet deb feed (hint: {_HINT_PROGET_DEB})")
274
+ p.add_argument("--nexus-url", default=None, help="Nexus base URL")
275
+ p.add_argument("--nexus-pypi", default=None, help=f"Nexus PyPI repo (hint: {_HINT_NEXUS_PYPI})")
276
+ p.add_argument("--nexus-npm", default=None, help=f"Nexus npm repo (hint: {_HINT_NEXUS_NPM})")
277
+ p.add_argument("--nexus-raw", default=None, help="Nexus raw repo for offline-cache mirror")
278
+ p.add_argument(
279
+ "--order",
280
+ default=None,
281
+ help="Resolve order, e.g. local,proget,nexus",
282
+ )
@@ -0,0 +1,111 @@
1
+ """Runtime paths derived from ProjectConfig."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from offline_docker.config import ProjectConfig, load_config
9
+ from offline_docker.utils import combined_files_hash
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ProjectContext:
14
+ config: ProjectConfig
15
+ wheels_dir: Path
16
+ wheels_hash_file: Path
17
+ debs_dir: Path
18
+ apt_meta_dir: Path
19
+ npm_cache_dir: Path
20
+ docker_cache_dir: Path
21
+ trivy_cache_dir: Path
22
+
23
+ @property
24
+ def root(self) -> Path:
25
+ return self.config.root
26
+
27
+ @property
28
+ def offline_cache(self) -> Path:
29
+ return self.config.offline_cache
30
+
31
+ @property
32
+ def log_dir(self) -> Path:
33
+ return self.config.log_dir
34
+
35
+ @classmethod
36
+ def from_config(cls, config: ProjectConfig) -> ProjectContext:
37
+ offline = config.offline_cache
38
+ wheels = offline / "wheels"
39
+ return cls(
40
+ config=config,
41
+ wheels_dir=wheels,
42
+ wheels_hash_file=wheels / ".deps.sha256",
43
+ debs_dir=offline / "debs",
44
+ apt_meta_dir=offline / "apt-meta",
45
+ npm_cache_dir=offline / "npm",
46
+ docker_cache_dir=offline / "docker",
47
+ trivy_cache_dir=offline / config.trivy.cache_subdir,
48
+ )
49
+
50
+ @classmethod
51
+ def load(
52
+ cls,
53
+ root: Path,
54
+ *,
55
+ config_file: Path | None = None,
56
+ ) -> ProjectContext:
57
+ config = load_config(root, config_file=config_file)
58
+ return cls.from_config(config)
59
+
60
+ def ensure_offline_dirs(self) -> None:
61
+ for d in (
62
+ self.wheels_dir,
63
+ self.debs_dir,
64
+ self.apt_meta_dir,
65
+ self.npm_cache_dir,
66
+ self.docker_cache_dir,
67
+ self.trivy_cache_dir,
68
+ ):
69
+ d.mkdir(parents=True, exist_ok=True)
70
+ from offline_docker.oc_discovery import discover_yarn_cache_specs
71
+
72
+ for spec in discover_yarn_cache_specs(self.config):
73
+ (self.offline_cache / spec.cache_subdir).mkdir(parents=True, exist_ok=True)
74
+ (self.offline_cache / spec.archive_subdir).mkdir(parents=True, exist_ok=True)
75
+
76
+ def ensure_log_dir(self) -> None:
77
+ self.log_dir.mkdir(parents=True, exist_ok=True)
78
+
79
+ def offline_build_log(self, stem: str) -> Path:
80
+ self.ensure_log_dir()
81
+ return self.log_dir / f"offline-build-{stem}.log"
82
+
83
+ def trivy_scan_log(self, target: str) -> Path:
84
+ self.ensure_log_dir()
85
+ return self.log_dir / f"trivy-scan-{target}.log"
86
+
87
+ def python_deps_hash(self) -> str:
88
+ paths = self.config.python_lock_paths()
89
+ if not paths:
90
+ raise RuntimeError("Python lock files not found")
91
+ return combined_files_hash(paths)
92
+
93
+ def set_offline_cache_env(self) -> None:
94
+ import os
95
+
96
+ os.environ.setdefault("DOCKER_BUILDKIT", "1")
97
+ os.environ.setdefault("OFFLINE_CACHE", str(self.offline_cache))
98
+ # Compose additional_contexts on Windows: absolute D:\... often yields empty
99
+ # BuildKit context (oc: 2B) and fails on source=vendor. Prefer ./relative.
100
+ try:
101
+ rel = self.offline_cache.resolve().relative_to(self.root.resolve()).as_posix()
102
+ oc_compose = rel if rel.startswith(".") else f"./{rel}"
103
+ except ValueError:
104
+ oc_compose = self.offline_cache.as_posix()
105
+ app_key = f"{self.config.app_name.upper()}_OFFLINE_CACHE"
106
+ existing = os.environ.get(app_key, "")
107
+ looks_win_abs = bool(existing) and (
108
+ (len(existing) >= 2 and existing[1] == ":") or existing.startswith("\\\\")
109
+ )
110
+ if not existing or looks_win_abs:
111
+ os.environ[app_key] = oc_compose
@@ -0,0 +1,48 @@
1
+ """Default values and shared types for offline-docker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class WheelPattern:
10
+ pattern: str
11
+ label: str
12
+
13
+
14
+ DEFAULT_DEB_PACKAGES = [
15
+ "locales",
16
+ "python3.12",
17
+ "python3.12-venv",
18
+ "python3.12-dev",
19
+ "python3-pip",
20
+ "git",
21
+ "curl",
22
+ "wget",
23
+ "ca-certificates",
24
+ "gnupg",
25
+ "libgl1",
26
+ "libglib2.0-0",
27
+ "libsm6",
28
+ "libxext6",
29
+ "libxrender1",
30
+ "ffmpeg",
31
+ ]
32
+
33
+ DEFAULT_DOCKER_IMAGES = [
34
+ "ubuntu:24.04",
35
+ "node:22-bookworm-slim",
36
+ "nginx:1.28-alpine",
37
+ "redis:7-alpine",
38
+ "aquasec/trivy:latest",
39
+ ]
40
+
41
+ DEFAULT_CRITICAL_WHEELS = [
42
+ WheelPattern("PyJWT-*.whl", "PyJWT"),
43
+ WheelPattern("psycopg-*.whl", "psycopg"),
44
+ WheelPattern("psycopg_binary-*.whl", "psycopg_binary"),
45
+ WheelPattern("redis-*.whl", "redis"),
46
+ WheelPattern("sqlalchemy-*.whl", "sqlalchemy"),
47
+ WheelPattern("alembic-*.whl", "alembic"),
48
+ ]
@@ -0,0 +1,139 @@
1
+ """Auto-discovery of Docker/Python/frontend dependencies in a project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from offline_docker.config import ProjectConfig
8
+ from offline_docker.scan import (
9
+ ScanInventory,
10
+ compute_init_gaps,
11
+ inventory_to_draft_config,
12
+ recursive_scan,
13
+ )
14
+
15
+
16
+ def discover_project(root: Path) -> ProjectConfig:
17
+ """Recursive scan + role heuristics (shared with init)."""
18
+ root = root.resolve()
19
+ inventory = recursive_scan(root)
20
+ return inventory_to_draft_config(root, inventory)
21
+
22
+
23
+ def discover_with_inventory(root: Path) -> tuple[ProjectConfig, ScanInventory]:
24
+ root = root.resolve()
25
+ inventory = recursive_scan(root)
26
+ cfg = inventory_to_draft_config(root, inventory)
27
+ return cfg, inventory
28
+
29
+
30
+ def format_discovery_report(
31
+ cfg: ProjectConfig,
32
+ *,
33
+ inventory: ScanInventory | None = None,
34
+ gaps: list[str] | None = None,
35
+ ) -> str:
36
+ if inventory is None:
37
+ inventory = recursive_scan(cfg.root)
38
+ if gaps is None:
39
+ gaps = compute_init_gaps(cfg, inventory)
40
+
41
+ lines = [
42
+ f"=== discover: {cfg.root} ===",
43
+ "",
44
+ f"Конфиг: {cfg.config_source or '(не задан)'}",
45
+ f"App name: {cfg.app_name}",
46
+ f"Offline cache: {cfg.offline_cache}",
47
+ f"Log dir: {cfg.log_dir}",
48
+ "",
49
+ "Compose:",
50
+ ]
51
+ for f in cfg.compose_files or ["(не найдены)"]:
52
+ lines.append(f" - {f}")
53
+ lines.extend(
54
+ [
55
+ f" online: {cfg.compose_online}",
56
+ f" offline: {cfg.compose_offline}",
57
+ "",
58
+ "Python lock files:",
59
+ ]
60
+ )
61
+ for f in cfg.python_lock_files:
62
+ exists = "ok" if cfg.path(f).is_file() else "missing"
63
+ lines.append(f" - {f} ({exists})")
64
+ tv = cfg.torch_version()
65
+ lines.extend(
66
+ [
67
+ f" torch: {tv or '(не найден)'}",
68
+ f" uv export: {cfg.use_uv_export}",
69
+ f" project dir: {cfg.effective_python_project_dir()}",
70
+ "",
71
+ "Yarn cache (oc source=yarn):",
72
+ ]
73
+ )
74
+ from offline_docker.oc_discovery import discover_yarn_cache_specs
75
+
76
+ yarn_specs = discover_yarn_cache_specs(cfg)
77
+ for spec in yarn_specs:
78
+ lines.append(
79
+ f" - cache={spec.cache_subdir}, archive={spec.archive_subdir}, "
80
+ f"arg={spec.version_arg}, dockerfile={spec.source_dockerfile}"
81
+ )
82
+ if not yarn_specs:
83
+ lines.append(" (не требуется)")
84
+ lines.extend(
85
+ [
86
+ "",
87
+ f"Frontend: {cfg.frontend_dir}/",
88
+ f" lock: {cfg.frontend_lock}",
89
+ "",
90
+ "Dockerfiles:",
91
+ f" base: {cfg.dockerfiles.base}",
92
+ f" base_offline: {cfg.dockerfiles.base_offline}",
93
+ f" backend: {cfg.dockerfiles.backend}",
94
+ f" backend_offline: {cfg.dockerfiles.backend_offline}",
95
+ f" frontend: {cfg.dockerfiles.frontend}",
96
+ f" frontend_offline: {cfg.dockerfiles.frontend_offline}",
97
+ "",
98
+ f"APT packages ({len(cfg.deb_packages)}):",
99
+ ]
100
+ )
101
+ for pkg in cfg.deb_packages[:15]:
102
+ lines.append(f" - {pkg}")
103
+ if len(cfg.deb_packages) > 15:
104
+ lines.append(f" ... и ещё {len(cfg.deb_packages) - 15}")
105
+ lines.extend(["", "Docker images:"])
106
+ for img in cfg.docker_images:
107
+ lines.append(f" - {img}")
108
+ lines.extend(
109
+ [
110
+ "",
111
+ "Image tags:",
112
+ f" base: {cfg.images.base}",
113
+ f" backend: {cfg.images.backend}",
114
+ f" frontend: {cfg.images.frontend}",
115
+ ]
116
+ )
117
+ if cfg.deb_extra_repos:
118
+ lines.extend(["", "Extra APT repos:"])
119
+ for repo in cfg.deb_extra_repos:
120
+ lines.append(f" - {repo.name}")
121
+
122
+ if inventory.dockerfiles or inventory.python_files or inventory.package_json_dirs:
123
+ lines.extend(["", "Scan inventory (recursive):"])
124
+ lines.append(f" compose: {len(inventory.compose_files)}")
125
+ lines.append(f" dockerfiles:{len(inventory.dockerfiles)}")
126
+ lines.append(f" python: {len(inventory.python_files)}")
127
+ lines.append(f" frontend locks: {len(inventory.frontend_lock_paths)}")
128
+
129
+ lines.extend(["", "Missing / to fix:"])
130
+ if gaps:
131
+ for g in gaps:
132
+ lines.append(f" - {g}")
133
+ else:
134
+ lines.append(" (нет — offline-артефакты на месте)")
135
+
136
+ lines.extend(["", "Package sources:"])
137
+ lines.extend(cfg.package_resolver().summary_lines())
138
+
139
+ return "\n".join(lines)