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.
- commitguard/__init__.py +26 -0
- commitguard/__main__.py +6 -0
- commitguard/api/__init__.py +18 -0
- commitguard/api/app.py +1376 -0
- commitguard/api/governance.py +1085 -0
- commitguard/api/hosting.py +196 -0
- commitguard/api/http.py +252 -0
- commitguard/api/settings.py +169 -0
- commitguard/audit/__init__.py +13 -0
- commitguard/audit/logger.py +34 -0
- commitguard/audit/models.py +222 -0
- commitguard/audit/storage.py +59 -0
- commitguard/ci/__init__.py +7 -0
- commitguard/ci/context.py +60 -0
- commitguard/cli/__init__.py +6 -0
- commitguard/cli/app.py +74 -0
- commitguard/cli/commands/__init__.py +1 -0
- commitguard/cli/commands/benchmark.py +441 -0
- commitguard/cli/commands/check.py +100 -0
- commitguard/cli/commands/ci.py +165 -0
- commitguard/cli/commands/dashboard.py +141 -0
- commitguard/cli/commands/doctor.py +533 -0
- commitguard/cli/commands/github.py +449 -0
- commitguard/cli/commands/hook.py +156 -0
- commitguard/cli/commands/init.py +137 -0
- commitguard/cli/commands/install.py +152 -0
- commitguard/cli/commands/policy.py +36 -0
- commitguard/cli/commands/report.py +39 -0
- commitguard/cli/commands/reproduce.py +123 -0
- commitguard/cli/commands/scan.py +47 -0
- commitguard/cli/common.py +44 -0
- commitguard/cli/output.py +89 -0
- commitguard/cli/render.py +367 -0
- commitguard/config/__init__.py +6 -0
- commitguard/config/defaults.py +53 -0
- commitguard/config/enforcement.py +53 -0
- commitguard/config/loader.py +174 -0
- commitguard/config/schema.py +105 -0
- commitguard/config/sources.py +183 -0
- commitguard/controlplane/__init__.py +24 -0
- commitguard/controlplane/access.py +231 -0
- commitguard/controlplane/commands.py +393 -0
- commitguard/controlplane/errors.py +88 -0
- commitguard/controlplane/identity.py +478 -0
- commitguard/controlplane/members.py +219 -0
- commitguard/controlplane/notifications.py +787 -0
- commitguard/controlplane/pagination.py +146 -0
- commitguard/controlplane/policies.py +1204 -0
- commitguard/controlplane/queries.py +1814 -0
- commitguard/controlplane/results.py +909 -0
- commitguard/controlplane/rules.py +184 -0
- commitguard/controlplane/views.py +799 -0
- commitguard/core/__init__.py +6 -0
- commitguard/core/context.py +31 -0
- commitguard/core/decision.py +58 -0
- commitguard/core/engine.py +82 -0
- commitguard/core/result.py +177 -0
- commitguard/detectors/__init__.py +6 -0
- commitguard/detectors/base.py +58 -0
- commitguard/detectors/bot.py +87 -0
- commitguard/detectors/coauthor.py +86 -0
- commitguard/detectors/identity.py +76 -0
- commitguard/detectors/registry.py +72 -0
- commitguard/detectors/trailer.py +211 -0
- commitguard/exceptions/__init__.py +33 -0
- commitguard/exceptions/base.py +9 -0
- commitguard/exceptions/configuration.py +22 -0
- commitguard/exceptions/detection.py +11 -0
- commitguard/exceptions/git.py +41 -0
- commitguard/exceptions/service.py +25 -0
- commitguard/git/__init__.py +12 -0
- commitguard/git/commands.py +101 -0
- commitguard/git/commit.py +97 -0
- commitguard/git/diff.py +36 -0
- commitguard/git/hooks.py +527 -0
- commitguard/git/push.py +93 -0
- commitguard/git/ranges.py +71 -0
- commitguard/git/repository.py +447 -0
- commitguard/github/__init__.py +34 -0
- commitguard/github/actions.py +163 -0
- commitguard/github/app.py +935 -0
- commitguard/github/auth.py +217 -0
- commitguard/github/check_runs.py +172 -0
- commitguard/github/checks.py +210 -0
- commitguard/github/client.py +844 -0
- commitguard/github/enforcement_status.py +209 -0
- commitguard/github/errors.py +129 -0
- commitguard/github/events.py +563 -0
- commitguard/github/identifiers.py +90 -0
- commitguard/github/installations.py +566 -0
- commitguard/github/markdown.py +19 -0
- commitguard/github/permissions.py +70 -0
- commitguard/github/pull_requests.py +53 -0
- commitguard/github/queue.py +47 -0
- commitguard/github/recovery.py +124 -0
- commitguard/github/repositories.py +305 -0
- commitguard/github/server.py +52 -0
- commitguard/github/settings.py +174 -0
- commitguard/github/storage.py +2315 -0
- commitguard/github/webhooks.py +129 -0
- commitguard/github/worker.py +628 -0
- commitguard/github/workflow.py +286 -0
- commitguard/governance/__init__.py +26 -0
- commitguard/governance/bulk.py +765 -0
- commitguard/governance/cache.py +88 -0
- commitguard/governance/common.py +216 -0
- commitguard/governance/exceptions.py +861 -0
- commitguard/governance/groups.py +448 -0
- commitguard/governance/inventory.py +386 -0
- commitguard/governance/posture.py +1272 -0
- commitguard/governance/resolver.py +632 -0
- commitguard/governance/rollouts.py +760 -0
- commitguard/governance/rules.py +371 -0
- commitguard/governance/schedules.py +663 -0
- commitguard/governance/service.py +120 -0
- commitguard/governance/settings.py +365 -0
- commitguard/governance/simulation.py +618 -0
- commitguard/governance/workflow.py +734 -0
- commitguard/notifications/__init__.py +2 -0
- commitguard/notifications/channels/__init__.py +1 -0
- commitguard/notifications/channels/base.py +22 -0
- commitguard/notifications/channels/email.py +110 -0
- commitguard/notifications/channels/in_app.py +74 -0
- commitguard/notifications/channels/sink.py +58 -0
- commitguard/notifications/channels/webhook.py +233 -0
- commitguard/notifications/deduplication.py +57 -0
- commitguard/notifications/dispatcher.py +201 -0
- commitguard/notifications/models.py +439 -0
- commitguard/notifications/outbox.py +106 -0
- commitguard/notifications/preferences.py +224 -0
- commitguard/notifications/retry.py +282 -0
- commitguard/notifications/service.py +128 -0
- commitguard/notifications/settings.py +167 -0
- commitguard/notifications/templates.py +108 -0
- commitguard/observability/__init__.py +5 -0
- commitguard/observability/logging.py +161 -0
- commitguard/observability/metrics.py +105 -0
- commitguard/policies/__init__.py +6 -0
- commitguard/policies/defaults.py +48 -0
- commitguard/policies/evaluator.py +66 -0
- commitguard/policies/governance.py +498 -0
- commitguard/policies/loader.py +23 -0
- commitguard/policies/mandatory.py +52 -0
- commitguard/policies/model.py +46 -0
- commitguard/provenance/__init__.py +9 -0
- commitguard/provenance/author.py +146 -0
- commitguard/provenance/committer.py +16 -0
- commitguard/provenance/normalization.py +158 -0
- commitguard/provenance/signatures.py +34 -0
- commitguard/provenance/trailers.py +256 -0
- commitguard/research/__init__.py +26 -0
- commitguard/research/compare.py +231 -0
- commitguard/research/datasets.py +1484 -0
- commitguard/research/detection.py +183 -0
- commitguard/research/environment.py +185 -0
- commitguard/research/gitenv.py +108 -0
- commitguard/research/hooks.py +247 -0
- commitguard/research/metrics.py +85 -0
- commitguard/research/performance.py +194 -0
- commitguard/research/platform.py +288 -0
- commitguard/research/report.py +372 -0
- commitguard/research/repository.py +111 -0
- commitguard/research/reproduction.py +297 -0
- commitguard/research/results.py +94 -0
- commitguard/rules/__init__.py +11 -0
- commitguard/rules/data/ai-domains.yaml +51 -0
- commitguard/rules/data/ai-identities.yaml +131 -0
- commitguard/rules/data/bot-identities.yaml +53 -0
- commitguard/rules/data/patterns.yaml +52 -0
- commitguard/rules/loader.py +102 -0
- commitguard/rules/matcher.py +212 -0
- commitguard/rules/models.py +269 -0
- commitguard/security/__init__.py +5 -0
- commitguard/security/hashing.py +30 -0
- commitguard/security/rate_limit.py +33 -0
- commitguard/security/safe_yaml.py +69 -0
- commitguard/security/sanitization.py +85 -0
- commitguard/security/secrets.py +169 -0
- commitguard/security/validation.py +89 -0
- commitguard/services/__init__.py +15 -0
- commitguard/services/analysis.py +119 -0
- commitguard/services/audit.py +95 -0
- commitguard/services/ci.py +383 -0
- commitguard/services/enforcement.py +102 -0
- commitguard/services/hooks.py +254 -0
- commitguard/services/remediation.py +99 -0
- commitguard/services/reports.py +146 -0
- commitguard/services/scan.py +172 -0
- commitguard/utils/__init__.py +1 -0
- commitguard/utils/filesystem.py +72 -0
- commitguard/utils/platform.py +35 -0
- commitguard/utils/subprocess.py +84 -0
- commitguardian-0.1.0.dist-info/METADATA +694 -0
- commitguardian-0.1.0.dist-info/RECORD +197 -0
- commitguardian-0.1.0.dist-info/WHEEL +4 -0
- commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|