commitguardian 0.1.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 (197) hide show
  1. commitguard/__init__.py +26 -0
  2. commitguard/__main__.py +6 -0
  3. commitguard/api/__init__.py +18 -0
  4. commitguard/api/app.py +1376 -0
  5. commitguard/api/governance.py +1085 -0
  6. commitguard/api/hosting.py +196 -0
  7. commitguard/api/http.py +252 -0
  8. commitguard/api/settings.py +169 -0
  9. commitguard/audit/__init__.py +13 -0
  10. commitguard/audit/logger.py +34 -0
  11. commitguard/audit/models.py +222 -0
  12. commitguard/audit/storage.py +59 -0
  13. commitguard/ci/__init__.py +7 -0
  14. commitguard/ci/context.py +60 -0
  15. commitguard/cli/__init__.py +6 -0
  16. commitguard/cli/app.py +74 -0
  17. commitguard/cli/commands/__init__.py +1 -0
  18. commitguard/cli/commands/benchmark.py +441 -0
  19. commitguard/cli/commands/check.py +100 -0
  20. commitguard/cli/commands/ci.py +165 -0
  21. commitguard/cli/commands/dashboard.py +141 -0
  22. commitguard/cli/commands/doctor.py +533 -0
  23. commitguard/cli/commands/github.py +449 -0
  24. commitguard/cli/commands/hook.py +156 -0
  25. commitguard/cli/commands/init.py +137 -0
  26. commitguard/cli/commands/install.py +152 -0
  27. commitguard/cli/commands/policy.py +36 -0
  28. commitguard/cli/commands/report.py +39 -0
  29. commitguard/cli/commands/reproduce.py +123 -0
  30. commitguard/cli/commands/scan.py +47 -0
  31. commitguard/cli/common.py +44 -0
  32. commitguard/cli/output.py +89 -0
  33. commitguard/cli/render.py +367 -0
  34. commitguard/config/__init__.py +6 -0
  35. commitguard/config/defaults.py +53 -0
  36. commitguard/config/enforcement.py +53 -0
  37. commitguard/config/loader.py +174 -0
  38. commitguard/config/schema.py +105 -0
  39. commitguard/config/sources.py +183 -0
  40. commitguard/controlplane/__init__.py +24 -0
  41. commitguard/controlplane/access.py +231 -0
  42. commitguard/controlplane/commands.py +393 -0
  43. commitguard/controlplane/errors.py +88 -0
  44. commitguard/controlplane/identity.py +478 -0
  45. commitguard/controlplane/members.py +219 -0
  46. commitguard/controlplane/notifications.py +787 -0
  47. commitguard/controlplane/pagination.py +146 -0
  48. commitguard/controlplane/policies.py +1204 -0
  49. commitguard/controlplane/queries.py +1814 -0
  50. commitguard/controlplane/results.py +909 -0
  51. commitguard/controlplane/rules.py +184 -0
  52. commitguard/controlplane/views.py +799 -0
  53. commitguard/core/__init__.py +6 -0
  54. commitguard/core/context.py +31 -0
  55. commitguard/core/decision.py +58 -0
  56. commitguard/core/engine.py +82 -0
  57. commitguard/core/result.py +177 -0
  58. commitguard/detectors/__init__.py +6 -0
  59. commitguard/detectors/base.py +58 -0
  60. commitguard/detectors/bot.py +87 -0
  61. commitguard/detectors/coauthor.py +86 -0
  62. commitguard/detectors/identity.py +76 -0
  63. commitguard/detectors/registry.py +72 -0
  64. commitguard/detectors/trailer.py +211 -0
  65. commitguard/exceptions/__init__.py +33 -0
  66. commitguard/exceptions/base.py +9 -0
  67. commitguard/exceptions/configuration.py +22 -0
  68. commitguard/exceptions/detection.py +11 -0
  69. commitguard/exceptions/git.py +41 -0
  70. commitguard/exceptions/service.py +25 -0
  71. commitguard/git/__init__.py +12 -0
  72. commitguard/git/commands.py +101 -0
  73. commitguard/git/commit.py +97 -0
  74. commitguard/git/diff.py +36 -0
  75. commitguard/git/hooks.py +527 -0
  76. commitguard/git/push.py +93 -0
  77. commitguard/git/ranges.py +71 -0
  78. commitguard/git/repository.py +447 -0
  79. commitguard/github/__init__.py +34 -0
  80. commitguard/github/actions.py +163 -0
  81. commitguard/github/app.py +935 -0
  82. commitguard/github/auth.py +217 -0
  83. commitguard/github/check_runs.py +172 -0
  84. commitguard/github/checks.py +210 -0
  85. commitguard/github/client.py +844 -0
  86. commitguard/github/enforcement_status.py +209 -0
  87. commitguard/github/errors.py +129 -0
  88. commitguard/github/events.py +563 -0
  89. commitguard/github/identifiers.py +90 -0
  90. commitguard/github/installations.py +566 -0
  91. commitguard/github/markdown.py +19 -0
  92. commitguard/github/permissions.py +70 -0
  93. commitguard/github/pull_requests.py +53 -0
  94. commitguard/github/queue.py +47 -0
  95. commitguard/github/recovery.py +124 -0
  96. commitguard/github/repositories.py +305 -0
  97. commitguard/github/server.py +52 -0
  98. commitguard/github/settings.py +174 -0
  99. commitguard/github/storage.py +2315 -0
  100. commitguard/github/webhooks.py +129 -0
  101. commitguard/github/worker.py +628 -0
  102. commitguard/github/workflow.py +286 -0
  103. commitguard/governance/__init__.py +26 -0
  104. commitguard/governance/bulk.py +765 -0
  105. commitguard/governance/cache.py +88 -0
  106. commitguard/governance/common.py +216 -0
  107. commitguard/governance/exceptions.py +861 -0
  108. commitguard/governance/groups.py +448 -0
  109. commitguard/governance/inventory.py +386 -0
  110. commitguard/governance/posture.py +1272 -0
  111. commitguard/governance/resolver.py +632 -0
  112. commitguard/governance/rollouts.py +760 -0
  113. commitguard/governance/rules.py +371 -0
  114. commitguard/governance/schedules.py +663 -0
  115. commitguard/governance/service.py +120 -0
  116. commitguard/governance/settings.py +365 -0
  117. commitguard/governance/simulation.py +618 -0
  118. commitguard/governance/workflow.py +734 -0
  119. commitguard/notifications/__init__.py +2 -0
  120. commitguard/notifications/channels/__init__.py +1 -0
  121. commitguard/notifications/channels/base.py +22 -0
  122. commitguard/notifications/channels/email.py +110 -0
  123. commitguard/notifications/channels/in_app.py +74 -0
  124. commitguard/notifications/channels/sink.py +58 -0
  125. commitguard/notifications/channels/webhook.py +233 -0
  126. commitguard/notifications/deduplication.py +57 -0
  127. commitguard/notifications/dispatcher.py +201 -0
  128. commitguard/notifications/models.py +439 -0
  129. commitguard/notifications/outbox.py +106 -0
  130. commitguard/notifications/preferences.py +224 -0
  131. commitguard/notifications/retry.py +282 -0
  132. commitguard/notifications/service.py +128 -0
  133. commitguard/notifications/settings.py +167 -0
  134. commitguard/notifications/templates.py +108 -0
  135. commitguard/observability/__init__.py +5 -0
  136. commitguard/observability/logging.py +161 -0
  137. commitguard/observability/metrics.py +105 -0
  138. commitguard/policies/__init__.py +6 -0
  139. commitguard/policies/defaults.py +48 -0
  140. commitguard/policies/evaluator.py +66 -0
  141. commitguard/policies/governance.py +498 -0
  142. commitguard/policies/loader.py +23 -0
  143. commitguard/policies/mandatory.py +52 -0
  144. commitguard/policies/model.py +46 -0
  145. commitguard/provenance/__init__.py +9 -0
  146. commitguard/provenance/author.py +146 -0
  147. commitguard/provenance/committer.py +16 -0
  148. commitguard/provenance/normalization.py +158 -0
  149. commitguard/provenance/signatures.py +34 -0
  150. commitguard/provenance/trailers.py +256 -0
  151. commitguard/research/__init__.py +26 -0
  152. commitguard/research/compare.py +231 -0
  153. commitguard/research/datasets.py +1484 -0
  154. commitguard/research/detection.py +183 -0
  155. commitguard/research/environment.py +185 -0
  156. commitguard/research/gitenv.py +108 -0
  157. commitguard/research/hooks.py +247 -0
  158. commitguard/research/metrics.py +85 -0
  159. commitguard/research/performance.py +194 -0
  160. commitguard/research/platform.py +288 -0
  161. commitguard/research/report.py +372 -0
  162. commitguard/research/repository.py +111 -0
  163. commitguard/research/reproduction.py +297 -0
  164. commitguard/research/results.py +94 -0
  165. commitguard/rules/__init__.py +11 -0
  166. commitguard/rules/data/ai-domains.yaml +51 -0
  167. commitguard/rules/data/ai-identities.yaml +131 -0
  168. commitguard/rules/data/bot-identities.yaml +53 -0
  169. commitguard/rules/data/patterns.yaml +52 -0
  170. commitguard/rules/loader.py +102 -0
  171. commitguard/rules/matcher.py +212 -0
  172. commitguard/rules/models.py +269 -0
  173. commitguard/security/__init__.py +5 -0
  174. commitguard/security/hashing.py +30 -0
  175. commitguard/security/rate_limit.py +33 -0
  176. commitguard/security/safe_yaml.py +69 -0
  177. commitguard/security/sanitization.py +85 -0
  178. commitguard/security/secrets.py +169 -0
  179. commitguard/security/validation.py +89 -0
  180. commitguard/services/__init__.py +15 -0
  181. commitguard/services/analysis.py +119 -0
  182. commitguard/services/audit.py +95 -0
  183. commitguard/services/ci.py +383 -0
  184. commitguard/services/enforcement.py +102 -0
  185. commitguard/services/hooks.py +254 -0
  186. commitguard/services/remediation.py +99 -0
  187. commitguard/services/reports.py +146 -0
  188. commitguard/services/scan.py +172 -0
  189. commitguard/utils/__init__.py +1 -0
  190. commitguard/utils/filesystem.py +72 -0
  191. commitguard/utils/platform.py +35 -0
  192. commitguard/utils/subprocess.py +84 -0
  193. commitguardian-0.1.0.dist-info/METADATA +694 -0
  194. commitguardian-0.1.0.dist-info/RECORD +197 -0
  195. commitguardian-0.1.0.dist-info/WHEEL +4 -0
  196. commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
  197. commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,663 @@
1
+ """Scheduled scans: re-evaluate default branches on a daily or weekly schedule.
2
+
3
+ A schedule targets the organization, a repository group or one repository and
4
+ runs at a local time in a time zone (the organization's by default)::
5
+
6
+ scan_schedules "Production nightly", group Production, daily 02:00 UTC
7
+ │ due (next_run_at <= now)
8
+
9
+ scan_schedule_runs one row per (schedule, slot) - a slot runs at most once
10
+ │ bounded batches per maintenance tick
11
+
12
+ DefaultBranchScanner GitHub reports the default branch head (installation token)
13
+
14
+
15
+ scan_jobs (trigger "scheduled") -> the normal worker: authorization, check ownership,
16
+ current effective policy, fail-closed errors
17
+
18
+ Scheduled scans use the existing scan service and worker; there is no other
19
+ scanner. They are:
20
+
21
+ * **idempotent** - a run is unique per schedule and slot, and each repository's
22
+ job key includes the schedule and slot, so a restarted or concurrent
23
+ maintenance loop never queues the same scan twice;
24
+ * **bounded** - at most :data:`JOBS_PER_TICK` repositories are processed per
25
+ maintenance pass, and nothing is queued while more than :data:`MAX_BACKLOG`
26
+ scans wait in the queue, so a schedule over thousands of repositories never
27
+ floods the workers or GitHub's API;
28
+ * **observable** - each run records repositories covered, queued, skipped (with
29
+ reasons) and failed, and ends ``completed``, ``partial`` or ``failed``.
30
+
31
+ Skipped, not scanned: archived repositories, repositories the GitHub App can no
32
+ longer access, paused monitoring, and a default branch head that was already
33
+ scanned with the current effective policy. The first scheduled scan of a
34
+ branch has no earlier trusted commit, so - like a push of a new default branch -
35
+ its repository configuration comes from built-in defaults while organization
36
+ governance still applies (see :mod:`commitguard.services.ci`).
37
+ """
38
+
39
+ import json
40
+ import sqlite3
41
+ from collections.abc import Callable
42
+ from datetime import UTC, datetime, timedelta
43
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
44
+
45
+ from pydantic import BaseModel, ConfigDict
46
+
47
+ from commitguard.audit.models import SYSTEM_ACTOR, Actor, AuditEventType
48
+ from commitguard.ci.context import CIContext, CIEventKind, CIProvider
49
+ from commitguard.controlplane.access import Permission, Principal
50
+ from commitguard.controlplane.errors import ConflictError, InputValidationError, NotFoundError
51
+ from commitguard.controlplane.policies import PolicyTarget, PolicyTargetType, target_label
52
+ from commitguard.controlplane.views import PolicyTargetView
53
+ from commitguard.exceptions.base import CommitGuardError
54
+ from commitguard.github.checks import APP_PUSH_CHECK_NAME
55
+ from commitguard.github.client import GitHubClient
56
+ from commitguard.github.identifiers import RepositoryRef
57
+ from commitguard.github.installations import InstallationService
58
+ from commitguard.github.pull_requests import branch_group_key
59
+ from commitguard.github.storage import NewScanJob, SqliteStateStore
60
+ from commitguard.governance.cache import group_member_ids
61
+ from commitguard.governance.common import (
62
+ MAX_NAME_CHARS,
63
+ account_repositories,
64
+ dt,
65
+ is_hex_id,
66
+ new_id,
67
+ req_dt,
68
+ require,
69
+ text,
70
+ ts,
71
+ visible_repository_ids,
72
+ )
73
+ from commitguard.governance.inventory import governance_state
74
+ from commitguard.governance.resolver import GovernanceResolver
75
+ from commitguard.governance.settings import load_settings
76
+ from commitguard.governance.workflow import parse_target
77
+ from commitguard.observability.logging import get_logger
78
+ from commitguard.security.hashing import fingerprint
79
+ from commitguard.services.audit import AuditService
80
+
81
+ log = get_logger(__name__)
82
+
83
+ JOBS_PER_TICK = 50
84
+ MAX_BACKLOG = 200
85
+ MAX_SCHEDULES_PER_ORGANIZATION = 100
86
+ CADENCES = ("daily", "weekly")
87
+
88
+
89
+ class ScheduleRunView(BaseModel):
90
+ model_config = ConfigDict(frozen=True, extra="forbid")
91
+
92
+ id: str
93
+ slot: datetime
94
+ state: str
95
+ started_at: datetime
96
+ completed_at: datetime | None
97
+ repositories: int
98
+ queued: int
99
+ skipped: int
100
+ failed: int
101
+ detail: dict[str, int]
102
+
103
+
104
+ class ScanScheduleView(BaseModel):
105
+ model_config = ConfigDict(frozen=True, extra="forbid")
106
+
107
+ id: str
108
+ organization_id: int
109
+ name: str
110
+ target: PolicyTargetView
111
+ cadence: str
112
+ hour: int
113
+ minute: int
114
+ weekday: int | None
115
+ timezone: str
116
+ enabled: bool
117
+ next_run_at: datetime | None
118
+ revision: int
119
+ repositories_covered: int
120
+ last_run: ScheduleRunView | None
121
+ created_by: str | None
122
+ created_at: datetime
123
+ updated_by: str | None
124
+ updated_at: datetime
125
+ can_manage: bool
126
+
127
+
128
+ def next_occurrence(
129
+ cadence: str, hour: int, minute: int, weekday: int | None, timezone: str, after: datetime
130
+ ) -> datetime:
131
+ """The first local ``hour:minute`` (on ``weekday`` for weekly) strictly after ``after``."""
132
+ zone = ZoneInfo(timezone)
133
+ local = after.astimezone(zone)
134
+ for offset in range(0, 15):
135
+ day = (local + timedelta(days=offset)).date()
136
+ if cadence == "weekly" and day.weekday() != weekday:
137
+ continue
138
+ candidate = datetime(day.year, day.month, day.day, hour, minute, tzinfo=zone)
139
+ if candidate > local:
140
+ return candidate.astimezone(UTC)
141
+ raise ValueError("no occurrence found") # pragma: no cover - 15 days cover any week
142
+
143
+
144
+ def _schedule_fields(
145
+ cadence: object, hour: object, minute: object, weekday: object, timezone: object
146
+ ) -> tuple[str, int, int, int | None, str]:
147
+ if cadence not in CADENCES:
148
+ raise InputValidationError("cadence must be daily or weekly", field="cadence")
149
+ assert isinstance(cadence, str) # noqa: S101 - checked above
150
+
151
+ def bounded(name: str, value: object, upper: int) -> int:
152
+ if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= upper:
153
+ raise InputValidationError(f"{name} must be between 0 and {upper}", field=name)
154
+ return value
155
+
156
+ clean_hour = bounded("hour", hour, 23)
157
+ clean_minute = bounded("minute", minute, 59)
158
+ clean_weekday = bounded("weekday", weekday, 6) if cadence == "weekly" else None
159
+ if not isinstance(timezone, str) or len(timezone) > 64:
160
+ raise InputValidationError("timezone must be an IANA time zone", field="timezone")
161
+ try:
162
+ ZoneInfo(timezone)
163
+ except (ZoneInfoNotFoundError, ValueError):
164
+ raise InputValidationError("unknown time zone", field="timezone") from None
165
+ return cadence, clean_hour, clean_minute, clean_weekday, timezone
166
+
167
+
168
+ class DefaultBranchScanner:
169
+ """Queue a scan of a repository's current default branch head."""
170
+
171
+ def __init__(
172
+ self,
173
+ store: SqliteStateStore,
174
+ installations: InstallationService,
175
+ client: GitHubClient,
176
+ resolver: GovernanceResolver,
177
+ enqueue: Callable[[str], bool],
178
+ *,
179
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
180
+ ) -> None:
181
+ self._store = store
182
+ self._installations = installations
183
+ self._client = client
184
+ self._resolver = resolver
185
+ self._enqueue = enqueue
186
+ self._now = now
187
+
188
+ def queue(
189
+ self,
190
+ account_id: int,
191
+ repository_id: int,
192
+ *,
193
+ key: tuple[str, ...],
194
+ requested_by: str | None,
195
+ schedule_id: str | None = None,
196
+ ) -> str:
197
+ """``queued``, ``skipped:<reason>``; raises when GitHub cannot be reached."""
198
+ repository = account_repositories(self._store, account_id).get(repository_id)
199
+ if repository is None:
200
+ return "skipped:not_found"
201
+ if repository.archived:
202
+ return "skipped:archived"
203
+ if not repository.connected:
204
+ return "skipped:disconnected"
205
+ if not self._store.monitoring_enabled(repository.installation_id, repository_id):
206
+ return "skipped:monitoring_paused"
207
+ if governance_state(self._store, account_id, repository_id).onboarding == "excluded":
208
+ return "skipped:excluded"
209
+ ref = RepositoryRef(id=repository_id, owner=repository.owner, name=repository.name)
210
+ authorized = self._installations.authorize(repository.installation_id, ref)
211
+ branch = authorized.default_branch
212
+ if not branch:
213
+ return "skipped:no_default_branch"
214
+ info = self._client.get_branch(authorized.token.token, authorized.repository, branch)
215
+ if info.commit is None:
216
+ return "skipped:no_default_branch"
217
+ head = info.commit.sha
218
+ branch_ref = f"refs/heads/{branch}"
219
+ group = branch_group_key(branch_ref)
220
+ previous = self._store.query(
221
+ "SELECT head_sha, governance_fingerprint FROM scan_jobs WHERE installation_id = ? "
222
+ "AND repository_id = ? AND group_key = ? AND state IN ('passed', 'failed') "
223
+ "ORDER BY sequence DESC LIMIT 1",
224
+ (repository.installation_id, repository_id, group),
225
+ )
226
+ before = str(previous[0]["head_sha"]) if previous else None
227
+ if previous and before == head:
228
+ current = self._resolver.for_repository(account_id, repository_id).fingerprint
229
+ if previous[0]["governance_fingerprint"] == current:
230
+ return "skipped:unchanged"
231
+ before = None # re-evaluate the branch head under the changed policy
232
+ context = CIContext(
233
+ provider=CIProvider.GITHUB,
234
+ event=CIEventKind.PUSH,
235
+ event_name="schedule",
236
+ repository=authorized.repository.full_name,
237
+ ref=branch_ref,
238
+ default_branch=branch,
239
+ before_sha=before if before != head else None,
240
+ after_sha=head,
241
+ )
242
+ job, created = self._store.create_job(
243
+ NewScanJob(
244
+ job_key=fingerprint(["scheduled", *key, str(repository_id), head]),
245
+ installation_id=repository.installation_id,
246
+ repository=authorized.repository,
247
+ delivery_id=None,
248
+ event="scheduled",
249
+ group_key=group,
250
+ head_sha=head,
251
+ check_name=APP_PUSH_CHECK_NAME,
252
+ pull_request_number=None,
253
+ context=context,
254
+ requested_by=requested_by,
255
+ ),
256
+ self._now(),
257
+ )
258
+ if not created:
259
+ return "skipped:already_queued"
260
+ if schedule_id is not None:
261
+ with self._store.transaction() as db:
262
+ db.execute(
263
+ "UPDATE scan_jobs SET schedule_id = ? WHERE job_id = ?",
264
+ (schedule_id, job.job_id),
265
+ )
266
+ self._enqueue(job.job_id) # a full queue is recovered by maintenance
267
+ return "queued"
268
+
269
+
270
+ class ScanScheduleService:
271
+ def __init__(
272
+ self,
273
+ store: SqliteStateStore,
274
+ audit: AuditService,
275
+ scanner: DefaultBranchScanner | None,
276
+ *,
277
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
278
+ ) -> None:
279
+ self._store = store
280
+ self._audit = audit
281
+ self._scanner = scanner
282
+ self._now = now
283
+
284
+ # -- scope ------------------------------------------------------------ #
285
+ def _scope(self, db: sqlite3.Connection, account_id: int, target: PolicyTarget) -> list[int]:
286
+ repositories = sorted(account_repositories(db, account_id))
287
+ if target.type is PolicyTargetType.GROUP:
288
+ members = set(group_member_ids(db, account_id, target.id))
289
+ return [r for r in repositories if r in members]
290
+ if target.type is PolicyTargetType.REPOSITORY:
291
+ return [r for r in repositories if r == int(target.id)]
292
+ return repositories
293
+
294
+ # -- views ------------------------------------------------------------ #
295
+ def _row(self, schedule_id: str) -> sqlite3.Row:
296
+ if not is_hex_id(schedule_id):
297
+ raise NotFoundError()
298
+ rows = self._store.query(
299
+ "SELECT * FROM scan_schedules WHERE schedule_id = ?", (schedule_id,)
300
+ )
301
+ if not rows:
302
+ raise NotFoundError()
303
+ return rows[0]
304
+
305
+ @staticmethod
306
+ def _run_view(row: sqlite3.Row) -> ScheduleRunView:
307
+ detail = json.loads(str(row["detail"] or "{}"))
308
+ return ScheduleRunView(
309
+ id=str(row["run_id"]),
310
+ slot=req_dt(row["slot"]),
311
+ state=str(row["state"]),
312
+ started_at=req_dt(row["started_at"]),
313
+ completed_at=dt(row["completed_at"]),
314
+ repositories=int(row["repositories"]),
315
+ queued=int(row["queued"]),
316
+ skipped=int(row["skipped"]),
317
+ failed=int(row["failed"]),
318
+ detail={str(k): int(v) for k, v in detail.items() if isinstance(v, int)},
319
+ )
320
+
321
+ def _view(self, row: sqlite3.Row, principal: Principal) -> ScanScheduleView:
322
+ account_id = int(row["account_id"])
323
+ target = PolicyTarget(PolicyTargetType(row["target_type"]), str(row["target_id"]))
324
+ with self._store.transaction() as db:
325
+ label = target_label(db, account_id, target) or "(removed)"
326
+ covered = len(self._scope(db, account_id, target))
327
+ runs = self._store.query(
328
+ "SELECT * FROM scan_schedule_runs WHERE schedule_id = ? ORDER BY slot DESC LIMIT 1",
329
+ (row["schedule_id"],),
330
+ )
331
+ return ScanScheduleView(
332
+ id=str(row["schedule_id"]),
333
+ organization_id=account_id,
334
+ name=str(row["name"]),
335
+ target=PolicyTargetView(type=target.type.value, id=target.id, label=label),
336
+ cadence=str(row["cadence"]),
337
+ hour=int(row["hour"]),
338
+ minute=int(row["minute"]),
339
+ weekday=row["weekday"],
340
+ timezone=str(row["timezone"]),
341
+ enabled=bool(row["enabled"]),
342
+ next_run_at=dt(row["next_run_at"]) if row["enabled"] else None,
343
+ revision=int(row["revision"]),
344
+ repositories_covered=covered,
345
+ last_run=self._run_view(runs[0]) if runs else None,
346
+ created_by=row["created_by_login"],
347
+ created_at=req_dt(row["created_at"]),
348
+ updated_by=row["updated_by_login"],
349
+ updated_at=req_dt(row["updated_at"]),
350
+ can_manage=principal.can(Permission.SECURITY_MANAGE, account_id),
351
+ )
352
+
353
+ def list_schedules(self, principal: Principal, account_id: int) -> list[ScanScheduleView]:
354
+ require(principal, Permission.SECURITY_READ, account_id)
355
+ rows = self._store.query(
356
+ "SELECT * FROM scan_schedules WHERE account_id = ? ORDER BY created_at LIMIT ?",
357
+ (account_id, MAX_SCHEDULES_PER_ORGANIZATION),
358
+ )
359
+ return [self._view(row, principal) for row in rows]
360
+
361
+ def get(
362
+ self, principal: Principal, schedule_id: str
363
+ ) -> tuple[ScanScheduleView, list[ScheduleRunView]]:
364
+ row = self._row(schedule_id)
365
+ require(principal, Permission.SECURITY_READ, int(row["account_id"]))
366
+ runs = self._store.query(
367
+ "SELECT * FROM scan_schedule_runs WHERE schedule_id = ? ORDER BY slot DESC LIMIT 20",
368
+ (schedule_id,),
369
+ )
370
+ return self._view(row, principal), [self._run_view(r) for r in runs]
371
+
372
+ # -- writes ----------------------------------------------------------- #
373
+ def create(
374
+ self, principal: Principal, account_id: int, body: dict[str, object]
375
+ ) -> ScanScheduleView:
376
+ require(principal, Permission.SECURITY_MANAGE, account_id)
377
+ target = parse_target(body.get("target_type"), body.get("target_id"))
378
+ with self._store.transaction() as db:
379
+ if target_label(db, account_id, target) is None:
380
+ raise NotFoundError()
381
+ if target.type is PolicyTargetType.REPOSITORY and int(target.id) not in (
382
+ visible_repository_ids(self._store, principal, account_id)
383
+ ):
384
+ raise NotFoundError()
385
+ name = text(body.get("name"), "name", limit=MAX_NAME_CHARS, required=True)
386
+ timezone = body.get("timezone") or load_settings(self._store, account_id).settings.timezone
387
+ cadence, hour, minute, weekday, zone = _schedule_fields(
388
+ body.get("cadence"),
389
+ body.get("hour"),
390
+ body.get("minute", 0),
391
+ body.get("weekday"),
392
+ timezone,
393
+ )
394
+ enabled = body.get("enabled", True)
395
+ if not isinstance(enabled, bool):
396
+ raise InputValidationError("enabled must be true or false", field="enabled")
397
+ now = self._now()
398
+ schedule_id = new_id()
399
+ next_run = next_occurrence(cadence, hour, minute, weekday, zone, now)
400
+ with self._store.transaction() as db:
401
+ count = db.execute(
402
+ "SELECT COUNT(*) AS n FROM scan_schedules WHERE account_id = ?", (account_id,)
403
+ ).fetchone()["n"]
404
+ if int(count) >= MAX_SCHEDULES_PER_ORGANIZATION:
405
+ raise ConflictError(
406
+ f"An organization can have at most {MAX_SCHEDULES_PER_ORGANIZATION} schedules."
407
+ )
408
+ db.execute(
409
+ "INSERT INTO scan_schedules (schedule_id, account_id, target_type, target_id, "
410
+ "name, cadence, hour, minute, weekday, timezone, enabled, next_run_at, created_at, "
411
+ "created_by_login, updated_at, updated_by_login) "
412
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
413
+ (
414
+ schedule_id,
415
+ account_id,
416
+ target.type.value,
417
+ target.id,
418
+ name,
419
+ cadence,
420
+ hour,
421
+ minute,
422
+ weekday,
423
+ zone,
424
+ 1 if enabled else 0,
425
+ ts(next_run),
426
+ ts(now),
427
+ principal.login,
428
+ ts(now),
429
+ principal.login,
430
+ ),
431
+ )
432
+ stored = self._store.insert_audit_event(
433
+ db,
434
+ self._audit.build(
435
+ AuditEventType.SCAN_SCHEDULE_CREATED,
436
+ actor=Actor.user(principal.user_id, principal.login),
437
+ account_id=account_id,
438
+ schedule=schedule_id,
439
+ name=name,
440
+ target_type=target.type.value,
441
+ target_id=target.id or None,
442
+ cadence=cadence,
443
+ time=f"{hour:02d}:{minute:02d} {zone}",
444
+ enabled=enabled,
445
+ ),
446
+ )
447
+ self._audit.log_stored(stored)
448
+ return self._view(self._row(schedule_id), principal)
449
+
450
+ def update(
451
+ self, principal: Principal, schedule_id: str, body: dict[str, object]
452
+ ) -> ScanScheduleView:
453
+ row = self._row(schedule_id)
454
+ account_id = int(row["account_id"])
455
+ require(principal, Permission.SECURITY_MANAGE, account_id)
456
+ expected = body.get("expected_revision")
457
+ if not isinstance(expected, int) or isinstance(expected, bool):
458
+ raise InputValidationError(
459
+ "expected_revision must be an integer", field="expected_revision"
460
+ )
461
+ name = (
462
+ text(body.get("name"), "name", limit=MAX_NAME_CHARS, required=True)
463
+ if "name" in body
464
+ else row["name"]
465
+ )
466
+ cadence, hour, minute, weekday, zone = _schedule_fields(
467
+ body.get("cadence", row["cadence"]),
468
+ body.get("hour", row["hour"]),
469
+ body.get("minute", row["minute"]),
470
+ body.get("weekday", row["weekday"]),
471
+ body.get("timezone", row["timezone"]),
472
+ )
473
+ enabled = body.get("enabled", bool(row["enabled"]))
474
+ if not isinstance(enabled, bool):
475
+ raise InputValidationError("enabled must be true or false", field="enabled")
476
+ now = self._now()
477
+ next_run = next_occurrence(cadence, hour, minute, weekday, zone, now)
478
+ with self._store.transaction() as db:
479
+ changed = db.execute(
480
+ "UPDATE scan_schedules SET name = ?, cadence = ?, hour = ?, minute = ?, "
481
+ "weekday = ?, timezone = ?, enabled = ?, next_run_at = ?, revision = revision + 1, "
482
+ "updated_at = ?, updated_by_login = ? WHERE schedule_id = ? AND revision = ?",
483
+ (
484
+ name,
485
+ cadence,
486
+ hour,
487
+ minute,
488
+ weekday,
489
+ zone,
490
+ 1 if enabled else 0,
491
+ ts(next_run),
492
+ ts(now),
493
+ principal.login,
494
+ schedule_id,
495
+ expected,
496
+ ),
497
+ ).rowcount
498
+ if changed != 1:
499
+ raise ConflictError("The schedule was changed by someone else. Reload first.")
500
+ stored = self._store.insert_audit_event(
501
+ db,
502
+ self._audit.build(
503
+ AuditEventType.SCAN_SCHEDULE_DISABLED
504
+ if bool(row["enabled"]) and not enabled
505
+ else AuditEventType.SCAN_SCHEDULE_CHANGED,
506
+ actor=Actor.user(principal.user_id, principal.login),
507
+ account_id=account_id,
508
+ schedule=schedule_id,
509
+ name=str(name),
510
+ cadence=cadence,
511
+ time=f"{hour:02d}:{minute:02d} {zone}",
512
+ enabled=enabled,
513
+ ),
514
+ )
515
+ self._audit.log_stored(stored)
516
+ return self._view(self._row(schedule_id), principal)
517
+
518
+ def disable(self, principal: Principal, schedule_id: str) -> ScanScheduleView:
519
+ row = self._row(schedule_id)
520
+ return self.update(
521
+ principal,
522
+ schedule_id,
523
+ {"expected_revision": int(row["revision"]), "enabled": False},
524
+ )
525
+
526
+ # -- background ------------------------------------------------------- #
527
+ def run_due(self) -> dict[str, int]:
528
+ """Start due runs and process running ones within the per-tick budget."""
529
+ now = self._now()
530
+ started = 0
531
+ for row in self._store.query(
532
+ "SELECT * FROM scan_schedules WHERE enabled = 1 AND next_run_at <= ? "
533
+ "ORDER BY next_run_at LIMIT 50",
534
+ (ts(now),),
535
+ ):
536
+ slot = float(row["next_run_at"])
537
+ following = next_occurrence(
538
+ row["cadence"], row["hour"], row["minute"], row["weekday"], row["timezone"], now
539
+ )
540
+ with self._store.transaction() as db:
541
+ db.execute(
542
+ "UPDATE scan_schedules SET next_run_at = ? WHERE schedule_id = ? "
543
+ "AND next_run_at = ?",
544
+ (ts(following), row["schedule_id"], slot),
545
+ )
546
+ target = PolicyTarget(PolicyTargetType(row["target_type"]), str(row["target_id"]))
547
+ repositories = self._scope(db, int(row["account_id"]), target)
548
+ cursor = db.execute(
549
+ "INSERT OR IGNORE INTO scan_schedule_runs (run_id, schedule_id, account_id, "
550
+ "slot, state, started_at, repositories, detail) "
551
+ "VALUES (?, ?, ?, ?, 'running', ?, ?, '{}')",
552
+ (
553
+ new_id(),
554
+ row["schedule_id"],
555
+ row["account_id"],
556
+ slot,
557
+ ts(now),
558
+ len(repositories),
559
+ ),
560
+ )
561
+ started += cursor.rowcount
562
+ processed = self._process_runs()
563
+ return {"started": started, **processed}
564
+
565
+ def _process_runs(self) -> dict[str, int]:
566
+ totals = {"queued": 0, "skipped": 0, "failed": 0}
567
+ backlog = int(
568
+ self._store.query("SELECT COUNT(*) AS n FROM scan_jobs WHERE state = 'queued'")[0]["n"]
569
+ )
570
+ budget = JOBS_PER_TICK if backlog < MAX_BACKLOG else 0
571
+ if budget == 0:
572
+ log.info("scheduled_scans_deferred", backlog=backlog)
573
+ for run in self._store.query(
574
+ "SELECT r.*, s.target_type, s.target_id, s.name FROM scan_schedule_runs r JOIN "
575
+ "scan_schedules s ON s.schedule_id = r.schedule_id WHERE r.state = 'running' "
576
+ "ORDER BY r.started_at LIMIT 20"
577
+ ):
578
+ account_id = int(run["account_id"])
579
+ target = PolicyTarget(PolicyTargetType(run["target_type"]), str(run["target_id"]))
580
+ with self._store.transaction() as db:
581
+ repositories = self._scope(db, account_id, target)
582
+ position = int(run["cursor"])
583
+ detail: dict[str, int] = json.loads(str(run["detail"] or "{}"))
584
+ counts = {k: int(run[k]) for k in ("queued", "skipped", "failed")}
585
+ while position < len(repositories) and budget > 0:
586
+ repository_id = repositories[position]
587
+ position += 1
588
+ budget -= 1
589
+ try:
590
+ outcome = (
591
+ self._scanner.queue(
592
+ account_id,
593
+ repository_id,
594
+ key=(str(run["schedule_id"]), str(run["slot"])),
595
+ requested_by=f"schedule:{run['name']}"[:64],
596
+ schedule_id=str(run["schedule_id"]),
597
+ )
598
+ if self._scanner is not None
599
+ else "skipped:no_scanner"
600
+ )
601
+ except (CommitGuardError, OSError) as exc:
602
+ log.warning(
603
+ "scheduled_scan_failed",
604
+ repository_id=repository_id,
605
+ error_type=type(exc).__name__,
606
+ )
607
+ outcome = "failed"
608
+ if outcome == "queued":
609
+ counts["queued"] += 1
610
+ elif outcome == "failed":
611
+ counts["failed"] += 1
612
+ else:
613
+ counts["skipped"] += 1
614
+ reason = outcome.split(":", 1)[-1]
615
+ detail[reason] = detail.get(reason, 0) + 1
616
+ finished = position >= len(repositories)
617
+ state = "running"
618
+ if finished:
619
+ if counts["failed"] and not counts["queued"] and not counts["skipped"]:
620
+ state = "failed"
621
+ elif counts["failed"]:
622
+ state = "partial"
623
+ else:
624
+ state = "completed"
625
+ with self._store.transaction() as db:
626
+ db.execute(
627
+ "UPDATE scan_schedule_runs SET cursor = ?, queued = ?, skipped = ?, "
628
+ "failed = ?, "
629
+ "detail = ?, state = ?, completed_at = ? WHERE run_id = ?",
630
+ (
631
+ position,
632
+ counts["queued"],
633
+ counts["skipped"],
634
+ counts["failed"],
635
+ json.dumps(detail, sort_keys=True),
636
+ state,
637
+ ts(self._now()) if finished else None,
638
+ run["run_id"],
639
+ ),
640
+ )
641
+ if finished:
642
+ stored = self._store.insert_audit_event(
643
+ db,
644
+ self._audit.build(
645
+ AuditEventType.SCHEDULED_SCANS_QUEUED,
646
+ actor=SYSTEM_ACTOR,
647
+ account_id=account_id,
648
+ schedule=str(run["schedule_id"]),
649
+ run=str(run["run_id"]),
650
+ result=state,
651
+ repositories=len(repositories),
652
+ queued=counts["queued"],
653
+ skipped=counts["skipped"],
654
+ failed=counts["failed"],
655
+ ),
656
+ )
657
+ if finished:
658
+ self._audit.log_stored(stored)
659
+ for key in totals:
660
+ totals[key] += counts[key] - int(run[key])
661
+ if budget <= 0:
662
+ break
663
+ return totals