mxready 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. mxready/__init__.py +1 -0
  2. mxready/api/__init__.py +4 -0
  3. mxready/api/middleware.py +149 -0
  4. mxready/api/rules.py +10 -0
  5. mxready/api/scans.py +164 -0
  6. mxready/api/workspace.py +156 -0
  7. mxready/app.py +179 -0
  8. mxready/cli/__init__.py +1 -0
  9. mxready/cli/build_verification_bundle.py +45 -0
  10. mxready/cli/generate_public_report.py +140 -0
  11. mxready/cli/scan_batch.py +368 -0
  12. mxready/cli/scan_repository.py +212 -0
  13. mxready/cli/serve.py +55 -0
  14. mxready/config.py +117 -0
  15. mxready/errors.py +11 -0
  16. mxready/logging_setup.py +88 -0
  17. mxready/mcp/__init__.py +5 -0
  18. mxready/mcp/server.py +433 -0
  19. mxready/models.py +382 -0
  20. mxready/persistence/__init__.py +1 -0
  21. mxready/persistence/sqlite.py +581 -0
  22. mxready/py.typed +0 -0
  23. mxready/reporting/__init__.py +4 -0
  24. mxready/reporting/badge.py +40 -0
  25. mxready/reporting/comparison.py +33 -0
  26. mxready/reporting/markdown.py +157 -0
  27. mxready/reporting/sarif.py +94 -0
  28. mxready/reporting/terminal.py +75 -0
  29. mxready/repository/__init__.py +14 -0
  30. mxready/repository/git_client.py +275 -0
  31. mxready/repository/github_archive.py +566 -0
  32. mxready/repository/identity.py +140 -0
  33. mxready/resources/__init__.py +19 -0
  34. mxready/resources/frontend/assets/index-BxJEwNUm.css +1 -0
  35. mxready/resources/frontend/assets/index-lzTCdWAt.js +9 -0
  36. mxready/resources/frontend/assets/mx-chip-C0HgFQyE.webp +0 -0
  37. mxready/resources/frontend/index.html +17 -0
  38. mxready/resources/rules/v1/core.yml +634 -0
  39. mxready/resources/rules/v1/manifest.yml +4 -0
  40. mxready/resources/schemas/verification-result-v1.json +259 -0
  41. mxready/scanning/__init__.py +17 -0
  42. mxready/scanning/analyzer.py +183 -0
  43. mxready/scanning/facts.py +596 -0
  44. mxready/scanning/indexer.py +299 -0
  45. mxready/scanning/path_context.py +174 -0
  46. mxready/scanning/python_source.py +67 -0
  47. mxready/scanning/rule_engine.py +595 -0
  48. mxready/scanning/rule_loader.py +282 -0
  49. mxready/scanning/toolchain.py +100 -0
  50. mxready/services/__init__.py +3 -0
  51. mxready/services/scans.py +239 -0
  52. mxready/services/worker.py +38 -0
  53. mxready/verification/__init__.py +11 -0
  54. mxready/verification/bundle.py +137 -0
  55. mxready/verification/validation.py +101 -0
  56. mxready-1.0.0.dist-info/METADATA +526 -0
  57. mxready-1.0.0.dist-info/RECORD +71 -0
  58. mxready-1.0.0.dist-info/WHEEL +5 -0
  59. mxready-1.0.0.dist-info/entry_points.txt +8 -0
  60. mxready-1.0.0.dist-info/licenses/LICENSE +201 -0
  61. mxready-1.0.0.dist-info/top_level.txt +2 -0
  62. mxready_runner/__init__.py +1 -0
  63. mxready_runner/__main__.py +3 -0
  64. mxready_runner/cli.py +88 -0
  65. mxready_runner/contracts.py +26 -0
  66. mxready_runner/execute.py +185 -0
  67. mxready_runner/inspect.py +79 -0
  68. mxready_runner/process.py +103 -0
  69. mxready_runner/py.typed +0 -0
  70. mxready_runner/redact.py +36 -0
  71. mxready_runner/schema.py +326 -0
@@ -0,0 +1,282 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+ from urllib.parse import urlsplit
9
+
10
+ import yaml
11
+ from pydantic import Field, ValidationError, field_validator, model_validator
12
+
13
+ from mxready.errors import MxReadyError
14
+ from mxready.models import Severity, SourceReference, StrictModel
15
+
16
+ _RULE_ID = re.compile(r"MXR-[A-Z]+-[0-9]{3}")
17
+ _CATEGORY = re.compile(r"[a-z][a-z0-9-]*")
18
+ _NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
19
+ _FACT_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_]*")
20
+ _RULE_FILE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*\.ya?ml")
21
+ _REGEX_FLAGS = {
22
+ "ASCII": re.ASCII,
23
+ "DOTALL": re.DOTALL,
24
+ "IGNORECASE": re.IGNORECASE,
25
+ "MULTILINE": re.MULTILINE,
26
+ "VERBOSE": re.VERBOSE,
27
+ }
28
+ _MAX_RULE_FILE_BYTES = 1_048_576
29
+ _RULE_UPDATED_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
30
+
31
+
32
+ class RulePattern(StrictModel):
33
+ type: Literal["regex", "dependency", "fact"]
34
+ expression: str | None = None
35
+ flags: list[str] = Field(default_factory=list)
36
+ name: str | None = None
37
+ equals: bool | str | None = None
38
+
39
+ @field_validator("flags")
40
+ @classmethod
41
+ def flags_are_supported_and_unique(cls, value: list[str]) -> list[str]:
42
+ if len(value) != len(set(value)) or any(flag not in _REGEX_FLAGS for flag in value):
43
+ raise ValueError("regex flags must be supported and unique")
44
+ return value
45
+
46
+ @model_validator(mode="after")
47
+ def pattern_contract_matches_type(self) -> RulePattern:
48
+ if self.type == "regex":
49
+ if (
50
+ not isinstance(self.expression, str)
51
+ or not self.expression.strip()
52
+ or len(self.expression) > 1_000
53
+ or self.name is not None
54
+ or self.equals is not None
55
+ ):
56
+ raise ValueError("invalid regex pattern contract")
57
+ try:
58
+ re.compile(self.expression, regex_flags(self.flags))
59
+ except re.error as error:
60
+ raise ValueError("invalid regex expression") from error
61
+ return self
62
+
63
+ if self.type == "dependency":
64
+ if (
65
+ not isinstance(self.name, str)
66
+ or _NAME.fullmatch(self.name) is None
67
+ or self.expression is not None
68
+ or self.flags
69
+ or self.equals is not None
70
+ ):
71
+ raise ValueError("invalid dependency pattern contract")
72
+ return self
73
+
74
+ if (
75
+ not isinstance(self.name, str)
76
+ or _FACT_NAME.fullmatch(self.name) is None
77
+ or self.equals is None
78
+ or self.expression is not None
79
+ or self.flags
80
+ ):
81
+ raise ValueError("invalid fact pattern contract")
82
+ return self
83
+
84
+
85
+ class RuleDefinition(StrictModel):
86
+ id: str
87
+ version: int = Field(ge=1)
88
+ title: str
89
+ category: str
90
+ severity: Severity
91
+ file_globs: list[str] = Field(min_length=1)
92
+ patterns: list[RulePattern] = Field(min_length=1)
93
+ message: str
94
+ recommendation: str
95
+ references: list[SourceReference] = Field(min_length=1)
96
+ updated: str | None = None
97
+ confidence: Literal["documented", "needs-review"] = "documented"
98
+
99
+ @field_validator("updated")
100
+ @classmethod
101
+ def updated_has_iso_date(cls, value: str | None) -> str | None:
102
+ if value is not None and _RULE_UPDATED_DATE.fullmatch(value) is None:
103
+ raise ValueError("updated must use YYYY-MM-DD")
104
+ return value
105
+
106
+ @field_validator("id")
107
+ @classmethod
108
+ def id_has_public_format(cls, value: str) -> str:
109
+ if _RULE_ID.fullmatch(value) is None:
110
+ raise ValueError("invalid rule id")
111
+ return value
112
+
113
+ @field_validator("category")
114
+ @classmethod
115
+ def category_has_stable_format(cls, value: str) -> str:
116
+ if _CATEGORY.fullmatch(value) is None:
117
+ raise ValueError("invalid rule category")
118
+ return value
119
+
120
+ @field_validator("title", "message", "recommendation")
121
+ @classmethod
122
+ def prose_is_non_empty(cls, value: str) -> str:
123
+ stripped = value.strip()
124
+ if not stripped:
125
+ raise ValueError("rule prose must not be empty")
126
+ return stripped
127
+
128
+ @field_validator("file_globs")
129
+ @classmethod
130
+ def file_globs_are_relative_and_unique(cls, value: list[str]) -> list[str]:
131
+ if len(value) != len(set(value)):
132
+ raise ValueError("file globs must be unique")
133
+ for pattern in value:
134
+ if (
135
+ not pattern
136
+ or len(pattern) > 200
137
+ or pattern.startswith(("/", "\\"))
138
+ or "\\" in pattern
139
+ or ".." in pattern.split("/")
140
+ ):
141
+ raise ValueError("file globs must be safe relative patterns")
142
+ return value
143
+
144
+ @field_validator("references")
145
+ @classmethod
146
+ def references_are_https(
147
+ cls,
148
+ value: list[SourceReference],
149
+ ) -> list[SourceReference]:
150
+ for reference in value:
151
+ try:
152
+ parsed = urlsplit(reference.url)
153
+ port = parsed.port
154
+ except ValueError as error:
155
+ raise ValueError("invalid reference URL") from error
156
+ if (
157
+ not reference.title.strip()
158
+ or not reference.url.startswith("https://")
159
+ or parsed.scheme != "https"
160
+ or not parsed.hostname
161
+ or parsed.username is not None
162
+ or parsed.password is not None
163
+ or port is not None
164
+ ):
165
+ raise ValueError("rule references must use public HTTPS URLs")
166
+ return value
167
+
168
+
169
+ class RuleCatalog(StrictModel):
170
+ version: str
171
+ rules: list[RuleDefinition]
172
+ extra_version: str | None = None
173
+
174
+ @property
175
+ def digest(self) -> str:
176
+ document = self.model_dump(mode="json")
177
+ document["rules"] = sorted(document["rules"], key=lambda rule: rule["id"])
178
+ payload = json.dumps(document, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
179
+ return "sha256:" + hashlib.sha256(payload.encode()).hexdigest()
180
+
181
+
182
+ class _RuleManifest(StrictModel):
183
+ schema_version: Literal["1.0"]
184
+ ruleset_version: str
185
+ rule_files: list[str] = Field(min_length=1)
186
+
187
+ @field_validator("ruleset_version")
188
+ @classmethod
189
+ def version_is_non_empty(cls, value: str) -> str:
190
+ stripped = value.strip()
191
+ if not stripped or len(stripped) > 100:
192
+ raise ValueError("invalid ruleset version")
193
+ return stripped
194
+
195
+ @field_validator("rule_files")
196
+ @classmethod
197
+ def files_are_safe_and_unique(cls, value: list[str]) -> list[str]:
198
+ if len(value) != len(set(value)) or any(
199
+ _RULE_FILE.fullmatch(item) is None for item in value
200
+ ):
201
+ raise ValueError("invalid rule file list")
202
+ return value
203
+
204
+
205
+ def load_rule_catalog(
206
+ directory: Path,
207
+ extra_directory: Path | None = None,
208
+ ) -> RuleCatalog:
209
+ """Load a complete, strictly validated, versioned YAML rule catalog.
210
+
211
+ `extra_directory` 可选:合并另一目录中的规则集(自定义规则扩展)。
212
+ """
213
+ try:
214
+ catalog = _load_catalog(Path(directory))
215
+ if extra_directory is None:
216
+ return catalog
217
+ return _merge_catalogs(catalog, _load_catalog(Path(extra_directory)))
218
+ except MxReadyError:
219
+ raise
220
+ except (
221
+ OSError,
222
+ TypeError,
223
+ UnicodeError,
224
+ ValidationError,
225
+ ValueError,
226
+ yaml.YAMLError,
227
+ ) as error:
228
+ raise _ruleset_invalid() from error
229
+
230
+
231
+ def _load_catalog(directory: Path) -> RuleCatalog:
232
+ manifest_document = _load_yaml(directory / "manifest.yml")
233
+ manifest = _RuleManifest.model_validate(manifest_document)
234
+
235
+ rules: list[RuleDefinition] = []
236
+ seen_ids: set[str] = set()
237
+ for filename in manifest.rule_files:
238
+ document = _load_yaml(directory / filename)
239
+ if not isinstance(document, list) or not document:
240
+ raise _ruleset_invalid()
241
+ for rule_document in document:
242
+ rule = RuleDefinition.model_validate(rule_document)
243
+ if rule.id in seen_ids:
244
+ raise _ruleset_invalid()
245
+ seen_ids.add(rule.id)
246
+ rules.append(rule)
247
+
248
+ if not rules:
249
+ raise _ruleset_invalid()
250
+ return RuleCatalog(version=manifest.ruleset_version, rules=rules)
251
+
252
+
253
+ def _merge_catalogs(base: RuleCatalog, extra: RuleCatalog) -> RuleCatalog:
254
+ merged = {rule.id: rule for rule in base.rules}
255
+ for rule in extra.rules:
256
+ existing = merged.get(rule.id)
257
+ if existing is not None and existing != rule:
258
+ raise _ruleset_invalid()
259
+ merged[rule.id] = rule
260
+ return RuleCatalog(
261
+ version=base.version, rules=list(merged.values()), extra_version=extra.version
262
+ )
263
+
264
+
265
+ def regex_flags(flags: list[str]) -> re.RegexFlag:
266
+ resolved = re.NOFLAG
267
+ for flag in flags:
268
+ resolved |= _REGEX_FLAGS[flag]
269
+ return resolved
270
+
271
+
272
+ def _load_yaml(path: Path) -> Any:
273
+ if path.stat().st_size > _MAX_RULE_FILE_BYTES:
274
+ raise ValueError("rule file is too large")
275
+ return yaml.safe_load(path.read_text(encoding="utf-8-sig"))
276
+
277
+
278
+ def _ruleset_invalid() -> MxReadyError:
279
+ return MxReadyError(
280
+ "RULESET_INVALID",
281
+ "规则集无法加载或未通过校验,请检查规则文件。",
282
+ )
@@ -0,0 +1,100 @@
1
+ """工具链画像:接入 cu-bridge 之后,部分"未迁移"前提下成立的命中不再是阻塞项。
2
+
3
+ 静态命中本身没有错——`NVCC = nvcc` 确实还在文件里。错的是严重级别所依赖的前提:
4
+ "项目尚未接入 MXMACA 兼容层"。cu-bridge 的 `make_maca` / `cucc` 会在构建期接管
5
+ 这些调用,此时该写法是**预期状态**,继续报 blocker 等于把已迁移项目判成未迁移。
6
+
7
+ 降级必须留痕,否则就变成用规则把结论藏起来。因此每次降级都会:
8
+ 1. 把严重级别下调一级,并在 message 末尾注明原因;
9
+ 2. 往 `analysis_warnings` 里写一条可追溯记录(含规则 ID 与文件数)。
10
+
11
+ 只降一级、不降到 info:cu-bridge 接管的是构建入口,无法证明每个 nvcc 调用都走了
12
+ 包装器——脚本里 `subprocess.run(["nvcc", ...])` 仍然绕过它,那属于真问题,降成
13
+ info 会放跑。
14
+
15
+ 同理,降级只作用于**声明式构建文件**(Makefile / `*.mk` / CMakeLists.txt / `*.cmake`)。
16
+ 这些文件里的 `NVCC = nvcc` 是构建设置,由 cu-bridge 的 conf.json 在编译期改写;
17
+ 而 `.py` / `.sh` 里的 nvcc 是运行时命令,是否走了包装器只能人工判断,保持 blocker。
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from mxready.models import AnalysisWarning, Finding, Severity
23
+ from mxready.scanning.facts import ProjectFacts
24
+
25
+ CU_BRIDGE_FLAG = "uses_cu_bridge_toolchain"
26
+ CU_BRIDGE_WARNING_CODE = "TOOLCHAIN_CU_BRIDGE_DOWNGRADE"
27
+
28
+ # 规则 ID -> 降级后的级别。只有"前提是未迁移"的规则才该进这张表;
29
+ # 描述源码语义(PTX、线程束宽度)的规则与构建方式无关,不适用。
30
+ _CU_BRIDGE_DOWNGRADES: dict[str, Severity] = {
31
+ "MXR-TOOLCHAIN-001": Severity.WARNING,
32
+ }
33
+
34
+ # 只在这些文件里认定"构建入口已被接管"。命令式脚本不在内。
35
+ _BUILD_DEFINITION_NAMES = {"cmakelists.txt", "gnumakefile", "makefile"}
36
+ _BUILD_DEFINITION_SUFFIXES = (".cmake", ".mk")
37
+
38
+ _DOWNGRADE_NOTE = (
39
+ "(已检测到 cu-bridge 工具链接入:该构建入口通常由 make_maca/cucc 接管,"
40
+ "此项从 blocker 降为 warning,请确认是否有脚本绕过包装器。)"
41
+ )
42
+
43
+
44
+ def apply_toolchain_profile(
45
+ findings: list[Finding],
46
+ facts: ProjectFacts,
47
+ ) -> tuple[list[Finding], list[AnalysisWarning]]:
48
+ """按工具链画像调整严重级别,返回新列表与需要记录到报告的告警。"""
49
+ if not facts.flags.get(CU_BRIDGE_FLAG):
50
+ return findings, []
51
+
52
+ adjusted: list[Finding] = []
53
+ downgraded_rules: dict[str, set[str]] = {}
54
+ for finding in findings:
55
+ target = _CU_BRIDGE_DOWNGRADES.get(finding.rule_id)
56
+ if (
57
+ target is None
58
+ or finding.severity is not Severity.BLOCKER
59
+ or not _is_build_definition(finding.relative_path)
60
+ ):
61
+ adjusted.append(finding)
62
+ continue
63
+ downgraded_rules.setdefault(finding.rule_id, set()).add(finding.relative_path)
64
+ adjusted.append(
65
+ finding.model_copy(
66
+ update={
67
+ "severity": target,
68
+ "message": _append_note(finding.message),
69
+ }
70
+ )
71
+ )
72
+
73
+ if not downgraded_rules:
74
+ return adjusted, []
75
+
76
+ details = ";".join(
77
+ f"{rule_id}({len(paths)} 个文件)" for rule_id, paths in sorted(downgraded_rules.items())
78
+ )
79
+ return adjusted, [
80
+ AnalysisWarning(
81
+ code=CU_BRIDGE_WARNING_CODE,
82
+ relative_path=None,
83
+ message=(
84
+ f"检测到 cu-bridge 工具链接入,已将 {details} 从 blocker 降为 warning。"
85
+ "降级只覆盖声明式构建文件;命令式脚本(.py / .sh)中的 nvcc 调用仍按 "
86
+ "blocker 报告,因为它可能绕过 make_maca 的 PATH 注入。"
87
+ ),
88
+ )
89
+ ]
90
+
91
+
92
+ def _is_build_definition(relative_path: str) -> bool:
93
+ name = relative_path.rsplit("/", 1)[-1].casefold()
94
+ return name in _BUILD_DEFINITION_NAMES or name.endswith(_BUILD_DEFINITION_SUFFIXES)
95
+
96
+
97
+ def _append_note(message: str) -> str:
98
+ if _DOWNGRADE_NOTE in message:
99
+ return message
100
+ return f"{message}{_DOWNGRADE_NOTE}"
@@ -0,0 +1,3 @@
1
+ from mxready.services.scans import ScanService
2
+
3
+ __all__ = ["ScanService"]
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Mapping
5
+ from contextlib import contextmanager
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from tempfile import TemporaryDirectory
9
+ from threading import Event, Thread
10
+ from uuid import UUID, uuid4
11
+
12
+ from mxready.config import Settings
13
+ from mxready.errors import MxReadyError
14
+ from mxready.models import ScanJob, ScanReport, ScanStatus
15
+ from mxready.persistence.sqlite import SQLiteStore
16
+ from mxready.repository.git_client import GitClient
17
+ from mxready.repository.identity import parse_repository_url, validate_git_ref
18
+ from mxready.scanning.analyzer import ScanAnalyzer
19
+ from mxready.verification.validation import (
20
+ validate_verification_upload,
21
+ verification_status_at,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _STAGE_MESSAGES = {
27
+ ScanStatus.CLONING: "正在安全获取仓库",
28
+ ScanStatus.INDEXING: "正在建立源码索引",
29
+ ScanStatus.ANALYZING: "正在执行兼容性规则",
30
+ ScanStatus.COMPLETED: "扫描完成",
31
+ ScanStatus.FAILED: "扫描失败",
32
+ }
33
+
34
+
35
+ class ScanService:
36
+ def __init__(
37
+ self,
38
+ store: SQLiteStore,
39
+ git_client: GitClient,
40
+ analyzer: ScanAnalyzer,
41
+ settings: Settings,
42
+ providers: Mapping[str, str] | None = None,
43
+ ) -> None:
44
+ self.store = store
45
+ self.git_client = git_client
46
+ self.analyzer = analyzer
47
+ self.settings = settings
48
+ self.providers = providers
49
+
50
+ def create_scan(
51
+ self,
52
+ repo_url: str,
53
+ requested_ref: str | None,
54
+ ) -> ScanJob:
55
+ identity = parse_repository_url(repo_url, self.providers)
56
+ reference = validate_git_ref(requested_ref)
57
+ canonical_url = identity.clone_url.removesuffix(".git")
58
+ return self.store.create_job(
59
+ canonical_url,
60
+ reference,
61
+ max_active=self.settings.max_concurrent_scans,
62
+ )
63
+
64
+ def get_scan(self, scan_id: UUID) -> ScanJob:
65
+ job = self.store.get_job(scan_id)
66
+ if job is None:
67
+ raise MxReadyError(
68
+ "SCAN_NOT_FOUND",
69
+ "The requested scan does not exist.",
70
+ )
71
+ return job
72
+
73
+ def get_report(self, scan_id: UUID) -> ScanReport:
74
+ job = self.get_scan(scan_id)
75
+ if job.status is not ScanStatus.COMPLETED:
76
+ raise MxReadyError(
77
+ "SCAN_NOT_COMPLETED",
78
+ "The requested scan has not completed successfully.",
79
+ )
80
+ report = self.store.get_report(scan_id)
81
+ if report is None:
82
+ raise MxReadyError(
83
+ "SCAN_INTERNAL_ERROR",
84
+ "The completed scan report could not be loaded.",
85
+ )
86
+ run = self.store.get_verification(scan_id)
87
+ if run is not None:
88
+ report = report.model_copy(
89
+ update={
90
+ "verification_status": verification_status_at(run, now=datetime.now(UTC)),
91
+ }
92
+ )
93
+ return report
94
+
95
+ def run_scan(self, scan_id: UUID) -> None:
96
+ job = self.get_scan(scan_id)
97
+ token = str(uuid4())
98
+ if not self.store.claim_job(scan_id, token, lease_seconds=self.settings.task_lease_seconds):
99
+ return
100
+ with self._heartbeat(scan_id, token):
101
+ self._run_claimed_scan(job, token)
102
+
103
+ @contextmanager
104
+ def _heartbeat(self, scan_id: UUID, token: str):
105
+ stopped = Event()
106
+
107
+ def renew() -> None:
108
+ while not stopped.wait(self.settings.task_lease_seconds / 3):
109
+ try:
110
+ if not self.store.renew_lease(
111
+ scan_id,
112
+ token,
113
+ lease_seconds=self.settings.task_lease_seconds,
114
+ ):
115
+ return
116
+ except Exception:
117
+ logger.exception("scan lease renewal failed", extra={"scan_id": str(scan_id)})
118
+ return
119
+
120
+ thread = Thread(target=renew, name=f"scan-heartbeat-{scan_id}", daemon=True)
121
+ thread.start()
122
+ try:
123
+ yield
124
+ finally:
125
+ stopped.set()
126
+ thread.join()
127
+
128
+ def _run_claimed_scan(self, job: ScanJob, token: str) -> None:
129
+ scan_id = job.id
130
+
131
+ logger.info("scan started", extra={"scan_id": str(scan_id)})
132
+ try:
133
+ self.store.update_job(
134
+ scan_id,
135
+ status=ScanStatus.CLONING,
136
+ stage_message=_STAGE_MESSAGES[ScanStatus.CLONING],
137
+ worker_token=token,
138
+ )
139
+ identity = parse_repository_url(job.repo_url, self.providers)
140
+
141
+ with TemporaryDirectory(
142
+ prefix=f"mxready-{scan_id}-",
143
+ dir=self.settings.temp_dir,
144
+ ) as temporary_directory:
145
+ repository_root = Path(temporary_directory) / "repository"
146
+ commit = self.git_client.clone(
147
+ identity,
148
+ job.requested_ref,
149
+ repository_root,
150
+ )
151
+ logger.info(
152
+ "repository cloned",
153
+ extra={"scan_id": str(scan_id), "commit": commit},
154
+ )
155
+
156
+ def persist_stage(status: ScanStatus) -> None:
157
+ self.store.update_job(
158
+ scan_id,
159
+ status=status,
160
+ stage_message=_STAGE_MESSAGES[status],
161
+ resolved_commit=commit,
162
+ worker_token=token,
163
+ )
164
+
165
+ report = self.analyzer.analyze(
166
+ repository_root,
167
+ repository_url=job.repo_url,
168
+ commit=commit,
169
+ scan_id=scan_id,
170
+ stage_callback=persist_stage,
171
+ )
172
+ self.store.save_report(report, worker_token=token)
173
+ logger.info(
174
+ "scan analysis completed",
175
+ extra={"scan_id": str(scan_id)},
176
+ )
177
+
178
+ self.store.update_job(
179
+ scan_id,
180
+ status=ScanStatus.COMPLETED,
181
+ stage_message=_STAGE_MESSAGES[ScanStatus.COMPLETED],
182
+ resolved_commit=commit,
183
+ worker_token=token,
184
+ )
185
+ except MxReadyError as error:
186
+ if error.code != "SCAN_LEASE_LOST":
187
+ self._mark_failed(scan_id, error.code, error.message, token)
188
+ except Exception:
189
+ logger.exception(
190
+ "Unexpected scan failure for scan_id=%s",
191
+ scan_id,
192
+ )
193
+ self._mark_failed(
194
+ scan_id,
195
+ "SCAN_INTERNAL_ERROR",
196
+ "The scan failed unexpectedly. Start a new scan and try again.",
197
+ token,
198
+ )
199
+
200
+ def attach_verification(
201
+ self,
202
+ scan_id: UUID,
203
+ payload: bytes,
204
+ *,
205
+ now: datetime | None = None,
206
+ ) -> ScanReport:
207
+ report = self.get_report(scan_id)
208
+ validated = validate_verification_upload(
209
+ report,
210
+ payload,
211
+ now=now or datetime.now(UTC),
212
+ )
213
+ updated_report = report.model_copy(update={"verification_status": validated.status})
214
+ self.store.save_verification_and_report(
215
+ scan_id,
216
+ validated.run,
217
+ updated_report,
218
+ )
219
+ return updated_report
220
+
221
+ def _mark_failed(
222
+ self,
223
+ scan_id: UUID,
224
+ code: str,
225
+ message: str,
226
+ token: str,
227
+ ) -> None:
228
+ try:
229
+ self.store.update_job(
230
+ scan_id,
231
+ status=ScanStatus.FAILED,
232
+ stage_message=_STAGE_MESSAGES[ScanStatus.FAILED],
233
+ failure_code=code,
234
+ failure_message=message,
235
+ worker_token=token,
236
+ )
237
+ except MxReadyError as error:
238
+ if error.code != "SCAN_LEASE_LOST":
239
+ raise
@@ -0,0 +1,38 @@
1
+ """Bounded consumers of the durable SQLite scan queue."""
2
+
3
+ import logging
4
+ from threading import Event, Thread
5
+
6
+ from mxready.services.scans import ScanService
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class ScanWorker:
12
+ def __init__(self, service: ScanService) -> None:
13
+ self.service = service
14
+ self.stopped = Event()
15
+ self.threads: list[Thread] = []
16
+
17
+ def start(self) -> None:
18
+ for index in range(self.service.settings.scan_worker_threads):
19
+ thread = Thread(target=self._consume, name=f"scan-worker-{index}", daemon=True)
20
+ self.threads.append(thread)
21
+ thread.start()
22
+
23
+ def stop(self) -> None:
24
+ self.stopped.set()
25
+ for thread in self.threads:
26
+ thread.join()
27
+
28
+ def _consume(self) -> None:
29
+ while not self.stopped.is_set():
30
+ try:
31
+ self.service.store.recover_expired_jobs()
32
+ for scan_id in self.service.store.queued_job_ids():
33
+ if self.stopped.is_set():
34
+ break
35
+ self.service.run_scan(scan_id)
36
+ except Exception:
37
+ logger.exception("scan queue consumer failed")
38
+ self.stopped.wait(0.5)
@@ -0,0 +1,11 @@
1
+ from mxready.verification.bundle import build_verification_bundle
2
+ from mxready.verification.validation import (
3
+ ValidatedVerification,
4
+ validate_verification_upload,
5
+ )
6
+
7
+ __all__ = [
8
+ "ValidatedVerification",
9
+ "build_verification_bundle",
10
+ "validate_verification_upload",
11
+ ]