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/db/postgres.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Low-level Postgres metadata database infrastructure."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from psycopg import DatabaseError, InterfaceError, OperationalError
|
|
8
|
+
from psycopg import errors as psycopg_errors
|
|
9
|
+
from psycopg.abc import Dumper
|
|
10
|
+
from psycopg.pq import Format
|
|
11
|
+
from psycopg.rows import class_row
|
|
12
|
+
from psycopg.types.array import ListDumper
|
|
13
|
+
from psycopg.types.json import Jsonb, JsonbDumper
|
|
14
|
+
from psycopg_pool import ConnectionPool
|
|
15
|
+
|
|
16
|
+
from cpkit.errors import (
|
|
17
|
+
RepositoryConflictError,
|
|
18
|
+
RepositoryError,
|
|
19
|
+
RepositoryPermissionError,
|
|
20
|
+
RepositoryUnavailableError,
|
|
21
|
+
RepositoryValidationError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
CPKIT_DB_URL = os.getenv("CPKIT_DB_URL")
|
|
25
|
+
pool: ConnectionPool | None = None
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Dict2JsonbDumper(JsonbDumper):
|
|
30
|
+
def dump(self, obj):
|
|
31
|
+
return super().dump(Jsonb(obj))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SelectorDumper(Dumper):
|
|
35
|
+
"""Choose the correct dumper for list payloads."""
|
|
36
|
+
|
|
37
|
+
format = Format.BINARY
|
|
38
|
+
oid = None
|
|
39
|
+
|
|
40
|
+
_dict_dumper = Dict2JsonbDumper(list)
|
|
41
|
+
_list_dumper = ListDumper(list)
|
|
42
|
+
|
|
43
|
+
def upgrade(self, obj, format: Format) -> Dumper:
|
|
44
|
+
if obj and isinstance(obj[0], dict):
|
|
45
|
+
return self._dict_dumper
|
|
46
|
+
return self._list_dumper
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def execute_stmt(
|
|
50
|
+
stmt: str,
|
|
51
|
+
bind_args: tuple = (),
|
|
52
|
+
*,
|
|
53
|
+
operation: str | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
with get_pool().connection() as conn:
|
|
56
|
+
_register_dumpers(conn)
|
|
57
|
+
|
|
58
|
+
with conn.cursor() as cur:
|
|
59
|
+
try:
|
|
60
|
+
stmt = _normalize_stmt(stmt)
|
|
61
|
+
cur.execute(stmt, bind_args)
|
|
62
|
+
except Exception as err:
|
|
63
|
+
raise translate_database_error(err, operation) from err
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def fetch_all(
|
|
67
|
+
stmt: str,
|
|
68
|
+
bind_args: tuple,
|
|
69
|
+
row_type,
|
|
70
|
+
*,
|
|
71
|
+
operation: str | None = None,
|
|
72
|
+
) -> list[Any]:
|
|
73
|
+
with get_pool().connection() as conn:
|
|
74
|
+
_register_dumpers(conn)
|
|
75
|
+
|
|
76
|
+
with conn.cursor(row_factory=class_row(row_type)) as cur:
|
|
77
|
+
try:
|
|
78
|
+
stmt = _normalize_stmt(stmt)
|
|
79
|
+
cur.execute(stmt, bind_args)
|
|
80
|
+
return cur.fetchall()
|
|
81
|
+
except Exception as err:
|
|
82
|
+
raise translate_database_error(err, operation) from err
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def fetch_one(
|
|
86
|
+
stmt: str,
|
|
87
|
+
bind_args: tuple,
|
|
88
|
+
row_type,
|
|
89
|
+
*,
|
|
90
|
+
operation: str | None = None,
|
|
91
|
+
) -> Any | None:
|
|
92
|
+
with get_pool().connection() as conn:
|
|
93
|
+
_register_dumpers(conn)
|
|
94
|
+
|
|
95
|
+
with conn.cursor(row_factory=class_row(row_type)) as cur:
|
|
96
|
+
try:
|
|
97
|
+
stmt = _normalize_stmt(stmt)
|
|
98
|
+
cur.execute(stmt, bind_args)
|
|
99
|
+
return cur.fetchone()
|
|
100
|
+
except Exception as err:
|
|
101
|
+
raise translate_database_error(err, operation) from err
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def fetch_scalar(
|
|
105
|
+
stmt: str,
|
|
106
|
+
bind_args: tuple = (),
|
|
107
|
+
*,
|
|
108
|
+
operation: str | None = None,
|
|
109
|
+
) -> Any | None:
|
|
110
|
+
with get_pool().connection() as conn:
|
|
111
|
+
_register_dumpers(conn)
|
|
112
|
+
|
|
113
|
+
with conn.cursor() as cur:
|
|
114
|
+
try:
|
|
115
|
+
stmt = _normalize_stmt(stmt)
|
|
116
|
+
cur.execute(stmt, bind_args)
|
|
117
|
+
row = cur.fetchone()
|
|
118
|
+
if row is None:
|
|
119
|
+
return None
|
|
120
|
+
return row[0]
|
|
121
|
+
except Exception as err:
|
|
122
|
+
raise translate_database_error(err, operation) from err
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _register_dumpers(conn) -> None:
|
|
126
|
+
conn.adapters.register_dumper(set, ListDumper)
|
|
127
|
+
conn.adapters.register_dumper(dict, Dict2JsonbDumper)
|
|
128
|
+
conn.adapters.register_dumper(list, SelectorDumper)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _normalize_stmt(stmt: str) -> str:
|
|
132
|
+
return " ".join([s.strip() for s in stmt.split("\n")])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def initialize_postgres(db_url: str | None = None) -> None:
|
|
136
|
+
global pool
|
|
137
|
+
|
|
138
|
+
effective_db_url = db_url or CPKIT_DB_URL
|
|
139
|
+
if not effective_db_url:
|
|
140
|
+
raise EnvironmentError("CPKIT_DB_URL env variable not found!")
|
|
141
|
+
|
|
142
|
+
if pool is not None:
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
pool = ConnectionPool(
|
|
146
|
+
effective_db_url,
|
|
147
|
+
kwargs={"autocommit": True},
|
|
148
|
+
configure=_register_dumpers,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_pool() -> ConnectionPool:
|
|
153
|
+
if pool is None:
|
|
154
|
+
raise RuntimeError("Database pool not initialized. Ensure lifespan ran.")
|
|
155
|
+
return pool
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def close_db() -> None:
|
|
159
|
+
global pool
|
|
160
|
+
|
|
161
|
+
if pool is not None:
|
|
162
|
+
pool.close()
|
|
163
|
+
|
|
164
|
+
pool = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def translate_database_error(
|
|
168
|
+
err: Exception,
|
|
169
|
+
operation: str | None,
|
|
170
|
+
*,
|
|
171
|
+
unavailable_error_types: tuple[type[Exception], ...] = (),
|
|
172
|
+
) -> RepositoryError:
|
|
173
|
+
operation_name = operation or "database.statement"
|
|
174
|
+
sqlstate = getattr(err, "sqlstate", None)
|
|
175
|
+
|
|
176
|
+
if unavailable_error_types and isinstance(err, unavailable_error_types):
|
|
177
|
+
logger.warning(
|
|
178
|
+
"Database unavailable [operation=%s error=%s reason=%s]",
|
|
179
|
+
operation_name,
|
|
180
|
+
err.__class__.__name__,
|
|
181
|
+
getattr(err, "reason", str(err)),
|
|
182
|
+
)
|
|
183
|
+
return RepositoryUnavailableError(
|
|
184
|
+
"Database is temporarily unavailable.",
|
|
185
|
+
operation=operation_name,
|
|
186
|
+
retryable=True,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
logger.exception(
|
|
190
|
+
"Database operation failed [operation=%s sqlstate=%s error=%s]",
|
|
191
|
+
operation_name,
|
|
192
|
+
sqlstate,
|
|
193
|
+
err.__class__.__name__,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if isinstance(
|
|
197
|
+
err,
|
|
198
|
+
(
|
|
199
|
+
OperationalError,
|
|
200
|
+
InterfaceError,
|
|
201
|
+
psycopg_errors.SerializationFailure,
|
|
202
|
+
psycopg_errors.DeadlockDetected,
|
|
203
|
+
),
|
|
204
|
+
):
|
|
205
|
+
return RepositoryUnavailableError(
|
|
206
|
+
"Database is temporarily unavailable.",
|
|
207
|
+
operation=operation_name,
|
|
208
|
+
retryable=True,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if isinstance(err, psycopg_errors.UniqueViolation):
|
|
212
|
+
return RepositoryConflictError(
|
|
213
|
+
"Database write conflicts with existing data.",
|
|
214
|
+
operation=operation_name,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if isinstance(err, psycopg_errors.DuplicateDatabase):
|
|
218
|
+
return RepositoryConflictError(
|
|
219
|
+
"Database already exists.",
|
|
220
|
+
operation=operation_name,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if isinstance(
|
|
224
|
+
err,
|
|
225
|
+
(
|
|
226
|
+
psycopg_errors.ForeignKeyViolation,
|
|
227
|
+
psycopg_errors.CheckViolation,
|
|
228
|
+
psycopg_errors.NotNullViolation,
|
|
229
|
+
psycopg_errors.InvalidTextRepresentation,
|
|
230
|
+
),
|
|
231
|
+
):
|
|
232
|
+
return RepositoryValidationError(
|
|
233
|
+
"Database rejected invalid data.",
|
|
234
|
+
operation=operation_name,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if isinstance(err, psycopg_errors.InsufficientPrivilege):
|
|
238
|
+
return RepositoryPermissionError(
|
|
239
|
+
"Database permission denied.",
|
|
240
|
+
operation=operation_name,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if isinstance(err, DatabaseError):
|
|
244
|
+
return RepositoryError(
|
|
245
|
+
"Database operation failed.",
|
|
246
|
+
operation=operation_name,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return RepositoryError(
|
|
250
|
+
"Repository operation failed.",
|
|
251
|
+
operation=operation_name,
|
|
252
|
+
)
|
cpkit/dependencies.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Application-scoped dependency callables exposed by cpkit."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, Request, Security
|
|
6
|
+
|
|
7
|
+
from .auth import access_key_scheme, signature_scheme, timestamp_scheme
|
|
8
|
+
from .repository import get_repo
|
|
9
|
+
|
|
10
|
+
_active_bundle: Any | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def configure_cpkit_dependencies(bundle: Any) -> None:
|
|
14
|
+
"""Install the active cpkit bundle used by exported dependencies."""
|
|
15
|
+
global _active_bundle
|
|
16
|
+
_active_bundle = bundle
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def require_authenticated(
|
|
20
|
+
request: Request,
|
|
21
|
+
repo: Any = Depends(get_repo),
|
|
22
|
+
access_key: str | None = Security(access_key_scheme),
|
|
23
|
+
signature: str | None = Security(signature_scheme),
|
|
24
|
+
timestamp: str | None = Security(timestamp_scheme),
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""Return claims for the current caller."""
|
|
27
|
+
return await _bundle().require_authenticated(
|
|
28
|
+
request,
|
|
29
|
+
repo,
|
|
30
|
+
access_key,
|
|
31
|
+
signature,
|
|
32
|
+
timestamp,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def require_user(
|
|
37
|
+
claims: dict[str, Any] = Security(require_authenticated),
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""Require a role that permits mutating application operations."""
|
|
40
|
+
return _bundle().require_user(claims)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def require_readonly(
|
|
44
|
+
request: Request,
|
|
45
|
+
claims: dict[str, Any] = Security(require_authenticated),
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
"""Allow read-only roles on GET and require user roles on writes."""
|
|
48
|
+
return _bundle().require_readonly(request, claims)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def require_admin(
|
|
52
|
+
claims: dict[str, Any] = Security(require_authenticated),
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
"""Require an administrator role."""
|
|
55
|
+
return _bundle().require_admin(claims)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_access_scope(claims: dict[str, Any]) -> tuple[list[str], bool]:
|
|
59
|
+
"""Return normalized caller groups plus whether the caller is an admin."""
|
|
60
|
+
return _bundle().get_access_scope(claims)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_audit_actor(
|
|
64
|
+
claims: dict[str, Any] = Security(require_authenticated),
|
|
65
|
+
) -> str:
|
|
66
|
+
"""Return the identifier that should be written into audit logs."""
|
|
67
|
+
return _bundle().get_audit_actor(claims)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _bundle() -> Any:
|
|
71
|
+
if _active_bundle is None:
|
|
72
|
+
raise RuntimeError("cpkit dependencies are not configured.")
|
|
73
|
+
return _active_bundle
|
cpkit/errors/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Errors
|
|
2
|
+
|
|
3
|
+
The errors package defines cpkit's exception layers. The layers are deliberately
|
|
4
|
+
separate so low-level DB details do not leak directly into API responses.
|
|
5
|
+
|
|
6
|
+
## Files
|
|
7
|
+
|
|
8
|
+
- `repository.py`: Errors raised by repository/database operations.
|
|
9
|
+
- `service.py`: User-facing service errors and repository-to-service
|
|
10
|
+
translation.
|
|
11
|
+
- `http.py`: Service-to-FastAPI HTTP response translation.
|
|
12
|
+
|
|
13
|
+
## Error Flow
|
|
14
|
+
|
|
15
|
+
1. DB helpers raise `RepositoryError` subclasses.
|
|
16
|
+
2. Services catch repository errors and call `from_repository_error()`.
|
|
17
|
+
3. Routers catch `ServiceError` and call `raise_http_from_service_error()`.
|
|
18
|
+
|
|
19
|
+
This keeps SQL and driver details out of routers while still letting the UI show
|
|
20
|
+
clear messages such as "not found", "not authorized", or "temporarily
|
|
21
|
+
unavailable".
|
|
22
|
+
|
cpkit/errors/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Shared framework exception types."""
|
|
2
|
+
|
|
3
|
+
from .http import raise_http_from_service_error
|
|
4
|
+
from .repository import (
|
|
5
|
+
RepositoryConflictError,
|
|
6
|
+
RepositoryError,
|
|
7
|
+
RepositoryPermissionError,
|
|
8
|
+
RepositoryUnavailableError,
|
|
9
|
+
RepositoryValidationError,
|
|
10
|
+
)
|
|
11
|
+
from .service import (
|
|
12
|
+
ServiceAuthorizationError,
|
|
13
|
+
ServiceConflictError,
|
|
14
|
+
ServiceError,
|
|
15
|
+
ServiceNotFoundError,
|
|
16
|
+
ServiceUnavailableError,
|
|
17
|
+
ServiceValidationError,
|
|
18
|
+
from_repository_error,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"RepositoryConflictError",
|
|
23
|
+
"RepositoryError",
|
|
24
|
+
"RepositoryPermissionError",
|
|
25
|
+
"RepositoryUnavailableError",
|
|
26
|
+
"RepositoryValidationError",
|
|
27
|
+
"ServiceAuthorizationError",
|
|
28
|
+
"ServiceConflictError",
|
|
29
|
+
"ServiceError",
|
|
30
|
+
"ServiceNotFoundError",
|
|
31
|
+
"ServiceUnavailableError",
|
|
32
|
+
"ServiceValidationError",
|
|
33
|
+
"from_repository_error",
|
|
34
|
+
"raise_http_from_service_error",
|
|
35
|
+
]
|
cpkit/errors/http.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""FastAPI translation helpers for framework service errors."""
|
|
2
|
+
|
|
3
|
+
from fastapi import HTTPException, status
|
|
4
|
+
|
|
5
|
+
from .service import (
|
|
6
|
+
ServiceAuthorizationError,
|
|
7
|
+
ServiceConflictError,
|
|
8
|
+
ServiceError,
|
|
9
|
+
ServiceNotFoundError,
|
|
10
|
+
ServiceUnavailableError,
|
|
11
|
+
ServiceValidationError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def raise_http_from_service_error(err: ServiceError) -> None:
|
|
16
|
+
"""Raise an HTTPException that matches a service-layer error."""
|
|
17
|
+
if isinstance(err, ServiceNotFoundError):
|
|
18
|
+
raise HTTPException(
|
|
19
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
20
|
+
detail=err.user_message,
|
|
21
|
+
)
|
|
22
|
+
if isinstance(err, ServiceValidationError):
|
|
23
|
+
raise HTTPException(
|
|
24
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
25
|
+
detail=err.user_message,
|
|
26
|
+
)
|
|
27
|
+
if isinstance(err, ServiceConflictError):
|
|
28
|
+
raise HTTPException(
|
|
29
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
30
|
+
detail=err.user_message,
|
|
31
|
+
)
|
|
32
|
+
if isinstance(err, ServiceAuthorizationError):
|
|
33
|
+
raise HTTPException(
|
|
34
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
35
|
+
detail=err.user_message,
|
|
36
|
+
)
|
|
37
|
+
if isinstance(err, ServiceUnavailableError):
|
|
38
|
+
raise HTTPException(
|
|
39
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
40
|
+
detail=err.user_message,
|
|
41
|
+
)
|
|
42
|
+
raise HTTPException(
|
|
43
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
44
|
+
detail=err.user_message,
|
|
45
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Repository-layer exception types."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RepositoryError(Exception):
|
|
5
|
+
"""Base exception for repository and infrastructure failures."""
|
|
6
|
+
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str = "Repository operation failed.",
|
|
10
|
+
*,
|
|
11
|
+
operation: str | None = None,
|
|
12
|
+
retryable: bool = False,
|
|
13
|
+
) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.operation = operation
|
|
16
|
+
self.retryable = retryable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RepositoryUnavailableError(RepositoryError):
|
|
20
|
+
"""Raised when the database is temporarily unavailable."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RepositoryConflictError(RepositoryError):
|
|
24
|
+
"""Raised when a write conflicts with existing data."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RepositoryValidationError(RepositoryError):
|
|
28
|
+
"""Raised when the database rejects invalid data."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RepositoryPermissionError(RepositoryError):
|
|
32
|
+
"""Raised when the database denies access to an operation."""
|
cpkit/errors/service.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Service-layer exception types and repository error translation."""
|
|
2
|
+
|
|
3
|
+
from .repository import (
|
|
4
|
+
RepositoryConflictError,
|
|
5
|
+
RepositoryError,
|
|
6
|
+
RepositoryPermissionError,
|
|
7
|
+
RepositoryUnavailableError,
|
|
8
|
+
RepositoryValidationError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ServiceError(Exception):
|
|
13
|
+
"""Base exception for errors that may be shown in a UI or API response."""
|
|
14
|
+
|
|
15
|
+
default_title = "Error"
|
|
16
|
+
default_message = "Something went wrong. Please try again."
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
message: str | None = None,
|
|
21
|
+
*,
|
|
22
|
+
title: str | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.user_title = title or self.default_title
|
|
25
|
+
self.user_message = message or self.default_message
|
|
26
|
+
super().__init__(self.user_message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ServiceUnavailableError(ServiceError):
|
|
30
|
+
default_title = "Temporarily Unavailable"
|
|
31
|
+
default_message = "The service is temporarily unavailable. Please try again."
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ServiceConflictError(ServiceError):
|
|
35
|
+
default_title = "Conflict"
|
|
36
|
+
default_message = "The requested change conflicts with existing data."
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ServiceValidationError(ServiceError):
|
|
40
|
+
default_title = "Invalid Request"
|
|
41
|
+
default_message = "The submitted data is invalid."
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ServiceAuthorizationError(ServiceError):
|
|
45
|
+
default_title = "Not Authorized"
|
|
46
|
+
default_message = "You are not authorized to perform this action."
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ServiceNotFoundError(ServiceError):
|
|
50
|
+
default_title = "Not Found"
|
|
51
|
+
default_message = "The requested item could not be found."
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def from_repository_error(
|
|
55
|
+
err: RepositoryError,
|
|
56
|
+
*,
|
|
57
|
+
unavailable_message: str | None = None,
|
|
58
|
+
conflict_message: str | None = None,
|
|
59
|
+
validation_message: str | None = None,
|
|
60
|
+
permission_message: str | None = None,
|
|
61
|
+
fallback_message: str | None = None,
|
|
62
|
+
fallback_title: str | None = None,
|
|
63
|
+
) -> ServiceError:
|
|
64
|
+
"""Translate a repository exception into a service exception."""
|
|
65
|
+
|
|
66
|
+
if isinstance(err, RepositoryUnavailableError):
|
|
67
|
+
return ServiceUnavailableError(unavailable_message)
|
|
68
|
+
if isinstance(err, RepositoryConflictError):
|
|
69
|
+
return ServiceConflictError(conflict_message)
|
|
70
|
+
if isinstance(err, RepositoryValidationError):
|
|
71
|
+
return ServiceValidationError(validation_message)
|
|
72
|
+
if isinstance(err, RepositoryPermissionError):
|
|
73
|
+
return ServiceAuthorizationError(permission_message)
|
|
74
|
+
return ServiceError(fallback_message, title=fallback_title)
|
cpkit/jobs/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Jobs
|
|
2
|
+
|
|
3
|
+
The jobs package owns cpkit's framework job history and queue mechanics. A job
|
|
4
|
+
is represented by a row in `cpkit.jobs`; queued work is represented by a row in
|
|
5
|
+
`cpkit.mq`, where the queue message id doubles as the job id for normal app
|
|
6
|
+
jobs.
|
|
7
|
+
|
|
8
|
+
## Files
|
|
9
|
+
|
|
10
|
+
- `types.py`: Job, task, queue, stats, and detail response models.
|
|
11
|
+
- `repository.py`: Repository mixins for jobs, tasks, linked resources, and the
|
|
12
|
+
queue table.
|
|
13
|
+
- `service.py`: Job listing, detail loading, stats, and rescheduling rules.
|
|
14
|
+
- `router.py`: Jobs API routes.
|
|
15
|
+
- `worker.py`: Background queue poller and handler dispatch.
|
|
16
|
+
- `maintenance.py`: Framework maintenance queue handlers, such as zombie job
|
|
17
|
+
cleanup.
|
|
18
|
+
|
|
19
|
+
## Runtime Flow
|
|
20
|
+
|
|
21
|
+
1. App code enqueues work through the repository/service.
|
|
22
|
+
2. The queue worker claims due messages from `cpkit.mq`.
|
|
23
|
+
3. The worker resolves a handler and calls it with `(job_id, payload, created_by)`.
|
|
24
|
+
4. Handlers update `cpkit.jobs` and write task rows as work progresses.
|
|
25
|
+
5. The Jobs page reads job rows, task rows, and stats through the Jobs API.
|
|
26
|
+
|
|
27
|
+
When a normal queue message is being processed, the worker sets the current
|
|
28
|
+
audit job context so audit records can store the job id separately from details.
|
|
29
|
+
|
cpkit/jobs/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Framework-owned job queue primitives."""
|
|
2
|
+
|
|
3
|
+
from .maintenance import FAIL_ZOMBIE_JOBS_MESSAGE_TYPE, create_fail_zombie_jobs_handler
|
|
4
|
+
from .repository import (
|
|
5
|
+
JOBS_TABLE,
|
|
6
|
+
QUEUE_TABLE,
|
|
7
|
+
TASKS_TABLE,
|
|
8
|
+
JobsRepositoryMixin,
|
|
9
|
+
QueueJobRepositoryMixin,
|
|
10
|
+
QueueRepositoryMixin,
|
|
11
|
+
)
|
|
12
|
+
from .router import create_jobs_router
|
|
13
|
+
from .service import JobsService
|
|
14
|
+
from .types import (
|
|
15
|
+
IntID,
|
|
16
|
+
Job,
|
|
17
|
+
JobDetailsResponse,
|
|
18
|
+
JobID,
|
|
19
|
+
JobRescheduleResponse,
|
|
20
|
+
JobStatsResponse,
|
|
21
|
+
LinkedResourceRef,
|
|
22
|
+
QueueMessage,
|
|
23
|
+
Task,
|
|
24
|
+
)
|
|
25
|
+
from .worker import create_queue_worker, run_queue_worker
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"FAIL_ZOMBIE_JOBS_MESSAGE_TYPE",
|
|
29
|
+
"IntID",
|
|
30
|
+
"JOBS_TABLE",
|
|
31
|
+
"Job",
|
|
32
|
+
"JobDetailsResponse",
|
|
33
|
+
"JobID",
|
|
34
|
+
"JobRescheduleResponse",
|
|
35
|
+
"JobStatsResponse",
|
|
36
|
+
"JobsRepositoryMixin",
|
|
37
|
+
"JobsService",
|
|
38
|
+
"LinkedResourceRef",
|
|
39
|
+
"QUEUE_TABLE",
|
|
40
|
+
"QueueMessage",
|
|
41
|
+
"QueueJobRepositoryMixin",
|
|
42
|
+
"QueueRepositoryMixin",
|
|
43
|
+
"TASKS_TABLE",
|
|
44
|
+
"Task",
|
|
45
|
+
"create_fail_zombie_jobs_handler",
|
|
46
|
+
"create_queue_worker",
|
|
47
|
+
"create_jobs_router",
|
|
48
|
+
"run_queue_worker",
|
|
49
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Framework job maintenance handlers."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
FAIL_ZOMBIE_JOBS_MESSAGE_TYPE = "FAIL_ZOMBIE_JOBS"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_fail_zombie_jobs_handler(get_repo: Callable[[], Any]):
|
|
10
|
+
"""Create a queue handler that marks stale framework jobs as failed."""
|
|
11
|
+
|
|
12
|
+
def fail_zombie_jobs(_job_id: int, _command: Any, _requested_by: str) -> None:
|
|
13
|
+
get_repo().fail_zombie_jobs()
|
|
14
|
+
|
|
15
|
+
return fail_zombie_jobs
|