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,400 @@
1
+ """Build source-path service images in the deployment registry and bind their digests.
2
+
3
+ The registry build service builds each baseline service from the pinned source snapshot and
4
+ the registry imports each dependency image by its pinned digest, so the source path needs no
5
+ container engine and writes no kit, signature, SBOM, or provenance statement. Every image is
6
+ read back by digest and by its `sha-<commit>` tag before the receipt binds it. A completed
7
+ receipt permits verification only; an interrupted stage repeats its builds, which only rewrite
8
+ the stage's own commit tags.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ import subprocess
16
+ from collections.abc import Callable, Mapping, Sequence
17
+ from concurrent.futures import Future, ThreadPoolExecutor
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Final, Protocol
21
+
22
+ from fdai_deployment_cli.contracts import canonical_digest, load_json_object
23
+ from fdai_deployment_cli.private_output import read_private_bytes, write_private_output
24
+
25
+ SERVICE_ORDER: Final[tuple[str, ...]] = (
26
+ "core-control-plane",
27
+ "operator-service",
28
+ "document-ingestion-api",
29
+ "document-processing-worker",
30
+ "isolated-executor",
31
+ )
32
+ DEPENDENCY_IMAGES: Final[Mapping[str, str]] = {
33
+ "clamav": "docker.io/clamav/clamav@sha256:"
34
+ "0af8760cd96f9ab67d07977af36e155431581a9fe9f0ec8b256c9f855fda183e",
35
+ "pgvector": "docker.io/pgvector/pgvector@sha256:"
36
+ "ccc6e83d6e35e931dc7c5def2022729d5a6c370318d099181995567ff1fb4d6b",
37
+ }
38
+ UNAVAILABLE: Final = "source_image_builder_unavailable"
39
+ FAILED: Final = "source_image_build_failed"
40
+ CHANGED: Final = "source_image_stage_inputs_changed"
41
+ RECEIPT_SCHEMA: Final = "fdai.source-image-stage-receipt.v1"
42
+ CLAIM_SCHEMA: Final = "fdai.source-image-stage-claim.v1"
43
+
44
+ _COMMIT = re.compile(r"[0-9a-f]{40}")
45
+ _SHA256 = re.compile(r"[0-9a-f]{64}")
46
+ _DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
47
+ _SUBSCRIPTION = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
48
+ _REGISTRY = re.compile(r"[a-z0-9]{5,50}")
49
+ _MAX_RECORD_BYTES = 1024 * 1024
50
+ _BUILD_TIMEOUT_SECONDS = 3600
51
+ _IMPORT_TIMEOUT_SECONDS = 1200
52
+ _READ_TIMEOUT_SECONDS = 120
53
+ MAX_PARALLEL_IMAGE_OPERATIONS: Final = 3
54
+
55
+ _GUIDANCE = {
56
+ UNAVAILABLE: (
57
+ "The deployment registry's build service is unavailable in this subscription. Enable "
58
+ "Azure Container Registry Tasks for the subscription, then rerun the same command."
59
+ ),
60
+ FAILED: (
61
+ "An image build, import, or digest readback failed. Review the registry runs, then "
62
+ "rerun the same command; the stage rewrites only its own commit tags."
63
+ ),
64
+ CHANGED: (
65
+ "This work directory already holds an image stage for different inputs. Rerun with the "
66
+ "original source and target, or start a new work directory."
67
+ ),
68
+ }
69
+
70
+
71
+ class SourceImageStageStopped(RuntimeError):
72
+ """Stop before any service apply with a fixed reason code and guidance."""
73
+
74
+ def __init__(self, reason_code: str) -> None:
75
+ super().__init__(_GUIDANCE[reason_code])
76
+ self.reason_code = reason_code
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class SourceImageTarget:
81
+ """The deployment's own registry; never a shared or publisher registry."""
82
+
83
+ subscription_id: str
84
+ registry_name: str
85
+
86
+ @property
87
+ def login_server(self) -> str:
88
+ return f"{self.registry_name}.azurecr.io"
89
+
90
+
91
+ @dataclass(frozen=True, slots=True)
92
+ class SourceImageSnapshot:
93
+ """A verified, pinned source tree for exactly one commit."""
94
+
95
+ root: Path
96
+ commit: str
97
+ digest: str
98
+
99
+
100
+ class RegistryBuildService(Protocol):
101
+ """The registry operations the stage needs; the stage owns ordering and evidence."""
102
+
103
+ def require_available(self, target: SourceImageTarget) -> None: ...
104
+
105
+ def build(
106
+ self, target: SourceImageTarget, *, context: Path, dockerfile: str, image: str
107
+ ) -> str: ...
108
+
109
+ def import_image(self, target: SourceImageTarget, *, source: str, image: str) -> None: ...
110
+
111
+ def read_digest(self, target: SourceImageTarget, *, reference: str) -> str: ...
112
+
113
+
114
+ def run_source_image_stage(
115
+ *,
116
+ snapshot: SourceImageSnapshot,
117
+ target: SourceImageTarget,
118
+ builder: RegistryBuildService,
119
+ work_dir: Path,
120
+ ) -> dict[str, object]:
121
+ """Build, import, and read back every runtime image, or stop before any service apply."""
122
+
123
+ _validate(snapshot, target)
124
+ claim = _claim(snapshot, target)
125
+ work_dir.mkdir(mode=0o700, exist_ok=True)
126
+ receipt_path = work_dir / f"source-images-{snapshot.commit}.receipt.json"
127
+ claim_path = work_dir / f"source-images-{snapshot.commit}.claim.json"
128
+ if receipt_path.exists():
129
+ existing = _load(receipt_path)
130
+ unsigned = {key: value for key, value in existing.items() if key != "receipt_digest"}
131
+ if existing.get("claim_digest") != claim["claim_digest"] or existing.get(
132
+ "receipt_digest"
133
+ ) != canonical_digest(unsigned):
134
+ raise SourceImageStageStopped(CHANGED)
135
+ _read_back(builder, target, snapshot.commit, _digests(existing))
136
+ return existing
137
+ builder.require_available(target)
138
+ if claim_path.exists():
139
+ if _load(claim_path) != claim:
140
+ raise SourceImageStageStopped(CHANGED)
141
+ else:
142
+ write_private_output(claim_path, json.dumps(claim, indent=2, sort_keys=True) + "\n")
143
+ operations: dict[str, Future[str]] = {}
144
+ with ThreadPoolExecutor(max_workers=MAX_PARALLEL_IMAGE_OPERATIONS) as executor:
145
+ for service in SERVICE_ORDER:
146
+ operations[service] = executor.submit(
147
+ _build_service,
148
+ builder,
149
+ target,
150
+ snapshot,
151
+ service,
152
+ )
153
+ for name, source in DEPENDENCY_IMAGES.items():
154
+ operations[name] = executor.submit(
155
+ _import_dependency,
156
+ builder,
157
+ target,
158
+ snapshot.commit,
159
+ name,
160
+ source,
161
+ )
162
+ digests = {name: _require_digest(future.result()) for name, future in operations.items()}
163
+ _read_back(builder, target, snapshot.commit, digests)
164
+ receipt: dict[str, object] = {
165
+ "schema_version": RECEIPT_SCHEMA,
166
+ "claim_digest": claim["claim_digest"],
167
+ "source_commit": snapshot.commit,
168
+ "snapshot_digest": snapshot.digest,
169
+ "registry_login_server": target.login_server,
170
+ "image_digests": dict(sorted(digests.items())),
171
+ "image_refs": {
172
+ name: f"{target.login_server}/{name}@{digest}"
173
+ for name, digest in sorted(digests.items())
174
+ },
175
+ "builder": "azure-container-registry-tasks",
176
+ "max_parallel_image_operations": MAX_PARALLEL_IMAGE_OPERATIONS,
177
+ "provenance": "operator-selected-source",
178
+ "release_signature_verified": False,
179
+ "effect_verified": True,
180
+ "mutation_performed": True,
181
+ "deployment_ready": False,
182
+ }
183
+ receipt["receipt_digest"] = canonical_digest(receipt)
184
+ write_private_output(receipt_path, json.dumps(receipt, indent=2, sort_keys=True) + "\n")
185
+ return receipt
186
+
187
+
188
+ def _build_service(
189
+ builder: RegistryBuildService,
190
+ target: SourceImageTarget,
191
+ snapshot: SourceImageSnapshot,
192
+ service: str,
193
+ ) -> str:
194
+ return builder.build(
195
+ target,
196
+ context=snapshot.root,
197
+ dockerfile=f"services/{service}/docker/Dockerfile",
198
+ image=f"{service}:sha-{snapshot.commit}",
199
+ )
200
+
201
+
202
+ def _import_dependency(
203
+ builder: RegistryBuildService,
204
+ target: SourceImageTarget,
205
+ commit: str,
206
+ name: str,
207
+ source: str,
208
+ ) -> str:
209
+ builder.import_image(target, source=source, image=f"{name}:sha-{commit}")
210
+ return source.rsplit("@", 1)[1]
211
+
212
+
213
+ def _validate(snapshot: SourceImageSnapshot, target: SourceImageTarget) -> None:
214
+ if (
215
+ _COMMIT.fullmatch(snapshot.commit) is None
216
+ or _SHA256.fullmatch(snapshot.digest) is None
217
+ or _SUBSCRIPTION.fullmatch(target.subscription_id) is None
218
+ or _REGISTRY.fullmatch(target.registry_name) is None
219
+ ):
220
+ raise ValueError("source image stage inputs are malformed")
221
+ for service in SERVICE_ORDER:
222
+ dockerfile = snapshot.root / "services" / service / "docker" / "Dockerfile"
223
+ if dockerfile.is_symlink() or not dockerfile.is_file():
224
+ raise ValueError(f"source snapshot lacks the {service} Dockerfile")
225
+
226
+
227
+ def _claim(snapshot: SourceImageSnapshot, target: SourceImageTarget) -> dict[str, object]:
228
+ claim: dict[str, object] = {
229
+ "schema_version": CLAIM_SCHEMA,
230
+ "source_commit": snapshot.commit,
231
+ "snapshot_digest": snapshot.digest,
232
+ "subscription_id": target.subscription_id,
233
+ "registry_login_server": target.login_server,
234
+ "services": list(SERVICE_ORDER),
235
+ "dependency_images": dict(sorted(DEPENDENCY_IMAGES.items())),
236
+ }
237
+ claim["claim_digest"] = canonical_digest(claim)
238
+ return claim
239
+
240
+
241
+ def _load(path: Path) -> dict[str, object]:
242
+ data = read_private_bytes(path, max_bytes=_MAX_RECORD_BYTES)
243
+ return dict(load_json_object(data, label=path.name, max_bytes=_MAX_RECORD_BYTES))
244
+
245
+
246
+ def _digests(receipt: Mapping[str, object]) -> dict[str, str]:
247
+ raw = receipt.get("image_digests")
248
+ expected = set(SERVICE_ORDER) | set(DEPENDENCY_IMAGES)
249
+ if not isinstance(raw, dict) or set(raw) != expected:
250
+ raise SourceImageStageStopped(CHANGED)
251
+ return {str(name): _require_digest(value) for name, value in raw.items()}
252
+
253
+
254
+ def _require_digest(value: object) -> str:
255
+ if not isinstance(value, str) or _DIGEST.fullmatch(value) is None:
256
+ raise SourceImageStageStopped(FAILED)
257
+ return value
258
+
259
+
260
+ def _read_back(
261
+ builder: RegistryBuildService,
262
+ target: SourceImageTarget,
263
+ commit: str,
264
+ digests: Mapping[str, str],
265
+ ) -> None:
266
+ for name, digest in sorted(digests.items()):
267
+ by_digest = builder.read_digest(target, reference=f"{name}@{digest}")
268
+ by_tag = builder.read_digest(target, reference=f"{name}:sha-{commit}")
269
+ if by_digest != digest or by_tag != digest:
270
+ raise SourceImageStageStopped(FAILED)
271
+
272
+
273
+ Runner = Callable[..., "subprocess.CompletedProcess[str]"]
274
+
275
+
276
+ def _run(
277
+ command: Sequence[str], timeout: int, *, cwd: Path | None = None
278
+ ) -> subprocess.CompletedProcess[str]:
279
+ return subprocess.run(
280
+ list(command), check=False, capture_output=True, text=True, timeout=timeout, cwd=cwd
281
+ )
282
+
283
+
284
+ class AzureRegistryBuildService:
285
+ """Azure Container Registry Tasks and import through the signed-in Azure CLI.
286
+
287
+ Every call carries the explicit subscription. Failures surface as fixed reason codes;
288
+ command output never reaches the error text.
289
+ """
290
+
291
+ def __init__(self, runner: Runner = _run) -> None:
292
+ self._runner = runner
293
+
294
+ def require_available(self, target: SourceImageTarget) -> None:
295
+ result = self._az(
296
+ target,
297
+ ("acr", "show", "--name", target.registry_name),
298
+ ("--query", "{loginServer:loginServer,state:provisioningState}", "--output", "json"),
299
+ timeout=_READ_TIMEOUT_SECONDS,
300
+ reason=UNAVAILABLE,
301
+ )
302
+ try:
303
+ registry = json.loads(result)
304
+ except json.JSONDecodeError:
305
+ raise SourceImageStageStopped(UNAVAILABLE) from None
306
+ if registry != {"loginServer": target.login_server, "state": "Succeeded"}:
307
+ raise SourceImageStageStopped(UNAVAILABLE)
308
+
309
+ def build(
310
+ self, target: SourceImageTarget, *, context: Path, dockerfile: str, image: str
311
+ ) -> str:
312
+ output = self._az(
313
+ target,
314
+ ("acr", "build", "--registry", target.registry_name, "--image", image),
315
+ (
316
+ "--file",
317
+ dockerfile,
318
+ "--platform",
319
+ "linux/amd64",
320
+ "--timeout",
321
+ str(_BUILD_TIMEOUT_SECONDS),
322
+ "--no-logs",
323
+ "--output",
324
+ "json",
325
+ str(context),
326
+ ),
327
+ timeout=_BUILD_TIMEOUT_SECONDS + 300,
328
+ reason=FAILED,
329
+ # The Azure CLI resolves a relative --file against the working directory.
330
+ cwd=context,
331
+ )
332
+ try:
333
+ run = json.loads(output)
334
+ except json.JSONDecodeError:
335
+ raise SourceImageStageStopped(FAILED) from None
336
+ repository, tag = image.split(":", 1)
337
+ if not isinstance(run, dict) or run.get("status") != "Succeeded":
338
+ raise SourceImageStageStopped(FAILED)
339
+ matches = [
340
+ record.get("digest")
341
+ for record in run.get("outputImages") or []
342
+ if isinstance(record, dict)
343
+ and record.get("repository") == repository
344
+ and record.get("tag") == tag
345
+ and record.get("registry") == target.login_server
346
+ ]
347
+ if len(matches) != 1:
348
+ raise SourceImageStageStopped(FAILED)
349
+ return _require_digest(matches[0])
350
+
351
+ def import_image(self, target: SourceImageTarget, *, source: str, image: str) -> None:
352
+ self._az(
353
+ target,
354
+ ("acr", "import", "--name", target.registry_name, "--source", source),
355
+ ("--image", image, "--force"),
356
+ timeout=_IMPORT_TIMEOUT_SECONDS,
357
+ reason=FAILED,
358
+ )
359
+
360
+ def read_digest(self, target: SourceImageTarget, *, reference: str) -> str:
361
+ output = self._az(
362
+ target,
363
+ ("acr", "manifest", "show-metadata", "--registry", target.registry_name),
364
+ ("--name", reference, "--query", "digest", "--output", "tsv"),
365
+ timeout=_READ_TIMEOUT_SECONDS,
366
+ reason=FAILED,
367
+ )
368
+ return _require_digest(output.strip())
369
+
370
+ def _az(
371
+ self,
372
+ target: SourceImageTarget,
373
+ head: tuple[str, ...],
374
+ tail: tuple[str, ...],
375
+ *,
376
+ timeout: int,
377
+ reason: str,
378
+ cwd: Path | None = None,
379
+ ) -> str:
380
+ command = (
381
+ "az",
382
+ *head,
383
+ "--subscription",
384
+ target.subscription_id,
385
+ *tail,
386
+ "--only-show-errors",
387
+ )
388
+ try:
389
+ result = (
390
+ self._runner(command, timeout)
391
+ if cwd is None
392
+ else self._runner(command, timeout, cwd=cwd)
393
+ )
394
+ except subprocess.TimeoutExpired:
395
+ raise SourceImageStageStopped(reason) from None
396
+ if result.returncode != 0:
397
+ if "TasksOperationsNotAllowed" in (result.stderr or ""):
398
+ raise SourceImageStageStopped(UNAVAILABLE)
399
+ raise SourceImageStageStopped(reason)
400
+ return result.stdout
@@ -0,0 +1,173 @@
1
+ """Pin operator-selected Git inputs without claiming release signature trust."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import re
8
+ import stat
9
+ import subprocess
10
+ from dataclasses import dataclass
11
+ from pathlib import Path, PurePosixPath
12
+
13
+ from fdai_deployment_cli.contracts import canonical_digest
14
+
15
+ _COMMIT = re.compile(r"[0-9a-f]{40}")
16
+ _MAX_FILES = 65_536
17
+ _MAX_FILE_BYTES = 64 * 1024 * 1024
18
+ _MAX_SOURCE_BYTES = 2 * 1024 * 1024 * 1024
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class SourceDeploymentInput:
23
+ """Exact source identity; a local content pin, not publisher trust or approval."""
24
+
25
+ root: Path
26
+ commit: str
27
+ tree: str
28
+ content_digest: str
29
+ file_count: int
30
+
31
+ @property
32
+ def digest(self) -> str:
33
+ """Bind portable source evidence without exposing the local checkout path."""
34
+ return canonical_digest(self.to_mapping())
35
+
36
+ def to_mapping(self) -> dict[str, object]:
37
+ """Return secret-free provenance for reviews and retained source runs."""
38
+ return {
39
+ "schema_version": "fdai.source-deployment-input.v1",
40
+ "provenance": "operator-selected-source",
41
+ "source_commit": self.commit,
42
+ "source_tree": self.tree,
43
+ "content_digest": self.content_digest,
44
+ "file_count": self.file_count,
45
+ "release_signature_verified": False,
46
+ }
47
+
48
+ def reverify(self) -> None:
49
+ """Reject changed source before another build, transfer, or deployment effect."""
50
+ if inspect_source(self.root, expected_commit=self.commit) != self:
51
+ raise ValueError("source deployment inputs changed; preserve the existing run")
52
+
53
+
54
+ def inspect_source(root: Path, *, expected_commit: str | None = None) -> SourceDeploymentInput:
55
+ """Verify a complete clean checkout against raw Git blobs, without executing its code.
56
+
57
+ Git clean filters and index flags cannot hide raw content changes. External links,
58
+ submodules, hardlinks, untracked files and oversized inputs are rejected. Ignored
59
+ files are never part of the evidence and must not enter a later build snapshot.
60
+ """
61
+ if root.is_symlink():
62
+ raise ValueError("source deployment requires a non-symlink checkout root")
63
+ root = root.resolve(strict=True)
64
+ if _git(root, "rev-parse", "--show-toplevel").decode().strip() != str(root):
65
+ raise ValueError("source deployment must select the checkout root")
66
+ commit = _git(root, "rev-parse", "--verify", "HEAD^{commit}").decode().strip()
67
+ if _COMMIT.fullmatch(commit) is None or expected_commit not in {None, commit}:
68
+ raise ValueError("source deployment revision differs from the selected commit")
69
+ if _git(root, "status", "--porcelain=v1", "--untracked-files=all"):
70
+ raise ValueError("source deployment requires a clean committed checkout")
71
+ tree = _git(root, "rev-parse", "HEAD^{tree}").decode().strip()
72
+ entries = _git(root, "ls-tree", "-rz", "--full-tree", commit).split(b"\0")[:-1]
73
+ if not 0 < len(entries) <= _MAX_FILES:
74
+ raise ValueError("source deployment file inventory is empty or exceeds its bound")
75
+ tracked = {entry.split(b"\t", 1)[1].decode("utf-8") for entry in entries}
76
+ records: list[dict[str, str]] = []
77
+ total = 0
78
+ for entry in entries:
79
+ metadata, raw_path = entry.split(b"\t", 1)
80
+ mode, kind, blob_digest = metadata.decode("ascii").split()
81
+ path = raw_path.decode("utf-8")
82
+ if kind != "blob" or mode not in {"100644", "100755", "120000"}:
83
+ raise ValueError("source deployment does not accept external submodules")
84
+ content = _read_tracked(root, path, mode=mode)
85
+ if mode == "120000":
86
+ try:
87
+ resolved = (root / path).resolve(strict=True).relative_to(root).as_posix()
88
+ except (OSError, ValueError, RuntimeError):
89
+ raise ValueError("source link must resolve inside the checkout") from None
90
+ if resolved not in tracked:
91
+ raise ValueError("source link target must be a tracked file")
92
+ total += len(content)
93
+ if total > _MAX_SOURCE_BYTES:
94
+ raise ValueError("source deployment exceeds its total size bound")
95
+ blob = b"blob " + str(len(content)).encode("ascii") + b"\0" + content
96
+ if hashlib.sha1(blob, usedforsecurity=False).hexdigest() != blob_digest:
97
+ raise ValueError("source bytes differ from the committed input")
98
+ records.append({"path": path, "mode": mode, "sha256": hashlib.sha256(content).hexdigest()})
99
+ if _git(root, "rev-parse", "HEAD").decode().strip() != commit:
100
+ raise ValueError("source revision changed during inspection")
101
+ return SourceDeploymentInput(
102
+ root, commit, tree, canonical_digest({"files": records}), len(records)
103
+ )
104
+
105
+
106
+ def _read_tracked(root: Path, relative: str, *, mode: str) -> bytes:
107
+ parts = PurePosixPath(relative).parts
108
+ if not parts or relative.startswith("/") or any(part in {".", ".."} for part in parts):
109
+ raise ValueError("source input path is invalid")
110
+ parent = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
111
+ try:
112
+ for part in parts[:-1]:
113
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
114
+ os.close(parent)
115
+ parent = child
116
+ if mode == "120000":
117
+ before_link = os.stat(parts[-1], dir_fd=parent, follow_symlinks=False)
118
+ target = os.readlink(parts[-1], dir_fd=parent)
119
+ after_link = os.stat(parts[-1], dir_fd=parent, follow_symlinks=False)
120
+ stable_fields = ("st_dev", "st_ino", "st_mode", "st_size", "st_mtime_ns", "st_ctime_ns")
121
+ if (
122
+ any(
123
+ getattr(before_link, field) != getattr(after_link, field)
124
+ for field in stable_fields
125
+ )
126
+ or Path(target).is_absolute()
127
+ ):
128
+ raise ValueError("source link changed or has an absolute target")
129
+ return os.fsencode(target)
130
+ descriptor = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
131
+ with os.fdopen(descriptor, "rb") as stream:
132
+ before = os.fstat(stream.fileno())
133
+ if (
134
+ not stat.S_ISREG(before.st_mode)
135
+ or before.st_nlink != 1
136
+ or before.st_size > _MAX_FILE_BYTES
137
+ or bool(before.st_mode & stat.S_IXUSR) != (mode == "100755")
138
+ ):
139
+ raise ValueError("source input type, mode, or size is invalid")
140
+ content = stream.read(_MAX_FILE_BYTES + 1)
141
+ after = os.fstat(stream.fileno())
142
+ if (
143
+ len(content) != before.st_size
144
+ or after.st_size != before.st_size
145
+ or after.st_mtime_ns != before.st_mtime_ns
146
+ or after.st_ctime_ns != before.st_ctime_ns
147
+ ):
148
+ raise ValueError("source input changed during inspection")
149
+ return content
150
+ finally:
151
+ os.close(parent)
152
+
153
+
154
+ def _git(root: Path, *arguments: str) -> bytes:
155
+ environment = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
156
+ environment["GIT_TERMINAL_PROMPT"] = "0"
157
+ try:
158
+ result = subprocess.run(
159
+ ["git", *arguments],
160
+ cwd=root,
161
+ env=environment,
162
+ stdin=subprocess.DEVNULL,
163
+ capture_output=True,
164
+ check=True,
165
+ timeout=30,
166
+ )
167
+ except (OSError, subprocess.SubprocessError):
168
+ raise ValueError(
169
+ "source checkout could not be verified; no deployment was started"
170
+ ) from None
171
+ if len(result.stdout) > 16 * 1024 * 1024:
172
+ raise ValueError("source Git response exceeds its size bound")
173
+ return result.stdout