cpkit 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.
- cpkit/README.md +82 -0
- cpkit/__init__.py +42 -0
- cpkit/admin.py +53 -0
- cpkit/app.py +130 -0
- cpkit/audit/README.md +33 -0
- cpkit/audit/__init__.py +44 -0
- cpkit/audit/events_service.py +36 -0
- cpkit/audit/recorder.py +240 -0
- cpkit/audit/repository.py +64 -0
- cpkit/audit/router.py +46 -0
- cpkit/audit/service.py +34 -0
- cpkit/audit/types.py +38 -0
- cpkit/auth/README.md +38 -0
- cpkit/auth/__init__.py +123 -0
- cpkit/auth/api_key_router.py +52 -0
- cpkit/auth/api_key_service.py +143 -0
- cpkit/auth/api_keys.py +143 -0
- cpkit/auth/bundle.py +113 -0
- cpkit/auth/claims.py +37 -0
- cpkit/auth/config.py +162 -0
- cpkit/auth/dependencies.py +155 -0
- cpkit/auth/oidc.py +703 -0
- cpkit/auth/redirects.py +12 -0
- cpkit/auth/repositories.py +181 -0
- cpkit/auth/router.py +214 -0
- cpkit/auth/secrets.py +78 -0
- cpkit/auth/types.py +49 -0
- cpkit/bundle.py +252 -0
- cpkit/cli/README.md +21 -0
- cpkit/cli/__init__.py +14 -0
- cpkit/cli/__main__.py +5 -0
- cpkit/cli/base.py +200 -0
- cpkit/cli/schema.py +30 -0
- cpkit/cli/server.py +22 -0
- cpkit/config/README.md +13 -0
- cpkit/config/__init__.py +9 -0
- cpkit/config/env.py +31 -0
- cpkit/db/README.md +24 -0
- cpkit/db/__init__.py +23 -0
- cpkit/db/postgres.py +252 -0
- cpkit/dependencies.py +73 -0
- cpkit/errors/README.md +22 -0
- cpkit/errors/__init__.py +35 -0
- cpkit/errors/http.py +45 -0
- cpkit/errors/repository.py +32 -0
- cpkit/errors/service.py +74 -0
- cpkit/jobs/README.md +29 -0
- cpkit/jobs/__init__.py +49 -0
- cpkit/jobs/maintenance.py +15 -0
- cpkit/jobs/repository.py +306 -0
- cpkit/jobs/router.py +105 -0
- cpkit/jobs/service.py +138 -0
- cpkit/jobs/types.py +67 -0
- cpkit/jobs/worker.py +200 -0
- cpkit/logging/README.md +19 -0
- cpkit/logging/__init__.py +13 -0
- cpkit/logging/context.py +29 -0
- cpkit/logging/middleware.py +55 -0
- cpkit/logging/setup.py +81 -0
- cpkit/playbooks/README.md +25 -0
- cpkit/playbooks/__init__.py +40 -0
- cpkit/playbooks/ansible.py +359 -0
- cpkit/playbooks/repository.py +95 -0
- cpkit/playbooks/router.py +95 -0
- cpkit/playbooks/service.py +232 -0
- cpkit/playbooks/types.py +43 -0
- cpkit/repository.py +63 -0
- cpkit/resources/__init__.py +17 -0
- cpkit/resources/ddl.sql +150 -0
- cpkit/settings/README.md +22 -0
- cpkit/settings/__init__.py +18 -0
- cpkit/settings/keys.py +30 -0
- cpkit/settings/repository.py +108 -0
- cpkit/settings/router.py +62 -0
- cpkit/settings/service.py +105 -0
- cpkit/settings/types.py +25 -0
- cpkit/time.py +4 -0
- cpkit/webapp/README.md +71 -0
- cpkit/webapp/__init__.py +12 -0
- cpkit/webapp/index.html +970 -0
- cpkit/webapp/script.js +1690 -0
- cpkit/webapp/style.css +1561 -0
- cpkit-0.1.0.dist-info/METADATA +105 -0
- cpkit-0.1.0.dist-info/RECORD +90 -0
- cpkit-0.1.0.dist-info/WHEEL +4 -0
- cpkit-0.1.0.dist-info/licenses/LICENSE +201 -0
- resources/README.md +16 -0
- resources/repository_maintenance_guide.md +90 -0
- resources/webapp_extension_guide.md +706 -0
- resources/webapp_extension_template.js +130 -0
cpkit/jobs/repository.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Repository helpers for the framework message queue."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from cpkit.db import execute_stmt, fetch_all, fetch_one
|
|
6
|
+
|
|
7
|
+
from .maintenance import FAIL_ZOMBIE_JOBS_MESSAGE_TYPE
|
|
8
|
+
from .types import IntID, Job, JobID, JobStatsResponse, LinkedResourceRef, Task
|
|
9
|
+
|
|
10
|
+
QUEUE_TABLE = "cpkit.mq"
|
|
11
|
+
JOBS_TABLE = "cpkit.jobs"
|
|
12
|
+
TASKS_TABLE = "cpkit.tasks"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class QueueRepositoryMixin:
|
|
16
|
+
"""Repository mixin for enqueueing framework queue messages."""
|
|
17
|
+
|
|
18
|
+
def enqueue_message(
|
|
19
|
+
self,
|
|
20
|
+
msg_type: Any,
|
|
21
|
+
payload: Any | None,
|
|
22
|
+
created_by: str,
|
|
23
|
+
*,
|
|
24
|
+
start_after_seconds: int = 0,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Enqueue a message to be processed by the framework worker."""
|
|
27
|
+
execute_stmt(
|
|
28
|
+
f"""
|
|
29
|
+
INSERT INTO {QUEUE_TABLE}
|
|
30
|
+
(msg_type, msg_data, created_by, start_after)
|
|
31
|
+
VALUES
|
|
32
|
+
(%s, %s, %s, now() + (%s * INTERVAL '1s'))
|
|
33
|
+
""",
|
|
34
|
+
(
|
|
35
|
+
_message_type_value(msg_type),
|
|
36
|
+
_payload_value(payload),
|
|
37
|
+
created_by,
|
|
38
|
+
start_after_seconds,
|
|
39
|
+
),
|
|
40
|
+
operation="jobs.enqueue_message",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class QueueJobRepositoryMixin(QueueRepositoryMixin):
|
|
45
|
+
"""Repository mixin for enqueueing queue messages backed by job records."""
|
|
46
|
+
|
|
47
|
+
def enqueue_command(
|
|
48
|
+
self,
|
|
49
|
+
command_type: Any,
|
|
50
|
+
payload: Any,
|
|
51
|
+
created_by: str,
|
|
52
|
+
) -> JobID:
|
|
53
|
+
payload_value = _payload_value(payload)
|
|
54
|
+
playbook_version = payload_value.get("playbook_version")
|
|
55
|
+
job_description = {
|
|
56
|
+
key: value for key, value in payload_value.items() if key != "playbook_version"
|
|
57
|
+
}
|
|
58
|
+
command_type_value = _message_type_value(command_type)
|
|
59
|
+
return fetch_one(
|
|
60
|
+
f"""
|
|
61
|
+
WITH
|
|
62
|
+
create_new_job AS (
|
|
63
|
+
INSERT INTO {QUEUE_TABLE}
|
|
64
|
+
(msg_type, msg_data, created_by)
|
|
65
|
+
VALUES
|
|
66
|
+
(%s, %s, %s)
|
|
67
|
+
RETURNING msg_id
|
|
68
|
+
)
|
|
69
|
+
INSERT INTO {JOBS_TABLE}
|
|
70
|
+
(job_id, job_type, status, playbook_version, description, created_by)
|
|
71
|
+
VALUES
|
|
72
|
+
((select msg_id from create_new_job), %s, %s, %s, %s, %s)
|
|
73
|
+
RETURNING job_id AS job_id
|
|
74
|
+
""",
|
|
75
|
+
(
|
|
76
|
+
command_type_value,
|
|
77
|
+
payload_value,
|
|
78
|
+
created_by,
|
|
79
|
+
command_type_value,
|
|
80
|
+
"QUEUED",
|
|
81
|
+
str(playbook_version) if playbook_version is not None else None,
|
|
82
|
+
job_description,
|
|
83
|
+
created_by,
|
|
84
|
+
),
|
|
85
|
+
JobID,
|
|
86
|
+
operation="jobs.enqueue_command",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class JobsRepositoryMixin:
|
|
91
|
+
"""Repository mixin for framework job history, status, and task records."""
|
|
92
|
+
|
|
93
|
+
def get_job_stats(
|
|
94
|
+
self,
|
|
95
|
+
groups: list[str],
|
|
96
|
+
is_admin: bool = False,
|
|
97
|
+
) -> JobStatsResponse:
|
|
98
|
+
if is_admin:
|
|
99
|
+
return (
|
|
100
|
+
fetch_one(
|
|
101
|
+
f"""
|
|
102
|
+
SELECT
|
|
103
|
+
COUNT(*) AS total,
|
|
104
|
+
COALESCE(SUM(CASE WHEN status = %s THEN 1 ELSE 0 END), 0) AS running,
|
|
105
|
+
COALESCE(SUM(CASE WHEN status = %s THEN 1 ELSE 0 END), 0) AS queued,
|
|
106
|
+
COALESCE(SUM(CASE WHEN status = %s THEN 1 ELSE 0 END), 0) AS failed
|
|
107
|
+
FROM {JOBS_TABLE}
|
|
108
|
+
""",
|
|
109
|
+
("RUNNING", "QUEUED", "FAILED"),
|
|
110
|
+
JobStatsResponse,
|
|
111
|
+
)
|
|
112
|
+
or JobStatsResponse(total=0, running=0, queued=0, failed=0)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
fetch_one(
|
|
117
|
+
f"""
|
|
118
|
+
WITH
|
|
119
|
+
c AS (
|
|
120
|
+
SELECT cluster_id
|
|
121
|
+
FROM clusters
|
|
122
|
+
WHERE grp = ANY (%s)
|
|
123
|
+
),
|
|
124
|
+
cj AS (
|
|
125
|
+
SELECT DISTINCT job_id
|
|
126
|
+
FROM {self.job_cluster_map_table}
|
|
127
|
+
WHERE cluster_id IN (SELECT * FROM c)
|
|
128
|
+
)
|
|
129
|
+
SELECT
|
|
130
|
+
COUNT(*) AS total,
|
|
131
|
+
COALESCE(SUM(CASE WHEN status = %s THEN 1 ELSE 0 END), 0) AS running,
|
|
132
|
+
COALESCE(SUM(CASE WHEN status = %s THEN 1 ELSE 0 END), 0) AS queued,
|
|
133
|
+
COALESCE(SUM(CASE WHEN status = %s THEN 1 ELSE 0 END), 0) AS failed
|
|
134
|
+
FROM {JOBS_TABLE}
|
|
135
|
+
WHERE job_id IN (SELECT * FROM cj)
|
|
136
|
+
""",
|
|
137
|
+
(groups, "RUNNING", "QUEUED", "FAILED"),
|
|
138
|
+
JobStatsResponse,
|
|
139
|
+
)
|
|
140
|
+
or JobStatsResponse(total=0, running=0, queued=0, failed=0)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def list_jobs(self, groups: list[str], is_admin: bool = False) -> list[Job]:
|
|
144
|
+
if is_admin:
|
|
145
|
+
return fetch_all(
|
|
146
|
+
f"""
|
|
147
|
+
SELECT *
|
|
148
|
+
FROM {JOBS_TABLE}
|
|
149
|
+
ORDER BY created_at DESC
|
|
150
|
+
""",
|
|
151
|
+
(),
|
|
152
|
+
Job,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return fetch_all(
|
|
156
|
+
f"""
|
|
157
|
+
WITH
|
|
158
|
+
c AS (
|
|
159
|
+
SELECT cluster_id
|
|
160
|
+
FROM clusters
|
|
161
|
+
WHERE grp = ANY (%s)
|
|
162
|
+
),
|
|
163
|
+
cj AS (
|
|
164
|
+
SELECT job_id
|
|
165
|
+
FROM {self.job_cluster_map_table}
|
|
166
|
+
WHERE cluster_id IN (SELECT * FROM c)
|
|
167
|
+
)
|
|
168
|
+
SELECT *
|
|
169
|
+
FROM {JOBS_TABLE}
|
|
170
|
+
WHERE job_id IN (SELECT * FROM cj)
|
|
171
|
+
ORDER BY created_at DESC;
|
|
172
|
+
""",
|
|
173
|
+
(groups,),
|
|
174
|
+
Job,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def get_job(
|
|
178
|
+
self,
|
|
179
|
+
job_id: int,
|
|
180
|
+
groups: list[str],
|
|
181
|
+
is_admin: bool = False,
|
|
182
|
+
) -> Job | None:
|
|
183
|
+
if is_admin:
|
|
184
|
+
return fetch_one(
|
|
185
|
+
f"""
|
|
186
|
+
SELECT *
|
|
187
|
+
FROM {JOBS_TABLE}
|
|
188
|
+
WHERE job_id = %s
|
|
189
|
+
""",
|
|
190
|
+
(job_id,),
|
|
191
|
+
Job,
|
|
192
|
+
)
|
|
193
|
+
return fetch_one(
|
|
194
|
+
f"""
|
|
195
|
+
WITH
|
|
196
|
+
c AS (
|
|
197
|
+
SELECT cluster_id
|
|
198
|
+
FROM clusters
|
|
199
|
+
WHERE grp = ANY (%s)
|
|
200
|
+
),
|
|
201
|
+
cj AS (
|
|
202
|
+
SELECT job_id
|
|
203
|
+
FROM {self.job_cluster_map_table}
|
|
204
|
+
WHERE cluster_id IN (SELECT * FROM c)
|
|
205
|
+
)
|
|
206
|
+
SELECT *
|
|
207
|
+
FROM {JOBS_TABLE}
|
|
208
|
+
WHERE job_id IN (SELECT * FROM cj)
|
|
209
|
+
AND job_id = %s
|
|
210
|
+
""",
|
|
211
|
+
(groups, job_id),
|
|
212
|
+
Job,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def list_tasks(self, job_id: int) -> list[Task]:
|
|
216
|
+
return fetch_all(
|
|
217
|
+
f"""
|
|
218
|
+
SELECT job_id, task_id,
|
|
219
|
+
created_at, task_name, task_desc
|
|
220
|
+
FROM {TASKS_TABLE}
|
|
221
|
+
WHERE job_id = %s
|
|
222
|
+
ORDER BY task_id DESC
|
|
223
|
+
""",
|
|
224
|
+
(job_id,),
|
|
225
|
+
Task,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def job_cluster_map_table(self) -> str:
|
|
230
|
+
raise NotImplementedError(
|
|
231
|
+
"Applications using group-scoped job visibility must provide "
|
|
232
|
+
"a job-cluster mapping table."
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def list_linked_resources(self, job_id: int) -> list[LinkedResourceRef]:
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
def update_job(self, job_id: int, status: str) -> None:
|
|
239
|
+
execute_stmt(
|
|
240
|
+
f"""
|
|
241
|
+
UPDATE {JOBS_TABLE}
|
|
242
|
+
SET status = %s
|
|
243
|
+
WHERE job_id = %s
|
|
244
|
+
""",
|
|
245
|
+
(_message_type_value(status), job_id),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def set_job_playbook_version(self, job_id: int, playbook_version: str) -> None:
|
|
249
|
+
execute_stmt(
|
|
250
|
+
f"""
|
|
251
|
+
UPDATE {JOBS_TABLE}
|
|
252
|
+
SET playbook_version = %s
|
|
253
|
+
WHERE job_id = %s
|
|
254
|
+
""",
|
|
255
|
+
(playbook_version, job_id),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def fail_zombie_jobs(self) -> list[IntID]:
|
|
259
|
+
return fetch_all(
|
|
260
|
+
f"""
|
|
261
|
+
WITH
|
|
262
|
+
fail_zombie_jobs AS (
|
|
263
|
+
INSERT INTO {QUEUE_TABLE} (msg_type, start_after)
|
|
264
|
+
VALUES (%s, now() + INTERVAL '300s' + (random()*10)::INTERVAL)
|
|
265
|
+
RETURNING 1
|
|
266
|
+
)
|
|
267
|
+
UPDATE {JOBS_TABLE}
|
|
268
|
+
SET status = %s
|
|
269
|
+
WHERE status in (%s, %s)
|
|
270
|
+
AND now() > updated_at + INTERVAL '300s'
|
|
271
|
+
RETURNING job_id AS id
|
|
272
|
+
""",
|
|
273
|
+
(FAIL_ZOMBIE_JOBS_MESSAGE_TYPE, "FAILED", "RUNNING", "QUEUED"),
|
|
274
|
+
IntID,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def create_task(
|
|
278
|
+
self,
|
|
279
|
+
job_id: int,
|
|
280
|
+
task_id: int,
|
|
281
|
+
created_at,
|
|
282
|
+
task_name: str,
|
|
283
|
+
task_desc,
|
|
284
|
+
) -> None:
|
|
285
|
+
execute_stmt(
|
|
286
|
+
f"""
|
|
287
|
+
INSERT INTO {TASKS_TABLE}
|
|
288
|
+
(job_id, task_id, created_at, task_name, task_desc)
|
|
289
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
290
|
+
""",
|
|
291
|
+
(job_id, task_id, created_at, task_name, task_desc),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _message_type_value(msg_type: Any) -> str:
|
|
296
|
+
return str(getattr(msg_type, "value", msg_type))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _payload_value(payload: Any | None) -> dict[str, Any]:
|
|
300
|
+
if payload is None:
|
|
301
|
+
return {}
|
|
302
|
+
if hasattr(payload, "model_dump"):
|
|
303
|
+
return payload.model_dump()
|
|
304
|
+
if isinstance(payload, dict):
|
|
305
|
+
return payload
|
|
306
|
+
raise TypeError("Queue message payload must be a mapping or Pydantic model.")
|
cpkit/jobs/router.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""FastAPI routes for framework job management."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
7
|
+
|
|
8
|
+
from .types import Job, JobDetailsResponse, JobRescheduleResponse, JobStatsResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_jobs_router(
|
|
12
|
+
*,
|
|
13
|
+
get_service: Callable[..., Any],
|
|
14
|
+
get_access_scope: Callable[[dict], tuple[list[str], bool]],
|
|
15
|
+
get_audit_actor: Callable[..., Any],
|
|
16
|
+
require_readonly: Callable[..., Any],
|
|
17
|
+
require_user: Callable[..., Any],
|
|
18
|
+
handle_service_error: Callable[[Exception], None],
|
|
19
|
+
service_error_type: type[Exception] = Exception,
|
|
20
|
+
) -> APIRouter:
|
|
21
|
+
router = APIRouter(prefix="/jobs", tags=["cpkit"])
|
|
22
|
+
|
|
23
|
+
@router.get("/")
|
|
24
|
+
async def list_jobs(
|
|
25
|
+
claims: dict = Depends(require_readonly),
|
|
26
|
+
service=Depends(get_service),
|
|
27
|
+
) -> list[Job]:
|
|
28
|
+
groups, is_admin = get_access_scope(claims)
|
|
29
|
+
try:
|
|
30
|
+
return service.list_visible_jobs(groups, is_admin)
|
|
31
|
+
except service_error_type as err:
|
|
32
|
+
handle_service_error(err)
|
|
33
|
+
|
|
34
|
+
@router.get("/stats", response_model=JobStatsResponse)
|
|
35
|
+
async def get_job_stats(
|
|
36
|
+
claims: dict = Depends(require_readonly),
|
|
37
|
+
service=Depends(get_service),
|
|
38
|
+
) -> JobStatsResponse:
|
|
39
|
+
groups, is_admin = get_access_scope(claims)
|
|
40
|
+
try:
|
|
41
|
+
return service.get_visible_job_stats(groups, is_admin)
|
|
42
|
+
except service_error_type as err:
|
|
43
|
+
handle_service_error(err)
|
|
44
|
+
|
|
45
|
+
@router.get("/{job_id}")
|
|
46
|
+
async def get_job(
|
|
47
|
+
job_id: int,
|
|
48
|
+
claims: dict = Depends(require_readonly),
|
|
49
|
+
service=Depends(get_service),
|
|
50
|
+
) -> Job:
|
|
51
|
+
groups, is_admin = get_access_scope(claims)
|
|
52
|
+
try:
|
|
53
|
+
job = service.get_job_for_user(job_id, groups, is_admin)
|
|
54
|
+
except service_error_type as err:
|
|
55
|
+
handle_service_error(err)
|
|
56
|
+
|
|
57
|
+
if job is None:
|
|
58
|
+
raise HTTPException(
|
|
59
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
60
|
+
detail=f"Job '{job_id}' was not found.",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return job
|
|
64
|
+
|
|
65
|
+
@router.get("/{job_id}/details", response_model=JobDetailsResponse)
|
|
66
|
+
async def get_job_details(
|
|
67
|
+
job_id: int,
|
|
68
|
+
claims: dict = Depends(require_readonly),
|
|
69
|
+
service=Depends(get_service),
|
|
70
|
+
) -> JobDetailsResponse:
|
|
71
|
+
groups, is_admin = get_access_scope(claims)
|
|
72
|
+
try:
|
|
73
|
+
details = service.get_job_details_for_user(job_id, groups, is_admin)
|
|
74
|
+
except service_error_type as err:
|
|
75
|
+
handle_service_error(err)
|
|
76
|
+
|
|
77
|
+
if details is None:
|
|
78
|
+
raise HTTPException(
|
|
79
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
80
|
+
detail=f"Job '{job_id}' was not found.",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return JobDetailsResponse(**details)
|
|
84
|
+
|
|
85
|
+
@router.post("/{job_id}/reschedule", response_model=JobRescheduleResponse)
|
|
86
|
+
async def reschedule_job(
|
|
87
|
+
job_id: int,
|
|
88
|
+
claims: dict = Depends(require_user),
|
|
89
|
+
actor_id: str = Depends(get_audit_actor),
|
|
90
|
+
service=Depends(get_service),
|
|
91
|
+
) -> JobRescheduleResponse:
|
|
92
|
+
groups, is_admin = get_access_scope(claims)
|
|
93
|
+
try:
|
|
94
|
+
new_job_id = service.enqueue_job_reschedule(
|
|
95
|
+
job_id,
|
|
96
|
+
groups,
|
|
97
|
+
is_admin,
|
|
98
|
+
actor_id,
|
|
99
|
+
)
|
|
100
|
+
except service_error_type as err:
|
|
101
|
+
handle_service_error(err)
|
|
102
|
+
|
|
103
|
+
return JobRescheduleResponse(job_id=new_job_id)
|
|
104
|
+
|
|
105
|
+
return router
|
cpkit/jobs/service.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Service helpers for framework job history and rescheduling."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from cpkit.errors import RepositoryError, ServiceNotFoundError, from_repository_error
|
|
9
|
+
|
|
10
|
+
from .types import Job, JobID, JobStatsResponse
|
|
11
|
+
|
|
12
|
+
PayloadParser = Callable[[Any, dict[str, Any]], Any]
|
|
13
|
+
RescheduleTypeResolver = Callable[[str], Any]
|
|
14
|
+
JobAuditHook = Callable[[Any, str, str, dict[str, Any]], None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JobsService:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
repo,
|
|
21
|
+
*,
|
|
22
|
+
parse_payload: PayloadParser,
|
|
23
|
+
reschedule_type_resolver: RescheduleTypeResolver,
|
|
24
|
+
rescheduled_hook: JobAuditHook | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.repo = repo
|
|
27
|
+
self.parse_payload = parse_payload
|
|
28
|
+
self.reschedule_type_resolver = reschedule_type_resolver
|
|
29
|
+
self.rescheduled_hook = rescheduled_hook
|
|
30
|
+
|
|
31
|
+
def list_visible_jobs(self, groups: list[str], is_admin: bool) -> list[Job]:
|
|
32
|
+
try:
|
|
33
|
+
return self.repo.list_jobs(groups, is_admin)
|
|
34
|
+
except RepositoryError as err:
|
|
35
|
+
raise from_repository_error(
|
|
36
|
+
err,
|
|
37
|
+
unavailable_message="Jobs are temporarily unavailable.",
|
|
38
|
+
fallback_message="Unable to load jobs.",
|
|
39
|
+
) from err
|
|
40
|
+
|
|
41
|
+
def get_visible_job_stats(
|
|
42
|
+
self,
|
|
43
|
+
groups: list[str],
|
|
44
|
+
is_admin: bool,
|
|
45
|
+
) -> JobStatsResponse:
|
|
46
|
+
try:
|
|
47
|
+
return self.repo.get_job_stats(groups, is_admin)
|
|
48
|
+
except RepositoryError as err:
|
|
49
|
+
raise from_repository_error(
|
|
50
|
+
err,
|
|
51
|
+
unavailable_message="Job stats are temporarily unavailable.",
|
|
52
|
+
fallback_message="Unable to load job stats.",
|
|
53
|
+
) from err
|
|
54
|
+
|
|
55
|
+
def get_job_for_user(
|
|
56
|
+
self,
|
|
57
|
+
job_id: int,
|
|
58
|
+
groups: list[str],
|
|
59
|
+
is_admin: bool,
|
|
60
|
+
) -> Job | None:
|
|
61
|
+
try:
|
|
62
|
+
return self.repo.get_job(job_id, groups, is_admin)
|
|
63
|
+
except RepositoryError as err:
|
|
64
|
+
raise from_repository_error(
|
|
65
|
+
err,
|
|
66
|
+
unavailable_message="Job details are temporarily unavailable.",
|
|
67
|
+
fallback_message=f"Unable to load job '{job_id}'.",
|
|
68
|
+
) from err
|
|
69
|
+
|
|
70
|
+
def get_job_details_for_user(
|
|
71
|
+
self,
|
|
72
|
+
job_id: int,
|
|
73
|
+
groups: list[str],
|
|
74
|
+
is_admin: bool,
|
|
75
|
+
) -> dict[str, Any] | None:
|
|
76
|
+
selected_job = self.get_job_for_user(job_id, groups, is_admin)
|
|
77
|
+
if selected_job is None:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
return {
|
|
82
|
+
"job": selected_job,
|
|
83
|
+
"description_yaml": yaml.dump(selected_job.description),
|
|
84
|
+
"tasks": self.repo.list_tasks(job_id),
|
|
85
|
+
"linked_resources": self.repo.list_linked_resources(job_id),
|
|
86
|
+
}
|
|
87
|
+
except RepositoryError as err:
|
|
88
|
+
raise from_repository_error(
|
|
89
|
+
err,
|
|
90
|
+
unavailable_message="Job details are temporarily unavailable.",
|
|
91
|
+
fallback_message=f"Unable to load tasks for job '{job_id}'.",
|
|
92
|
+
) from err
|
|
93
|
+
|
|
94
|
+
def enqueue_job_reschedule(
|
|
95
|
+
self,
|
|
96
|
+
job_id: int,
|
|
97
|
+
groups: list[str],
|
|
98
|
+
is_admin: bool,
|
|
99
|
+
requested_by: str,
|
|
100
|
+
) -> int:
|
|
101
|
+
selected_job = self.get_job_for_user(job_id, groups, is_admin)
|
|
102
|
+
if selected_job is None:
|
|
103
|
+
raise ServiceNotFoundError(f"Job '{job_id}' was not found.")
|
|
104
|
+
|
|
105
|
+
command_type = self.reschedule_type_resolver(selected_job.job_type)
|
|
106
|
+
payload = self.parse_payload(command_type, selected_job.description)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
msg_id: JobID = self.repo.enqueue_command(
|
|
110
|
+
command_type,
|
|
111
|
+
payload,
|
|
112
|
+
requested_by,
|
|
113
|
+
)
|
|
114
|
+
if self.rescheduled_hook is not None:
|
|
115
|
+
self.rescheduled_hook(
|
|
116
|
+
self.repo,
|
|
117
|
+
requested_by,
|
|
118
|
+
"JOB_RESCHEDULE_REQUESTED",
|
|
119
|
+
_payload_value(payload) | {"job_id": msg_id.job_id},
|
|
120
|
+
)
|
|
121
|
+
return msg_id.job_id
|
|
122
|
+
except RepositoryError as err:
|
|
123
|
+
raise from_repository_error(
|
|
124
|
+
err,
|
|
125
|
+
unavailable_message="Job rescheduling is temporarily unavailable.",
|
|
126
|
+
validation_message=(
|
|
127
|
+
"The job could not be rescheduled with its current payload."
|
|
128
|
+
),
|
|
129
|
+
fallback_message=f"Unable to reschedule job '{job_id}'.",
|
|
130
|
+
) from err
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _payload_value(payload: Any) -> dict[str, Any]:
|
|
134
|
+
if hasattr(payload, "model_dump"):
|
|
135
|
+
return payload.model_dump()
|
|
136
|
+
if isinstance(payload, dict):
|
|
137
|
+
return payload
|
|
138
|
+
return {"payload": payload}
|
cpkit/jobs/types.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Generic job queue data types."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class QueueMessage(BaseModel):
|
|
10
|
+
"""Message claimed from the framework queue."""
|
|
11
|
+
|
|
12
|
+
msg_id: int
|
|
13
|
+
start_after: datetime
|
|
14
|
+
msg_type: str
|
|
15
|
+
msg_data: dict[str, Any] = Field(default_factory=dict)
|
|
16
|
+
created_at: datetime
|
|
17
|
+
created_by: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JobID(BaseModel):
|
|
21
|
+
job_id: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IntID(BaseModel):
|
|
25
|
+
id: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LinkedResourceRef(BaseModel):
|
|
29
|
+
resource_type: str
|
|
30
|
+
resource_id: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class JobStatsResponse(BaseModel):
|
|
34
|
+
total: int
|
|
35
|
+
running: int
|
|
36
|
+
queued: int
|
|
37
|
+
failed: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Job(BaseModel):
|
|
41
|
+
job_id: int
|
|
42
|
+
job_type: str
|
|
43
|
+
status: str | None = None
|
|
44
|
+
playbook_version: str | None = None
|
|
45
|
+
description: dict[str, Any] = Field(default_factory=dict)
|
|
46
|
+
created_at: datetime
|
|
47
|
+
created_by: str | None = None
|
|
48
|
+
updated_at: datetime
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Task(BaseModel):
|
|
52
|
+
job_id: int
|
|
53
|
+
task_id: int
|
|
54
|
+
created_at: datetime
|
|
55
|
+
task_name: str | None = None
|
|
56
|
+
task_desc: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class JobDetailsResponse(BaseModel):
|
|
60
|
+
job: Job
|
|
61
|
+
description_yaml: str
|
|
62
|
+
tasks: list[Task]
|
|
63
|
+
linked_resources: list[LinkedResourceRef]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class JobRescheduleResponse(BaseModel):
|
|
67
|
+
job_id: int
|