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.
Files changed (90) hide show
  1. cpkit/README.md +82 -0
  2. cpkit/__init__.py +42 -0
  3. cpkit/admin.py +53 -0
  4. cpkit/app.py +130 -0
  5. cpkit/audit/README.md +33 -0
  6. cpkit/audit/__init__.py +44 -0
  7. cpkit/audit/events_service.py +36 -0
  8. cpkit/audit/recorder.py +240 -0
  9. cpkit/audit/repository.py +64 -0
  10. cpkit/audit/router.py +46 -0
  11. cpkit/audit/service.py +34 -0
  12. cpkit/audit/types.py +38 -0
  13. cpkit/auth/README.md +38 -0
  14. cpkit/auth/__init__.py +123 -0
  15. cpkit/auth/api_key_router.py +52 -0
  16. cpkit/auth/api_key_service.py +143 -0
  17. cpkit/auth/api_keys.py +143 -0
  18. cpkit/auth/bundle.py +113 -0
  19. cpkit/auth/claims.py +37 -0
  20. cpkit/auth/config.py +162 -0
  21. cpkit/auth/dependencies.py +155 -0
  22. cpkit/auth/oidc.py +703 -0
  23. cpkit/auth/redirects.py +12 -0
  24. cpkit/auth/repositories.py +181 -0
  25. cpkit/auth/router.py +214 -0
  26. cpkit/auth/secrets.py +78 -0
  27. cpkit/auth/types.py +49 -0
  28. cpkit/bundle.py +252 -0
  29. cpkit/cli/README.md +21 -0
  30. cpkit/cli/__init__.py +14 -0
  31. cpkit/cli/__main__.py +5 -0
  32. cpkit/cli/base.py +200 -0
  33. cpkit/cli/schema.py +30 -0
  34. cpkit/cli/server.py +22 -0
  35. cpkit/config/README.md +13 -0
  36. cpkit/config/__init__.py +9 -0
  37. cpkit/config/env.py +31 -0
  38. cpkit/db/README.md +24 -0
  39. cpkit/db/__init__.py +23 -0
  40. cpkit/db/postgres.py +252 -0
  41. cpkit/dependencies.py +73 -0
  42. cpkit/errors/README.md +22 -0
  43. cpkit/errors/__init__.py +35 -0
  44. cpkit/errors/http.py +45 -0
  45. cpkit/errors/repository.py +32 -0
  46. cpkit/errors/service.py +74 -0
  47. cpkit/jobs/README.md +29 -0
  48. cpkit/jobs/__init__.py +49 -0
  49. cpkit/jobs/maintenance.py +15 -0
  50. cpkit/jobs/repository.py +306 -0
  51. cpkit/jobs/router.py +105 -0
  52. cpkit/jobs/service.py +138 -0
  53. cpkit/jobs/types.py +67 -0
  54. cpkit/jobs/worker.py +200 -0
  55. cpkit/logging/README.md +19 -0
  56. cpkit/logging/__init__.py +13 -0
  57. cpkit/logging/context.py +29 -0
  58. cpkit/logging/middleware.py +55 -0
  59. cpkit/logging/setup.py +81 -0
  60. cpkit/playbooks/README.md +25 -0
  61. cpkit/playbooks/__init__.py +40 -0
  62. cpkit/playbooks/ansible.py +359 -0
  63. cpkit/playbooks/repository.py +95 -0
  64. cpkit/playbooks/router.py +95 -0
  65. cpkit/playbooks/service.py +232 -0
  66. cpkit/playbooks/types.py +43 -0
  67. cpkit/repository.py +63 -0
  68. cpkit/resources/__init__.py +17 -0
  69. cpkit/resources/ddl.sql +150 -0
  70. cpkit/settings/README.md +22 -0
  71. cpkit/settings/__init__.py +18 -0
  72. cpkit/settings/keys.py +30 -0
  73. cpkit/settings/repository.py +108 -0
  74. cpkit/settings/router.py +62 -0
  75. cpkit/settings/service.py +105 -0
  76. cpkit/settings/types.py +25 -0
  77. cpkit/time.py +4 -0
  78. cpkit/webapp/README.md +71 -0
  79. cpkit/webapp/__init__.py +12 -0
  80. cpkit/webapp/index.html +970 -0
  81. cpkit/webapp/script.js +1690 -0
  82. cpkit/webapp/style.css +1561 -0
  83. cpkit-0.1.0.dist-info/METADATA +105 -0
  84. cpkit-0.1.0.dist-info/RECORD +90 -0
  85. cpkit-0.1.0.dist-info/WHEEL +4 -0
  86. cpkit-0.1.0.dist-info/licenses/LICENSE +201 -0
  87. resources/README.md +16 -0
  88. resources/repository_maintenance_guide.md +90 -0
  89. resources/webapp_extension_guide.md +706 -0
  90. resources/webapp_extension_template.js +130 -0
cpkit/jobs/worker.py ADDED
@@ -0,0 +1,200 @@
1
+ """Generic queue worker loop."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import random
6
+ from collections.abc import Callable, Mapping
7
+ from typing import Any
8
+
9
+ from psycopg.rows import class_row
10
+
11
+ from cpkit.audit import job_id_ctx
12
+
13
+ from .maintenance import FAIL_ZOMBIE_JOBS_MESSAGE_TYPE, create_fail_zombie_jobs_handler
14
+ from .repository import QUEUE_TABLE
15
+ from .types import QueueMessage
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ QueueHandler = Callable[[int, Any, str], None]
20
+ ResolveHandler = Callable[[QueueMessage], QueueHandler | None]
21
+ ParseMessage = Callable[[QueueMessage], Any]
22
+ FailureHandler = Callable[[QueueMessage, Exception], None]
23
+
24
+
25
+ def create_queue_worker(
26
+ *,
27
+ get_pool: Callable[[], Any],
28
+ get_repo: Callable[[], Any],
29
+ handlers: Mapping[Any, QueueHandler] | None = None,
30
+ resolve_handler: ResolveHandler | None = None,
31
+ parse_message: ParseMessage | None = None,
32
+ handle_failure: FailureHandler | None = None,
33
+ failed_status: str = "FAILED",
34
+ ) -> Callable[[], Any]:
35
+ """Create a background task that polls the framework queue."""
36
+ maintenance_handlers = {
37
+ FAIL_ZOMBIE_JOBS_MESSAGE_TYPE: create_fail_zombie_jobs_handler(get_repo),
38
+ }
39
+
40
+ def record_failure(message: QueueMessage, err: Exception) -> None:
41
+ try:
42
+ get_repo().update_job(message.msg_id, failed_status)
43
+ except Exception:
44
+ logger.exception("Unable to mark job %s as failed", message.msg_id)
45
+ if handle_failure is not None:
46
+ handle_failure(message, err)
47
+
48
+ async def pull_from_mq() -> None:
49
+ await run_queue_worker(
50
+ get_pool=get_pool,
51
+ resolve_handler=_resolve_worker_handler(
52
+ maintenance_handlers,
53
+ handlers,
54
+ resolve_handler,
55
+ ),
56
+ parse_message=_parse_worker_message(
57
+ maintenance_handlers,
58
+ parse_message,
59
+ ),
60
+ handle_failure=record_failure,
61
+ )
62
+
63
+ return pull_from_mq
64
+
65
+
66
+ def _resolve_worker_handler(
67
+ maintenance_handlers: Mapping[str, QueueHandler],
68
+ handlers: Mapping[Any, QueueHandler] | None,
69
+ resolve_handler: ResolveHandler | None,
70
+ ) -> ResolveHandler:
71
+ def resolve(message: QueueMessage) -> QueueHandler | None:
72
+ handler = maintenance_handlers.get(message.msg_type)
73
+ if handler is not None:
74
+ return handler
75
+ if handlers is not None:
76
+ handler = handlers.get(message.msg_type)
77
+ if handler is not None:
78
+ return handler
79
+ if resolve_handler is None:
80
+ return None
81
+ return resolve_handler(message)
82
+
83
+ return resolve
84
+
85
+
86
+ def _parse_worker_message(
87
+ maintenance_handlers: Mapping[str, QueueHandler],
88
+ parse_message: ParseMessage | None,
89
+ ) -> ParseMessage:
90
+ def parse(message: QueueMessage) -> Any:
91
+ if message.msg_type in maintenance_handlers:
92
+ return message.msg_data
93
+ if parse_message is None:
94
+ return message.msg_data
95
+ return parse_message(message)
96
+
97
+ return parse
98
+
99
+
100
+ async def run_queue_worker(
101
+ *,
102
+ get_pool: Callable[[], Any],
103
+ handlers: Mapping[Any, QueueHandler] | None = None,
104
+ resolve_handler: ResolveHandler | None = None,
105
+ parse_message: ParseMessage | None = None,
106
+ handle_failure: FailureHandler | None = None,
107
+ poll_seconds: float = 5,
108
+ jitter_min: float = 0.7,
109
+ jitter_max: float = 1.3,
110
+ ) -> None:
111
+ """Continuously claim due queue messages and dispatch them to handlers."""
112
+ try:
113
+ while True:
114
+ await asyncio.sleep(poll_seconds * random.uniform(jitter_min, jitter_max))
115
+ try:
116
+ with get_pool().connection() as conn:
117
+ with conn.cursor(row_factory=class_row(QueueMessage)) as cur:
118
+ with conn.transaction():
119
+ message = _claim_due_message(cur)
120
+ if message is None:
121
+ continue
122
+
123
+ logger.info(
124
+ "Processing queue message %s of type %s",
125
+ message.msg_id,
126
+ message.msg_type,
127
+ )
128
+
129
+ job_id_token = _set_current_job_id(message)
130
+ try:
131
+ handler = _resolve_handler(
132
+ message,
133
+ handlers=handlers,
134
+ resolve_handler=resolve_handler,
135
+ )
136
+ payload = (
137
+ parse_message(message)
138
+ if parse_message is not None
139
+ else message.msg_data
140
+ )
141
+ handler(message.msg_id, payload, message.created_by)
142
+ except Exception as err:
143
+ logger.exception(
144
+ "Queue message %s failed during dispatch",
145
+ message.msg_id,
146
+ )
147
+ if handle_failure is not None:
148
+ try:
149
+ handle_failure(message, err)
150
+ except Exception:
151
+ logger.exception(
152
+ "Queue message %s failure hook failed",
153
+ message.msg_id,
154
+ )
155
+ finally:
156
+ if job_id_token is not None:
157
+ job_id_ctx.reset(job_id_token)
158
+ cur.execute(
159
+ f"DELETE FROM {QUEUE_TABLE} WHERE msg_id = %s;",
160
+ (message.msg_id,),
161
+ )
162
+ except Exception:
163
+ logger.exception("Unexpected failure while polling the message queue")
164
+
165
+ except asyncio.CancelledError:
166
+ logger.info("Queue worker was stopped")
167
+
168
+
169
+ def _claim_due_message(cur) -> QueueMessage | None:
170
+ return cur.execute(f"""
171
+ SELECT *
172
+ FROM {QUEUE_TABLE}
173
+ WHERE now() > start_after
174
+ LIMIT 1
175
+ FOR UPDATE SKIP LOCKED
176
+ """).fetchone()
177
+
178
+
179
+ def _set_current_job_id(message: QueueMessage):
180
+ if message.msg_type == FAIL_ZOMBIE_JOBS_MESSAGE_TYPE:
181
+ return None
182
+ return job_id_ctx.set(message.msg_id)
183
+
184
+
185
+ def _resolve_handler(
186
+ message: QueueMessage,
187
+ *,
188
+ handlers: Mapping[Any, QueueHandler] | None,
189
+ resolve_handler: ResolveHandler | None,
190
+ ) -> QueueHandler:
191
+ if resolve_handler is not None:
192
+ handler = resolve_handler(message)
193
+ elif handlers is not None:
194
+ handler = handlers.get(message.msg_type)
195
+ else:
196
+ handler = None
197
+
198
+ if handler is None:
199
+ raise ValueError(f"Unknown task type requested: {message.msg_type}")
200
+ return handler
@@ -0,0 +1,19 @@
1
+ # Logging
2
+
3
+ The logging package configures operational logs and carries per-request context.
4
+
5
+ ## Files
6
+
7
+ - `context.py`: `request_id_ctx`, a logging filter, and compact formatter.
8
+ - `middleware.py`: FastAPI middleware that assigns/request-propagates
9
+ `X-Request-ID` and logs inbound/outbound requests.
10
+ - `setup.py`: Root logger setup, log levels, and journald-friendly formatting.
11
+
12
+ ## Runtime Flow
13
+
14
+ 1. `create_cpkit_app()` configures logging during startup.
15
+ 2. Request middleware sets `request_id_ctx` for each HTTP request.
16
+ 3. Audit recording uses `request_id_ctx.get` as the default request id provider.
17
+ 4. Log formatters include the request id so API logs and audit records can be
18
+ correlated.
19
+
@@ -0,0 +1,13 @@
1
+ """Logging setup and request context helpers."""
2
+
3
+ from .context import RequestIDFilter, ShorthandFormatter, request_id_ctx
4
+ from .middleware import request_logging_middleware
5
+ from .setup import configure_logging
6
+
7
+ __all__ = [
8
+ "RequestIDFilter",
9
+ "ShorthandFormatter",
10
+ "configure_logging",
11
+ "request_logging_middleware",
12
+ "request_id_ctx",
13
+ ]
@@ -0,0 +1,29 @@
1
+ """Request-aware logging context."""
2
+
3
+ import logging
4
+ from contextvars import ContextVar
5
+
6
+ request_id_ctx: ContextVar[str | None] = ContextVar("request_id", default=None)
7
+
8
+
9
+ class RequestIDFilter(logging.Filter):
10
+ def filter(self, record):
11
+ record.request_id = request_id_ctx.get() or "-"
12
+ return True
13
+
14
+
15
+ class ShorthandFormatter(logging.Formatter):
16
+ LEVEL_MAP = {
17
+ "DEBUG": "D",
18
+ "INFO": "I",
19
+ "WARNING": "W",
20
+ "ERROR": "E",
21
+ "CRITICAL": "C",
22
+ }
23
+
24
+ def format(self, record):
25
+ original_levelname = record.levelname
26
+ record.levelname = self.LEVEL_MAP.get(original_levelname, original_levelname)
27
+ result = super().format(record)
28
+ record.levelname = original_levelname
29
+ return result
@@ -0,0 +1,55 @@
1
+ """FastAPI request logging middleware helpers."""
2
+
3
+ import logging
4
+ import time
5
+ import uuid
6
+ from collections.abc import Awaitable, Callable
7
+ from typing import Any
8
+
9
+ from fastapi import Request, Response
10
+
11
+ from .context import request_id_ctx
12
+
13
+
14
+ async def request_logging_middleware(
15
+ request: Request,
16
+ call_next: Callable[[Request], Awaitable[Response]],
17
+ ) -> Response:
18
+ """Attach a request id and log inbound/outbound request details."""
19
+ request_id = _request_id(request.headers.get("X-Request-ID"))
20
+ request_id_ctx.set(request_id)
21
+
22
+ start_time = time.perf_counter()
23
+
24
+ client = _client_address(request)
25
+ logging.debug(f'<- {client} - "{request.method} {request.url.path}"')
26
+
27
+ response = await call_next(request)
28
+
29
+ process_time_ms = (time.perf_counter() - start_time) * 1000
30
+ logging.info(
31
+ f'-> {client} - "{request.method} {request.url.path}" '
32
+ f"{response.status_code} | {process_time_ms:.2f}"
33
+ )
34
+
35
+ response.headers["X-Request-ID"] = request_id
36
+ response.headers["X-Process-Time-ms"] = f"{process_time_ms:.2f}"
37
+
38
+ return response
39
+
40
+
41
+ def _client_address(request: Request) -> str:
42
+ client: Any = request.client
43
+ if client is None:
44
+ return "-"
45
+ return f"{client.host}:{client.port}"
46
+
47
+
48
+ def _request_id(value: str | None) -> str:
49
+ normalized = (str(value).strip() or None) if value else None
50
+ if normalized is not None:
51
+ try:
52
+ return str(uuid.UUID(normalized))
53
+ except ValueError:
54
+ pass
55
+ return str(uuid.uuid4())
cpkit/logging/setup.py ADDED
@@ -0,0 +1,81 @@
1
+ """Logging configuration for operational messages."""
2
+
3
+ import logging
4
+ import sys
5
+ import time
6
+ from typing import Any
7
+
8
+ from cpkit.settings import FrameworkSettingKey
9
+
10
+ from .context import RequestIDFilter, ShorthandFormatter
11
+
12
+
13
+ def configure_logging(
14
+ settings_provider=None,
15
+ *,
16
+ force: bool = False,
17
+ default_level: str = "INFO",
18
+ default_journald_identifier: str = "app",
19
+ level_key: Any = FrameworkSettingKey.logging_level,
20
+ journald_identifier_key: Any = FrameworkSettingKey.logging_journald_identifier,
21
+ ) -> None:
22
+ """Configure root logging with journald when available."""
23
+
24
+ if getattr(configure_logging, "_configured", False) and not force:
25
+ return
26
+
27
+ level_name = _setting_value(settings_provider, level_key, default_level).upper()
28
+ journald_identifier = _setting_value(
29
+ settings_provider,
30
+ journald_identifier_key,
31
+ default_journald_identifier,
32
+ )
33
+ level = getattr(logging, level_name, logging.INFO)
34
+ root_logger = logging.getLogger()
35
+ root_logger.setLevel(level)
36
+ logging.getLogger("uvicorn").setLevel(logging.ERROR)
37
+ logging.getLogger("uvicorn.access").setLevel(logging.ERROR)
38
+
39
+ if force:
40
+ for handler in list(root_logger.handlers):
41
+ root_logger.removeHandler(handler)
42
+ try:
43
+ handler.close()
44
+ except Exception:
45
+ pass
46
+ elif root_logger.handlers:
47
+ configure_logging._configured = True
48
+ return
49
+
50
+ formatter = ShorthandFormatter(
51
+ "%(asctime)s [%(levelname)s] [%(request_id)s] %(message)s"
52
+ )
53
+ formatter.converter = time.gmtime
54
+ formatter.default_msec_format = "%s.%03d"
55
+
56
+ handler: logging.Handler
57
+ try:
58
+ if sys.platform != "linux":
59
+ raise RuntimeError("journald unavailable")
60
+
61
+ from systemd.journal import JournalHandler
62
+
63
+ handler = JournalHandler(SYSLOG_IDENTIFIER=journald_identifier)
64
+ except Exception:
65
+ handler = logging.StreamHandler()
66
+
67
+ handler.addFilter(RequestIDFilter())
68
+ handler.setFormatter(formatter)
69
+ root_logger.addHandler(handler)
70
+ configure_logging._configured = True
71
+
72
+
73
+ def _setting_value(settings_provider, key: Any, default: str) -> str:
74
+ if settings_provider is None:
75
+ return default
76
+
77
+ setting = settings_provider.get_setting(key)
78
+ value = getattr(setting, "value", setting)
79
+ if value in (None, ""):
80
+ return default
81
+ return str(value)
@@ -0,0 +1,25 @@
1
+ # Playbooks
2
+
3
+ The playbooks package owns versioned playbook storage and Ansible execution.
4
+ It lets apps store playbook versions in the database, choose a default version,
5
+ edit playbooks from the admin webapp, and run the selected playbook as part of a
6
+ job.
7
+
8
+ ## Files
9
+
10
+ - `types.py`: Playbook records, list/version responses, and save requests.
11
+ - `repository.py`: Repository mixin for playbook version storage and defaults.
12
+ - `service.py`: Version listing, loading, saving, deleting, and default-setting
13
+ rules.
14
+ - `router.py`: Admin playbook API routes.
15
+ - `ansible.py`: Ansible runner wrappers that load stored playbooks, update jobs,
16
+ and record task output.
17
+
18
+ ## Runtime Flow
19
+
20
+ 1. Admin APIs manage compressed playbook content and default versions.
21
+ 2. A job handler calls `run_playbook()` or `run_playbook_lite()`.
22
+ 3. The runner loads the default playbook version from the database.
23
+ 4. The runner records the selected version on the job and writes task output as
24
+ Ansible events arrive.
25
+
@@ -0,0 +1,40 @@
1
+ """Versioned playbook models and repository helpers."""
2
+
3
+ from .ansible import (
4
+ AnsibleRunner,
5
+ LiteAnsibleRunner,
6
+ LiteRunnerResult,
7
+ RunnerResult,
8
+ run_playbook,
9
+ run_playbook_lite,
10
+ )
11
+ from .repository import PLAYBOOKS_TABLE, PlaybooksRepositoryMixin
12
+ from .router import create_playbooks_router
13
+ from .service import PlaybooksService
14
+ from .types import (
15
+ Playbook,
16
+ PlaybookListResponse,
17
+ PlaybookOverview,
18
+ PlaybookResponse,
19
+ PlaybookSaveRequest,
20
+ PlaybookVersionResponse,
21
+ )
22
+
23
+ __all__ = [
24
+ "PLAYBOOKS_TABLE",
25
+ "AnsibleRunner",
26
+ "LiteAnsibleRunner",
27
+ "LiteRunnerResult",
28
+ "Playbook",
29
+ "PlaybookListResponse",
30
+ "PlaybookOverview",
31
+ "PlaybookResponse",
32
+ "PlaybookSaveRequest",
33
+ "PlaybookVersionResponse",
34
+ "PlaybooksRepositoryMixin",
35
+ "PlaybooksService",
36
+ "RunnerResult",
37
+ "create_playbooks_router",
38
+ "run_playbook",
39
+ "run_playbook_lite",
40
+ ]