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,760 @@
1
+ """Staged policy rollout: pilot repositories first, then wider, with safety thresholds.
2
+
3
+ ::
4
+
5
+ publish v15 with a rollout
6
+ stage 1 (pilot) 10 repositories ─ enrolled: they resolve v15
7
+ stage 2 50 % ─ the rest still resolve v14
8
+ stage 3 100 % ─ state becomes "active"
9
+
10
+ A rollout only decides **which version applies to which repository** while it
11
+ is in progress; the version itself is an ordinary immutable version. Resolution
12
+ is in :meth:`commitguard.governance.resolver.GovernanceResolver._version_for`:
13
+ an enrolled repository gets the new version, every other repository keeps the
14
+ previous one. Enrolling a stage invalidates exactly the repositories it adds.
15
+
16
+ States: ``pilot`` -> ``rollout`` -> ``active`` (every repository enrolled);
17
+ ``paused`` at any point (manually or by a threshold), and ``rolled_back`` after
18
+ a rollback, which publishes a *new* version restoring the previous document
19
+ through the Phase 7 rollback path - history, audit and notifications are kept.
20
+
21
+ Safety thresholds (organization settings, or per rollout): once a stage has at
22
+ least ``min_scans`` completed scans of enrolled repositories, a share of scan
23
+ **errors** above ``max_error_rate`` or of **blocked** scans above
24
+ ``max_block_rate`` pauses the rollout (``auto_pause``, on by default) and
25
+ notifies. Automatic *rollback* is off unless explicitly configured.
26
+
27
+ Progress is reported honestly: a rollout is complete only when every repository
28
+ in its scope is enrolled **and** its effective policy has been resolved with
29
+ the new version; repositories still stale, syncing or in error are reported as
30
+ such, never as done.
31
+ """
32
+
33
+ import json
34
+ import sqlite3
35
+ from collections.abc import Callable, Sequence
36
+ from datetime import UTC, datetime
37
+ from typing import Any
38
+
39
+ from pydantic import BaseModel, ConfigDict
40
+
41
+ from commitguard.audit.models import SYSTEM_ACTOR, Actor, AuditEventType
42
+ from commitguard.controlplane.access import Permission, Principal
43
+ from commitguard.controlplane.errors import (
44
+ ConflictError,
45
+ InputValidationError,
46
+ NotFoundError,
47
+ PermissionDeniedError,
48
+ )
49
+ from commitguard.controlplane.policies import (
50
+ OrganizationPolicyService,
51
+ PolicyTarget,
52
+ PolicyTargetType,
53
+ PublishedPolicy,
54
+ target_label,
55
+ )
56
+ from commitguard.controlplane.views import PolicyTargetView
57
+ from commitguard.core.result import Severity
58
+ from commitguard.github.storage import SqliteStateStore
59
+ from commitguard.governance.cache import group_member_ids, invalidate_repositories
60
+ from commitguard.governance.common import (
61
+ MAX_REASON_CHARS,
62
+ account_repositories,
63
+ dt,
64
+ is_hex_id,
65
+ new_id,
66
+ req_dt,
67
+ require,
68
+ text,
69
+ ts,
70
+ )
71
+ from commitguard.governance.settings import load_settings
72
+ from commitguard.notifications.deduplication import domain_key
73
+ from commitguard.notifications.models import NotificationEvent, NotificationType
74
+ from commitguard.notifications.outbox import emit
75
+ from commitguard.observability.logging import get_logger
76
+ from commitguard.security.hashing import sha256_hex
77
+ from commitguard.services.audit import AuditService
78
+
79
+ log = get_logger(__name__)
80
+
81
+ MAX_STAGES = 10
82
+ IN_PROGRESS = ("pilot", "rollout", "paused")
83
+
84
+
85
+ class RolloutStageView(BaseModel):
86
+ model_config = ConfigDict(frozen=True, extra="forbid")
87
+
88
+ index: int
89
+ name: str
90
+ kind: str # repositories | percent
91
+ percent: int | None
92
+ repositories: int # planned size (explicit list) or enrolled so far
93
+ enrolled: int
94
+ state: str # done | current | planned
95
+
96
+
97
+ class RolloutView(BaseModel):
98
+ model_config = ConfigDict(frozen=True, extra="forbid")
99
+
100
+ id: str
101
+ organization_id: int
102
+ target: PolicyTargetView
103
+ from_version: int
104
+ to_version: int
105
+ state: str
106
+ stages: tuple[RolloutStageView, ...]
107
+ current_stage: int
108
+ scope_repositories: int
109
+ enrolled: int
110
+ propagated: int # enrolled and effective policy resolved with the new version
111
+ scanned: int
112
+ passed: int
113
+ blocked: int
114
+ errors: int
115
+ complete: bool
116
+ thresholds: dict[str, float]
117
+ auto_pause: bool
118
+ auto_rollback: bool
119
+ paused_reason: str | None
120
+ created_by: str | None
121
+ created_at: datetime
122
+ stage_started_at: datetime
123
+ completed_at: datetime | None
124
+ rolled_back_at: datetime | None
125
+ rollback_version: int | None
126
+ can_manage: bool
127
+
128
+
129
+ def parse_stages(raw: object) -> list[dict[str, Any]]:
130
+ """``[{"repositories": [...]} | {"percent": 25}, ...]`` -> validated stages."""
131
+ if not isinstance(raw, list) or not raw:
132
+ raise InputValidationError("stages must be a non-empty list", field="stages")
133
+ if len(raw) > MAX_STAGES:
134
+ raise InputValidationError(f"at most {MAX_STAGES} stages", field="stages")
135
+ stages: list[dict[str, Any]] = []
136
+ last_percent = 0
137
+ for index, item in enumerate(raw):
138
+ if not isinstance(item, dict):
139
+ raise InputValidationError("each stage is an object", field=f"stages.{index}")
140
+ name = item.get("name")
141
+ label = name if isinstance(name, str) and name.strip() else f"Stage {index + 1}"
142
+ if "repositories" in item:
143
+ ids = item["repositories"]
144
+ if not isinstance(ids, list) or not ids or len(ids) > 5000:
145
+ raise InputValidationError(
146
+ "stage repositories must be a list of repository IDs",
147
+ field=f"stages.{index}.repositories",
148
+ )
149
+ for value in ids:
150
+ if not isinstance(value, int) or isinstance(value, bool) or value < 1:
151
+ raise InputValidationError(
152
+ "stage repositories must be repository IDs",
153
+ field=f"stages.{index}.repositories",
154
+ )
155
+ stages.append(
156
+ {"name": label[:100], "kind": "repositories", "repositories": sorted(set(ids))}
157
+ )
158
+ elif "percent" in item:
159
+ percent = item["percent"]
160
+ if not isinstance(percent, int) or isinstance(percent, bool) or not 1 <= percent <= 100:
161
+ raise InputValidationError(
162
+ "stage percent must be between 1 and 100", field=f"stages.{index}.percent"
163
+ )
164
+ if percent < last_percent:
165
+ raise InputValidationError(
166
+ "stage percentages must increase", field=f"stages.{index}.percent"
167
+ )
168
+ last_percent = percent
169
+ stages.append({"name": label[:100], "kind": "percent", "percent": percent})
170
+ else:
171
+ raise InputValidationError(
172
+ "a stage names repositories or a percent", field=f"stages.{index}"
173
+ )
174
+ if stages[-1].get("kind") == "percent" and stages[-1].get("percent") != 100:
175
+ stages.append({"name": "All repositories", "kind": "percent", "percent": 100})
176
+ return stages
177
+
178
+
179
+ def _order_key(rollout_id: str, repository_id: int) -> str:
180
+ """Deterministic, stable order for percentage stages."""
181
+ return sha256_hex(f"{rollout_id}:{repository_id}".encode())
182
+
183
+
184
+ class PolicyRolloutService:
185
+ def __init__(
186
+ self,
187
+ store: SqliteStateStore,
188
+ audit: AuditService,
189
+ policies: OrganizationPolicyService,
190
+ *,
191
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
192
+ ) -> None:
193
+ self._store = store
194
+ self._audit = audit
195
+ self._policies = policies
196
+ self._now = now
197
+
198
+ # -- scope ------------------------------------------------------------ #
199
+ def _scope_repositories(
200
+ self, db: sqlite3.Connection, account_id: int, target: PolicyTarget
201
+ ) -> list[int]:
202
+ repositories = sorted(account_repositories(db, account_id))
203
+ if target.type is PolicyTargetType.GROUP:
204
+ members = set(group_member_ids(db, account_id, target.id))
205
+ return [r for r in repositories if r in members]
206
+ return repositories
207
+
208
+ # -- creation --------------------------------------------------------- #
209
+ def creator(
210
+ self,
211
+ principal: Principal,
212
+ *,
213
+ stages: list[dict[str, Any]],
214
+ thresholds: dict[str, float] | None,
215
+ auto_pause: bool | None,
216
+ auto_rollback: bool | None,
217
+ ) -> Callable[[sqlite3.Connection, PublishedPolicy], None]:
218
+ """A hook that starts a rollout inside the publishing transaction."""
219
+
220
+ def start(db: sqlite3.Connection, published: PublishedPolicy) -> None:
221
+ self.start_in(
222
+ db,
223
+ published,
224
+ actor=Actor.user(principal.user_id, principal.login),
225
+ stages=stages,
226
+ thresholds=thresholds,
227
+ auto_pause=auto_pause,
228
+ auto_rollback=auto_rollback,
229
+ )
230
+
231
+ return start
232
+
233
+ def start_in(
234
+ self,
235
+ db: sqlite3.Connection,
236
+ published: PublishedPolicy,
237
+ *,
238
+ actor: Actor,
239
+ stages: list[dict[str, Any]],
240
+ thresholds: dict[str, float] | None,
241
+ auto_pause: bool | None,
242
+ auto_rollback: bool | None,
243
+ ) -> str:
244
+ account_id = published.account_id
245
+ target = published.target
246
+ if target.type is PolicyTargetType.REPOSITORY:
247
+ raise InputValidationError(
248
+ "A repository policy applies to one repository; it has no staged rollout.",
249
+ field="rollout",
250
+ )
251
+ settings = load_settings(db, account_id).settings
252
+ limits = {
253
+ "max_error_rate": settings.rollout_max_error_rate,
254
+ "max_block_rate": settings.rollout_max_block_rate,
255
+ "min_scans": float(settings.rollout_min_scans),
256
+ **(thresholds or {}),
257
+ }
258
+ rollout_id = new_id()
259
+ now = published.now
260
+ db.execute(
261
+ "INSERT INTO policy_rollouts (rollout_id, account_id, target_type, target_id, "
262
+ "from_version, to_version, state, stages, current_stage, thresholds, auto_pause, "
263
+ "auto_rollback, created_at, created_by_id, created_by_login, updated_at, "
264
+ "stage_started_at) VALUES (?, ?, ?, ?, ?, ?, 'pilot', ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)",
265
+ (
266
+ rollout_id,
267
+ account_id,
268
+ target.type.value,
269
+ target.id,
270
+ published.previous_version,
271
+ published.version,
272
+ json.dumps(stages),
273
+ json.dumps(limits),
274
+ 1 if (settings.rollout_auto_pause if auto_pause is None else auto_pause) else 0,
275
+ 1
276
+ if (settings.rollout_auto_rollback if auto_rollback is None else auto_rollback)
277
+ else 0,
278
+ ts(now),
279
+ actor.id,
280
+ actor.login,
281
+ ts(now),
282
+ ts(now),
283
+ ),
284
+ )
285
+ enrolled = self._enroll_stage(db, rollout_id, account_id, target, stages, 0, now)
286
+ self._store.insert_audit_event(
287
+ db,
288
+ self._audit.build(
289
+ AuditEventType.POLICY_ROLLOUT_STARTED,
290
+ actor=actor,
291
+ account_id=account_id,
292
+ rollout=rollout_id,
293
+ target_type=target.type.value,
294
+ target_id=target.id or None,
295
+ from_version=published.previous_version,
296
+ to_version=published.version,
297
+ stages=len(stages),
298
+ pilot_repositories=len(enrolled),
299
+ ),
300
+ )
301
+ return rollout_id
302
+
303
+ def _enroll_stage(
304
+ self,
305
+ db: sqlite3.Connection,
306
+ rollout_id: str,
307
+ account_id: int,
308
+ target: PolicyTarget,
309
+ stages: Sequence[dict[str, Any]],
310
+ index: int,
311
+ now: datetime,
312
+ ) -> list[int]:
313
+ scope = self._scope_repositories(db, account_id, target)
314
+ already = {
315
+ int(r["repository_id"])
316
+ for r in db.execute(
317
+ "SELECT repository_id FROM policy_rollout_repositories WHERE rollout_id = ?",
318
+ (rollout_id,),
319
+ ).fetchall()
320
+ }
321
+ stage = stages[index]
322
+ if stage["kind"] == "repositories":
323
+ wanted = [r for r in stage["repositories"] if r in set(scope) and r not in already]
324
+ else:
325
+ # Cumulative: after this stage, ``percent`` of the scope is enrolled (earlier
326
+ # explicit pilot repositories count towards it).
327
+ share = int(stage["percent"])
328
+ ordered = sorted(scope, key=lambda r: _order_key(rollout_id, r))
329
+ target_count = max(1, (len(ordered) * share + 99) // 100)
330
+ enrolled_in_scope = len(already & set(scope))
331
+ remaining = [r for r in ordered if r not in already]
332
+ wanted = remaining[: max(0, target_count - enrolled_in_scope)]
333
+ for repository_id in wanted:
334
+ db.execute(
335
+ "INSERT OR IGNORE INTO policy_rollout_repositories (rollout_id, repository_id, "
336
+ "stage, enrolled_at) VALUES (?, ?, ?, ?)",
337
+ (rollout_id, repository_id, index, ts(now)),
338
+ )
339
+ invalidate_repositories(db, account_id, wanted, now)
340
+ return wanted
341
+
342
+ # -- reads ------------------------------------------------------------ #
343
+ def _row(self, rollout_id: str) -> sqlite3.Row:
344
+ if not is_hex_id(rollout_id):
345
+ raise NotFoundError()
346
+ rows = self._store.query(
347
+ "SELECT * FROM policy_rollouts WHERE rollout_id = ?", (rollout_id,)
348
+ )
349
+ if not rows:
350
+ raise NotFoundError()
351
+ return rows[0]
352
+
353
+ def list_rollouts(
354
+ self, principal: Principal, account_id: int, *, active_only: bool = False
355
+ ) -> list[RolloutView]:
356
+ require(principal, Permission.POLICIES_READ, account_id)
357
+ sql = "SELECT * FROM policy_rollouts WHERE account_id = ?"
358
+ if active_only:
359
+ sql += " AND state IN ('pilot', 'rollout', 'paused')"
360
+ rows = self._store.query(sql + " ORDER BY created_at DESC LIMIT 100", (account_id,))
361
+ return [self._view(row, principal) for row in rows]
362
+
363
+ def get(self, principal: Principal, rollout_id: str) -> RolloutView:
364
+ row = self._row(rollout_id)
365
+ require(principal, Permission.POLICIES_READ, int(row["account_id"]))
366
+ return self._view(row, principal)
367
+
368
+ def _view(self, row: sqlite3.Row, principal: Principal) -> RolloutView:
369
+ account_id = int(row["account_id"])
370
+ rollout_id = str(row["rollout_id"])
371
+ target = PolicyTarget(PolicyTargetType(row["target_type"]), str(row["target_id"]))
372
+ stages = json.loads(str(row["stages"]))
373
+ with self._store.transaction() as db:
374
+ label = target_label(db, account_id, target) or "(removed)"
375
+ scope = self._scope_repositories(db, account_id, target)
376
+ enrolled_rows = self._store.query(
377
+ "SELECT repository_id, stage FROM policy_rollout_repositories WHERE rollout_id = ?",
378
+ (rollout_id,),
379
+ )
380
+ enrolled = [int(r["repository_id"]) for r in enrolled_rows]
381
+ by_stage: dict[int, int] = {}
382
+ for r in enrolled_rows:
383
+ by_stage[int(r["stage"])] = by_stage.get(int(r["stage"]), 0) + 1
384
+ state = str(row["state"])
385
+ current_stage = int(row["current_stage"])
386
+ stage_views = []
387
+ for index, stage in enumerate(stages):
388
+ stage_views.append(
389
+ RolloutStageView(
390
+ index=index,
391
+ name=str(stage.get("name", f"Stage {index + 1}")),
392
+ kind=str(stage["kind"]),
393
+ percent=stage.get("percent"),
394
+ repositories=len(stage.get("repositories", []))
395
+ if stage["kind"] == "repositories"
396
+ else 0,
397
+ enrolled=by_stage.get(index, 0),
398
+ state="done"
399
+ if index < current_stage or state == "active"
400
+ else ("current" if index == current_stage else "planned"),
401
+ )
402
+ )
403
+ stats = self._stage_statistics(rollout_id, int(row["to_version"]))
404
+ propagated = self._propagated(account_id, enrolled, int(row["to_version"]))
405
+ complete = state == "active" and len(enrolled) >= len(scope) and propagated >= len(scope)
406
+ return RolloutView(
407
+ id=rollout_id,
408
+ organization_id=account_id,
409
+ target=PolicyTargetView(type=target.type.value, id=target.id, label=label),
410
+ from_version=int(row["from_version"]),
411
+ to_version=int(row["to_version"]),
412
+ state=state,
413
+ stages=tuple(stage_views),
414
+ current_stage=current_stage,
415
+ scope_repositories=len(scope),
416
+ enrolled=len(enrolled),
417
+ propagated=propagated,
418
+ scanned=stats["scanned"],
419
+ passed=stats["passed"],
420
+ blocked=stats["blocked"],
421
+ errors=stats["errors"],
422
+ complete=complete,
423
+ thresholds=json.loads(str(row["thresholds"])),
424
+ auto_pause=bool(row["auto_pause"]),
425
+ auto_rollback=bool(row["auto_rollback"]),
426
+ paused_reason=row["paused_reason"],
427
+ created_by=row["created_by_login"],
428
+ created_at=req_dt(row["created_at"]),
429
+ stage_started_at=req_dt(row["stage_started_at"]),
430
+ completed_at=dt(row["completed_at"]),
431
+ rolled_back_at=dt(row["rolled_back_at"]),
432
+ rollback_version=row["rollback_version"],
433
+ can_manage=principal.can(Permission.POLICIES_PUBLISH, account_id),
434
+ )
435
+
436
+ def _propagated(self, account_id: int, enrolled: Sequence[int], version: int) -> int:
437
+ if not enrolled:
438
+ return 0
439
+ rows = self._store.query(
440
+ "SELECT document FROM repository_effective_policies WHERE account_id = ? "
441
+ "AND state = 'up_to_date' AND repository_id IN (SELECT value FROM json_each(?))",
442
+ (account_id, json.dumps(list(enrolled))),
443
+ )
444
+ count = 0
445
+ for row in rows:
446
+ try:
447
+ document = json.loads(str(row["document"]))
448
+ except (ValueError, TypeError):
449
+ continue
450
+ versions = document.get("versions", {})
451
+ if (
452
+ versions.get("organization_policy") == version
453
+ or version in (versions.get("groups") or {}).values()
454
+ ):
455
+ count += 1
456
+ return count
457
+
458
+ def _stage_statistics(self, rollout_id: str, version: int) -> dict[str, int]:
459
+ rows = self._store.query(
460
+ "SELECT j.state, COUNT(*) AS n FROM scan_jobs j JOIN policy_rollout_repositories e "
461
+ "ON e.repository_id = j.repository_id WHERE e.rollout_id = ? "
462
+ "AND j.completed_at >= e.enrolled_at AND j.organization_policy_version = ? "
463
+ "AND j.state IN ('passed', 'failed', 'error') GROUP BY j.state",
464
+ (rollout_id, version),
465
+ )
466
+ counts = {str(r["state"]): int(r["n"]) for r in rows}
467
+ return {
468
+ "scanned": sum(counts.values()),
469
+ "passed": counts.get("passed", 0),
470
+ "blocked": counts.get("failed", 0),
471
+ "errors": counts.get("error", 0),
472
+ }
473
+
474
+ # -- operations ------------------------------------------------------- #
475
+ def _manageable(self, principal: Principal, rollout_id: str) -> sqlite3.Row:
476
+ row = self._row(rollout_id)
477
+ account_id = int(row["account_id"])
478
+ require(principal, Permission.POLICIES_READ, account_id)
479
+ if not principal.can(Permission.POLICIES_PUBLISH, account_id):
480
+ raise PermissionDeniedError()
481
+ return row
482
+
483
+ def advance(self, principal: Principal, rollout_id: str) -> RolloutView:
484
+ row = self._manageable(principal, rollout_id)
485
+ state = str(row["state"])
486
+ if state not in ("pilot", "rollout"):
487
+ raise ConflictError(f"A {state} rollout cannot be expanded.")
488
+ account_id = int(row["account_id"])
489
+ target = PolicyTarget(PolicyTargetType(row["target_type"]), str(row["target_id"]))
490
+ stages = json.loads(str(row["stages"]))
491
+ index = int(row["current_stage"]) + 1
492
+ now = self._now()
493
+ actor = Actor.user(principal.user_id, principal.login)
494
+ with self._store.transaction() as db:
495
+ if index < len(stages):
496
+ enrolled = self._enroll_stage(
497
+ db, rollout_id, account_id, target, stages, index, now
498
+ )
499
+ remaining = set(self._scope_repositories(db, account_id, target)) - {
500
+ int(r["repository_id"])
501
+ for r in db.execute(
502
+ "SELECT repository_id FROM policy_rollout_repositories "
503
+ "WHERE rollout_id = ?",
504
+ (rollout_id,),
505
+ ).fetchall()
506
+ }
507
+ finished = index == len(stages) - 1 and not remaining
508
+ else:
509
+ enrolled = []
510
+ finished = True
511
+ if finished:
512
+ enrolled += self._enroll_remaining(db, rollout_id, account_id, target, index, now)
513
+ db.execute(
514
+ "UPDATE policy_rollouts SET current_stage = ?, state = ?, stage_started_at = ?, "
515
+ "updated_at = ?, completed_at = ? WHERE rollout_id = ?",
516
+ (
517
+ min(index, len(stages) - 1),
518
+ "active" if finished else "rollout",
519
+ ts(now),
520
+ ts(now),
521
+ ts(now) if finished else None,
522
+ rollout_id,
523
+ ),
524
+ )
525
+ stored = self._store.insert_audit_event(
526
+ db,
527
+ self._audit.build(
528
+ AuditEventType.POLICY_ROLLOUT_COMPLETED
529
+ if finished
530
+ else AuditEventType.POLICY_ROLLOUT_ADVANCED,
531
+ actor=actor,
532
+ account_id=account_id,
533
+ rollout=rollout_id,
534
+ stage=index,
535
+ repositories=len(enrolled),
536
+ to_version=int(row["to_version"]),
537
+ ),
538
+ )
539
+ self._audit.log_stored(stored)
540
+ return self.get(principal, rollout_id)
541
+
542
+ def _enroll_remaining(
543
+ self,
544
+ db: sqlite3.Connection,
545
+ rollout_id: str,
546
+ account_id: int,
547
+ target: PolicyTarget,
548
+ stage: int,
549
+ now: datetime,
550
+ ) -> list[int]:
551
+ scope = self._scope_repositories(db, account_id, target)
552
+ already = {
553
+ int(r["repository_id"])
554
+ for r in db.execute(
555
+ "SELECT repository_id FROM policy_rollout_repositories WHERE rollout_id = ?",
556
+ (rollout_id,),
557
+ ).fetchall()
558
+ }
559
+ remaining = [r for r in scope if r not in already]
560
+ for repository_id in remaining:
561
+ db.execute(
562
+ "INSERT OR IGNORE INTO policy_rollout_repositories (rollout_id, repository_id, "
563
+ "stage, enrolled_at) VALUES (?, ?, ?, ?)",
564
+ (rollout_id, repository_id, stage, ts(now)),
565
+ )
566
+ invalidate_repositories(db, account_id, remaining, now)
567
+ return remaining
568
+
569
+ def pause(self, principal: Principal, rollout_id: str, reason: object) -> RolloutView:
570
+ row = self._manageable(principal, rollout_id)
571
+ if str(row["state"]) not in ("pilot", "rollout"):
572
+ raise ConflictError(f"A {row['state']} rollout cannot be paused.")
573
+ note = text(reason, "reason", limit=MAX_REASON_CHARS, required=True)
574
+ self._pause_row(
575
+ row, reason=note or "paused", actor=Actor.user(principal.user_id, principal.login)
576
+ )
577
+ return self.get(principal, rollout_id)
578
+
579
+ def _pause_row(self, row: sqlite3.Row, *, reason: str, actor: Actor) -> None:
580
+ now = self._now()
581
+ rollout_id = str(row["rollout_id"])
582
+ account_id = int(row["account_id"])
583
+ with self._store.transaction() as db:
584
+ changed = db.execute(
585
+ "UPDATE policy_rollouts SET state = 'paused', paused_at = ?, paused_reason = ?, "
586
+ "paused_from = state, updated_at = ? WHERE rollout_id = ? "
587
+ "AND state IN ('pilot', 'rollout')",
588
+ (ts(now), reason, ts(now), rollout_id),
589
+ ).rowcount
590
+ if changed != 1:
591
+ raise ConflictError("The rollout was changed by someone else.")
592
+ stored = self._store.insert_audit_event(
593
+ db,
594
+ self._audit.build(
595
+ AuditEventType.POLICY_ROLLOUT_PAUSED,
596
+ actor=actor,
597
+ account_id=account_id,
598
+ rollout=rollout_id,
599
+ to_version=int(row["to_version"]),
600
+ reason=reason,
601
+ ),
602
+ )
603
+ emit(
604
+ db,
605
+ NotificationEvent(
606
+ type=NotificationType.POLICY_ROLLOUT_FAILED,
607
+ account_id=account_id,
608
+ severity=Severity.HIGH,
609
+ resource_type="rollout",
610
+ resource_id=rollout_id,
611
+ dedup_key=domain_key(
612
+ NotificationType.POLICY_ROLLOUT_FAILED, account_id, rollout_id
613
+ ),
614
+ title=f"Policy rollout paused: v{row['to_version']}",
615
+ body=(
616
+ f"The staged rollout of policy v{row['to_version']} was paused: {reason}. "
617
+ "Enrolled repositories keep the new version; the rest keep "
618
+ f"v{row['from_version']}. Resume or roll back from the dashboard."
619
+ ),
620
+ metadata={"rollout": rollout_id, "reason": reason},
621
+ ),
622
+ now,
623
+ )
624
+ self._audit.log_stored(stored)
625
+
626
+ def resume(self, principal: Principal, rollout_id: str) -> RolloutView:
627
+ row = self._manageable(principal, rollout_id)
628
+ if str(row["state"]) != "paused":
629
+ raise ConflictError(f"The rollout is {row['state']}, not paused.")
630
+ now = self._now()
631
+ with self._store.transaction() as db:
632
+ db.execute(
633
+ "UPDATE policy_rollouts SET state = COALESCE(paused_from, 'rollout'), "
634
+ "paused_at = NULL, paused_reason = NULL, paused_from = NULL, updated_at = ? "
635
+ "WHERE rollout_id = ? AND state = 'paused'",
636
+ (ts(now), rollout_id),
637
+ )
638
+ stored = self._store.insert_audit_event(
639
+ db,
640
+ self._audit.build(
641
+ AuditEventType.POLICY_ROLLOUT_RESUMED,
642
+ actor=Actor.user(principal.user_id, principal.login),
643
+ account_id=int(row["account_id"]),
644
+ rollout=rollout_id,
645
+ ),
646
+ )
647
+ self._audit.log_stored(stored)
648
+ return self.get(principal, rollout_id)
649
+
650
+ def rollback(
651
+ self, principal: Principal, rollout_id: str, *, reason: object, confirm: object
652
+ ) -> RolloutView:
653
+ row = self._row(rollout_id)
654
+ account_id = int(row["account_id"])
655
+ require(principal, Permission.POLICIES_ROLLBACK, account_id)
656
+ if str(row["state"]) not in IN_PROGRESS:
657
+ raise ConflictError(f"A {row['state']} rollout cannot be rolled back.")
658
+ self._rollback_row(
659
+ row,
660
+ actor=Actor.user(principal.user_id, principal.login),
661
+ authenticated_at=principal.authenticated_at,
662
+ reason=text(reason, "reason", limit=MAX_REASON_CHARS, required=True) or "",
663
+ confirm=confirm is True,
664
+ )
665
+ return self.get(principal, rollout_id)
666
+
667
+ def _rollback_row(
668
+ self,
669
+ row: sqlite3.Row,
670
+ *,
671
+ actor: Actor,
672
+ authenticated_at: datetime,
673
+ reason: str,
674
+ confirm: bool,
675
+ ) -> None:
676
+ account_id = int(row["account_id"])
677
+ rollout_id = str(row["rollout_id"])
678
+ target = PolicyTarget(PolicyTargetType(row["target_type"]), str(row["target_id"]))
679
+ from_version = int(row["from_version"])
680
+ if from_version < 1:
681
+ raise ConflictError(
682
+ "This was the first policy version; there is nothing to roll back to. "
683
+ "Publish a new version instead."
684
+ )
685
+ current = self._policies.current(account_id, target)
686
+
687
+ def finish(db: sqlite3.Connection, published: PublishedPolicy) -> None:
688
+ db.execute(
689
+ "UPDATE policy_rollouts SET state = 'rolled_back', rolled_back_at = ?, "
690
+ "rollback_version = ?, updated_at = ? WHERE rollout_id = ?",
691
+ (ts(published.now), published.version, ts(published.now), rollout_id),
692
+ )
693
+ # Every repository of the scope resolves the restored version again.
694
+ invalidate_repositories(
695
+ db, account_id, self._scope_repositories(db, account_id, target), published.now
696
+ )
697
+ self._store.insert_audit_event(
698
+ db,
699
+ self._audit.build(
700
+ AuditEventType.POLICY_ROLLOUT_ROLLED_BACK,
701
+ actor=actor,
702
+ account_id=account_id,
703
+ rollout=rollout_id,
704
+ to_version=int(row["to_version"]),
705
+ restored_version=from_version,
706
+ new_version=published.version,
707
+ reason=reason,
708
+ ),
709
+ )
710
+
711
+ self._policies.rollback(
712
+ account_id=account_id,
713
+ actor=actor,
714
+ authenticated_at=authenticated_at,
715
+ target_version=from_version,
716
+ expected_current_version=current.version,
717
+ reason=reason,
718
+ confirm=confirm,
719
+ target=target,
720
+ hooks=[finish],
721
+ )
722
+
723
+ # -- automatic safety -------------------------------------------------- #
724
+ def evaluate(self) -> int:
725
+ """Pause (or roll back) rollouts whose thresholds are exceeded. Returns actions taken."""
726
+ rows = self._store.query(
727
+ "SELECT * FROM policy_rollouts WHERE state IN ('pilot', 'rollout') AND auto_pause = 1"
728
+ )
729
+ acted = 0
730
+ for row in rows:
731
+ thresholds = json.loads(str(row["thresholds"]))
732
+ stats = self._stage_statistics(str(row["rollout_id"]), int(row["to_version"]))
733
+ scanned = stats["scanned"]
734
+ if scanned < int(thresholds.get("min_scans", 5)):
735
+ continue
736
+ error_rate = stats["errors"] / scanned
737
+ block_rate = stats["blocked"] / scanned
738
+ breach = None
739
+ if error_rate > float(thresholds.get("max_error_rate", 0.2)):
740
+ breach = f"{stats['errors']} of {scanned} scans could not be completed"
741
+ elif block_rate > float(thresholds.get("max_block_rate", 0.5)):
742
+ breach = f"{stats['blocked']} of {scanned} scans were blocked"
743
+ if breach is None:
744
+ continue
745
+ try:
746
+ self._pause_row(
747
+ row, reason=f"safety threshold exceeded: {breach}", actor=SYSTEM_ACTOR
748
+ )
749
+ acted += 1
750
+ if row["auto_rollback"]:
751
+ self._rollback_row(
752
+ self._row(str(row["rollout_id"])),
753
+ actor=SYSTEM_ACTOR,
754
+ authenticated_at=self._now(),
755
+ reason=f"automatic rollback: {breach}",
756
+ confirm=True,
757
+ )
758
+ except ConflictError:
759
+ continue
760
+ return acted