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.
- offline_docker/__init__.py +3 -0
- offline_docker/__main__.py +4 -0
- offline_docker/actions.py +173 -0
- offline_docker/build.py +245 -0
- offline_docker/cli.py +486 -0
- offline_docker/config.py +692 -0
- offline_docker/configure.py +282 -0
- offline_docker/context.py +111 -0
- offline_docker/defaults.py +48 -0
- offline_docker/discover.py +139 -0
- offline_docker/docker_cmd.py +136 -0
- offline_docker/download_debs.py +142 -0
- offline_docker/download_images.py +26 -0
- offline_docker/download_npm.py +106 -0
- offline_docker/download_trivy.py +90 -0
- offline_docker/download_wheels.py +363 -0
- offline_docker/download_yarn.py +208 -0
- offline_docker/ensure_base.py +61 -0
- offline_docker/hooks.py +84 -0
- offline_docker/init_cmd.py +54 -0
- offline_docker/load_images.py +34 -0
- offline_docker/oc_discovery.py +206 -0
- offline_docker/resolve.py +306 -0
- offline_docker/scan.py +496 -0
- offline_docker/sources/__init__.py +18 -0
- offline_docker/sources/base.py +18 -0
- offline_docker/sources/filesystem.py +17 -0
- offline_docker/sources/nexus.py +61 -0
- offline_docker/sources/nexus_client.py +67 -0
- offline_docker/sources/proget.py +49 -0
- offline_docker/sources/proget_client.py +48 -0
- offline_docker/sources/upstream.py +36 -0
- offline_docker/status.py +247 -0
- offline_docker/sync_cache.py +125 -0
- offline_docker/trivy_scan.py +86 -0
- offline_docker/tui/__init__.py +19 -0
- offline_docker/tui/app.py +56 -0
- offline_docker/tui/runner.py +87 -0
- offline_docker/tui/screens.py +333 -0
- offline_docker/user_config.py +89 -0
- offline_docker/utils.py +73 -0
- offline_docker/validate.py +53 -0
- offline_docker/wheel_prune.py +141 -0
- offline_docker_cli-2.2.2.dist-info/METADATA +417 -0
- offline_docker_cli-2.2.2.dist-info/RECORD +49 -0
- offline_docker_cli-2.2.2.dist-info/WHEEL +5 -0
- offline_docker_cli-2.2.2.dist-info/entry_points.txt +2 -0
- offline_docker_cli-2.2.2.dist-info/licenses/LICENSE +21 -0
- 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 []
|
offline_docker/status.py
ADDED
|
@@ -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())
|