midstream-platform 1.0.0rc1__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 (187) hide show
  1. midstream/__init__.py +5 -0
  2. midstream/config.py +577 -0
  3. midstream/handlers/__init__.py +34 -0
  4. midstream/handlers/identity.py +60 -0
  5. midstream/handlers/maintenance.py +207 -0
  6. midstream/handlers/request_intake.py +51 -0
  7. midstream/handlers/resource_observation.py +272 -0
  8. midstream/handlers/runtime_status.py +14 -0
  9. midstream/handlers/settings.py +29 -0
  10. midstream/handlers/table_events.py +34 -0
  11. midstream/handlers/workflow_apply.py +276 -0
  12. midstream/identity/__init__.py +72 -0
  13. midstream/identity/access_tokens.py +72 -0
  14. midstream/identity/authorization.py +296 -0
  15. midstream/identity/directory.py +144 -0
  16. midstream/identity/passwords.py +169 -0
  17. midstream/identity/permissions.py +519 -0
  18. midstream/identity/principals.py +375 -0
  19. midstream/identity/secrets.py +119 -0
  20. midstream/identity/tokens.py +167 -0
  21. midstream/identity/validation.py +121 -0
  22. midstream/kafka/__init__.py +25 -0
  23. midstream/kafka/admin.py +403 -0
  24. midstream/kafka/metadata.py +429 -0
  25. midstream/kubernetes/__init__.py +5 -0
  26. midstream/kubernetes/client.py +5 -0
  27. midstream/kubernetes/kaspr_apps.py +341 -0
  28. midstream/lakehouse/__init__.py +5 -0
  29. midstream/lakehouse/catalog.py +406 -0
  30. midstream/lakehouse/commits.py +595 -0
  31. midstream/lakehouse/files.py +241 -0
  32. midstream/lakehouse/gcs.py +116 -0
  33. midstream/lakehouse/io.py +180 -0
  34. midstream/lakehouse/lifecycle.py +81 -0
  35. midstream/lakehouse/maintenance.py +604 -0
  36. midstream/lakehouse/offsets.py +147 -0
  37. midstream/lakehouse/parquet.py +103 -0
  38. midstream/lakehouse/pointer.py +228 -0
  39. midstream/lakehouse/rest.py +137 -0
  40. midstream/lakehouse/rows.py +82 -0
  41. midstream/lakehouse/schema/__init__.py +1 -0
  42. midstream/lakehouse/schema/conform.py +225 -0
  43. midstream/lakehouse/schema/forkmap.py +111 -0
  44. midstream/lakehouse/schema/index.py +304 -0
  45. midstream/lakehouse/schema/infer.py +124 -0
  46. midstream/lakehouse/schema/policy.py +867 -0
  47. midstream/lakehouse/schema/render.py +166 -0
  48. midstream/lakehouse/tables.py +280 -0
  49. midstream/maintenance_app/__init__.py +1 -0
  50. midstream/maintenance_app/compact_worker.py +234 -0
  51. midstream/maintenance_app/config.py +54 -0
  52. midstream/maintenance_app/handlers.py +42 -0
  53. midstream/maintenance_app/worker.py +981 -0
  54. midstream/models/__init__.py +163 -0
  55. midstream/models/autoscale.py +85 -0
  56. midstream/models/binding.py +98 -0
  57. midstream/models/envelopes.py +44 -0
  58. midstream/models/events.py +624 -0
  59. midstream/models/identity.py +893 -0
  60. midstream/models/live_tail.py +162 -0
  61. midstream/models/maintenance.py +139 -0
  62. midstream/models/materialization.py +429 -0
  63. midstream/models/offsets.py +43 -0
  64. midstream/models/settings.py +374 -0
  65. midstream/models/status.py +641 -0
  66. midstream/models/table.py +299 -0
  67. midstream/observation/__init__.py +41 -0
  68. midstream/observation/mapping.py +332 -0
  69. midstream/observation/policy.py +73 -0
  70. midstream/observation/progress.py +328 -0
  71. midstream/observation/reader.py +46 -0
  72. midstream/observation/restarts.py +101 -0
  73. midstream/protocol_logging.py +66 -0
  74. midstream/runtime/__init__.py +51 -0
  75. midstream/runtime/bindings.py +33 -0
  76. midstream/runtime/naming.py +86 -0
  77. midstream/runtime/templates.py +434 -0
  78. midstream/runtime_app/__init__.py +1 -0
  79. midstream/runtime_app/acks.py +329 -0
  80. midstream/runtime_app/committer.py +885 -0
  81. midstream/runtime_app/config.py +207 -0
  82. midstream/runtime_app/flow.py +100 -0
  83. midstream/runtime_app/handlers/__init__.py +11 -0
  84. midstream/runtime_app/handlers/runtime_settings.py +124 -0
  85. midstream/runtime_app/handlers/status.py +207 -0
  86. midstream/runtime_app/handlers/writer.py +366 -0
  87. midstream/runtime_app/live_settings.py +222 -0
  88. midstream/runtime_app/models/__init__.py +23 -0
  89. midstream/runtime_app/models/processing_state.py +402 -0
  90. midstream/runtime_app/outbox.py +74 -0
  91. midstream/runtime_app/performance.py +180 -0
  92. midstream/runtime_app/pipeline.py +995 -0
  93. midstream/runtime_app/schema/__init__.py +1 -0
  94. midstream/runtime_app/schema/drift.py +146 -0
  95. midstream/runtime_app/state.py +478 -0
  96. midstream/runtime_app/table_state.py +366 -0
  97. midstream/runtime_app/templates/commit-timer-task.yaml.j2 +40 -0
  98. midstream/runtime_app/templates/committer-agent.yaml.j2 +68 -0
  99. midstream/runtime_app/templates/kaspr-app.yaml.j2 +11 -0
  100. midstream/runtime_app/templates/pipeline-task.yaml.j2 +45 -0
  101. midstream/runtime_app/templates/settings-agent.yaml.j2 +39 -0
  102. midstream/runtime_app/templates/state-table.yaml.j2 +15 -0
  103. midstream/runtime_app/templates/status-task.yaml.j2 +57 -0
  104. midstream/runtime_app/templates/writer-agent.yaml.j2 +58 -0
  105. midstream/runtime_app/writer/__init__.py +19 -0
  106. midstream/runtime_app/writer/batches.py +230 -0
  107. midstream/runtime_app/writer/context.py +145 -0
  108. midstream/runtime_app/writer/seal.py +261 -0
  109. midstream/runtime_app/writer/seal_worker.py +45 -0
  110. midstream/runtime_app/writer/upload.py +58 -0
  111. midstream/scale.py +180 -0
  112. midstream/scheduler/__init__.py +81 -0
  113. midstream/schema/__init__.py +8 -0
  114. midstream/schema/discovery.py +423 -0
  115. midstream/services/__init__.py +51 -0
  116. midstream/services/autoscaling.py +294 -0
  117. midstream/services/consumer_lag.py +349 -0
  118. midstream/services/identity.py +1998 -0
  119. midstream/services/live_tail.py +139 -0
  120. midstream/services/observation.py +550 -0
  121. midstream/services/request_intake.py +608 -0
  122. midstream/services/retention.py +216 -0
  123. midstream/services/schema_projection.py +43 -0
  124. midstream/services/settings.py +456 -0
  125. midstream/services/sweep_cursor.py +39 -0
  126. midstream/services/table_claims.py +153 -0
  127. midstream/services/table_events.py +384 -0
  128. midstream/services/table_maintenance.py +155 -0
  129. midstream/services/topic_analysis.py +121 -0
  130. midstream/services/topic_catalog.py +174 -0
  131. midstream/services/topic_changes.py +210 -0
  132. midstream/services/workflow.py +3231 -0
  133. midstream/settings/__init__.py +49 -0
  134. midstream/settings/catalog.py +1332 -0
  135. midstream/settings/effective.py +221 -0
  136. midstream/storage/__init__.py +139 -0
  137. midstream/storage/autoscale.py +38 -0
  138. midstream/storage/bindings.py +47 -0
  139. midstream/storage/envelopes.py +72 -0
  140. midstream/storage/identity.py +749 -0
  141. midstream/storage/keys.py +79 -0
  142. midstream/storage/materializations.py +230 -0
  143. midstream/storage/objects.py +550 -0
  144. midstream/storage/operations.py +72 -0
  145. midstream/storage/resolve.py +42 -0
  146. midstream/storage/schemas.py +180 -0
  147. midstream/storage/settings.py +119 -0
  148. midstream/storage/status.py +49 -0
  149. midstream/storage/table.py +20 -0
  150. midstream/storage/tables.py +66 -0
  151. midstream/table_names.py +251 -0
  152. midstream/tail_app/__init__.py +1 -0
  153. midstream/tail_app/auth.py +149 -0
  154. midstream/tail_app/buffer.py +445 -0
  155. midstream/tail_app/config.py +191 -0
  156. midstream/tail_app/consumer.py +238 -0
  157. midstream/tail_app/control_plane.py +90 -0
  158. midstream/tail_app/events.py +62 -0
  159. midstream/tail_app/handlers.py +97 -0
  160. midstream/tail_app/http.py +71 -0
  161. midstream/tail_app/ingest.py +127 -0
  162. midstream/tail_app/peek.py +218 -0
  163. midstream/tail_app/routing.py +213 -0
  164. midstream/tail_app/serve.py +233 -0
  165. midstream/tail_app/table_tail.py +625 -0
  166. midstream/tail_app/views.py +396 -0
  167. midstream/tail_app/worker.py +808 -0
  168. midstream/utils/__init__.py +1 -0
  169. midstream/utils/background_loop.py +127 -0
  170. midstream/version.py +33 -0
  171. midstream/web/__init__.py +52 -0
  172. midstream/web/auth.py +554 -0
  173. midstream/web/catalog.py +299 -0
  174. midstream/web/datasets.py +455 -0
  175. midstream/web/errors.py +136 -0
  176. midstream/web/http.py +29 -0
  177. midstream/web/identity.py +870 -0
  178. midstream/web/materializations.py +1287 -0
  179. midstream/web/responses.py +327 -0
  180. midstream/web/settings.py +424 -0
  181. midstream/web/system.py +75 -0
  182. midstream/web/tokens.py +251 -0
  183. midstream/web/topics.py +122 -0
  184. midstream_platform-1.0.0rc1.dist-info/METADATA +38 -0
  185. midstream_platform-1.0.0rc1.dist-info/RECORD +187 -0
  186. midstream_platform-1.0.0rc1.dist-info/WHEEL +5 -0
  187. midstream_platform-1.0.0rc1.dist-info/top_level.txt +1 -0
midstream/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Kaspr control-plane package for Kafka topic materialization."""
2
+
3
+ from midstream.config import ControlPlaneConfig, get_config, normalize_list_limit
4
+
5
+ __all__ = ["ControlPlaneConfig", "get_config", "normalize_list_limit"]
midstream/config.py ADDED
@@ -0,0 +1,577 @@
1
+ """Control-plane configuration and shared defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from functools import lru_cache
7
+ import os
8
+ import re
9
+ from collections.abc import Mapping
10
+ from typing import Any
11
+
12
+ import yaml
13
+
14
+ ALLOWED_LIST_LIMITS = ("10", "30", "100", "all")
15
+ DEFAULT_LIST_LIMIT = "30"
16
+ # Where a pod finds its own namespace; the deployment can name another for the runtimes.
17
+ IN_CLUSTER_NAMESPACE_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
18
+ DEFAULT_RUNTIME_NAME_PREFIX = "kaspr"
19
+ DEFAULT_RUNTIME_TEMPLATE_VERSION = "v1"
20
+ DEFAULT_RUNTIME_APP_SPEC: dict[str, Any] = {}
21
+ CONTROL_PLANE_TABLE_NAME = "state"
22
+ MATERIALIZATION_REQUESTS_TOPIC = "materialization-requests"
23
+ MATERIALIZATION_WORK_TOPIC = "materialization-work"
24
+ MATERIALIZATION_STATUS_TOPIC = "materialization-status"
25
+ # Carries runtime settings (and maintenance requests) to every runtime. The name predates 1.0, when
26
+ # it also carried schema decisions; it is kept so no overlay has to rename a topic.
27
+ SCHEMA_AUTHORITY_UPDATES_TOPIC = "materialization-schema-authority-updates"
28
+ TABLE_EVENTS_TOPIC = "materialization-table-events"
29
+ SETTINGS_REQUESTS_TOPIC = "materializer-settings-requests"
30
+ IDENTITY_REQUESTS_TOPIC = "materializer-identity-requests"
31
+ PROTOCOL_LOGGING_ENABLED = False
32
+ PROTOCOL_LOG_SLOW_WAIT_SECONDS = 30
33
+ SCHEMA_HISTORY_LIMIT = 50
34
+ # Iceberg: the prefix in the destination every materialization's tables are stored under (it keeps
35
+ # deployments sharing a bucket apart; datasets, not this, are the tables' namespaces), and the
36
+ # tables' partitioning.
37
+ STORAGE_PREFIX = "materializer"
38
+ DEFAULT_PARTITION_GRANULARITY = "day"
39
+ PARTITION_GRANULARITIES = ("hour", "day", "month", "none")
40
+ # Discovery samples up to this many records, from both ends of every partition, for schema 0.
41
+ DISCOVERY_SAMPLE_RECORDS = 200
42
+ DISCOVERY_SAMPLE_SECONDS = 5
43
+ # The Iceberg REST catalog facade the control plane serves at /catalog/v1.
44
+ CATALOG_API_ENABLED = True
45
+ CATALOG_METADATA_CACHE_SECONDS = 5
46
+ # The public base URL of that facade, for the snippets the console shows; unset hides them.
47
+ CATALOG_BASE_URL: str | None = None
48
+ DELETED_RETENTION_HOURS = 168
49
+ # Deleting a materialization removes everything it owns, including its control-plane records.
50
+ # Disable to keep a tombstone until the retention sweep collects it instead.
51
+ PURGE_RECORDS_ON_DELETE = True
52
+ DELETED_PURGE_BATCH_LIMIT = 50
53
+ # The persistent volume of every runtime member: it holds only the runtime's state table. The control
54
+ # plane's own volume is its KasprApp's, set in its manifest.
55
+ DEFAULT_RUNTIME_STORAGE_SIZE = "64Mi"
56
+ DEFAULT_RUNTIME_REPLICAS = 1
57
+ MAX_RUNTIME_REPLICAS = 64
58
+ # Runtime autoscaling: a runtime far behind its topics gets more members, up to
59
+ # RUNTIME_AUTOSCALE_MAX_REPLICAS, so it catches up within the target; it goes back to its own replica
60
+ # count once it has. Each materialization can turn it off for itself (services/autoscaling.py).
61
+ RUNTIME_AUTOSCALING_ENABLED = True
62
+ RUNTIME_AUTOSCALE_MAX_REPLICAS = 16
63
+ RUNTIME_AUTOSCALE_TARGET_MINUTES = 30
64
+ RUNTIME_AUTOSCALE_MIN_LAG_RECORDS = 100_000
65
+ RUNTIME_AUTOSCALE_COOLDOWN_SECONDS = 300
66
+ # Most source topics one materialization may read. Each is a table its one committer commits to and
67
+ # maintains, one at a time, so the bound keeps a commit cycle and the maintenance rotation short.
68
+ MAX_MATERIALIZATION_TOPICS = 20
69
+ # Where every new materialization's tables are created. Unset, no materialization can be created.
70
+ DEFAULT_DESTINATION_URI: str | None = None
71
+ DESTINATION_CLEANUP_ENABLED = True
72
+ CONSUMER_LAG_REFRESH_BATCH_LIMIT = 100
73
+ TOPIC_ANALYSIS_MAX_TOPICS = 20
74
+ # The topic catalog answers from memory for this long before asking Kafka again; a forced refresh
75
+ # still waits this many seconds after the previous load, however many clients ask for one.
76
+ TOPIC_CATALOG_TTL_SECONDS = 15
77
+ TOPIC_CATALOG_MIN_REFRESH_SECONDS = 5
78
+ # Sign-in: a session ends after this long without a request, and after the maximum however
79
+ # active it is. Clients renew an active session at the refresh interval, which is also how often
80
+ # a person's last activity is recorded.
81
+ SESSION_IDLE_TIMEOUT_MINUTES = 8 * 60
82
+ SESSION_MAX_LIFETIME_HOURS = 7 * 24
83
+ SESSION_REFRESH_MINUTES = 10
84
+ PASSWORD_MIN_LENGTH = 12
85
+ # Failed sign-ins for one email, in a row, that lock it out for the lockout duration.
86
+ SIGN_IN_MAX_FAILED_ATTEMPTS = 10
87
+ SIGN_IN_LOCKOUT_MINUTES = 15
88
+ AUDIT_LOG_RETENTION_DAYS = 90
89
+ # The live tail (handbook 19): the newest records of a materialization's tables, served from Kafka
90
+ # by the tail app before they are committed. Every materialization has one; `LIVE_TAIL_ENABLED` is
91
+ # the deployment's switch, for all of them. The rest travel with the tail app's
92
+ # ticks: what one table's tail may hold, how long committed records stay in it, how long a read of
93
+ # Kafka waits, how fast one table is read, the most records one peek at a topic's head reads, and how
94
+ # long a table stays tailed after the last query asked for it.
95
+ LIVE_TAIL_ENABLED = True
96
+ LIVE_TAIL_MAX_ROWS_PER_TABLE = 250_000
97
+ LIVE_TAIL_MAX_BYTES_PER_TABLE = 256 * 1024 * 1024
98
+ LIVE_TAIL_RETENTION_SECONDS = 120
99
+ LIVE_TAIL_FETCH_WAIT_MS = 200
100
+ LIVE_TAIL_MAX_RECORDS_PER_SECOND_PER_TABLE = 5_000
101
+ LIVE_TAIL_PEEK_MAX_ROWS = 1_000
102
+ LIVE_TAIL_IDLE_SECONDS = 600
103
+
104
+
105
+ #: A destination a table can live under: a GCS bucket, and optionally a prefix in it.
106
+ GCS_DESTINATION_PATTERN = re.compile(r"^gs://[a-z0-9][a-z0-9._-]*[a-z0-9](/\S*)?$")
107
+ GCS_DESTINATION_MAX_LENGTH = 1024
108
+
109
+
110
+ def is_gcs_destination_uri(value: str | None) -> bool:
111
+ """Whether `value` (normalized) is a `gs://bucket[/prefix]` a table can be created under."""
112
+ return value is not None and len(value) <= GCS_DESTINATION_MAX_LENGTH and GCS_DESTINATION_PATTERN.match(value) is not None
113
+
114
+
115
+ def normalize_destination_uri(value: str | None) -> str | None:
116
+ """Trim a destination prefix, dropping trailing slashes so object paths join cleanly.
117
+
118
+ Blank is no destination at all, which is how an unset default reads from the environment.
119
+ """
120
+ if value is None:
121
+ return None
122
+ trimmed = value.strip()
123
+ if not trimmed:
124
+ return None
125
+ return trimmed.rstrip("/") or trimmed
126
+
127
+
128
+ def normalize_list_limit(limit: str | None) -> str:
129
+ """Validate and normalize the list page-size value used by web views."""
130
+ if limit in (None, ""):
131
+ return DEFAULT_LIST_LIMIT
132
+ if limit not in ALLOWED_LIST_LIMITS:
133
+ allowed = ", ".join(ALLOWED_LIST_LIMITS)
134
+ raise ValueError(f"limit must be one of: {allowed}")
135
+ return limit
136
+
137
+
138
+ @dataclass(frozen=True, slots=True)
139
+ class ControlPlaneConfig:
140
+ """Immutable runtime configuration loaded from environment variables."""
141
+
142
+ #: The namespace every runtime is created in: the deployment's, or the pod's own
143
+ #: (`_resolve_default_namespace`). Nothing in the code names one.
144
+ default_namespace: str = ""
145
+ default_runtime_name_prefix: str = DEFAULT_RUNTIME_NAME_PREFIX
146
+ default_runtime_template_version: str = DEFAULT_RUNTIME_TEMPLATE_VERSION
147
+ runtime_app_spec: dict[str, Any] = field(default_factory=dict)
148
+ default_list_limit: str = DEFAULT_LIST_LIMIT
149
+ control_plane_table_name: str = CONTROL_PLANE_TABLE_NAME
150
+ materialization_requests_topic: str = MATERIALIZATION_REQUESTS_TOPIC
151
+ materialization_work_topic: str = MATERIALIZATION_WORK_TOPIC
152
+ materialization_status_topic: str = MATERIALIZATION_STATUS_TOPIC
153
+ schema_authority_updates_topic: str = SCHEMA_AUTHORITY_UPDATES_TOPIC
154
+ table_events_topic: str = TABLE_EVENTS_TOPIC
155
+ settings_requests_topic: str = SETTINGS_REQUESTS_TOPIC
156
+ protocol_logging_enabled: bool = PROTOCOL_LOGGING_ENABLED
157
+ protocol_log_slow_wait_seconds: int = PROTOCOL_LOG_SLOW_WAIT_SECONDS
158
+ schema_history_limit: int = SCHEMA_HISTORY_LIMIT
159
+ storage_prefix: str = STORAGE_PREFIX
160
+ default_partition_granularity: str = DEFAULT_PARTITION_GRANULARITY
161
+ discovery_sample_records: int = DISCOVERY_SAMPLE_RECORDS
162
+ discovery_sample_seconds: int = DISCOVERY_SAMPLE_SECONDS
163
+ catalog_api_enabled: bool = CATALOG_API_ENABLED
164
+ catalog_metadata_cache_seconds: int = CATALOG_METADATA_CACHE_SECONDS
165
+ catalog_base_url: str | None = CATALOG_BASE_URL
166
+ purge_records_on_delete: bool = PURGE_RECORDS_ON_DELETE
167
+ deleted_retention_hours: int = DELETED_RETENTION_HOURS
168
+ deleted_purge_batch_limit: int = DELETED_PURGE_BATCH_LIMIT
169
+ default_runtime_storage_size: str = DEFAULT_RUNTIME_STORAGE_SIZE
170
+ default_runtime_replicas: int = DEFAULT_RUNTIME_REPLICAS
171
+ max_runtime_replicas: int = MAX_RUNTIME_REPLICAS
172
+ runtime_autoscaling_enabled: bool = RUNTIME_AUTOSCALING_ENABLED
173
+ runtime_autoscale_max_replicas: int = RUNTIME_AUTOSCALE_MAX_REPLICAS
174
+ runtime_autoscale_target_minutes: int = RUNTIME_AUTOSCALE_TARGET_MINUTES
175
+ runtime_autoscale_min_lag_records: int = RUNTIME_AUTOSCALE_MIN_LAG_RECORDS
176
+ runtime_autoscale_cooldown_seconds: int = RUNTIME_AUTOSCALE_COOLDOWN_SECONDS
177
+ max_materialization_topics: int = MAX_MATERIALIZATION_TOPICS
178
+ default_destination_uri: str | None = DEFAULT_DESTINATION_URI
179
+ destination_cleanup_enabled: bool = DESTINATION_CLEANUP_ENABLED
180
+ consumer_lag_refresh_batch_limit: int = CONSUMER_LAG_REFRESH_BATCH_LIMIT
181
+ topic_analysis_max_topics: int = TOPIC_ANALYSIS_MAX_TOPICS
182
+ topic_catalog_ttl_seconds: int = TOPIC_CATALOG_TTL_SECONDS
183
+ topic_catalog_min_refresh_seconds: int = TOPIC_CATALOG_MIN_REFRESH_SECONDS
184
+ #: This control plane's own Kaspr app name; its internal topics are prefixed with it.
185
+ kaspr_app_name: str | None = None
186
+ identity_requests_topic: str = IDENTITY_REQUESTS_TOPIC
187
+ session_idle_timeout_minutes: int = SESSION_IDLE_TIMEOUT_MINUTES
188
+ session_max_lifetime_hours: int = SESSION_MAX_LIFETIME_HOURS
189
+ session_refresh_minutes: int = SESSION_REFRESH_MINUTES
190
+ password_min_length: int = PASSWORD_MIN_LENGTH
191
+ sign_in_max_failed_attempts: int = SIGN_IN_MAX_FAILED_ATTEMPTS
192
+ sign_in_lockout_minutes: int = SIGN_IN_LOCKOUT_MINUTES
193
+ audit_log_retention_days: int = AUDIT_LOG_RETENTION_DAYS
194
+ live_tail_enabled: bool = LIVE_TAIL_ENABLED
195
+ live_tail_max_rows_per_table: int = LIVE_TAIL_MAX_ROWS_PER_TABLE
196
+ live_tail_max_bytes_per_table: int = LIVE_TAIL_MAX_BYTES_PER_TABLE
197
+ live_tail_retention_seconds: int = LIVE_TAIL_RETENTION_SECONDS
198
+ live_tail_fetch_wait_ms: int = LIVE_TAIL_FETCH_WAIT_MS
199
+ live_tail_max_records_per_second_per_table: int = LIVE_TAIL_MAX_RECORDS_PER_SECOND_PER_TABLE
200
+ live_tail_peek_max_rows: int = LIVE_TAIL_PEEK_MAX_ROWS
201
+ live_tail_idle_seconds: int = LIVE_TAIL_IDLE_SECONDS
202
+
203
+ def __post_init__(self) -> None:
204
+ if self.default_partition_granularity not in PARTITION_GRANULARITIES:
205
+ raise ValueError(
206
+ "default_partition_granularity must be one of: " + ", ".join(PARTITION_GRANULARITIES)
207
+ )
208
+
209
+ def runtime_name_for_materialization(self, materialization_id: str) -> str:
210
+ """The name of a materialization's dedicated runtime: always generated, never chosen."""
211
+ return f"{self.default_runtime_name_prefix}-{materialization_id}"
212
+
213
+
214
+ def _resolve_default_namespace(*, in_cluster_namespace_file: str | None = None) -> str:
215
+ """The namespace the control plane creates runtimes in.
216
+
217
+ The deployment names it with `CONTROL_PLANE_DEFAULT_NAMESPACE`; without that, a pod
218
+ reads its own namespace from the service-account mount Kubernetes gives every pod. With neither,
219
+ the configuration cannot be loaded: no namespace is ever assumed.
220
+ """
221
+ configured = (os.getenv("CONTROL_PLANE_DEFAULT_NAMESPACE") or "").strip()
222
+ if configured:
223
+ return configured
224
+ path = IN_CLUSTER_NAMESPACE_FILE if in_cluster_namespace_file is None else in_cluster_namespace_file
225
+ try:
226
+ with open(path, encoding="utf-8") as handle:
227
+ own = handle.read().strip()
228
+ except OSError:
229
+ own = ""
230
+ if own:
231
+ return own
232
+ raise ValueError(
233
+ "CONTROL_PLANE_DEFAULT_NAMESPACE is not set and no in-cluster namespace file was "
234
+ "found: the control plane needs the namespace its runtimes are created in"
235
+ )
236
+
237
+
238
+ @lru_cache(maxsize=1)
239
+ def get_config() -> ControlPlaneConfig:
240
+ """Load and cache the control-plane configuration from the environment."""
241
+ return ControlPlaneConfig(
242
+ control_plane_table_name=os.getenv(
243
+ "CONTROL_PLANE_TABLE_NAME",
244
+ CONTROL_PLANE_TABLE_NAME,
245
+ ),
246
+ default_namespace=_resolve_default_namespace(),
247
+ default_runtime_name_prefix=os.getenv(
248
+ "CONTROL_PLANE_RUNTIME_NAME_PREFIX",
249
+ DEFAULT_RUNTIME_NAME_PREFIX,
250
+ ),
251
+ default_runtime_template_version=os.getenv(
252
+ "CONTROL_PLANE_RUNTIME_TEMPLATE_VERSION",
253
+ DEFAULT_RUNTIME_TEMPLATE_VERSION,
254
+ ),
255
+ runtime_app_spec=_load_runtime_app_spec(
256
+ os.getenv("CONTROL_PLANE_RUNTIME_APP_SPEC")
257
+ ),
258
+ default_list_limit=normalize_list_limit(
259
+ os.getenv(
260
+ "CONTROL_PLANE_DEFAULT_LIST_LIMIT",
261
+ DEFAULT_LIST_LIMIT,
262
+ )
263
+ ),
264
+ materialization_requests_topic=os.getenv(
265
+ "CONTROL_PLANE_MATERIALIZATION_REQUESTS_TOPIC",
266
+ MATERIALIZATION_REQUESTS_TOPIC,
267
+ ),
268
+ materialization_work_topic=os.getenv(
269
+ "CONTROL_PLANE_MATERIALIZATION_WORK_TOPIC",
270
+ MATERIALIZATION_WORK_TOPIC,
271
+ ),
272
+ materialization_status_topic=os.getenv(
273
+ "CONTROL_PLANE_MATERIALIZATION_STATUS_TOPIC",
274
+ MATERIALIZATION_STATUS_TOPIC,
275
+ ),
276
+ schema_authority_updates_topic=os.getenv(
277
+ "CONTROL_PLANE_SCHEMA_AUTHORITY_UPDATES_TOPIC",
278
+ SCHEMA_AUTHORITY_UPDATES_TOPIC,
279
+ ),
280
+ table_events_topic=os.getenv(
281
+ "CONTROL_PLANE_TABLE_EVENTS_TOPIC",
282
+ TABLE_EVENTS_TOPIC,
283
+ ),
284
+ settings_requests_topic=os.getenv(
285
+ "CONTROL_PLANE_SETTINGS_REQUESTS_TOPIC",
286
+ SETTINGS_REQUESTS_TOPIC,
287
+ ),
288
+ protocol_logging_enabled=_parse_bool_env(
289
+ os.getenv("PROTOCOL_LOGGING_ENABLED"),
290
+ default=PROTOCOL_LOGGING_ENABLED,
291
+ ),
292
+ protocol_log_slow_wait_seconds=_parse_int_env(
293
+ os.getenv("PROTOCOL_LOG_SLOW_WAIT_SECONDS"),
294
+ default=PROTOCOL_LOG_SLOW_WAIT_SECONDS,
295
+ minimum=1,
296
+ name="PROTOCOL_LOG_SLOW_WAIT_SECONDS",
297
+ ),
298
+ schema_history_limit=_parse_int_env(
299
+ os.getenv("CONTROL_PLANE_SCHEMA_HISTORY_LIMIT"),
300
+ default=SCHEMA_HISTORY_LIMIT,
301
+ minimum=1,
302
+ name="CONTROL_PLANE_SCHEMA_HISTORY_LIMIT",
303
+ ),
304
+ storage_prefix=os.getenv("CONTROL_PLANE_STORAGE_PREFIX", STORAGE_PREFIX).strip().strip("/")
305
+ or STORAGE_PREFIX,
306
+ default_partition_granularity=os.getenv(
307
+ "CONTROL_PLANE_DEFAULT_PARTITION_GRANULARITY",
308
+ DEFAULT_PARTITION_GRANULARITY,
309
+ ).strip(),
310
+ discovery_sample_records=_parse_int_env(
311
+ os.getenv("CONTROL_PLANE_DISCOVERY_SAMPLE_RECORDS"),
312
+ default=DISCOVERY_SAMPLE_RECORDS,
313
+ minimum=1,
314
+ name="CONTROL_PLANE_DISCOVERY_SAMPLE_RECORDS",
315
+ ),
316
+ discovery_sample_seconds=_parse_int_env(
317
+ os.getenv("CONTROL_PLANE_DISCOVERY_SAMPLE_SECONDS"),
318
+ default=DISCOVERY_SAMPLE_SECONDS,
319
+ minimum=1,
320
+ name="CONTROL_PLANE_DISCOVERY_SAMPLE_SECONDS",
321
+ ),
322
+ catalog_api_enabled=_parse_bool_env(
323
+ os.getenv("CONTROL_PLANE_CATALOG_API_ENABLED"),
324
+ default=CATALOG_API_ENABLED,
325
+ ),
326
+ catalog_metadata_cache_seconds=_parse_int_env(
327
+ os.getenv("CONTROL_PLANE_CATALOG_METADATA_CACHE_SECONDS"),
328
+ default=CATALOG_METADATA_CACHE_SECONDS,
329
+ minimum=1,
330
+ name="CONTROL_PLANE_CATALOG_METADATA_CACHE_SECONDS",
331
+ ),
332
+ catalog_base_url=(os.getenv("CONTROL_PLANE_CATALOG_BASE_URL") or "").strip().rstrip("/") or None,
333
+ purge_records_on_delete=_parse_bool_env(
334
+ os.getenv("CONTROL_PLANE_PURGE_RECORDS_ON_DELETE"),
335
+ default=PURGE_RECORDS_ON_DELETE,
336
+ ),
337
+ deleted_retention_hours=_parse_int_env(
338
+ os.getenv("CONTROL_PLANE_DELETED_RETENTION_HOURS"),
339
+ default=DELETED_RETENTION_HOURS,
340
+ minimum=1,
341
+ name="CONTROL_PLANE_DELETED_RETENTION_HOURS",
342
+ ),
343
+ deleted_purge_batch_limit=_parse_int_env(
344
+ os.getenv("CONTROL_PLANE_DELETED_PURGE_BATCH_LIMIT"),
345
+ default=DELETED_PURGE_BATCH_LIMIT,
346
+ minimum=1,
347
+ name="CONTROL_PLANE_DELETED_PURGE_BATCH_LIMIT",
348
+ ),
349
+ default_runtime_storage_size=os.getenv(
350
+ "CONTROL_PLANE_DEFAULT_RUNTIME_STORAGE_SIZE",
351
+ DEFAULT_RUNTIME_STORAGE_SIZE,
352
+ ),
353
+ default_runtime_replicas=_parse_int_env(
354
+ os.getenv("CONTROL_PLANE_DEFAULT_RUNTIME_REPLICAS"),
355
+ default=DEFAULT_RUNTIME_REPLICAS,
356
+ minimum=1,
357
+ name="CONTROL_PLANE_DEFAULT_RUNTIME_REPLICAS",
358
+ ),
359
+ max_runtime_replicas=_parse_int_env(
360
+ os.getenv("CONTROL_PLANE_MAX_RUNTIME_REPLICAS"),
361
+ default=MAX_RUNTIME_REPLICAS,
362
+ minimum=1,
363
+ name="CONTROL_PLANE_MAX_RUNTIME_REPLICAS",
364
+ ),
365
+ runtime_autoscaling_enabled=_parse_bool_env(
366
+ os.getenv("CONTROL_PLANE_RUNTIME_AUTOSCALING_ENABLED"),
367
+ default=RUNTIME_AUTOSCALING_ENABLED,
368
+ ),
369
+ runtime_autoscale_max_replicas=_parse_int_env(
370
+ os.getenv("CONTROL_PLANE_RUNTIME_AUTOSCALE_MAX_REPLICAS"),
371
+ default=RUNTIME_AUTOSCALE_MAX_REPLICAS,
372
+ minimum=1,
373
+ name="CONTROL_PLANE_RUNTIME_AUTOSCALE_MAX_REPLICAS",
374
+ ),
375
+ runtime_autoscale_target_minutes=_parse_int_env(
376
+ os.getenv("CONTROL_PLANE_RUNTIME_AUTOSCALE_TARGET_MINUTES"),
377
+ default=RUNTIME_AUTOSCALE_TARGET_MINUTES,
378
+ minimum=1,
379
+ name="CONTROL_PLANE_RUNTIME_AUTOSCALE_TARGET_MINUTES",
380
+ ),
381
+ runtime_autoscale_min_lag_records=_parse_int_env(
382
+ os.getenv("CONTROL_PLANE_RUNTIME_AUTOSCALE_MIN_LAG_RECORDS"),
383
+ default=RUNTIME_AUTOSCALE_MIN_LAG_RECORDS,
384
+ minimum=1,
385
+ name="CONTROL_PLANE_RUNTIME_AUTOSCALE_MIN_LAG_RECORDS",
386
+ ),
387
+ runtime_autoscale_cooldown_seconds=_parse_int_env(
388
+ os.getenv("CONTROL_PLANE_RUNTIME_AUTOSCALE_COOLDOWN_SECONDS"),
389
+ default=RUNTIME_AUTOSCALE_COOLDOWN_SECONDS,
390
+ minimum=60,
391
+ name="CONTROL_PLANE_RUNTIME_AUTOSCALE_COOLDOWN_SECONDS",
392
+ ),
393
+ max_materialization_topics=_parse_int_env(
394
+ os.getenv("CONTROL_PLANE_MAX_MATERIALIZATION_TOPICS"),
395
+ default=MAX_MATERIALIZATION_TOPICS,
396
+ minimum=1,
397
+ name="CONTROL_PLANE_MAX_MATERIALIZATION_TOPICS",
398
+ ),
399
+ default_destination_uri=normalize_destination_uri(
400
+ os.getenv("CONTROL_PLANE_DEFAULT_DESTINATION_URI")
401
+ ),
402
+ destination_cleanup_enabled=_parse_bool_env(
403
+ os.getenv("CONTROL_PLANE_DESTINATION_CLEANUP_ENABLED"),
404
+ default=DESTINATION_CLEANUP_ENABLED,
405
+ ),
406
+ consumer_lag_refresh_batch_limit=_parse_int_env(
407
+ os.getenv("CONTROL_PLANE_CONSUMER_LAG_REFRESH_BATCH_LIMIT"),
408
+ default=CONSUMER_LAG_REFRESH_BATCH_LIMIT,
409
+ minimum=1,
410
+ name="CONTROL_PLANE_CONSUMER_LAG_REFRESH_BATCH_LIMIT",
411
+ ),
412
+ topic_analysis_max_topics=_parse_int_env(
413
+ os.getenv("CONTROL_PLANE_TOPIC_ANALYSIS_MAX_TOPICS"),
414
+ default=TOPIC_ANALYSIS_MAX_TOPICS,
415
+ minimum=1,
416
+ name="CONTROL_PLANE_TOPIC_ANALYSIS_MAX_TOPICS",
417
+ ),
418
+ topic_catalog_ttl_seconds=_parse_int_env(
419
+ os.getenv("CONTROL_PLANE_TOPIC_CATALOG_TTL_SECONDS"),
420
+ default=TOPIC_CATALOG_TTL_SECONDS,
421
+ minimum=1,
422
+ name="CONTROL_PLANE_TOPIC_CATALOG_TTL_SECONDS",
423
+ ),
424
+ topic_catalog_min_refresh_seconds=_parse_int_env(
425
+ os.getenv("CONTROL_PLANE_TOPIC_CATALOG_MIN_REFRESH_SECONDS"),
426
+ default=TOPIC_CATALOG_MIN_REFRESH_SECONDS,
427
+ minimum=1,
428
+ name="CONTROL_PLANE_TOPIC_CATALOG_MIN_REFRESH_SECONDS",
429
+ ),
430
+ kaspr_app_name=os.getenv("K_APP_NAME") or None,
431
+ identity_requests_topic=os.getenv(
432
+ "CONTROL_PLANE_IDENTITY_REQUESTS_TOPIC",
433
+ IDENTITY_REQUESTS_TOPIC,
434
+ ),
435
+ session_idle_timeout_minutes=_parse_int_env(
436
+ os.getenv("CONTROL_PLANE_SESSION_IDLE_TIMEOUT_MINUTES"),
437
+ default=SESSION_IDLE_TIMEOUT_MINUTES,
438
+ minimum=1,
439
+ name="CONTROL_PLANE_SESSION_IDLE_TIMEOUT_MINUTES",
440
+ ),
441
+ session_max_lifetime_hours=_parse_int_env(
442
+ os.getenv("CONTROL_PLANE_SESSION_MAX_LIFETIME_HOURS"),
443
+ default=SESSION_MAX_LIFETIME_HOURS,
444
+ minimum=1,
445
+ name="CONTROL_PLANE_SESSION_MAX_LIFETIME_HOURS",
446
+ ),
447
+ session_refresh_minutes=_parse_int_env(
448
+ os.getenv("CONTROL_PLANE_SESSION_REFRESH_MINUTES"),
449
+ default=SESSION_REFRESH_MINUTES,
450
+ minimum=1,
451
+ name="CONTROL_PLANE_SESSION_REFRESH_MINUTES",
452
+ ),
453
+ password_min_length=_parse_int_env(
454
+ os.getenv("CONTROL_PLANE_PASSWORD_MIN_LENGTH"),
455
+ default=PASSWORD_MIN_LENGTH,
456
+ minimum=8,
457
+ name="CONTROL_PLANE_PASSWORD_MIN_LENGTH",
458
+ ),
459
+ sign_in_max_failed_attempts=_parse_int_env(
460
+ os.getenv("CONTROL_PLANE_SIGN_IN_MAX_FAILED_ATTEMPTS"),
461
+ default=SIGN_IN_MAX_FAILED_ATTEMPTS,
462
+ minimum=1,
463
+ name="CONTROL_PLANE_SIGN_IN_MAX_FAILED_ATTEMPTS",
464
+ ),
465
+ sign_in_lockout_minutes=_parse_int_env(
466
+ os.getenv("CONTROL_PLANE_SIGN_IN_LOCKOUT_MINUTES"),
467
+ default=SIGN_IN_LOCKOUT_MINUTES,
468
+ minimum=1,
469
+ name="CONTROL_PLANE_SIGN_IN_LOCKOUT_MINUTES",
470
+ ),
471
+ audit_log_retention_days=_parse_int_env(
472
+ os.getenv("CONTROL_PLANE_AUDIT_LOG_RETENTION_DAYS"),
473
+ default=AUDIT_LOG_RETENTION_DAYS,
474
+ minimum=1,
475
+ name="CONTROL_PLANE_AUDIT_LOG_RETENTION_DAYS",
476
+ ),
477
+ live_tail_enabled=_parse_bool_env(
478
+ os.getenv("CONTROL_PLANE_LIVE_TAIL_ENABLED"),
479
+ default=LIVE_TAIL_ENABLED,
480
+ ),
481
+ live_tail_max_rows_per_table=_parse_int_env(
482
+ os.getenv("CONTROL_PLANE_LIVE_TAIL_MAX_ROWS_PER_TABLE"),
483
+ default=LIVE_TAIL_MAX_ROWS_PER_TABLE,
484
+ minimum=1000,
485
+ name="CONTROL_PLANE_LIVE_TAIL_MAX_ROWS_PER_TABLE",
486
+ ),
487
+ live_tail_max_bytes_per_table=_parse_int_env(
488
+ os.getenv("CONTROL_PLANE_LIVE_TAIL_MAX_BYTES_PER_TABLE"),
489
+ default=LIVE_TAIL_MAX_BYTES_PER_TABLE,
490
+ minimum=8 * 1024 * 1024,
491
+ name="CONTROL_PLANE_LIVE_TAIL_MAX_BYTES_PER_TABLE",
492
+ ),
493
+ live_tail_retention_seconds=_parse_int_env(
494
+ os.getenv("CONTROL_PLANE_LIVE_TAIL_RETENTION_SECONDS"),
495
+ default=LIVE_TAIL_RETENTION_SECONDS,
496
+ minimum=10,
497
+ name="CONTROL_PLANE_LIVE_TAIL_RETENTION_SECONDS",
498
+ ),
499
+ live_tail_fetch_wait_ms=_parse_int_env(
500
+ os.getenv("CONTROL_PLANE_LIVE_TAIL_FETCH_WAIT_MS"),
501
+ default=LIVE_TAIL_FETCH_WAIT_MS,
502
+ minimum=50,
503
+ name="CONTROL_PLANE_LIVE_TAIL_FETCH_WAIT_MS",
504
+ ),
505
+ live_tail_max_records_per_second_per_table=_parse_int_env(
506
+ os.getenv("CONTROL_PLANE_LIVE_TAIL_MAX_RECORDS_PER_SECOND_PER_TABLE"),
507
+ default=LIVE_TAIL_MAX_RECORDS_PER_SECOND_PER_TABLE,
508
+ minimum=100,
509
+ name="CONTROL_PLANE_LIVE_TAIL_MAX_RECORDS_PER_SECOND_PER_TABLE",
510
+ ),
511
+ live_tail_peek_max_rows=_parse_int_env(
512
+ os.getenv("CONTROL_PLANE_LIVE_TAIL_PEEK_MAX_ROWS"),
513
+ default=LIVE_TAIL_PEEK_MAX_ROWS,
514
+ minimum=10,
515
+ name="CONTROL_PLANE_LIVE_TAIL_PEEK_MAX_ROWS",
516
+ ),
517
+ live_tail_idle_seconds=_parse_int_env(
518
+ os.getenv("CONTROL_PLANE_LIVE_TAIL_IDLE_SECONDS"),
519
+ default=LIVE_TAIL_IDLE_SECONDS,
520
+ minimum=0,
521
+ name="CONTROL_PLANE_LIVE_TAIL_IDLE_SECONDS",
522
+ ),
523
+ )
524
+
525
+
526
+ def deployment_environment() -> Mapping[str, str]:
527
+ """The process environment, to report which settings the deployment sets explicitly.
528
+
529
+ `get_config()` folds the environment and the built-in defaults together; the settings page
530
+ also needs to say which of the two a value came from.
531
+ """
532
+ return os.environ
533
+
534
+
535
+ def _load_runtime_app_spec(raw_value: str | None) -> dict[str, Any]:
536
+ if raw_value in (None, ""):
537
+ return dict(DEFAULT_RUNTIME_APP_SPEC)
538
+ parsed = yaml.safe_load(raw_value)
539
+ if parsed is None:
540
+ return {}
541
+ if not isinstance(parsed, dict):
542
+ raise ValueError(
543
+ "CONTROL_PLANE_RUNTIME_APP_SPEC must parse to a mapping"
544
+ )
545
+ return parsed
546
+
547
+
548
+ def _parse_bool_env(raw_value: str | None, *, default: bool) -> bool:
549
+ if raw_value is None:
550
+ return default
551
+ normalized = raw_value.strip().lower()
552
+ if normalized in {"1", "true", "yes", "on"}:
553
+ return True
554
+ if normalized in {"0", "false", "no", "off"}:
555
+ return False
556
+ raise ValueError(
557
+ "boolean environment values must be one of: "
558
+ "1, 0, true, false, yes, no, on, off"
559
+ )
560
+
561
+
562
+ def _parse_int_env(
563
+ raw_value: str | None,
564
+ *,
565
+ default: int,
566
+ minimum: int,
567
+ name: str,
568
+ ) -> int:
569
+ if raw_value is None:
570
+ return default
571
+ try:
572
+ parsed = int(raw_value)
573
+ except ValueError as exc:
574
+ raise ValueError(f"{name} must be an integer") from exc
575
+ if parsed < minimum:
576
+ raise ValueError(f"{name} must be >= {minimum}")
577
+ return parsed
@@ -0,0 +1,34 @@
1
+ """Kaspr-facing adapter handlers live here."""
2
+
3
+ from midstream.handlers.identity import handle_identity_request
4
+ from midstream.handlers.request_intake import handle_request_event, handle_request_event_for_output
5
+ from midstream.handlers.resource_observation import (
6
+ handle_observation_work_for_output,
7
+ handle_observation_work_for_output_async,
8
+ handle_resource_observation,
9
+ )
10
+ from midstream.handlers.runtime_status import handle_runtime_status
11
+ from midstream.handlers.settings import handle_settings_request_for_output
12
+ from midstream.handlers.table_events import handle_table_event
13
+ from midstream.handlers.workflow_apply import (
14
+ handle_work_event,
15
+ handle_work_event_for_output,
16
+ handle_work_event_for_routed_output,
17
+ handle_work_event_for_routed_output_async,
18
+ )
19
+
20
+ __all__ = [
21
+ "handle_identity_request",
22
+ "handle_request_event",
23
+ "handle_request_event_for_output",
24
+ "handle_observation_work_for_output",
25
+ "handle_observation_work_for_output_async",
26
+ "handle_resource_observation",
27
+ "handle_settings_request_for_output",
28
+ "handle_runtime_status",
29
+ "handle_table_event",
30
+ "handle_work_event",
31
+ "handle_work_event_for_output",
32
+ "handle_work_event_for_routed_output",
33
+ "handle_work_event_for_routed_output_async",
34
+ ]
@@ -0,0 +1,60 @@
1
+ """Thin Kaspr-facing handler for identity requests, and the triggers the identity tasks send."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from midstream.config import ControlPlaneConfig
8
+ from midstream.models.identity import IdentityRequest
9
+ from midstream.services.identity import apply_identity_request
10
+ from midstream.services.table_claims import claim_outcome_work
11
+ from midstream.settings import resolve_config
12
+ from midstream.storage.identity import SYSTEM_ACTOR
13
+ from midstream.storage.table import TableProtocol
14
+
15
+
16
+ def handle_identity_request(
17
+ value: dict,
18
+ directory_table: TableProtocol,
19
+ audit_table: TableProtocol,
20
+ settings_table: TableProtocol | None,
21
+ control_plane_table: TableProtocol | None = None,
22
+ *,
23
+ config: ControlPlaneConfig | None = None,
24
+ ) -> dict[str, Any] | None:
25
+ """Deserialize an identity request and apply it with the effective configuration.
26
+
27
+ Returns the work event a claim of table names answers its materialization with, routed for the
28
+ agent's output; None for every other request. `control_plane_table` is only read, by the hourly
29
+ sweep of claims.
30
+ """
31
+ cfg = resolve_config(settings_table) if config is None else config
32
+ request = IdentityRequest.from_dict(value)
33
+ outcome = apply_identity_request(
34
+ directory_table,
35
+ audit_table,
36
+ request,
37
+ config=cfg,
38
+ control_plane_table=control_plane_table,
39
+ )
40
+ return claim_outcome_work(request, outcome)
41
+
42
+
43
+ def build_identity_bootstrap_request(*, requested_at: str) -> dict:
44
+ """The startup trigger: store the built-in groups and make sure an administrator exists."""
45
+ return _system_request("bootstrap", requested_at)
46
+
47
+
48
+ def build_identity_maintenance_request(*, requested_at: str) -> dict:
49
+ """The hourly trigger: remove expired sessions, stale sign-in counts and old audit entries."""
50
+ return _system_request("maintenance", requested_at)
51
+
52
+
53
+ def _system_request(action: str, requested_at: str) -> dict:
54
+ return IdentityRequest(
55
+ request_id=f"idr-{action}-{requested_at}",
56
+ action=action,
57
+ submitted_at=requested_at,
58
+ actor_id=SYSTEM_ACTOR,
59
+ actor_email=SYSTEM_ACTOR,
60
+ ).to_dict()