fdai-deployment-cli 0.1.2__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 (203) hide show
  1. fdai_deployment_cli/__about__.py +3 -0
  2. fdai_deployment_cli/__init__.py +5 -0
  3. fdai_deployment_cli/__main__.py +5 -0
  4. fdai_deployment_cli/acr_publish.py +585 -0
  5. fdai_deployment_cli/aks_catalog_review.py +137 -0
  6. fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
  7. fdai_deployment_cli/aks_job_environment.py +59 -0
  8. fdai_deployment_cli/aks_job_execution.py +794 -0
  9. fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
  10. fdai_deployment_cli/aks_preflight.py +422 -0
  11. fdai_deployment_cli/aks_readiness.py +220 -0
  12. fdai_deployment_cli/aks_service_update.py +180 -0
  13. fdai_deployment_cli/aks_workload_jobs.py +281 -0
  14. fdai_deployment_cli/application_state_adoption.py +347 -0
  15. fdai_deployment_cli/azure_actor.py +61 -0
  16. fdai_deployment_cli/azure_naming.py +252 -0
  17. fdai_deployment_cli/azure_retail_prices.py +173 -0
  18. fdai_deployment_cli/bootstrap_approval.py +99 -0
  19. fdai_deployment_cli/bootstrap_channel.py +321 -0
  20. fdai_deployment_cli/bootstrap_executor.py +311 -0
  21. fdai_deployment_cli/bootstrap_plan.py +237 -0
  22. fdai_deployment_cli/bootstrap_reconcile.py +402 -0
  23. fdai_deployment_cli/bootstrap_support.py +138 -0
  24. fdai_deployment_cli/bootstrap_worker.py +448 -0
  25. fdai_deployment_cli/bundle.py +352 -0
  26. fdai_deployment_cli/capacity.py +143 -0
  27. fdai_deployment_cli/catalog_review_profile.py +306 -0
  28. fdai_deployment_cli/cli.py +798 -0
  29. fdai_deployment_cli/cli_help.py +214 -0
  30. fdai_deployment_cli/cli_parser.py +791 -0
  31. fdai_deployment_cli/cli_plan.py +450 -0
  32. fdai_deployment_cli/compiler.py +99 -0
  33. fdai_deployment_cli/console_artifact.py +218 -0
  34. fdai_deployment_cli/console_config.py +214 -0
  35. fdai_deployment_cli/console_update.py +832 -0
  36. fdai_deployment_cli/contracts.py +307 -0
  37. fdai_deployment_cli/control_package.py +140 -0
  38. fdai_deployment_cli/deadline_transport.py +70 -0
  39. fdai_deployment_cli/deployment_cost.py +131 -0
  40. fdai_deployment_cli/deployment_deadline.py +28 -0
  41. fdai_deployment_cli/deployment_kit.py +790 -0
  42. fdai_deployment_cli/deployment_kit_cache.py +191 -0
  43. fdai_deployment_cli/deployment_progress.py +648 -0
  44. fdai_deployment_cli/development_authority_cli.py +56 -0
  45. fdai_deployment_cli/development_authority_profile.py +319 -0
  46. fdai_deployment_cli/doctor.py +136 -0
  47. fdai_deployment_cli/entitlement_preflight.py +68 -0
  48. fdai_deployment_cli/entitlement_transport.py +49 -0
  49. fdai_deployment_cli/entra_bootstrap.py +579 -0
  50. fdai_deployment_cli/entra_control_profile.py +214 -0
  51. fdai_deployment_cli/entra_graph.py +349 -0
  52. fdai_deployment_cli/entra_profiles.py +215 -0
  53. fdai_deployment_cli/entra_source.py +244 -0
  54. fdai_deployment_cli/execution_bundle.py +478 -0
  55. fdai_deployment_cli/execution_copies.py +63 -0
  56. fdai_deployment_cli/foundation_adoption.py +438 -0
  57. fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
  58. fdai_deployment_cli/foundation_adoption_host.py +157 -0
  59. fdai_deployment_cli/foundation_adoption_transport.py +51 -0
  60. fdai_deployment_cli/foundation_failure.py +72 -0
  61. fdai_deployment_cli/foundation_image.py +168 -0
  62. fdai_deployment_cli/foundation_input.py +235 -0
  63. fdai_deployment_cli/foundation_output.py +154 -0
  64. fdai_deployment_cli/foundation_plan.py +381 -0
  65. fdai_deployment_cli/foundation_process.py +64 -0
  66. fdai_deployment_cli/foundation_progress.py +231 -0
  67. fdai_deployment_cli/github_actions.py +171 -0
  68. fdai_deployment_cli/github_apply_artifact.py +259 -0
  69. fdai_deployment_cli/github_artifact_io.py +48 -0
  70. fdai_deployment_cli/github_dispatch.py +248 -0
  71. fdai_deployment_cli/github_plan_artifact.py +181 -0
  72. fdai_deployment_cli/github_provider_artifact.py +170 -0
  73. fdai_deployment_cli/github_transport.py +46 -0
  74. fdai_deployment_cli/github_workflow_values.py +312 -0
  75. fdai_deployment_cli/hub_image_deploy.py +354 -0
  76. fdai_deployment_cli/hub_image_stage.py +162 -0
  77. fdai_deployment_cli/hub_image_trust.py +281 -0
  78. fdai_deployment_cli/hub_ownership.py +161 -0
  79. fdai_deployment_cli/hub_workload_handoff.py +264 -0
  80. fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
  81. fdai_deployment_cli/identity_profile.py +227 -0
  82. fdai_deployment_cli/image_targets.py +197 -0
  83. fdai_deployment_cli/installation_scope.py +280 -0
  84. fdai_deployment_cli/license.py +311 -0
  85. fdai_deployment_cli/license_issue.py +239 -0
  86. fdai_deployment_cli/lifecycle_authorization.py +252 -0
  87. fdai_deployment_cli/lifecycle_commands.py +173 -0
  88. fdai_deployment_cli/lifecycle_configuration.py +722 -0
  89. fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
  90. fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
  91. fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
  92. fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
  93. fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
  94. fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
  95. fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
  96. fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
  97. fdai_deployment_cli/lifecycle_enrollment.py +45 -0
  98. fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
  99. fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
  100. fdai_deployment_cli/lifecycle_native.py +247 -0
  101. fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
  102. fdai_deployment_cli/lifecycle_native_transition.py +339 -0
  103. fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
  104. fdai_deployment_cli/lifecycle_plan.py +800 -0
  105. fdai_deployment_cli/lifecycle_promotion.py +185 -0
  106. fdai_deployment_cli/lifecycle_release.py +308 -0
  107. fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
  108. fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
  109. fdai_deployment_cli/notification_recipients_input.py +222 -0
  110. fdai_deployment_cli/observer_artifact.py +106 -0
  111. fdai_deployment_cli/oci_archive.py +353 -0
  112. fdai_deployment_cli/offline_kit.py +797 -0
  113. fdai_deployment_cli/offline_prepare.py +135 -0
  114. fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
  115. fdai_deployment_cli/plan_input.py +101 -0
  116. fdai_deployment_cli/private_output.py +192 -0
  117. fdai_deployment_cli/profile.py +70 -0
  118. fdai_deployment_cli/progress.py +183 -0
  119. fdai_deployment_cli/readiness.py +286 -0
  120. fdai_deployment_cli/run_command_receiver.py +418 -0
  121. fdai_deployment_cli/runtime_build.py +386 -0
  122. fdai_deployment_cli/runtime_profile.py +287 -0
  123. fdai_deployment_cli/runtime_release.py +745 -0
  124. fdai_deployment_cli/runtime_stage.py +82 -0
  125. fdai_deployment_cli/runtime_support_installation.py +215 -0
  126. fdai_deployment_cli/simulation.py +80 -0
  127. fdai_deployment_cli/source_application_inputs.py +397 -0
  128. fdai_deployment_cli/source_azure.py +586 -0
  129. fdai_deployment_cli/source_change_cli.py +107 -0
  130. fdai_deployment_cli/source_change_parser.py +75 -0
  131. fdai_deployment_cli/source_change_plan.py +181 -0
  132. fdai_deployment_cli/source_changed_update.py +196 -0
  133. fdai_deployment_cli/source_deploy.py +170 -0
  134. fdai_deployment_cli/source_foundation.py +174 -0
  135. fdai_deployment_cli/source_host_tools.py +163 -0
  136. fdai_deployment_cli/source_image_stage.py +400 -0
  137. fdai_deployment_cli/source_input.py +173 -0
  138. fdai_deployment_cli/source_projection.py +164 -0
  139. fdai_deployment_cli/source_receiver.py +76 -0
  140. fdai_deployment_cli/source_recovery.py +264 -0
  141. fdai_deployment_cli/source_runtime.py +81 -0
  142. fdai_deployment_cli/source_runtime_support.py +368 -0
  143. fdai_deployment_cli/source_service_update.py +726 -0
  144. fdai_deployment_cli/source_service_update_cli.py +47 -0
  145. fdai_deployment_cli/source_snapshot.py +178 -0
  146. fdai_deployment_cli/source_teardown.py +574 -0
  147. fdai_deployment_cli/source_terraform.py +85 -0
  148. fdai_deployment_cli/source_transport.py +399 -0
  149. fdai_deployment_cli/standalone_aks_inventory.py +558 -0
  150. fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
  151. fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
  152. fdai_deployment_cli/standalone_application.py +977 -0
  153. fdai_deployment_cli/standalone_application_completion.py +151 -0
  154. fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
  155. fdai_deployment_cli/standalone_application_recovery.py +116 -0
  156. fdai_deployment_cli/standalone_approval_input.py +24 -0
  157. fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
  158. fdai_deployment_cli/standalone_catalog_review.py +251 -0
  159. fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
  160. fdai_deployment_cli/standalone_console_publish.py +174 -0
  161. fdai_deployment_cli/standalone_database_principals.py +41 -0
  162. fdai_deployment_cli/standalone_deploy.py +638 -0
  163. fdai_deployment_cli/standalone_development_authority.py +166 -0
  164. fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
  165. fdai_deployment_cli/standalone_foundation_transition.py +767 -0
  166. fdai_deployment_cli/standalone_host.py +4608 -0
  167. fdai_deployment_cli/standalone_host_rebind.py +226 -0
  168. fdai_deployment_cli/standalone_host_residual.py +104 -0
  169. fdai_deployment_cli/standalone_host_state.py +145 -0
  170. fdai_deployment_cli/standalone_host_values.py +563 -0
  171. fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
  172. fdai_deployment_cli/standalone_license_installation.py +177 -0
  173. fdai_deployment_cli/standalone_management_egress.py +83 -0
  174. fdai_deployment_cli/standalone_migration_evidence.py +38 -0
  175. fdai_deployment_cli/standalone_operational_evidence.py +356 -0
  176. fdai_deployment_cli/standalone_planned_outputs.py +147 -0
  177. fdai_deployment_cli/standalone_private_access_probe.py +100 -0
  178. fdai_deployment_cli/standalone_product_profile.py +110 -0
  179. fdai_deployment_cli/standalone_remote_prepare.py +442 -0
  180. fdai_deployment_cli/standalone_residual_apply.py +540 -0
  181. fdai_deployment_cli/standalone_residual_successor.py +431 -0
  182. fdai_deployment_cli/standalone_review.py +266 -0
  183. fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
  184. fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
  185. fdai_deployment_cli/standalone_stage_targets.py +325 -0
  186. fdai_deployment_cli/standalone_status.py +67 -0
  187. fdai_deployment_cli/standalone_terraform_environment.py +237 -0
  188. fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
  189. fdai_deployment_cli/standalone_trial_activation.py +193 -0
  190. fdai_deployment_cli/state.py +434 -0
  191. fdai_deployment_cli/state_handoff.py +253 -0
  192. fdai_deployment_cli/status_projection.py +97 -0
  193. fdai_deployment_cli/support_install.py +257 -0
  194. fdai_deployment_cli/target.py +46 -0
  195. fdai_deployment_cli/trust/capabilities.json +62 -0
  196. fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
  197. fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
  198. fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
  199. fdai_deployment_cli/trust_roots.py +50 -0
  200. fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
  201. fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
  202. fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
  203. fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,312 @@
1
+ """Validated immutable values for protected GitHub workflow operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+
10
+ from fdai_deployment_cli.contracts import canonical_digest
11
+
12
+ _REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}$")
13
+
14
+ _COMMIT = re.compile(r"^[0-9a-f]{40}$")
15
+
16
+ _DIGEST = re.compile(r"^[0-9a-f]{64}$")
17
+
18
+ _OCI_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
19
+
20
+ _PROVIDER_SOURCE_REVISION = re.compile(r"^[0-9a-f]{40,64}$")
21
+
22
+ _PLAN_ID = re.compile(r"^plan-[1-9][0-9]*-[1-9][0-9]*$")
23
+
24
+ _REQUEST_ID = re.compile(
25
+ r"^(?:plan|apply)-(?:cost-|history-|identity-|provider-cost-|provider-|rca-)?"
26
+ r"[0-9a-f]{48}$"
27
+ )
28
+
29
+ _ENVIRONMENTS = frozenset({"dev", "staging", "prod"})
30
+
31
+ _RUNTIME_IMAGE_PROFILES = frozenset({"core-control-plane", "cost-governance"})
32
+
33
+ _BOOL_INPUTS = (
34
+ "deploy_console",
35
+ "deploy_dev_operations_gateway",
36
+ "deploy_document_ingestion",
37
+ "deploy_identity_migration",
38
+ "deploy_isolated_executor",
39
+ "deploy_monitoring",
40
+ "deploy_operational_history",
41
+ "deploy_operator_api",
42
+ )
43
+
44
+ _EXPIRES_AT_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class CommandResult:
49
+ """Bounded process result from one fixed GitHub CLI invocation."""
50
+
51
+ returncode: int
52
+ stdout: str
53
+ stderr: str = ""
54
+
55
+
56
+ CommandRunner = Callable[[tuple[str, ...]], CommandResult]
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class DeploymentSelection:
61
+ """Feature selection sealed into both protected plan and apply requests."""
62
+
63
+ deploy_console: bool = True
64
+ deploy_dev_operations_gateway: bool = False
65
+ deploy_document_ingestion: bool = False
66
+ deploy_identity_migration: bool = False
67
+ deploy_isolated_executor: bool = False
68
+ deploy_monitoring: bool = False
69
+ deploy_operational_history: bool = False
70
+ deploy_operator_api: bool = True
71
+ deploy_operator_channel_edge: bool = False
72
+ deploy_provider_schema: bool = False
73
+ deploy_rca_reader_identity: bool = False
74
+ runtime_image_revision: str = ""
75
+ runtime_image_profile: str = "core-control-plane"
76
+
77
+ def __post_init__(self) -> None:
78
+ application_targets = (
79
+ self.deploy_console,
80
+ self.deploy_dev_operations_gateway,
81
+ self.deploy_document_ingestion,
82
+ self.deploy_isolated_executor,
83
+ self.deploy_operational_history,
84
+ self.deploy_operator_api,
85
+ self.deploy_operator_channel_edge,
86
+ self.deploy_provider_schema,
87
+ )
88
+ if self.deploy_monitoring and not any(application_targets) and self.runtime_image_revision:
89
+ raise ValueError("monitoring-only deployment cannot specify runtime_image_revision")
90
+ if self.runtime_image_revision and _COMMIT.fullmatch(self.runtime_image_revision) is None:
91
+ raise ValueError("runtime_image_revision MUST be a lowercase 40-character git SHA")
92
+ if self.runtime_image_profile not in _RUNTIME_IMAGE_PROFILES:
93
+ raise ValueError("runtime_image_profile is unsupported")
94
+ if self.runtime_image_profile != "core-control-plane" and not self.runtime_image_revision:
95
+ raise ValueError("non-default runtime_image_profile requires runtime_image_revision")
96
+ if self.runtime_image_profile != "core-control-plane" and (
97
+ self.deploy_identity_migration
98
+ or self.deploy_operational_history
99
+ or self.deploy_rca_reader_identity
100
+ ):
101
+ raise ValueError(
102
+ "non-default runtime_image_profile cannot be combined with a bounded operation"
103
+ )
104
+ if self.deploy_provider_schema:
105
+ provider_schema_mixed = (
106
+ self.deploy_console,
107
+ self.deploy_dev_operations_gateway,
108
+ self.deploy_document_ingestion,
109
+ self.deploy_identity_migration,
110
+ self.deploy_isolated_executor,
111
+ self.deploy_monitoring,
112
+ self.deploy_operational_history,
113
+ self.deploy_operator_api,
114
+ self.deploy_operator_channel_edge,
115
+ self.deploy_rca_reader_identity,
116
+ )
117
+ if any(provider_schema_mixed):
118
+ raise ValueError(
119
+ "deploy_provider_schema cannot be combined with another deployment target"
120
+ )
121
+ if not self.runtime_image_revision:
122
+ raise ValueError("deploy_provider_schema requires runtime_image_revision")
123
+ if self.deploy_rca_reader_identity and (
124
+ any(application_targets) or self.deploy_monitoring or self.runtime_image_revision
125
+ ):
126
+ raise ValueError(
127
+ "deploy_rca_reader_identity cannot be combined with another deployment target"
128
+ )
129
+ if self.deploy_identity_migration and (
130
+ any(application_targets)
131
+ or self.deploy_monitoring
132
+ or self.deploy_operational_history
133
+ or self.deploy_rca_reader_identity
134
+ or self.runtime_image_revision
135
+ ):
136
+ raise ValueError(
137
+ "deploy_identity_migration cannot be combined with another bounded operation"
138
+ )
139
+
140
+ def to_mapping(self) -> dict[str, bool | str]:
141
+ """Return workflow input names in stable order."""
142
+
143
+ result: dict[str, bool | str] = {name: bool(getattr(self, name)) for name in _BOOL_INPUTS}
144
+ if self.deploy_operator_channel_edge:
145
+ result["deploy_operator_channel_edge"] = True
146
+ if self.deploy_provider_schema:
147
+ result["deploy_provider_schema"] = True
148
+ result["deploy_rca_reader_identity"] = self.deploy_rca_reader_identity
149
+ result["document_ocr_action"] = "preserve"
150
+ result["runtime_call_evidence_transition"] = False
151
+ result["runtime_image_revision"] = self.runtime_image_revision
152
+ result["runtime_image_profile"] = self.runtime_image_profile
153
+ return result
154
+
155
+
156
+ @dataclass(frozen=True, slots=True)
157
+ class WorkflowDispatch:
158
+ """Sanitized receipt for one accepted workflow dispatch."""
159
+
160
+ request_id: str
161
+ run_name: str
162
+ context_digest: str
163
+ mode: str
164
+
165
+ def to_mapping(self) -> dict[str, object]:
166
+ """Return stable dispatch output without credentials or target identifiers."""
167
+
168
+ return {
169
+ "schema_version": "fdai.workflow-dispatch.v1",
170
+ "request_id": self.request_id,
171
+ "run_name": self.run_name,
172
+ "context_digest": self.context_digest,
173
+ "mode": self.mode,
174
+ "mutation_performed": True,
175
+ }
176
+
177
+
178
+ def parse_plan_expiry(expires_at: str) -> datetime:
179
+ """Parse a strict ISO-8601 UTC timestamp or raise fail-closed."""
180
+
181
+ if _EXPIRES_AT_RE.fullmatch(expires_at) is None:
182
+ raise ValueError("expires_at MUST be an ISO-8601 UTC timestamp ending in Z")
183
+ try:
184
+ return datetime.fromisoformat(expires_at)
185
+ except ValueError as exc:
186
+ raise ValueError("expires_at contains an invalid date or time") from exc
187
+
188
+
189
+ def enforce_plan_not_expired(
190
+ expires_at: str,
191
+ *,
192
+ now: datetime | None = None,
193
+ ) -> None:
194
+ """Fail-closed: reject expired plans before apply dispatch."""
195
+
196
+ deadline = parse_plan_expiry(expires_at)
197
+ current = now if now is not None else datetime.now(UTC)
198
+ if current >= deadline:
199
+ raise ValueError("protected plan has expired; replan before applying")
200
+
201
+
202
+ def deployment_context_digest(
203
+ *,
204
+ environment: str,
205
+ commit_sha: str,
206
+ selection: DeploymentSelection,
207
+ ) -> str:
208
+ """Seal the non-secret deployment inputs shared by plan and apply."""
209
+
210
+ if environment not in _ENVIRONMENTS:
211
+ raise ValueError("environment is unsupported")
212
+ if _COMMIT.fullmatch(commit_sha) is None:
213
+ raise ValueError("commit_sha MUST be a lowercase git SHA")
214
+ return canonical_digest(
215
+ {
216
+ "schema_version": "fdai.deployment-context.v1",
217
+ "environment": environment,
218
+ "commit_sha": commit_sha,
219
+ "selection": selection.to_mapping(),
220
+ }
221
+ )
222
+
223
+
224
+ def request_id(
225
+ mode: str,
226
+ *,
227
+ run_id: str,
228
+ context_digest: str,
229
+ target_binding: str,
230
+ region: str,
231
+ attempt: int = 1,
232
+ ) -> str:
233
+ """Derive one bounded request id without exposing the local run id."""
234
+
235
+ if mode not in {"plan", "apply", "resume"}:
236
+ raise ValueError("workflow request mode is unsupported")
237
+ if not run_id or len(run_id) > 128:
238
+ raise ValueError("run_id MUST be from 1 through 128 characters")
239
+ if _DIGEST.fullmatch(context_digest) is None:
240
+ raise ValueError("context_digest MUST be a lowercase SHA-256")
241
+ if _DIGEST.fullmatch(target_binding) is None:
242
+ raise ValueError("target_binding MUST be a lowercase SHA-256")
243
+ if not region or len(region) > 64:
244
+ raise ValueError("region is invalid")
245
+ if not 1 <= attempt <= 9_999:
246
+ raise ValueError("workflow attempt MUST be from 1 through 9999")
247
+ binding = request_binding_prefix(
248
+ target_binding=target_binding,
249
+ context_digest=context_digest,
250
+ mode=mode,
251
+ region=region,
252
+ )
253
+ run_key = canonical_digest({"run_id": run_id})[:20]
254
+ suffix = f"{binding}{run_key}{attempt:04x}"
255
+ prefix = "apply" if mode == "resume" else mode
256
+ return f"{prefix}-{suffix}"
257
+
258
+
259
+ def _request_binding_from_id(request_id_value: str) -> str:
260
+ """Return the target/context binding embedded in one validated request id."""
261
+
262
+ for prefix in (
263
+ "plan-history-",
264
+ "apply-history-",
265
+ "plan-identity-",
266
+ "apply-identity-",
267
+ "plan-provider-cost-",
268
+ "apply-provider-cost-",
269
+ "plan-provider-",
270
+ "apply-provider-",
271
+ "plan-rca-",
272
+ "apply-rca-",
273
+ "plan-cost-",
274
+ "apply-cost-",
275
+ "plan-",
276
+ "apply-",
277
+ ):
278
+ if request_id_value.startswith(prefix):
279
+ return request_id_value.removeprefix(prefix)[:24]
280
+ raise ValueError("request_id is invalid")
281
+
282
+
283
+ def request_binding_prefix(
284
+ *,
285
+ target_binding: str,
286
+ context_digest: str,
287
+ mode: str,
288
+ region: str,
289
+ ) -> str:
290
+ """Return the target-bound prefix independently recomputed by the workflow."""
291
+
292
+ if _DIGEST.fullmatch(target_binding) is None:
293
+ raise ValueError("target_binding MUST be a lowercase SHA-256")
294
+ if _DIGEST.fullmatch(context_digest) is None:
295
+ raise ValueError("context_digest MUST be a lowercase SHA-256")
296
+ if mode not in {"plan", "apply", "resume"}:
297
+ raise ValueError("workflow request mode is unsupported")
298
+ if not region or len(region) > 64:
299
+ raise ValueError("region is invalid")
300
+ return canonical_digest(
301
+ {
302
+ "target_binding": target_binding,
303
+ "context_digest": context_digest,
304
+ "mode": mode,
305
+ "region": region.casefold(),
306
+ }
307
+ )[:24]
308
+
309
+
310
+ def _validate_repository(repository: str) -> None:
311
+ if _REPOSITORY.fullmatch(repository) is None or ".." in repository:
312
+ raise ValueError("repository MUST be owner/name")
@@ -0,0 +1,354 @@
1
+ """Connected image-bootstrap inputs and public, self-reported request intake."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import http.client
8
+ import os
9
+ import re
10
+ import ssl
11
+ import time
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.parse import urlsplit
16
+ from uuid import UUID
17
+
18
+ from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest, load_json_object
19
+ from fdai_deployment_cli.hub_image_trust import (
20
+ MAX_EXPORT_BYTES,
21
+ MAX_SELECTION_BYTES,
22
+ HubImageSelection,
23
+ load_selection,
24
+ public_key,
25
+ selection_bytes,
26
+ validate_channel,
27
+ verify_export,
28
+ )
29
+ from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
30
+ from fdai_deployment_cli.source_input import inspect_source
31
+
32
+ DEFAULT_HUB_URL = "https://hub.msfdai.com"
33
+
34
+
35
+ def hub_source_inputs(args: argparse.Namespace) -> tuple[bool, Path | None, Path | None]:
36
+ hub_mode = args.hub is not None
37
+ source_root = args.hub if hub_mode else args.source
38
+ if not hub_mode and any(
39
+ getattr(args, name) is not None
40
+ for name in ("hub_url", "hub_public_key", "hub_release", "hub_channel")
41
+ ):
42
+ raise ValueError("Hub options require --hub and cannot modify source or offline mode")
43
+ hub_key = args.hub_public_key
44
+ if hub_mode and hub_key is None and os.environ.get("FDAI_HUB_PUBLIC_KEY"):
45
+ hub_key = Path(os.environ["FDAI_HUB_PUBLIC_KEY"])
46
+ if hub_mode and hub_key is None:
47
+ raise ValueError(
48
+ "--hub requires --hub-public-key PATH or FDAI_HUB_PUBLIC_KEY; provision the "
49
+ "Ed25519 public key independently, never download a TOFU key from the Hub"
50
+ )
51
+ if hub_mode and args.runtime != "aks":
52
+ raise ValueError("Hub image bootstrap currently requires AKS")
53
+ return hub_mode, source_root, hub_key
54
+
55
+
56
+ def deployment_work_dir(args: argparse.Namespace) -> Path:
57
+ selected_dir: Path | None = args.work_dir
58
+ if selected_dir is None:
59
+ selected_dir = Path.home() / (
60
+ ".local/state/fdai/azure-hub"
61
+ if args.hub is not None
62
+ else ".local/state/fdai/azure-source"
63
+ if args.source is not None
64
+ else ".local/state/fdai/azure"
65
+ )
66
+ return selected_dir if selected_dir.is_absolute() else Path.cwd() / selected_dir
67
+
68
+
69
+ def prepare_cli_selection(
70
+ args: argparse.Namespace, work_dir: Path, source_root: Path | None, hub_key: Path | None
71
+ ) -> HubImageSelection | None:
72
+ """Seal Hub inputs without permitting source/offline reuse of a retained Hub run."""
73
+ if args.hub is not None:
74
+ assert source_root is not None and hub_key is not None
75
+ if args.teardown:
76
+ raise ValueError(
77
+ "Hub image bootstrap does not authorize teardown; retain the run for review"
78
+ )
79
+ return prepare_hub_selection(
80
+ source_root=source_root,
81
+ work_dir=work_dir,
82
+ public_key_path=hub_key,
83
+ hub_url=DEFAULT_HUB_URL if args.hub_url is None else args.hub_url,
84
+ channel="RELEASE" if args.hub_channel is None else args.hub_channel,
85
+ release_id=args.hub_release,
86
+ )
87
+ if (work_dir / "hub-images").exists():
88
+ raise ValueError("source or offline artifact mode cannot reuse a Hub-image work directory")
89
+ return None
90
+
91
+
92
+ def bind_support_receipt(
93
+ result: dict[str, Any], selection: HubImageSelection | None, *, require_receipt: bool = False
94
+ ) -> None:
95
+ """Preserve the support receipt identity before binding signed image provenance."""
96
+ if selection is not None:
97
+ result.update(selection.metadata())
98
+ if require_receipt or "receipt_digest" in result:
99
+ result["support_receipt_digest"] = result.pop("receipt_digest")
100
+ result["receipt_digest"] = canonical_digest(result)
101
+
102
+
103
+ def application_selection(
104
+ selection: HubImageSelection | None, source_mode: bool, prepared: Any
105
+ ) -> HubImageSelection | None:
106
+ """Reverify Hub provenance without adding source/offline preparation prerequisites."""
107
+ if selection is not None:
108
+ selection = load_selection(selection.path, selection.digest)
109
+ if not source_mode or selection.source_commit != prepared.source_commit:
110
+ raise ValueError("Hub images require the matching checkout support snapshot")
111
+ return selection
112
+
113
+
114
+ def source_binding(
115
+ snapshot_digest: str | None, selection: HubImageSelection | None
116
+ ) -> dict[str, object]:
117
+ return {
118
+ "source_snapshot_digest": snapshot_digest,
119
+ "provenance": "operator-selected-source",
120
+ **(selection.metadata() if selection is not None else {}),
121
+ }
122
+
123
+
124
+ def require_retained_hub_recovery(
125
+ selection: HubImageSelection, *, work_dir: Path, target_binding: str
126
+ ) -> None:
127
+ """Check the original mode and human target before recovery can advance any effect."""
128
+ intent = load_json_object(
129
+ read_private_bytes(work_dir / "source-intent.json", max_bytes=65_536),
130
+ label="retained Hub support intent",
131
+ strict=True,
132
+ )
133
+ marker = load_json_object(
134
+ read_private_bytes(work_dir / "foundation/source-genesis.json", max_bytes=65_536),
135
+ label="retained Hub Foundation binding",
136
+ strict=True,
137
+ )
138
+ digest = marker.pop("receipt_digest", None)
139
+ if (
140
+ intent.get("hub_selection_digest") != selection.digest
141
+ or intent.get("artifact_source") != "hub-signed-images"
142
+ or canonical_digest(marker) != digest
143
+ or marker.get("target_binding") != target_binding
144
+ or marker.get("source_commit") != selection.source_commit
145
+ ):
146
+ raise ValueError("Hub recovery mode, Release or selected Azure target drifted")
147
+
148
+
149
+ def validate_hub_url(url: str) -> str:
150
+ parsed = urlsplit(url)
151
+ if (
152
+ len(url) > 512
153
+ or parsed.scheme != "https"
154
+ or not parsed.hostname
155
+ or parsed.username is not None
156
+ or parsed.password is not None
157
+ or parsed.path not in {"", "/"}
158
+ or parsed.query
159
+ or parsed.fragment
160
+ or re.fullmatch(r"https://[a-z0-9][a-z0-9.-]*(?::[1-9][0-9]{0,4})?/?", url) is None
161
+ ):
162
+ raise ValueError("Hub URL must be one explicit HTTPS origin without credentials or path")
163
+ if parsed.port is not None and parsed.port > 65535:
164
+ raise ValueError("Hub HTTPS port is invalid")
165
+ return url.rstrip("/")
166
+
167
+
168
+ def hub_request(
169
+ hub_url: str, path: str, *, body: bytes | None = None
170
+ ) -> tuple[int, dict[str, str], bytes]:
171
+ """One bounded TLS request, without proxies, redirects, tokens or automatic retries."""
172
+ origin = urlsplit(validate_hub_url(hub_url))
173
+ assert origin.hostname is not None
174
+ if path not in {"/v1/catalog/export", "/v1/deployment-requests"}:
175
+ raise ValueError("Hub request path is not allowed")
176
+ limit = MAX_EXPORT_BYTES if body is None else 16_384
177
+ connection = http.client.HTTPSConnection(
178
+ origin.hostname, origin.port or 443, timeout=15, context=ssl.create_default_context()
179
+ )
180
+ deadline = time.monotonic() + 30
181
+ try:
182
+ headers = {"Accept": "application/json", "Connection": "close"}
183
+ if body is not None:
184
+ headers["Content-Type"] = "application/json"
185
+ connection.request("GET" if body is None else "POST", path, body=body, headers=headers)
186
+ response = connection.getresponse()
187
+ if response.status not in ({200} if body is None else {200, 201}):
188
+ raise ValueError(
189
+ f"Hub request was refused (HTTP {response.status}); redirects and "
190
+ "source-build fallback are disabled"
191
+ )
192
+ if response.getheader("Content-Encoding", "identity") != "identity":
193
+ raise ValueError("Hub response encoding is unsupported")
194
+ length = response.getheader("Content-Length")
195
+ if length is not None and (not length.isdecimal() or int(length) > limit):
196
+ raise ValueError("Hub response exceeds its size bound")
197
+ result = bytearray()
198
+ while True:
199
+ remaining = deadline - time.monotonic()
200
+ if remaining <= 0:
201
+ raise ValueError("Hub request deadline exceeded")
202
+ if connection.sock is not None:
203
+ connection.sock.settimeout(min(15, remaining))
204
+ chunk = response.read1(min(65_536, limit + 1 - len(result)))
205
+ if not chunk:
206
+ break
207
+ result.extend(chunk)
208
+ if len(result) > limit:
209
+ raise ValueError("Hub response exceeds its size bound")
210
+ if length is not None and len(result) != int(length):
211
+ raise ValueError("Hub response body is incomplete")
212
+ signature_headers: dict[str, str] = {}
213
+ for name in ("X-FDAI-Export-Signature", "X-FDAI-Export-Key-Id"):
214
+ values = response.headers.get_all(name, [])
215
+ if len(values) > 1:
216
+ raise ValueError("Hub response has ambiguous signature headers")
217
+ signature_headers[name.lower()] = values[0] if values else ""
218
+ return response.status, signature_headers, bytes(result)
219
+ except (TimeoutError, OSError, http.client.HTTPException) as error:
220
+ raise ValueError(
221
+ "Hub network request failed; preserve retained inputs and inspect any pending "
222
+ "deployment request before retrying. No automatic retry or source-build fallback."
223
+ ) from error
224
+ finally:
225
+ connection.close()
226
+
227
+
228
+ def prepare_hub_selection(
229
+ *,
230
+ source_root: Path,
231
+ work_dir: Path,
232
+ public_key_path: Path,
233
+ hub_url: str = DEFAULT_HUB_URL,
234
+ channel: str = "RELEASE",
235
+ release_id: str | None = None,
236
+ ) -> HubImageSelection:
237
+ """Verify before any Azure call and seal a selection that reruns cannot retarget."""
238
+ hub_url = validate_hub_url(hub_url)
239
+ validate_channel(channel)
240
+ with public_key_path.open("rb") as stream:
241
+ pem = stream.read(4097)
242
+ public_key(pem)
243
+ source = inspect_source(source_root)
244
+ if work_dir.resolve().is_relative_to(source.root):
245
+ raise ValueError("Hub work directory must be outside the selected checkout")
246
+ inputs = work_dir / "hub-images"
247
+ path = inputs / "selection.json"
248
+ previous = None
249
+ if path.exists():
250
+ raw = read_private_bytes(path, max_bytes=MAX_SELECTION_BYTES)
251
+ previous = load_selection(path, hashlib.sha256(raw).hexdigest())
252
+ if (
253
+ previous.document["hub_url"] != hub_url
254
+ or previous.document["channel"] != channel
255
+ or previous.document["public_key"] != pem.decode("ascii")
256
+ or release_id not in {None, previous.release_id}
257
+ ):
258
+ raise ValueError(
259
+ "Hub selection mode, origin, channel, key or Release drift; use a new work directory"
260
+ )
261
+ release_id = previous.release_id
262
+ elif work_dir.exists() and any(work_dir.iterdir()):
263
+ raise ValueError("Hub images cannot adopt source, kit, or partial work state")
264
+ _, headers, raw = hub_request(hub_url, "/v1/catalog/export")
265
+ verified = verify_export(
266
+ raw,
267
+ signature=headers["x-fdai-export-signature"],
268
+ export_key_id=headers["x-fdai-export-key-id"],
269
+ trusted_pem=pem,
270
+ channel=channel,
271
+ release_id=release_id,
272
+ minimum_revision=previous.document["catalog_revision"] if previous else 0,
273
+ previous_digest=previous.document["catalog_digest"] if previous else None,
274
+ )
275
+ if source.commit != verified["source_commit"]:
276
+ raise ValueError(
277
+ "Hub Release source_commit differs from checkout migrations and Console. "
278
+ f"Check out the exact approved commit {verified['source_commit']} and rerun; "
279
+ "no Azure deployment effect or source-build fallback was attempted."
280
+ )
281
+ selected = selection_bytes(
282
+ raw=raw,
283
+ signature=headers["x-fdai-export-signature"],
284
+ export_key_id=headers["x-fdai-export-key-id"],
285
+ pem=pem,
286
+ hub_url=hub_url,
287
+ channel=channel,
288
+ verified=verified,
289
+ )
290
+ digest = hashlib.sha256(selected).hexdigest()
291
+ if previous is not None:
292
+ if digest != previous.digest:
293
+ raise ValueError(
294
+ "Hub immutable catalog selection changed; preserve this run and use a new work directory"
295
+ )
296
+ return previous
297
+ inputs.mkdir(mode=0o700, parents=True, exist_ok=True)
298
+ work_dir.chmod(0o700)
299
+ write_private_bytes(path, selected)
300
+ return load_selection(path, digest)
301
+
302
+
303
+ def record_deployment_request(
304
+ selection: HubImageSelection, *, subscription_id: str, region: str
305
+ ) -> dict[str, object]:
306
+ """Record self-reported metadata once; uncertain mutations remain an explicit stop."""
307
+ subscription_id = str(UUID(subscription_id))
308
+ if re.fullmatch(r"[a-z][a-z0-9]{0,63}", region) is None:
309
+ raise ValueError("Hub deployment request region is invalid")
310
+ payload = {
311
+ "subscription_id": subscription_id,
312
+ "release_id": selection.release_id,
313
+ "channel": selection.document["channel"],
314
+ "region": region,
315
+ "source_commit": selection.source_commit,
316
+ "catalog_digest": selection.document["catalog_digest"],
317
+ }
318
+ payload["request_id"] = hashlib.sha256(canonical_bytes(payload)).hexdigest()
319
+ root = selection.path.parent
320
+ pending, receipt = root / "request-pending.json", root / "request-receipt.json"
321
+ if pending.exists():
322
+ retained = load_json_object(
323
+ read_private_bytes(pending, max_bytes=16_384), label="Hub request", strict=True
324
+ )
325
+ if retained != payload:
326
+ raise ValueError("Hub deployment request target or inputs drifted")
327
+ if not receipt.exists():
328
+ raise ValueError(
329
+ "Hub deployment request has an uncertain outcome. Inspect its retained "
330
+ "request_id with a Hub operator; do not automatically resubmit."
331
+ )
332
+ result = load_json_object(
333
+ read_private_bytes(receipt, max_bytes=16_384), label="Hub request receipt", strict=True
334
+ )
335
+ else:
336
+ if receipt.exists():
337
+ raise ValueError("Hub request pending evidence is missing")
338
+ write_private_bytes(pending, canonical_bytes(payload))
339
+ _, _, raw = hub_request(
340
+ selection.document["hub_url"], "/v1/deployment-requests", body=canonical_bytes(payload)
341
+ )
342
+ result = load_json_object(raw, label="Hub request response", max_bytes=16_384, strict=True)
343
+ if (
344
+ set(result) != set(payload) | {"status", "trust", "recorded_at"}
345
+ or any(result.get(name) != value for name, value in payload.items())
346
+ or result["status"] != "requested"
347
+ or result["trust"] != "self-reported"
348
+ or not isinstance(result["recorded_at"], str)
349
+ or datetime.fromisoformat(result["recorded_at"]).tzinfo is None
350
+ ):
351
+ raise ValueError("Hub request receipt differs; deployment is stopped")
352
+ if not receipt.exists():
353
+ write_private_bytes(receipt, canonical_bytes(result))
354
+ return result