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,765 @@
|
|
|
1
|
+
"""Bulk repository operations: authorized, audited, bounded, idempotent - and never inline.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
Admin ─ POST bulk operation ─► validate (permission, repositories visible, limits)
|
|
6
|
+
│ one row + one item per repository (202, "queued")
|
|
7
|
+
▼
|
|
8
|
+
maintenance loop ─ claim (lease) ─► next batch of pending items
|
|
9
|
+
│ BATCH_SIZE items per transaction; a failing
|
|
10
|
+
│ batch is retried item by item
|
|
11
|
+
▼
|
|
12
|
+
completed / failed (with a reason) / skipped
|
|
13
|
+
│
|
|
14
|
+
▼
|
|
15
|
+
status: queued -> running -> completed | partial | failed | cancelled
|
|
16
|
+
|
|
17
|
+
Operations
|
|
18
|
+
==========
|
|
19
|
+
|
|
20
|
+
===================== =================================== ==========================
|
|
21
|
+
Type Parameters Permission
|
|
22
|
+
===================== =================================== ==========================
|
|
23
|
+
``add_to_group`` ``group_id`` ``repositories:manage``
|
|
24
|
+
``remove_from_group`` ``group_id`` ``repositories:manage``
|
|
25
|
+
``onboard`` ``mode`` (``monitor``/``enforce``) ``repositories:manage``
|
|
26
|
+
``set_mode`` ``mode``, ``reason`` for monitor ``repositories:manage``
|
|
27
|
+
``set_monitoring`` ``enabled`` ``repositories:manage``
|
|
28
|
+
``schedule_scan`` - ``scans:trigger``
|
|
29
|
+
===================== =================================== ==========================
|
|
30
|
+
|
|
31
|
+
"Apply a policy" to many repositories is done by adding them to a repository
|
|
32
|
+
group that has the policy: the group's policy then applies through normal
|
|
33
|
+
resolution, with its versioning, approval and rollback.
|
|
34
|
+
|
|
35
|
+
Safety:
|
|
36
|
+
|
|
37
|
+
* at most :data:`MAX_ITEMS` repositories per operation and
|
|
38
|
+
:data:`MAX_OPEN_OPERATIONS` open operations per organization; the request
|
|
39
|
+
only validates and stores the work;
|
|
40
|
+
* the request's ``idempotency_key`` (or a hash of the operation) is unique per
|
|
41
|
+
organization: submitting the same operation twice returns the first one;
|
|
42
|
+
* every item operation is idempotent (adding a member twice is one member), so
|
|
43
|
+
a crash mid-batch is retried safely after the lease expires;
|
|
44
|
+
* changing modes to ``enforce`` or ``monitor`` needs ``confirm`` at request time,
|
|
45
|
+
like the single-repository action;
|
|
46
|
+
* failed items can be retried; an operation can be cancelled while items are
|
|
47
|
+
pending. One audit event records the request, one the outcome.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
import json
|
|
51
|
+
import sqlite3
|
|
52
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
53
|
+
from datetime import UTC, datetime, timedelta
|
|
54
|
+
from typing import Any
|
|
55
|
+
|
|
56
|
+
from pydantic import BaseModel, ConfigDict
|
|
57
|
+
|
|
58
|
+
from commitguard.audit.models import Actor, AuditEvent, AuditEventType
|
|
59
|
+
from commitguard.controlplane.access import Permission, Principal
|
|
60
|
+
from commitguard.controlplane.errors import (
|
|
61
|
+
ConfirmationRequiredError,
|
|
62
|
+
ConflictError,
|
|
63
|
+
ControlPlaneError,
|
|
64
|
+
InputValidationError,
|
|
65
|
+
NotFoundError,
|
|
66
|
+
PermissionDeniedError,
|
|
67
|
+
)
|
|
68
|
+
from commitguard.exceptions.base import CommitGuardError
|
|
69
|
+
from commitguard.github.storage import SqliteStateStore
|
|
70
|
+
from commitguard.governance.common import (
|
|
71
|
+
MAX_REASON_CHARS,
|
|
72
|
+
account_repositories,
|
|
73
|
+
dt,
|
|
74
|
+
is_hex_id,
|
|
75
|
+
new_id,
|
|
76
|
+
req_dt,
|
|
77
|
+
require,
|
|
78
|
+
require_visible_repositories,
|
|
79
|
+
text,
|
|
80
|
+
ts,
|
|
81
|
+
visible_repository_ids,
|
|
82
|
+
)
|
|
83
|
+
from commitguard.governance.groups import RepositoryGroupService
|
|
84
|
+
from commitguard.governance.inventory import (
|
|
85
|
+
ENFORCE_WARNING,
|
|
86
|
+
MONITOR_WARNING,
|
|
87
|
+
RepositoryInventory,
|
|
88
|
+
)
|
|
89
|
+
from commitguard.governance.schedules import DefaultBranchScanner
|
|
90
|
+
from commitguard.observability.logging import get_logger
|
|
91
|
+
from commitguard.policies.governance import RepositoryMode
|
|
92
|
+
from commitguard.security.hashing import fingerprint
|
|
93
|
+
from commitguard.services.audit import AuditService
|
|
94
|
+
|
|
95
|
+
log = get_logger(__name__)
|
|
96
|
+
|
|
97
|
+
MAX_ITEMS = 5000
|
|
98
|
+
MAX_OPEN_OPERATIONS = 10
|
|
99
|
+
ITEMS_PER_TICK = 200
|
|
100
|
+
#: Items applied per database transaction (a failure is isolated per batch, then per item).
|
|
101
|
+
BATCH_SIZE = 100
|
|
102
|
+
LEASE = timedelta(minutes=5)
|
|
103
|
+
TYPES = (
|
|
104
|
+
"add_to_group",
|
|
105
|
+
"remove_from_group",
|
|
106
|
+
"onboard",
|
|
107
|
+
"set_mode",
|
|
108
|
+
"set_monitoring",
|
|
109
|
+
"schedule_scan",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class BulkItemView(BaseModel):
|
|
114
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
115
|
+
|
|
116
|
+
repository_id: int
|
|
117
|
+
full_name: str | None
|
|
118
|
+
status: str
|
|
119
|
+
attempts: int
|
|
120
|
+
detail: str | None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class BulkOperationView(BaseModel):
|
|
124
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
125
|
+
|
|
126
|
+
id: str
|
|
127
|
+
organization_id: int
|
|
128
|
+
type: str
|
|
129
|
+
parameters: dict[str, Any]
|
|
130
|
+
status: str
|
|
131
|
+
total: int
|
|
132
|
+
completed: int
|
|
133
|
+
failed: int
|
|
134
|
+
skipped: int
|
|
135
|
+
pending: int
|
|
136
|
+
requested_by: str | None
|
|
137
|
+
created_at: datetime
|
|
138
|
+
started_at: datetime | None
|
|
139
|
+
completed_at: datetime | None
|
|
140
|
+
cancelled_by: str | None
|
|
141
|
+
items: tuple[BulkItemView, ...]
|
|
142
|
+
can_manage: bool
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class BulkOperationService:
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
store: SqliteStateStore,
|
|
149
|
+
audit: AuditService,
|
|
150
|
+
groups: RepositoryGroupService,
|
|
151
|
+
inventory: RepositoryInventory,
|
|
152
|
+
scanner: DefaultBranchScanner | None,
|
|
153
|
+
*,
|
|
154
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
155
|
+
) -> None:
|
|
156
|
+
self._store = store
|
|
157
|
+
self._audit = audit
|
|
158
|
+
self._groups = groups
|
|
159
|
+
self._inventory = inventory
|
|
160
|
+
self._scanner = scanner
|
|
161
|
+
self._now = now
|
|
162
|
+
|
|
163
|
+
# -- requests --------------------------------------------------------- #
|
|
164
|
+
def create(
|
|
165
|
+
self,
|
|
166
|
+
principal: Principal,
|
|
167
|
+
account_id: int,
|
|
168
|
+
*,
|
|
169
|
+
operation_type: object,
|
|
170
|
+
repository_ids: Sequence[int],
|
|
171
|
+
parameters: object,
|
|
172
|
+
idempotency_key: object,
|
|
173
|
+
confirm: object,
|
|
174
|
+
) -> BulkOperationView:
|
|
175
|
+
if operation_type not in TYPES:
|
|
176
|
+
raise InputValidationError(f"type must be one of {', '.join(TYPES)}", field="type")
|
|
177
|
+
assert isinstance(operation_type, str) # noqa: S101 - checked above
|
|
178
|
+
permission = (
|
|
179
|
+
Permission.SCANS_TRIGGER
|
|
180
|
+
if operation_type == "schedule_scan"
|
|
181
|
+
else Permission.REPOSITORIES_MANAGE
|
|
182
|
+
)
|
|
183
|
+
require(principal, permission, account_id)
|
|
184
|
+
if len(repository_ids) > MAX_ITEMS:
|
|
185
|
+
raise InputValidationError(
|
|
186
|
+
f"a bulk operation covers at most {MAX_ITEMS} repositories",
|
|
187
|
+
field="repository_ids",
|
|
188
|
+
)
|
|
189
|
+
ids = require_visible_repositories(self._store, principal, account_id, repository_ids)
|
|
190
|
+
params = self._parameters(principal, account_id, operation_type, parameters, confirm)
|
|
191
|
+
if idempotency_key is not None and (
|
|
192
|
+
not isinstance(idempotency_key, str) or not 8 <= len(idempotency_key) <= 128
|
|
193
|
+
):
|
|
194
|
+
raise InputValidationError(
|
|
195
|
+
"idempotency_key must be 8-128 characters", field="idempotency_key"
|
|
196
|
+
)
|
|
197
|
+
key = idempotency_key or fingerprint(
|
|
198
|
+
[operation_type, json.dumps(params, sort_keys=True), *map(str, ids)]
|
|
199
|
+
)
|
|
200
|
+
now = self._now()
|
|
201
|
+
operation_id = new_id()
|
|
202
|
+
with self._store.transaction() as db:
|
|
203
|
+
existing = db.execute(
|
|
204
|
+
"SELECT operation_id FROM bulk_operations WHERE account_id = ? "
|
|
205
|
+
"AND idempotency_key = ?",
|
|
206
|
+
(account_id, key),
|
|
207
|
+
).fetchone()
|
|
208
|
+
if existing is not None:
|
|
209
|
+
existing_id = str(existing["operation_id"])
|
|
210
|
+
else:
|
|
211
|
+
existing_id = None
|
|
212
|
+
open_count = db.execute(
|
|
213
|
+
"SELECT COUNT(*) AS n FROM bulk_operations WHERE account_id = ? "
|
|
214
|
+
"AND status IN ('queued', 'running')",
|
|
215
|
+
(account_id,),
|
|
216
|
+
).fetchone()["n"]
|
|
217
|
+
if int(open_count) >= MAX_OPEN_OPERATIONS:
|
|
218
|
+
raise ConflictError(
|
|
219
|
+
"Too many bulk operations are in progress for this organization. "
|
|
220
|
+
"Wait for one to finish."
|
|
221
|
+
)
|
|
222
|
+
db.execute(
|
|
223
|
+
"INSERT INTO bulk_operations (operation_id, account_id, type, parameters, "
|
|
224
|
+
"idempotency_key, requested_by_id, requested_by_login, created_at, status, "
|
|
225
|
+
"total, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?)",
|
|
226
|
+
(
|
|
227
|
+
operation_id,
|
|
228
|
+
account_id,
|
|
229
|
+
operation_type,
|
|
230
|
+
json.dumps(params, sort_keys=True),
|
|
231
|
+
key,
|
|
232
|
+
principal.user_id,
|
|
233
|
+
principal.login,
|
|
234
|
+
ts(now),
|
|
235
|
+
len(ids),
|
|
236
|
+
ts(now),
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
db.executemany(
|
|
240
|
+
"INSERT INTO bulk_operation_items (operation_id, repository_id, status, "
|
|
241
|
+
"updated_at) VALUES (?, ?, 'pending', ?)",
|
|
242
|
+
[(operation_id, repository_id, ts(now)) for repository_id in ids],
|
|
243
|
+
)
|
|
244
|
+
stored = self._store.insert_audit_event(
|
|
245
|
+
db,
|
|
246
|
+
self._audit.build(
|
|
247
|
+
AuditEventType.BULK_OPERATION_REQUESTED,
|
|
248
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
249
|
+
account_id=account_id,
|
|
250
|
+
operation=operation_id,
|
|
251
|
+
operation_type=operation_type,
|
|
252
|
+
repositories=len(ids),
|
|
253
|
+
parameters=json.dumps(params, sort_keys=True)[:400],
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
if existing_id is not None:
|
|
257
|
+
return self.get(principal, existing_id)
|
|
258
|
+
self._audit.log_stored(stored)
|
|
259
|
+
return self.get(principal, operation_id)
|
|
260
|
+
|
|
261
|
+
def _parameters(
|
|
262
|
+
self,
|
|
263
|
+
principal: Principal,
|
|
264
|
+
account_id: int,
|
|
265
|
+
operation_type: str,
|
|
266
|
+
raw: object,
|
|
267
|
+
confirm: object,
|
|
268
|
+
) -> dict[str, Any]:
|
|
269
|
+
params = raw if isinstance(raw, dict) else {}
|
|
270
|
+
if raw is not None and not isinstance(raw, dict):
|
|
271
|
+
raise InputValidationError("parameters must be an object", field="parameters")
|
|
272
|
+
if operation_type in ("add_to_group", "remove_from_group"):
|
|
273
|
+
group_id = params.get("group_id")
|
|
274
|
+
if not is_hex_id(group_id):
|
|
275
|
+
raise InputValidationError("group_id is required", field="parameters.group_id")
|
|
276
|
+
assert isinstance(group_id, str) # noqa: S101 - checked above
|
|
277
|
+
if self._groups.account_of(principal, group_id, Permission.REPOSITORIES_MANAGE) != (
|
|
278
|
+
account_id
|
|
279
|
+
):
|
|
280
|
+
raise NotFoundError()
|
|
281
|
+
return {"group_id": group_id}
|
|
282
|
+
if operation_type in ("onboard", "set_mode"):
|
|
283
|
+
mode = RepositoryInventory.parse_mode(params.get("mode"))
|
|
284
|
+
reason = text(params.get("reason"), "reason", limit=MAX_REASON_CHARS)
|
|
285
|
+
if confirm is not True:
|
|
286
|
+
raise ConfirmationRequiredError(
|
|
287
|
+
ENFORCE_WARNING if mode is RepositoryMode.ENFORCE else MONITOR_WARNING
|
|
288
|
+
)
|
|
289
|
+
if mode is RepositoryMode.MONITOR and reason is None:
|
|
290
|
+
raise InputValidationError(
|
|
291
|
+
"A reason is required to stop blocking (monitor mode).",
|
|
292
|
+
field="parameters.reason",
|
|
293
|
+
)
|
|
294
|
+
return {"mode": mode.value, "reason": reason}
|
|
295
|
+
if operation_type == "set_monitoring":
|
|
296
|
+
enabled = params.get("enabled")
|
|
297
|
+
if not isinstance(enabled, bool):
|
|
298
|
+
raise InputValidationError(
|
|
299
|
+
"enabled must be true or false", field="parameters.enabled"
|
|
300
|
+
)
|
|
301
|
+
if not enabled and confirm is not True:
|
|
302
|
+
raise ConfirmationRequiredError(
|
|
303
|
+
"Pausing monitoring stops CommitGuard checks for these repositories."
|
|
304
|
+
)
|
|
305
|
+
return {"enabled": enabled}
|
|
306
|
+
return {}
|
|
307
|
+
|
|
308
|
+
# -- reads ------------------------------------------------------------ #
|
|
309
|
+
def _row(self, operation_id: str) -> sqlite3.Row:
|
|
310
|
+
if not is_hex_id(operation_id):
|
|
311
|
+
raise NotFoundError()
|
|
312
|
+
rows = self._store.query(
|
|
313
|
+
"SELECT * FROM bulk_operations WHERE operation_id = ?", (operation_id,)
|
|
314
|
+
)
|
|
315
|
+
if not rows:
|
|
316
|
+
raise NotFoundError()
|
|
317
|
+
return rows[0]
|
|
318
|
+
|
|
319
|
+
def get(self, principal: Principal, operation_id: str) -> BulkOperationView:
|
|
320
|
+
row = self._row(operation_id)
|
|
321
|
+
account_id = int(row["account_id"])
|
|
322
|
+
require(principal, Permission.REPOSITORIES_READ, account_id)
|
|
323
|
+
return self._view(row, principal, with_items=True)
|
|
324
|
+
|
|
325
|
+
def list_operations(self, principal: Principal, account_id: int) -> list[BulkOperationView]:
|
|
326
|
+
require(principal, Permission.REPOSITORIES_READ, account_id)
|
|
327
|
+
rows = self._store.query(
|
|
328
|
+
"SELECT * FROM bulk_operations WHERE account_id = ? ORDER BY created_at DESC LIMIT 50",
|
|
329
|
+
(account_id,),
|
|
330
|
+
)
|
|
331
|
+
return [self._view(row, principal, with_items=False) for row in rows]
|
|
332
|
+
|
|
333
|
+
def _view(
|
|
334
|
+
self, row: sqlite3.Row, principal: Principal, *, with_items: bool
|
|
335
|
+
) -> BulkOperationView:
|
|
336
|
+
account_id = int(row["account_id"])
|
|
337
|
+
operation_id = str(row["operation_id"])
|
|
338
|
+
counts = {
|
|
339
|
+
str(r["status"]): int(r["n"])
|
|
340
|
+
for r in self._store.query(
|
|
341
|
+
"SELECT status, COUNT(*) AS n FROM bulk_operation_items WHERE operation_id = ? "
|
|
342
|
+
"GROUP BY status",
|
|
343
|
+
(operation_id,),
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
items: tuple[BulkItemView, ...] = ()
|
|
347
|
+
if with_items:
|
|
348
|
+
names = {
|
|
349
|
+
repository_id: repository.full_name
|
|
350
|
+
for repository_id, repository in account_repositories(
|
|
351
|
+
self._store, account_id
|
|
352
|
+
).items()
|
|
353
|
+
}
|
|
354
|
+
visible = visible_repository_ids(self._store, principal, account_id)
|
|
355
|
+
items = tuple(
|
|
356
|
+
BulkItemView(
|
|
357
|
+
repository_id=int(r["repository_id"]),
|
|
358
|
+
full_name=names.get(int(r["repository_id"]))
|
|
359
|
+
if int(r["repository_id"]) in visible
|
|
360
|
+
else None,
|
|
361
|
+
status=str(r["status"]),
|
|
362
|
+
attempts=int(r["attempts"]),
|
|
363
|
+
detail=r["detail"],
|
|
364
|
+
)
|
|
365
|
+
for r in self._store.query(
|
|
366
|
+
"SELECT * FROM bulk_operation_items WHERE operation_id = ? "
|
|
367
|
+
"ORDER BY CASE status WHEN 'failed' THEN 0 WHEN 'pending' THEN 1 ELSE 2 END, "
|
|
368
|
+
"repository_id LIMIT 500",
|
|
369
|
+
(operation_id,),
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
permission = (
|
|
373
|
+
Permission.SCANS_TRIGGER
|
|
374
|
+
if row["type"] == "schedule_scan"
|
|
375
|
+
else Permission.REPOSITORIES_MANAGE
|
|
376
|
+
)
|
|
377
|
+
return BulkOperationView(
|
|
378
|
+
id=operation_id,
|
|
379
|
+
organization_id=account_id,
|
|
380
|
+
type=str(row["type"]),
|
|
381
|
+
parameters=json.loads(str(row["parameters"])),
|
|
382
|
+
status=str(row["status"]),
|
|
383
|
+
total=int(row["total"]),
|
|
384
|
+
completed=counts.get("completed", 0),
|
|
385
|
+
failed=counts.get("failed", 0),
|
|
386
|
+
skipped=counts.get("skipped", 0),
|
|
387
|
+
pending=counts.get("pending", 0),
|
|
388
|
+
requested_by=row["requested_by_login"],
|
|
389
|
+
created_at=req_dt(row["created_at"]),
|
|
390
|
+
started_at=dt(row["started_at"]),
|
|
391
|
+
completed_at=dt(row["completed_at"]),
|
|
392
|
+
cancelled_by=row["cancelled_by_login"],
|
|
393
|
+
items=items,
|
|
394
|
+
can_manage=principal.can(permission, account_id),
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# -- control ---------------------------------------------------------- #
|
|
398
|
+
def _manageable(self, principal: Principal, operation_id: str) -> sqlite3.Row:
|
|
399
|
+
row = self._row(operation_id)
|
|
400
|
+
account_id = int(row["account_id"])
|
|
401
|
+
require(principal, Permission.REPOSITORIES_READ, account_id)
|
|
402
|
+
permission = (
|
|
403
|
+
Permission.SCANS_TRIGGER
|
|
404
|
+
if row["type"] == "schedule_scan"
|
|
405
|
+
else Permission.REPOSITORIES_MANAGE
|
|
406
|
+
)
|
|
407
|
+
if not principal.can(permission, account_id):
|
|
408
|
+
raise PermissionDeniedError()
|
|
409
|
+
return row
|
|
410
|
+
|
|
411
|
+
def cancel(self, principal: Principal, operation_id: str) -> BulkOperationView:
|
|
412
|
+
row = self._manageable(principal, operation_id)
|
|
413
|
+
if str(row["status"]) not in ("queued", "running"):
|
|
414
|
+
raise ConflictError(f"A {row['status']} operation cannot be cancelled.")
|
|
415
|
+
now = self._now()
|
|
416
|
+
with self._store.transaction() as db:
|
|
417
|
+
db.execute(
|
|
418
|
+
"UPDATE bulk_operation_items SET status = 'cancelled', updated_at = ? "
|
|
419
|
+
"WHERE operation_id = ? AND status = 'pending'",
|
|
420
|
+
(ts(now), operation_id),
|
|
421
|
+
)
|
|
422
|
+
db.execute(
|
|
423
|
+
"UPDATE bulk_operations SET status = 'cancelled', completed_at = ?, "
|
|
424
|
+
"cancelled_by_login = ?, lease_expires_at = NULL, updated_at = ? "
|
|
425
|
+
"WHERE operation_id = ?",
|
|
426
|
+
(ts(now), principal.login, ts(now), operation_id),
|
|
427
|
+
)
|
|
428
|
+
stored = self._store.insert_audit_event(
|
|
429
|
+
db,
|
|
430
|
+
self._audit.build(
|
|
431
|
+
AuditEventType.BULK_OPERATION_CANCELLED,
|
|
432
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
433
|
+
account_id=int(row["account_id"]),
|
|
434
|
+
operation=operation_id,
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
self._audit.log_stored(stored)
|
|
438
|
+
return self.get(principal, operation_id)
|
|
439
|
+
|
|
440
|
+
def retry_failed(self, principal: Principal, operation_id: str) -> BulkOperationView:
|
|
441
|
+
row = self._manageable(principal, operation_id)
|
|
442
|
+
if str(row["status"]) not in ("partial", "failed"):
|
|
443
|
+
raise ConflictError("Only an operation with failed items can be retried.")
|
|
444
|
+
now = self._now()
|
|
445
|
+
with self._store.transaction() as db:
|
|
446
|
+
retried = db.execute(
|
|
447
|
+
"UPDATE bulk_operation_items SET status = 'pending', updated_at = ? "
|
|
448
|
+
"WHERE operation_id = ? AND status = 'failed'",
|
|
449
|
+
(ts(now), operation_id),
|
|
450
|
+
).rowcount
|
|
451
|
+
if retried:
|
|
452
|
+
db.execute(
|
|
453
|
+
"UPDATE bulk_operations SET status = 'queued', completed_at = NULL, "
|
|
454
|
+
"updated_at = ? WHERE operation_id = ?",
|
|
455
|
+
(ts(now), operation_id),
|
|
456
|
+
)
|
|
457
|
+
return self.get(principal, operation_id)
|
|
458
|
+
|
|
459
|
+
# -- processing ------------------------------------------------------- #
|
|
460
|
+
def run_pending(self, budget: int = ITEMS_PER_TICK) -> int:
|
|
461
|
+
"""Process pending items of queued/running operations. Returns items processed."""
|
|
462
|
+
now = self._now()
|
|
463
|
+
processed = 0
|
|
464
|
+
operations = self._store.query(
|
|
465
|
+
"SELECT * FROM bulk_operations WHERE status = 'queued' OR (status = 'running' "
|
|
466
|
+
"AND (lease_expires_at IS NULL OR lease_expires_at < ?)) ORDER BY created_at LIMIT 5",
|
|
467
|
+
(ts(now),),
|
|
468
|
+
)
|
|
469
|
+
for operation in operations:
|
|
470
|
+
if processed >= budget:
|
|
471
|
+
break
|
|
472
|
+
operation_id = str(operation["operation_id"])
|
|
473
|
+
with self._store.transaction() as db:
|
|
474
|
+
claimed = db.execute(
|
|
475
|
+
"UPDATE bulk_operations SET status = 'running', started_at = "
|
|
476
|
+
"COALESCE(started_at, ?), lease_expires_at = ?, updated_at = ? "
|
|
477
|
+
"WHERE operation_id = ? AND (status = 'queued' OR (status = 'running' AND "
|
|
478
|
+
"(lease_expires_at IS NULL OR lease_expires_at < ?)))",
|
|
479
|
+
(ts(now), ts(now + LEASE), ts(now), operation_id, ts(now)),
|
|
480
|
+
).rowcount
|
|
481
|
+
if claimed != 1:
|
|
482
|
+
continue
|
|
483
|
+
items = self._store.query(
|
|
484
|
+
"SELECT repository_id FROM bulk_operation_items WHERE operation_id = ? "
|
|
485
|
+
"AND status = 'pending' ORDER BY repository_id LIMIT ?",
|
|
486
|
+
(operation_id, budget - processed),
|
|
487
|
+
)
|
|
488
|
+
ids = [int(item["repository_id"]) for item in items]
|
|
489
|
+
if str(operation["type"]) == "schedule_scan":
|
|
490
|
+
for repository_id in ids: # each one calls GitHub: one at a time
|
|
491
|
+
self._process_item(operation, repository_id)
|
|
492
|
+
else:
|
|
493
|
+
# The organization's repositories, once per pass (not per batch).
|
|
494
|
+
known = account_repositories(self._store, int(operation["account_id"]))
|
|
495
|
+
for start in range(0, len(ids), BATCH_SIZE):
|
|
496
|
+
self._process_batch(operation, ids[start : start + BATCH_SIZE], known)
|
|
497
|
+
processed += len(ids)
|
|
498
|
+
self._finish_if_done(operation)
|
|
499
|
+
return processed
|
|
500
|
+
|
|
501
|
+
def _process_batch(
|
|
502
|
+
self, operation: sqlite3.Row, ids: list[int], known: Mapping[int, object]
|
|
503
|
+
) -> None:
|
|
504
|
+
"""Apply a batch in one transaction; on an unexpected database error, item by item."""
|
|
505
|
+
account_id = int(operation["account_id"])
|
|
506
|
+
operation_id = str(operation["operation_id"])
|
|
507
|
+
params = json.loads(str(operation["parameters"]))
|
|
508
|
+
kind = str(operation["type"])
|
|
509
|
+
actor = Actor.user(
|
|
510
|
+
int(operation["requested_by_id"] or 0),
|
|
511
|
+
str(operation["requested_by_login"] or "bulk operation"),
|
|
512
|
+
)
|
|
513
|
+
now = self._now()
|
|
514
|
+
events: list[AuditEvent] = []
|
|
515
|
+
try:
|
|
516
|
+
with self._store.transaction() as db:
|
|
517
|
+
results: dict[int, tuple[str, str | None]] = {
|
|
518
|
+
i: ("failed", "repository not found in this organization")
|
|
519
|
+
for i in ids
|
|
520
|
+
if i not in known
|
|
521
|
+
}
|
|
522
|
+
valid = [i for i in ids if i in known]
|
|
523
|
+
changed: set[int] = set()
|
|
524
|
+
db.execute("SAVEPOINT bulk_batch")
|
|
525
|
+
try:
|
|
526
|
+
if kind in ("add_to_group", "remove_from_group"):
|
|
527
|
+
method = (
|
|
528
|
+
self._groups.add_members_in
|
|
529
|
+
if kind == "add_to_group"
|
|
530
|
+
else self._groups.remove_members_in
|
|
531
|
+
)
|
|
532
|
+
done, event = method(
|
|
533
|
+
db,
|
|
534
|
+
account_id,
|
|
535
|
+
str(params["group_id"]),
|
|
536
|
+
valid,
|
|
537
|
+
actor=actor,
|
|
538
|
+
known=known.keys(),
|
|
539
|
+
)
|
|
540
|
+
changed.update(done)
|
|
541
|
+
events.extend([event] if event is not None else [])
|
|
542
|
+
elif kind in ("onboard", "set_mode"):
|
|
543
|
+
done, mode_events = self._inventory.apply_in(
|
|
544
|
+
db,
|
|
545
|
+
account_id,
|
|
546
|
+
valid,
|
|
547
|
+
mode=RepositoryMode(params["mode"]),
|
|
548
|
+
actor=actor,
|
|
549
|
+
onboard=kind == "onboard",
|
|
550
|
+
reason=params.get("reason"),
|
|
551
|
+
known=known.keys(),
|
|
552
|
+
)
|
|
553
|
+
changed.update(done)
|
|
554
|
+
events.extend(mode_events)
|
|
555
|
+
else:
|
|
556
|
+
for repository_id in valid:
|
|
557
|
+
if self._apply_in(db, kind, account_id, repository_id, params, actor):
|
|
558
|
+
continue
|
|
559
|
+
changed.add(repository_id)
|
|
560
|
+
db.execute("RELEASE bulk_batch")
|
|
561
|
+
except (ControlPlaneError, CommitGuardError) as exc:
|
|
562
|
+
db.execute("ROLLBACK TO bulk_batch")
|
|
563
|
+
db.execute("RELEASE bulk_batch")
|
|
564
|
+
events.clear()
|
|
565
|
+
changed.clear()
|
|
566
|
+
message = (
|
|
567
|
+
str(exc)[:200]
|
|
568
|
+
if isinstance(exc, ControlPlaneError)
|
|
569
|
+
else (type(exc).__name__)
|
|
570
|
+
)
|
|
571
|
+
results.update({i: ("failed", message) for i in valid})
|
|
572
|
+
for repository_id in valid:
|
|
573
|
+
results.setdefault(
|
|
574
|
+
repository_id,
|
|
575
|
+
("completed", None)
|
|
576
|
+
if repository_id in changed
|
|
577
|
+
else ("skipped", "unchanged"),
|
|
578
|
+
)
|
|
579
|
+
db.executemany(
|
|
580
|
+
"UPDATE bulk_operation_items SET status = ?, attempts = attempts + 1, "
|
|
581
|
+
"detail = ?, updated_at = ? WHERE operation_id = ? AND repository_id = ? "
|
|
582
|
+
"AND status = 'pending'",
|
|
583
|
+
[
|
|
584
|
+
(status, detail, ts(now), operation_id, repository_id)
|
|
585
|
+
for repository_id, (status, detail) in results.items()
|
|
586
|
+
],
|
|
587
|
+
)
|
|
588
|
+
except CommitGuardError:
|
|
589
|
+
log.warning("bulk_batch_failed_retrying_items", operation=operation_id)
|
|
590
|
+
for repository_id in ids:
|
|
591
|
+
self._process_item(operation, repository_id)
|
|
592
|
+
return
|
|
593
|
+
for event in events:
|
|
594
|
+
self._audit.log_stored(event)
|
|
595
|
+
|
|
596
|
+
def _process_item(self, operation: sqlite3.Row, repository_id: int) -> None:
|
|
597
|
+
account_id = int(operation["account_id"])
|
|
598
|
+
operation_id = str(operation["operation_id"])
|
|
599
|
+
params = json.loads(str(operation["parameters"]))
|
|
600
|
+
actor = Actor.user(
|
|
601
|
+
int(operation["requested_by_id"] or 0),
|
|
602
|
+
str(operation["requested_by_login"] or "bulk operation"),
|
|
603
|
+
)
|
|
604
|
+
status, detail = "completed", None
|
|
605
|
+
now = self._now()
|
|
606
|
+
try:
|
|
607
|
+
kind = str(operation["type"])
|
|
608
|
+
if kind == "schedule_scan":
|
|
609
|
+
outcome = (
|
|
610
|
+
self._scanner.queue(
|
|
611
|
+
account_id,
|
|
612
|
+
repository_id,
|
|
613
|
+
key=("bulk", operation_id),
|
|
614
|
+
requested_by=actor.login,
|
|
615
|
+
)
|
|
616
|
+
if self._scanner is not None
|
|
617
|
+
else "skipped:no_scanner"
|
|
618
|
+
)
|
|
619
|
+
if outcome != "queued":
|
|
620
|
+
status, detail = "skipped", outcome.split(":", 1)[-1]
|
|
621
|
+
else:
|
|
622
|
+
with self._store.transaction() as db:
|
|
623
|
+
detail = self._apply_in(db, kind, account_id, repository_id, params, actor)
|
|
624
|
+
if detail is not None:
|
|
625
|
+
status = "skipped"
|
|
626
|
+
except (ControlPlaneError, CommitGuardError, OSError) as exc:
|
|
627
|
+
status = "failed"
|
|
628
|
+
detail = str(exc)[:200] if isinstance(exc, ControlPlaneError) else type(exc).__name__
|
|
629
|
+
with self._store.transaction() as db:
|
|
630
|
+
db.execute(
|
|
631
|
+
"UPDATE bulk_operation_items SET status = ?, attempts = attempts + 1, detail = ?, "
|
|
632
|
+
"updated_at = ? WHERE operation_id = ? AND repository_id = ? AND status = "
|
|
633
|
+
"'pending'",
|
|
634
|
+
(status, detail, ts(now), operation_id, repository_id),
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
def _apply_in(
|
|
638
|
+
self,
|
|
639
|
+
db: sqlite3.Connection,
|
|
640
|
+
kind: str,
|
|
641
|
+
account_id: int,
|
|
642
|
+
repository_id: int,
|
|
643
|
+
params: dict[str, Any],
|
|
644
|
+
actor: Actor,
|
|
645
|
+
) -> str | None:
|
|
646
|
+
"""Apply one item in a transaction. Returns a skip reason, or None when applied."""
|
|
647
|
+
if repository_id not in account_repositories(db, account_id):
|
|
648
|
+
return "not_found"
|
|
649
|
+
if kind in ("add_to_group", "remove_from_group"):
|
|
650
|
+
method = (
|
|
651
|
+
self._groups.add_members_in
|
|
652
|
+
if kind == "add_to_group"
|
|
653
|
+
else self._groups.remove_members_in
|
|
654
|
+
)
|
|
655
|
+
_, event = method(db, account_id, str(params["group_id"]), [repository_id], actor=actor)
|
|
656
|
+
return None if event is not None else "unchanged"
|
|
657
|
+
if kind in ("onboard", "set_mode"):
|
|
658
|
+
changed, _ = self._inventory.apply_in(
|
|
659
|
+
db,
|
|
660
|
+
account_id,
|
|
661
|
+
[repository_id],
|
|
662
|
+
mode=RepositoryMode(params["mode"]),
|
|
663
|
+
actor=actor,
|
|
664
|
+
onboard=kind == "onboard",
|
|
665
|
+
reason=params.get("reason"),
|
|
666
|
+
)
|
|
667
|
+
return None if changed else "unchanged"
|
|
668
|
+
if kind == "set_monitoring":
|
|
669
|
+
installations = [
|
|
670
|
+
int(r["installation_id"])
|
|
671
|
+
for r in db.execute(
|
|
672
|
+
"SELECT k.installation_id FROM known_repositories k JOIN installations i ON "
|
|
673
|
+
"i.installation_id = k.installation_id WHERE i.account_id = ? "
|
|
674
|
+
"AND k.repository_id = ?",
|
|
675
|
+
(account_id, repository_id),
|
|
676
|
+
).fetchall()
|
|
677
|
+
]
|
|
678
|
+
for installation_id in installations:
|
|
679
|
+
self._store.insert_audit_event(
|
|
680
|
+
db,
|
|
681
|
+
self._audit.build(
|
|
682
|
+
AuditEventType.REPOSITORY_MONITORING_ENABLED
|
|
683
|
+
if params["enabled"]
|
|
684
|
+
else AuditEventType.REPOSITORY_MONITORING_DISABLED,
|
|
685
|
+
actor=actor,
|
|
686
|
+
account_id=account_id,
|
|
687
|
+
installation_id=installation_id,
|
|
688
|
+
repository_id=repository_id,
|
|
689
|
+
source="bulk operation",
|
|
690
|
+
),
|
|
691
|
+
)
|
|
692
|
+
db.execute(
|
|
693
|
+
"INSERT INTO repository_settings (installation_id, repository_id, "
|
|
694
|
+
"monitoring_enabled, updated_at, updated_by_login) VALUES (?, ?, ?, ?, ?) "
|
|
695
|
+
"ON CONFLICT (installation_id, repository_id) DO UPDATE SET "
|
|
696
|
+
"monitoring_enabled = excluded.monitoring_enabled, "
|
|
697
|
+
"updated_at = excluded.updated_at, updated_by_login = "
|
|
698
|
+
"excluded.updated_by_login",
|
|
699
|
+
(
|
|
700
|
+
installation_id,
|
|
701
|
+
repository_id,
|
|
702
|
+
1 if params["enabled"] else 0,
|
|
703
|
+
ts(self._now()),
|
|
704
|
+
actor.login,
|
|
705
|
+
),
|
|
706
|
+
)
|
|
707
|
+
return None
|
|
708
|
+
raise InputValidationError("unknown bulk operation") # pragma: no cover
|
|
709
|
+
|
|
710
|
+
def _finish_if_done(self, operation: sqlite3.Row) -> None:
|
|
711
|
+
operation_id = str(operation["operation_id"])
|
|
712
|
+
counts = {
|
|
713
|
+
str(r["status"]): int(r["n"])
|
|
714
|
+
for r in self._store.query(
|
|
715
|
+
"SELECT status, COUNT(*) AS n FROM bulk_operation_items WHERE operation_id = ? "
|
|
716
|
+
"GROUP BY status",
|
|
717
|
+
(operation_id,),
|
|
718
|
+
)
|
|
719
|
+
}
|
|
720
|
+
now = self._now()
|
|
721
|
+
if counts.get("pending", 0):
|
|
722
|
+
with self._store.transaction() as db:
|
|
723
|
+
db.execute(
|
|
724
|
+
"UPDATE bulk_operations SET completed = ?, failed = ?, lease_expires_at = "
|
|
725
|
+
"NULL, "
|
|
726
|
+
"updated_at = ? WHERE operation_id = ?",
|
|
727
|
+
(
|
|
728
|
+
counts.get("completed", 0) + counts.get("skipped", 0),
|
|
729
|
+
counts.get("failed", 0),
|
|
730
|
+
ts(now),
|
|
731
|
+
operation_id,
|
|
732
|
+
),
|
|
733
|
+
)
|
|
734
|
+
return
|
|
735
|
+
failed = counts.get("failed", 0)
|
|
736
|
+
done = counts.get("completed", 0) + counts.get("skipped", 0)
|
|
737
|
+
status = "completed" if not failed else ("failed" if not done else "partial")
|
|
738
|
+
with self._store.transaction() as db:
|
|
739
|
+
changed = db.execute(
|
|
740
|
+
"UPDATE bulk_operations SET status = ?, completed = ?, failed = ?, completed_at = "
|
|
741
|
+
"?, "
|
|
742
|
+
"lease_expires_at = NULL, updated_at = ? WHERE operation_id = ? "
|
|
743
|
+
"AND status = 'running'",
|
|
744
|
+
(status, done, failed, ts(now), ts(now), operation_id),
|
|
745
|
+
).rowcount
|
|
746
|
+
if changed != 1:
|
|
747
|
+
return
|
|
748
|
+
stored = self._store.insert_audit_event(
|
|
749
|
+
db,
|
|
750
|
+
self._audit.build(
|
|
751
|
+
AuditEventType.BULK_OPERATION_FINISHED,
|
|
752
|
+
actor=Actor.user(
|
|
753
|
+
int(operation["requested_by_id"] or 0),
|
|
754
|
+
str(operation["requested_by_login"] or "bulk operation"),
|
|
755
|
+
),
|
|
756
|
+
account_id=int(operation["account_id"]),
|
|
757
|
+
operation=operation_id,
|
|
758
|
+
operation_type=str(operation["type"]),
|
|
759
|
+
result=status,
|
|
760
|
+
completed=counts.get("completed", 0),
|
|
761
|
+
skipped=counts.get("skipped", 0),
|
|
762
|
+
failed=failed,
|
|
763
|
+
),
|
|
764
|
+
)
|
|
765
|
+
self._audit.log_stored(stored)
|