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,49 @@
1
+ """Inedo ProGet feed source."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from offline_docker.sources.base import Ecosystem
6
+
7
+
8
+ class ProGetSource:
9
+ source_type = "proget"
10
+
11
+ def __init__(
12
+ self,
13
+ name: str = "proget",
14
+ *,
15
+ url: str,
16
+ feeds: dict[str, str] | None = None,
17
+ auth_env: str = "PROGET_API_KEY",
18
+ enabled: bool = True,
19
+ publish: bool = False,
20
+ ) -> None:
21
+ self.name = name
22
+ self.url = url.rstrip("/")
23
+ self.feeds = dict(feeds or {})
24
+ self.auth_env = auth_env
25
+ self.enabled = enabled and bool(url.strip())
26
+ self.publish = publish
27
+
28
+ def index_urls(self, ecosystem: Ecosystem) -> list[str]:
29
+ if not self.enabled:
30
+ return []
31
+ if ecosystem == "pypi":
32
+ feed = self.feeds.get("pypi")
33
+ if not feed:
34
+ return []
35
+ return [f"{self.url}/pypi/{feed}/simple"]
36
+ if ecosystem == "npm":
37
+ feed = self.feeds.get("npm")
38
+ if not feed:
39
+ return []
40
+ return [f"{self.url}/npm/{feed}/"]
41
+ if ecosystem == "apt":
42
+ feed = self.feeds.get("deb") or self.feeds.get("apt")
43
+ if not feed:
44
+ return []
45
+ return [f"{self.url}/debian/{feed}/"]
46
+ return []
47
+
48
+ def universal_feed(self) -> str | None:
49
+ return self.feeds.get("universal") or self.feeds.get("asset")
@@ -0,0 +1,48 @@
1
+ """ProGet universal/asset feed helpers (optional mirror of offline-cache tree)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import urllib.error
7
+ import urllib.request
8
+ from pathlib import Path
9
+ from urllib.parse import quote
10
+
11
+
12
+ def _api_key_header(auth_env: str) -> dict[str, str]:
13
+ key = os.environ.get(auth_env, "").strip()
14
+ if not key:
15
+ return {}
16
+ if key.lower().startswith("x-api-key:"):
17
+ return {"X-ApiKey": key.split(":", 1)[1].strip()}
18
+ return {"X-ApiKey": key}
19
+
20
+
21
+ def download_asset(base_url: str, feed: str, path: str, dest: Path, *, auth_env: str) -> None:
22
+ url = f"{base_url.rstrip('/')}/endpoints/{quote(feed)}/content/{path.lstrip('/')}"
23
+ req = urllib.request.Request(url, headers=_api_key_header(auth_env))
24
+ try:
25
+ with urllib.request.urlopen(req, timeout=120) as resp:
26
+ dest.parent.mkdir(parents=True, exist_ok=True)
27
+ dest.write_bytes(resp.read())
28
+ except urllib.error.HTTPError as exc:
29
+ if exc.code == 404:
30
+ raise FileNotFoundError(path) from exc
31
+ raise RuntimeError(f"ProGet HTTP {exc.code} {url}") from exc
32
+ except urllib.error.URLError as exc:
33
+ raise RuntimeError(f"ProGet unreachable {url}: {exc}") from exc
34
+
35
+
36
+ def upload_asset(base_url: str, feed: str, path: str, src: Path, *, auth_env: str) -> None:
37
+ url = f"{base_url.rstrip('/')}/endpoints/{quote(feed)}/content/{path.lstrip('/')}"
38
+ data = src.read_bytes()
39
+ headers = dict(_api_key_header(auth_env))
40
+ headers["Content-Type"] = "application/octet-stream"
41
+ req = urllib.request.Request(url, data=data, headers=headers, method="PUT")
42
+ try:
43
+ with urllib.request.urlopen(req, timeout=120) as resp:
44
+ resp.read()
45
+ except urllib.error.HTTPError as exc:
46
+ raise RuntimeError(f"ProGet HTTP {exc.code} {url}") from exc
47
+ except urllib.error.URLError as exc:
48
+ raise RuntimeError(f"ProGet unreachable {url}: {exc}") from exc
@@ -0,0 +1,36 @@
1
+ """Public upstream registries (PyPI / npmjs)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from offline_docker.sources.base import Ecosystem
6
+
7
+
8
+ class UpstreamSource:
9
+ source_type = "upstream"
10
+
11
+ def __init__(
12
+ self,
13
+ name: str = "upstream",
14
+ *,
15
+ enabled: bool = False,
16
+ pypi_index: str = "https://pypi.org/simple",
17
+ npm_registry: str = "https://registry.npmjs.org/",
18
+ pytorch_index: str | None = None,
19
+ ) -> None:
20
+ self.name = name
21
+ self.enabled = enabled
22
+ self.pypi_index = pypi_index.rstrip("/")
23
+ self.npm_registry = npm_registry.rstrip("/") + "/"
24
+ self.pytorch_index = pytorch_index.rstrip("/") if pytorch_index else None
25
+
26
+ def index_urls(self, ecosystem: Ecosystem) -> list[str]:
27
+ if not self.enabled:
28
+ return []
29
+ if ecosystem == "pypi":
30
+ urls = [self.pypi_index]
31
+ if self.pytorch_index:
32
+ urls.append(self.pytorch_index)
33
+ return urls
34
+ if ecosystem == "npm":
35
+ return [self.npm_registry]
36
+ return []
@@ -0,0 +1,247 @@
1
+ """Structured offline-cache status for CLI validate and TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from offline_docker.config import dockerfile_uses_vendor_mount
10
+ from offline_docker.context import ProjectContext
11
+ from offline_docker.oc_discovery import archive_tar_path, discover_yarn_cache_specs
12
+ from offline_docker.utils import file_sha256
13
+
14
+ StatusLevel = Literal["ok", "warn", "error", "skip"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class CacheStatusItem:
19
+ level: StatusLevel
20
+ label: str
21
+ detail: str = ""
22
+
23
+
24
+ @dataclass
25
+ class CacheStatusReport:
26
+ project: Path
27
+ offline_cache: Path
28
+ resolve_order: list[str]
29
+ on_miss: str
30
+ soft: bool
31
+ items: list[CacheStatusItem] = field(default_factory=list)
32
+
33
+ @property
34
+ def error_count(self) -> int:
35
+ return sum(1 for i in self.items if i.level == "error")
36
+
37
+ @property
38
+ def warn_count(self) -> int:
39
+ return sum(1 for i in self.items if i.level == "warn")
40
+
41
+ @property
42
+ def ok_count(self) -> int:
43
+ return sum(1 for i in self.items if i.level == "ok")
44
+
45
+ @property
46
+ def ok(self) -> bool:
47
+ return self.error_count == 0
48
+
49
+
50
+ def collect_cache_status(ctx: ProjectContext) -> CacheStatusReport:
51
+ cfg = ctx.config
52
+ resolver = cfg.package_resolver()
53
+ soft = resolver.settings.resolve.on_miss == "warn" and not resolver.is_local_only()
54
+ report = CacheStatusReport(
55
+ project=ctx.root,
56
+ offline_cache=ctx.offline_cache,
57
+ resolve_order=list(resolver.settings.resolve.order),
58
+ on_miss=resolver.settings.resolve.on_miss,
59
+ soft=soft,
60
+ )
61
+
62
+ def add(level: StatusLevel, label: str, detail: str = "") -> None:
63
+ report.items.append(CacheStatusItem(level=level, label=label, detail=detail))
64
+
65
+ def fail(msg: str, *, label: str | None = None) -> None:
66
+ if soft:
67
+ add("warn", label or "cache", msg)
68
+ else:
69
+ add("error", label or "cache", msg)
70
+
71
+ def check_dir(d: Path, name: str) -> bool:
72
+ if not d.is_dir():
73
+ fail(f"нет каталога {name} ({d})", label=name)
74
+ return False
75
+ add("ok", name, str(d))
76
+ return True
77
+
78
+ def check_min_files(d: Path, pattern: str, minimum: int, label: str) -> None:
79
+ count = len(list(d.glob(pattern)))
80
+ if count < minimum:
81
+ fail(f"найдено {count} (минимум {minimum}) в {d}", label=label)
82
+ else:
83
+ add("ok", label, f"{count} файлов")
84
+
85
+ has_python = bool(cfg.python_lock_paths()) or (ctx.root / "requirements.txt").is_file()
86
+ has_frontend = cfg.frontend_lock_path() is not None
87
+
88
+ check_dir(ctx.wheels_dir, "wheels")
89
+ check_dir(ctx.debs_dir, "debs")
90
+ check_dir(ctx.npm_cache_dir, "npm")
91
+ check_dir(ctx.docker_cache_dir, "docker")
92
+
93
+ if ctx.trivy_cache_dir.is_dir():
94
+ add("ok", "trivy", str(ctx.trivy_cache_dir))
95
+ else:
96
+ add("warn", "trivy", f"нет каталога {ctx.trivy_cache_dir}")
97
+
98
+ if has_python:
99
+ if (ctx.wheels_dir / ".mount-check").is_file():
100
+ add("ok", "wheels/.mount-check")
101
+ else:
102
+ fail("wheels/.mount-check отсутствует - выполните offdock cache wheels", label="wheels")
103
+
104
+ if ctx.wheels_hash_file.is_file():
105
+ try:
106
+ current = ctx.python_deps_hash()
107
+ stored = ctx.wheels_hash_file.read_text(encoding="utf-8").strip()
108
+ if current == stored:
109
+ lock_names = ", ".join(cfg.python_lock_files)
110
+ add("ok", "wheels cache", f"соответствует {lock_names}")
111
+ else:
112
+ fail("Python lockfile изменился - выполните offdock cache wheels", label="wheels")
113
+ except RuntimeError:
114
+ pass
115
+ else:
116
+ fail("нет wheels/.deps.sha256 - выполните offdock cache wheels", label="wheels")
117
+
118
+ check_min_files(ctx.wheels_dir, "*.whl", 1, "Python wheels")
119
+ for wp in cfg.critical_wheels:
120
+ if list(ctx.wheels_dir.glob(wp.pattern)):
121
+ add("ok", f"wheel {wp.label}")
122
+ else:
123
+ fail(f"нет {wp.pattern} - выполните offdock cache wheels", label=f"wheel {wp.label}")
124
+ else:
125
+ add("skip", "Python wheels", "нет lockfile")
126
+
127
+ check_min_files(ctx.debs_dir, "*.deb", 5, "deb-пакеты")
128
+
129
+ for repo in cfg.deb_extra_repos:
130
+ gpg = ctx.apt_meta_dir / Path(repo.gpg_dest).name
131
+ lst = ctx.apt_meta_dir / Path(repo.list_dest).name
132
+ if gpg.is_file() and lst.is_file():
133
+ add("ok", f"apt-meta/{repo.name}")
134
+ else:
135
+ fail(f"нет apt-meta для {repo.name} - выполните offdock cache debs", label=f"apt-meta/{repo.name}")
136
+
137
+ if has_frontend:
138
+ if (ctx.npm_cache_dir / "_cacache").is_dir():
139
+ add("ok", "npm/_cacache")
140
+ else:
141
+ fail("нет npm/_cacache - выполните offdock cache npm", label="npm")
142
+
143
+ npm_hash = ctx.npm_cache_dir / ".package-lock.sha256"
144
+ lock = cfg.frontend_lock_path()
145
+ assert lock is not None
146
+ if npm_hash.is_file():
147
+ current = file_sha256(lock)
148
+ stored = npm_hash.read_text(encoding="utf-8").strip()
149
+ if current == stored:
150
+ add("ok", "npm cache", f"соответствует {cfg.frontend_dir}/{cfg.frontend_lock}")
151
+ else:
152
+ fail("package-lock.json изменился - выполните offdock cache npm", label="npm")
153
+ else:
154
+ fail("нет npm/.package-lock.sha256 - выполните offdock cache npm", label="npm")
155
+ else:
156
+ add("skip", "npm cache", "нет frontend lockfile")
157
+
158
+ yarn_specs = discover_yarn_cache_specs(cfg)
159
+ if yarn_specs:
160
+ for spec in yarn_specs:
161
+ cache_dir = ctx.offline_cache / spec.cache_subdir
162
+ archive_tar = archive_tar_path(cfg, spec)
163
+ check_dir(cache_dir, spec.cache_subdir)
164
+ check_dir(archive_tar.parent, spec.archive_subdir)
165
+ if archive_tar.is_file():
166
+ add("ok", f"{spec.archive_subdir} archive", archive_tar.name)
167
+ else:
168
+ fail(
169
+ f"нет {archive_tar.name} - выполните offdock cache yarn",
170
+ label=spec.archive_subdir,
171
+ )
172
+
173
+ yarn_hash = cache_dir / f".{spec.archive_subdir}.sha256"
174
+ if (cache_dir / ".mount-check").is_file():
175
+ add("ok", f"{spec.cache_subdir}/.mount-check")
176
+ else:
177
+ fail(
178
+ f"{spec.cache_subdir}/.mount-check отсутствует - выполните offdock cache yarn",
179
+ label=spec.cache_subdir,
180
+ )
181
+ if yarn_hash.is_file() and archive_tar.is_file():
182
+ current = file_sha256(archive_tar)
183
+ stored = yarn_hash.read_text(encoding="utf-8").strip()
184
+ if current == stored:
185
+ add(
186
+ "ok",
187
+ f"{spec.cache_subdir} hash",
188
+ f"соответствует {archive_tar.name}",
189
+ )
190
+ else:
191
+ fail("archive изменился - выполните offdock cache yarn", label=spec.cache_subdir)
192
+ else:
193
+ fail(
194
+ f"нет {spec.cache_subdir}/.{spec.archive_subdir}.sha256 - выполните offdock cache yarn",
195
+ label=spec.cache_subdir,
196
+ )
197
+ if cache_dir.is_dir():
198
+ cache_entries = [
199
+ p
200
+ for p in cache_dir.iterdir()
201
+ if p.name not in {".mount-check"} and not p.name.startswith(".")
202
+ ]
203
+ v6_dir = cache_dir / "v6"
204
+ if v6_dir.is_dir():
205
+ cache_entries.append(v6_dir)
206
+ if not cache_entries:
207
+ fail(
208
+ f"{spec.cache_subdir}/ пуст (нет пакетов) - выполните offdock cache yarn",
209
+ label=spec.cache_subdir,
210
+ )
211
+ else:
212
+ add("ok", f"{spec.cache_subdir} entries", str(len(cache_entries)))
213
+ else:
214
+ add("skip", "yarn cache", "нет source=yarn в *Dockerfile.offline")
215
+
216
+ tar_count = len(list(ctx.docker_cache_dir.glob("*.tar"))) if ctx.docker_cache_dir.is_dir() else 0
217
+ if tar_count >= 1:
218
+ add("ok", "docker tars", f"{tar_count} файлов")
219
+ else:
220
+ add("warn", "docker tars", "нет docker/*.tar - выполните offdock cache images")
221
+
222
+ # vendor: explicit critical_vendor and/or source=vendor in Dockerfile.offline
223
+ vendor_hint = "выполните offdock cache (hooks.after_cache) или cache-vendor"
224
+ if cfg.critical_vendor:
225
+ for vp in cfg.critical_vendor:
226
+ pattern = vp.path.replace("\\", "/")
227
+ if any(ch in pattern for ch in "*?["):
228
+ matches = list(ctx.offline_cache.glob(pattern))
229
+ else:
230
+ candidate = ctx.offline_cache / pattern
231
+ matches = [candidate] if candidate.is_file() or candidate.is_dir() else []
232
+ # files only for globs; dirs ok for exact paths like vendor/
233
+ ok_matches = [m for m in matches if m.is_file() or (m.is_dir() and any(m.iterdir()))]
234
+ if ok_matches:
235
+ add("ok", f"vendor {vp.label}", ok_matches[0].name if ok_matches[0].is_file() else str(ok_matches[0]))
236
+ else:
237
+ fail(f"нет {pattern} - {vendor_hint}", label=f"vendor {vp.label}")
238
+ elif dockerfile_uses_vendor_mount(cfg):
239
+ vendor_dir = ctx.offline_cache / "vendor"
240
+ if vendor_dir.is_dir() and any(vendor_dir.iterdir()):
241
+ add("ok", "vendor", str(vendor_dir))
242
+ else:
243
+ fail(f"нет offline-cache/vendor - {vendor_hint}", label="vendor")
244
+ else:
245
+ add("skip", "vendor", "нет source=vendor / critical_vendor")
246
+
247
+ return report
@@ -0,0 +1,125 @@
1
+ """Materialize / publish offline-cache trees from remote raw/universal feeds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from offline_docker.context import ProjectContext
8
+ from offline_docker.resolve import PackageResolver
9
+ from offline_docker.sources.nexus import NexusSource
10
+ from offline_docker.sources.nexus_client import download_file, join_url, upload_file
11
+ from offline_docker.sources.proget import ProGetSource
12
+ from offline_docker.sources.proget_client import download_asset, upload_asset
13
+
14
+ # Marker files / dirs commonly present after download-*
15
+ _SYNC_REL_PATHS = (
16
+ "wheels/.mount-check",
17
+ "wheels/.deps.sha256",
18
+ "npm/.package-lock.sha256",
19
+ "apt-meta/.ready",
20
+ )
21
+
22
+
23
+ def materialize_from_remotes(ctx: ProjectContext) -> None:
24
+ """Best-effort pull of known marker/meta files from Nexus raw / ProGet universal."""
25
+ resolver = ctx.config.package_resolver()
26
+ if resolver.is_local_only():
27
+ return
28
+
29
+ offline = ctx.offline_cache
30
+ offline.mkdir(parents=True, exist_ok=True)
31
+
32
+ for src in resolver.nexus_sources():
33
+ base = src.raw_base_url()
34
+ if not base:
35
+ continue
36
+ print(f">>> materialize from Nexus raw [{src.name}]: {base}")
37
+ for rel in _SYNC_REL_PATHS:
38
+ url = join_url(base, rel)
39
+ dest = offline / rel
40
+ try:
41
+ download_file(url, dest, auth_env=src.auth_env)
42
+ print(f" OK {rel}")
43
+ except Exception as exc: # noqa: BLE001 — best-effort sync
44
+ print(f" skip {rel}: {exc}")
45
+
46
+ for src in resolver.proget_sources():
47
+ feed = src.universal_feed()
48
+ if not feed:
49
+ continue
50
+ print(f">>> materialize from ProGet universal [{src.name}/{feed}]")
51
+ for rel in _SYNC_REL_PATHS:
52
+ dest = offline / rel
53
+ try:
54
+ download_asset(src.url, feed, rel, dest, auth_env=src.auth_env)
55
+ print(f" OK {rel}")
56
+ except FileNotFoundError:
57
+ print(f" skip {rel}: not found")
58
+ except Exception as exc: # noqa: BLE001
59
+ print(f" skip {rel}: {exc}")
60
+
61
+
62
+ def publish_to_remotes(ctx: ProjectContext) -> None:
63
+ """Upload local offline-cache files to remotes with publish=true."""
64
+ resolver = ctx.config.package_resolver()
65
+ offline = ctx.offline_cache
66
+ if not offline.is_dir():
67
+ return
68
+
69
+ for src in resolver.nexus_sources():
70
+ if not getattr(src, "publish", False):
71
+ continue
72
+ base = src.raw_base_url()
73
+ if not base:
74
+ print(f"WARN: nexus [{src.name}] publish=true but raw repo not set")
75
+ continue
76
+ print(f">>> publish to Nexus raw [{src.name}]")
77
+ _upload_tree_nexus(offline, base, src.auth_env)
78
+
79
+ for src in resolver.proget_sources():
80
+ if not getattr(src, "publish", False):
81
+ continue
82
+ feed = src.universal_feed()
83
+ if not feed:
84
+ print(f"WARN: proget [{src.name}] publish=true but universal feed not set")
85
+ continue
86
+ print(f">>> publish to ProGet [{src.name}/{feed}]")
87
+ _upload_tree_proget(offline, src.url, feed, src.auth_env)
88
+
89
+
90
+ def _upload_tree_nexus(root: Path, base: str, auth_env: str) -> None:
91
+ for path in root.rglob("*"):
92
+ if not path.is_file():
93
+ continue
94
+ rel = path.relative_to(root).as_posix()
95
+ url = join_url(base, rel)
96
+ try:
97
+ upload_file(url, path, auth_env=auth_env)
98
+ print(f" uploaded {rel}")
99
+ except Exception as exc: # noqa: BLE001
100
+ print(f" FAIL {rel}: {exc}")
101
+
102
+
103
+ def _upload_tree_proget(root: Path, base_url: str, feed: str, auth_env: str) -> None:
104
+ for path in root.rglob("*"):
105
+ if not path.is_file():
106
+ continue
107
+ rel = path.relative_to(root).as_posix()
108
+ try:
109
+ upload_asset(base_url, feed, rel, path, auth_env=auth_env)
110
+ print(f" uploaded {rel}")
111
+ except Exception as exc: # noqa: BLE001
112
+ print(f" FAIL {rel}: {exc}")
113
+
114
+
115
+ def maybe_materialize(ctx: ProjectContext) -> None:
116
+ resolver: PackageResolver = ctx.config.package_resolver()
117
+ if resolver.has_remote_indexes() or any(
118
+ isinstance(s, NexusSource) and s.raw_base_url() for s in resolver.nexus_sources()
119
+ ) or any(
120
+ isinstance(s, ProGetSource) and s.universal_feed() for s in resolver.proget_sources()
121
+ ):
122
+ try:
123
+ materialize_from_remotes(ctx)
124
+ except Exception as exc: # noqa: BLE001
125
+ print(f"WARN: materialize skipped: {exc}")
@@ -0,0 +1,86 @@
1
+ """Offline Trivy image scanning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+
7
+ from offline_docker import docker_cmd
8
+ from offline_docker.context import ProjectContext
9
+ from offline_docker.download_trivy import download_trivy_db, ensure_trivy_image, trivy_db_stale
10
+ from offline_docker.utils import docker_mount_path
11
+
12
+
13
+ def scan_trivy_offline(
14
+ ctx: ProjectContext,
15
+ target: str,
16
+ image: str | None = None,
17
+ ) -> None:
18
+ if target not in ("backend", "frontend"):
19
+ raise SystemExit(f"ERROR: target должен быть backend или frontend, получено: {target}")
20
+
21
+ if image is None:
22
+ image = (
23
+ ctx.config.images.backend
24
+ if target == "backend"
25
+ else ctx.config.images.frontend
26
+ )
27
+
28
+ if not docker_cmd.image_exists(image):
29
+ raise SystemExit(f"ERROR: образ {image} не найден локально")
30
+
31
+ if trivy_db_stale(ctx):
32
+ print("БД Trivy устарела или отсутствует — offdock cache trivy")
33
+ try:
34
+ download_trivy_db(ctx)
35
+ except SystemExit:
36
+ raise
37
+ except Exception as exc:
38
+ raise SystemExit(
39
+ "ERROR: не удалось обновить БД Trivy. "
40
+ "Выполните offdock cache trivy на машине с интернетом."
41
+ ) from exc
42
+
43
+ ensure_trivy_image(ctx)
44
+ log_file = ctx.trivy_scan_log(target)
45
+ cache_docker = docker_mount_path(ctx.trivy_cache_dir)
46
+ trivy = ctx.config.trivy
47
+
48
+ fanal = ctx.trivy_cache_dir / "fanal"
49
+ if fanal.exists():
50
+ shutil.rmtree(fanal, ignore_errors=True)
51
+
52
+ print("=== scan-trivy-offline ===")
53
+ print(f"Target: {target}")
54
+ print(f"Image: {image}")
55
+ print(f"Severity: {trivy.severity}")
56
+ print(f"Scanners: {trivy.scanners}")
57
+ print(f"Лог: {log_file}")
58
+ print()
59
+
60
+ docker_cmd.run_tee(
61
+ [
62
+ "docker",
63
+ "run",
64
+ "--rm",
65
+ "-v",
66
+ "/var/run/docker.sock:/var/run/docker.sock",
67
+ "-v",
68
+ f"{cache_docker}:/root/.cache/trivy",
69
+ trivy.scanner_image,
70
+ "image",
71
+ "--skip-db-update",
72
+ "--scanners",
73
+ trivy.scanners,
74
+ "--severity",
75
+ trivy.severity,
76
+ "--exit-code",
77
+ "1",
78
+ "--ignore-unfixed",
79
+ image,
80
+ ],
81
+ log_file,
82
+ env={"MSYS_NO_PATHCONV": "1"},
83
+ )
84
+
85
+ print()
86
+ print(f"=== scan-trivy-offline: OK (лог: {log_file}) ===")
@@ -0,0 +1,19 @@
1
+ """Optional Textual TUI for offline-docker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def run_tui(
9
+ project: Path | None = None,
10
+ *,
11
+ config_file: Path | None = None,
12
+ ) -> int:
13
+ """Launch Textual TUI. Returns process exit code."""
14
+ from offline_docker.tui.app import OfflineDockerApp
15
+
16
+ root = (project or Path.cwd()).resolve()
17
+ app = OfflineDockerApp(project=root, config_file=config_file)
18
+ app.run()
19
+ return 0
@@ -0,0 +1,56 @@
1
+ """Textual application entry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual.app import App
8
+
9
+ from offline_docker.context import ProjectContext
10
+ from offline_docker.tui.screens import HomeScreen
11
+
12
+
13
+ class OfflineDockerApp(App[None]):
14
+ """Interactive TUI for offline-docker actions."""
15
+
16
+ TITLE = "offdock"
17
+ CSS = """
18
+ Screen {
19
+ background: $surface;
20
+ }
21
+ .title {
22
+ text-style: bold;
23
+ margin: 1 1 0 1;
24
+ }
25
+ #discover-modal {
26
+ width: 90%;
27
+ height: 90%;
28
+ border: thick $primary;
29
+ background: $surface;
30
+ padding: 1;
31
+ }
32
+ #discover-log {
33
+ height: 1fr;
34
+ margin-bottom: 1;
35
+ }
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ project: Path,
41
+ *,
42
+ config_file: Path | None = None,
43
+ ) -> None:
44
+ super().__init__()
45
+ self.project = project.resolve()
46
+ self.config_file = config_file
47
+ self.ctx = ProjectContext.load(self.project, config_file=config_file)
48
+
49
+ def set_project(self, project: Path, *, config_file: Path | None = None) -> None:
50
+ self.project = project.resolve()
51
+ if config_file is not None:
52
+ self.config_file = config_file
53
+ self.ctx = ProjectContext.load(self.project, config_file=self.config_file)
54
+
55
+ def on_mount(self) -> None:
56
+ self.push_screen(HomeScreen())