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,3 @@
1
+ """Package version for the independently installable deployment CLI."""
2
+
3
+ __version__ = "0.1.2"
@@ -0,0 +1,5 @@
1
+ """Installable, fail-closed FDAI deployment orchestration."""
2
+
3
+ from fdai_deployment_cli.__about__ import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,5 @@
1
+ """Module entry point for ``python -m fdai.deployment_cli``."""
2
+
3
+ from fdai_deployment_cli.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,585 @@
1
+ """Bounded ACR Distribution v2 publication for a future protected executor.
2
+
3
+ This is not an installer, approval mechanism, or public mutating CLI. The caller
4
+ must bind the authenticated target and distinct executor identity to current
5
+ protected authorization, safeguards, and audit before calling either publication function.
6
+ Tokens stay in memory. Cross-host/data-endpoint redirects are refused: supporting
7
+ them would require a separately attested allowlist, which this adapter does not
8
+ accept. An Azure-public login-server name can resolve through approved private DNS.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import http.client
15
+ import json
16
+ import math
17
+ import re
18
+ import socket
19
+ import ssl
20
+ import threading
21
+ import time
22
+ from collections.abc import Callable, Iterable, Mapping
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from queue import Empty, Queue
26
+ from socket import getaddrinfo as _getaddrinfo
27
+ from socket import socket as _socket
28
+ from typing import Protocol, cast
29
+ from urllib.parse import parse_qsl, urlencode, urlsplit
30
+
31
+ from fdai_deployment_cli.oci_archive import (
32
+ OCI_MANIFEST,
33
+ VerifiedOciImage,
34
+ validate_dependency_oci_archive,
35
+ validate_oci_archive,
36
+ )
37
+
38
+ _HOST = re.compile(r"[a-z0-9]{5,50}\.azurecr\.io")
39
+ _COMPONENT = r"[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*"
40
+ _REPOSITORY = re.compile(rf"{_COMPONENT}(?:/{_COMPONENT})*")
41
+ _TOKEN = re.compile(r"[A-Za-z0-9._~+/-]+=*")
42
+ _RESPONSE_LIMIT = 4 * 1024 * 1024
43
+ _DNS_SLOTS = threading.BoundedSemaphore(4)
44
+ _Address = tuple[int, tuple[str, int] | tuple[str, int, int, int]]
45
+
46
+
47
+ class AcrPublishError(RuntimeError):
48
+ """Sanitized failure with stable stage/status codes and optional HTTP status."""
49
+
50
+ def __init__(self, stage: str, status: str, http_status: int | None = None) -> None:
51
+ self.stage, self.status, self.http_status = stage, status, http_status
52
+ super().__init__(f"ACR publication {stage}: {status}")
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class RegistryResponse:
57
+ """Bounded transport response; payload and headers are never represented."""
58
+
59
+ status: int
60
+ headers: Mapping[str, str] = field(default_factory=dict, repr=False)
61
+ body: bytes = field(default=b"", repr=False)
62
+
63
+ def header(self, name: str) -> str | None:
64
+ """Read one case-insensitive header, rejecting ambiguous duplicates."""
65
+
66
+ values = [value for key, value in self.headers.items() if key.lower() == name.lower()]
67
+ if len(values) > 1:
68
+ raise AcrPublishError("response", "duplicate-header")
69
+ return values[0] if values else None
70
+
71
+
72
+ class RegistryTransport(Protocol):
73
+ """Injected I/O boundary: no redirects/retries, bounded body and monotonic deadline."""
74
+
75
+ def request(
76
+ self,
77
+ host: str,
78
+ method: str,
79
+ target: str,
80
+ headers: Mapping[str, str],
81
+ body: Iterable[bytes],
82
+ *,
83
+ content_length: int,
84
+ deadline: float,
85
+ ) -> RegistryResponse:
86
+ """Transmit exactly content_length bytes without persisting credentials."""
87
+ ...
88
+
89
+
90
+ def _lookup_address(
91
+ host: str,
92
+ deadline: float,
93
+ result: Queue[_Address | str],
94
+ slots: threading.BoundedSemaphore,
95
+ ) -> None:
96
+ """Resolver workers never receive a connection, request body, or credentials."""
97
+ outcome: _Address | str = "transport-failure"
98
+ try:
99
+ if time.monotonic() >= deadline:
100
+ outcome = "timeout"
101
+ else:
102
+ addresses = _getaddrinfo(host, 443, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP)
103
+ if addresses:
104
+ family, _, _, _, target = addresses[0]
105
+ if family in {socket.AF_INET, socket.AF_INET6}:
106
+ endpoint = cast(tuple[str, int] | tuple[str, int, int, int], target)
107
+ socket.inet_pton(family, endpoint[0])
108
+ outcome = family, endpoint
109
+ except TimeoutError:
110
+ outcome = "timeout"
111
+ except OSError:
112
+ outcome = "transport-failure"
113
+ finally:
114
+ result.put_nowait(outcome)
115
+ slots.release()
116
+
117
+
118
+ def _resolve_address(host: str, deadline: float) -> _Address:
119
+ if time.monotonic() >= deadline:
120
+ raise AcrPublishError("transport", "timeout")
121
+ slots = _DNS_SLOTS
122
+ if not slots.acquire(blocking=False):
123
+ raise AcrPublishError("transport", "resolver-busy")
124
+ result: Queue[_Address | str] = Queue(maxsize=1)
125
+ worker = threading.Thread(
126
+ target=_lookup_address,
127
+ args=(host, deadline, result, slots),
128
+ daemon=True,
129
+ )
130
+ try:
131
+ worker.start()
132
+ except RuntimeError:
133
+ slots.release()
134
+ raise AcrPublishError("transport", "resolver-unavailable") from None
135
+ try:
136
+ address = result.get(timeout=max(0, deadline - time.monotonic()))
137
+ except Empty:
138
+ raise AcrPublishError("transport", "timeout") from None
139
+ if time.monotonic() >= deadline:
140
+ raise AcrPublishError("transport", "timeout")
141
+ if isinstance(address, str):
142
+ raise AcrPublishError("transport", address)
143
+ return address
144
+
145
+
146
+ class HttpsRegistryTransport:
147
+ """Real stdlib HTTPS transport with normal certificate/hostname verification.
148
+
149
+ DNS waits obey the socket/call budget, with at most four daemon resolver workers.
150
+ An abandoned worker can finish DNS only; it cannot connect or transmit HTTP.
151
+ One numeric address is connected without a second lookup or address retries.
152
+ TLS verifies the approved login-server hostname, not the resolved IP address.
153
+ No proxy, custom insecure TLS context, or redirect handler is used.
154
+ """
155
+
156
+ def __init__(self, *, io_timeout: float = 15.0) -> None:
157
+ if not math.isfinite(io_timeout) or not 0 < io_timeout <= 120:
158
+ raise AcrPublishError("transport", "invalid-timeout")
159
+ self.io_timeout = io_timeout
160
+
161
+ def request(
162
+ self,
163
+ host: str,
164
+ method: str,
165
+ target: str,
166
+ headers: Mapping[str, str],
167
+ body: Iterable[bytes],
168
+ *,
169
+ content_length: int,
170
+ deadline: float,
171
+ ) -> RegistryResponse:
172
+ """Send one bounded request; error bodies are neither read nor exposed."""
173
+
174
+ if not _HOST.fullmatch(host) or not _request_target(target):
175
+ raise AcrPublishError("transport", "invalid-target")
176
+ family, address = _resolve_address(
177
+ host, min(deadline, time.monotonic() + self._remaining(deadline))
178
+ )
179
+ context = ssl.create_default_context()
180
+ connection = http.client.HTTPSConnection(
181
+ host,
182
+ timeout=self._remaining(deadline),
183
+ context=context,
184
+ )
185
+ connection.auto_open = 0
186
+ stream: socket.socket | None = None
187
+ timer: threading.Timer | None = None
188
+ try:
189
+ self._remaining(deadline)
190
+ stream = _socket(family, socket.SOCK_STREAM, socket.IPPROTO_TCP)
191
+ stream.settimeout(self._remaining(deadline))
192
+ stream.connect(address)
193
+ stream.settimeout(self._remaining(deadline))
194
+ connection.sock = context.wrap_socket(stream, server_hostname=host)
195
+ self._socket_budget(connection, deadline)
196
+ timer = threading.Timer(
197
+ max(0, deadline - time.monotonic()),
198
+ _shutdown,
199
+ args=(connection.sock,),
200
+ )
201
+ timer.daemon = True
202
+ timer.start()
203
+ self._remaining(deadline)
204
+ connection.putrequest(method, target, skip_accept_encoding=True)
205
+ connection.putheader("Content-Length", str(content_length))
206
+ for key, value in headers.items():
207
+ if key.lower() not in {"authorization", "content-type", "accept"}:
208
+ raise AcrPublishError("transport", "invalid-header")
209
+ connection.putheader(key, value)
210
+ self._socket_budget(connection, deadline)
211
+ connection.endheaders()
212
+ sent = 0
213
+ for chunk in body:
214
+ if not isinstance(chunk, bytes) or len(chunk) > 1024 * 1024:
215
+ raise AcrPublishError("transport", "invalid-body")
216
+ sent += len(chunk)
217
+ if sent > content_length:
218
+ raise AcrPublishError("transport", "invalid-body")
219
+ self._socket_budget(connection, deadline)
220
+ connection.send(chunk)
221
+ if sent != content_length:
222
+ raise AcrPublishError("transport", "invalid-body")
223
+ self._socket_budget(connection, deadline)
224
+ response = connection.getresponse()
225
+ selected: dict[str, str] = {}
226
+ for key, value in response.getheaders():
227
+ key = key.lower()
228
+ if key in {"location", "docker-content-digest", "content-type", "content-length"}:
229
+ if key in selected:
230
+ raise AcrPublishError("transport", "duplicate-header")
231
+ selected[key] = value
232
+ payload = bytearray()
233
+ if response.status == 200 and method != "HEAD":
234
+ while True:
235
+ self._socket_budget(connection, deadline)
236
+ chunk = response.read1(min(65536, _RESPONSE_LIMIT + 1 - len(payload)))
237
+ if not chunk:
238
+ break
239
+ payload.extend(chunk)
240
+ if len(payload) > _RESPONSE_LIMIT:
241
+ raise AcrPublishError("transport", "response-limit")
242
+ self._remaining(deadline)
243
+ return RegistryResponse(response.status, selected, bytes(payload))
244
+ except TimeoutError:
245
+ raise AcrPublishError("transport", "timeout") from None
246
+ except (OSError, ValueError, http.client.HTTPException):
247
+ if time.monotonic() >= deadline:
248
+ raise AcrPublishError("transport", "timeout") from None
249
+ raise AcrPublishError("transport", "transport-failure") from None
250
+ finally:
251
+ if timer is not None:
252
+ timer.cancel()
253
+ connection.close()
254
+ if stream is not None:
255
+ stream.close()
256
+
257
+ def _remaining(self, deadline: float) -> float:
258
+ remaining = deadline - time.monotonic()
259
+ if not math.isfinite(remaining) or remaining <= 0:
260
+ raise AcrPublishError("transport", "timeout")
261
+ return min(self.io_timeout, remaining)
262
+
263
+ def _socket_budget(self, connection: http.client.HTTPSConnection, deadline: float) -> None:
264
+ timeout = self._remaining(deadline)
265
+ if connection.sock is not None:
266
+ connection.sock.settimeout(timeout)
267
+
268
+
269
+ def _shutdown(stream: socket.socket | None) -> None:
270
+ """Interrupt slow-drip headers/body at the absolute deadline, without retries."""
271
+ if stream is not None:
272
+ try:
273
+ stream.shutdown(socket.SHUT_RDWR)
274
+ except OSError:
275
+ return
276
+
277
+
278
+ @dataclass(frozen=True, slots=True)
279
+ class PublishedImage[Revision: (str, None)]:
280
+ """Independent digest-readback evidence, not installation or operational readiness."""
281
+
282
+ manifest_digest: str
283
+ source_commit: Revision
284
+ platform_tag: str
285
+ requests: int
286
+
287
+
288
+ class _Session:
289
+ def __init__(
290
+ self,
291
+ host: str,
292
+ transport: RegistryTransport,
293
+ deadline: float,
294
+ call_timeout: float,
295
+ max_requests: int,
296
+ ) -> None:
297
+ self.host, self.transport, self.deadline = host, transport, deadline
298
+ self.call_timeout, self.max_requests, self.requests = call_timeout, max_requests, 0
299
+ self.authorization = ""
300
+
301
+ def request(
302
+ self,
303
+ stage: str,
304
+ method: str,
305
+ target: str,
306
+ expected: set[int],
307
+ *,
308
+ body: Iterable[bytes] = (),
309
+ size: int = 0,
310
+ media_type: str = "application/octet-stream",
311
+ ) -> RegistryResponse:
312
+ if self.requests >= self.max_requests:
313
+ raise AcrPublishError(stage, "request-limit")
314
+ if time.monotonic() >= self.deadline:
315
+ raise AcrPublishError(stage, "timeout")
316
+ headers = {
317
+ "Content-Type": media_type,
318
+ "Accept": "application/json" if stage == "token" else OCI_MANIFEST,
319
+ }
320
+ if self.authorization:
321
+ headers["Authorization"] = self.authorization
322
+ self.requests += 1
323
+ call_deadline = min(self.deadline, time.monotonic() + self.call_timeout)
324
+ try:
325
+ result = self.transport.request(
326
+ self.host,
327
+ method,
328
+ target,
329
+ headers,
330
+ body,
331
+ content_length=size,
332
+ deadline=call_deadline,
333
+ )
334
+ except AcrPublishError as exc:
335
+ raise AcrPublishError(stage, exc.status, exc.http_status) from None
336
+ except TimeoutError:
337
+ raise AcrPublishError(stage, "timeout") from None
338
+ except (OSError, ValueError, http.client.HTTPException):
339
+ raise AcrPublishError(stage, "transport-failure") from None
340
+ if time.monotonic() >= call_deadline:
341
+ raise AcrPublishError(stage, "timeout")
342
+ if result.status not in expected:
343
+ raise AcrPublishError(stage, "http-error", result.status)
344
+ if len(result.body) > _RESPONSE_LIMIT:
345
+ raise AcrPublishError(stage, "response-limit")
346
+ return result
347
+
348
+
349
+ def publish_oci_archive(
350
+ path: Path,
351
+ *,
352
+ registry: str,
353
+ repository: str,
354
+ expected_archive_sha256: str,
355
+ expected_manifest_digest: str,
356
+ expected_source_commit: str,
357
+ expected_platform_tag: str,
358
+ credential_provider: Callable[[], str],
359
+ transport: RegistryTransport | None = None,
360
+ total_timeout: float = 300.0,
361
+ call_timeout: float = 60.0,
362
+ max_requests: int = 256,
363
+ ) -> PublishedImage[str]:
364
+ """Upload verified bytes by digest and independently GET the resulting manifest.
365
+
366
+ Full local validation precedes credential acquisition and all HTTP. The injected
367
+ provider must return a short-lived ACR refresh token for this approved registry
368
+ using its own bounded identity flow. There is no credential fallback or retry.
369
+ Failure may leave uploaded content; the caller owns audit and approved recovery.
370
+ Invalid archives raise OciArchiveError; publication failures raise AcrPublishError.
371
+ """
372
+
373
+ session = _publication_session(
374
+ registry, repository, transport, total_timeout, call_timeout, max_requests
375
+ )
376
+ image = validate_oci_archive(
377
+ path,
378
+ expected_archive_sha256=expected_archive_sha256,
379
+ expected_manifest_digest=expected_manifest_digest,
380
+ expected_source_commit=expected_source_commit,
381
+ expected_platform_tag=expected_platform_tag,
382
+ )
383
+ return _publish_image(session, repository, image, credential_provider)
384
+
385
+
386
+ def publish_dependency_oci_archive(
387
+ path: Path,
388
+ *,
389
+ registry: str,
390
+ repository: str,
391
+ expected_archive_sha256: str,
392
+ expected_manifest_digest: str,
393
+ expected_platform_tag: str,
394
+ credential_provider: Callable[[], str],
395
+ transport: RegistryTransport | None = None,
396
+ total_timeout: float = 300.0,
397
+ call_timeout: float = 60.0,
398
+ max_requests: int = 256,
399
+ ) -> PublishedImage[None]:
400
+ """Publish approved dependency content without inventing an FDAI source revision.
401
+
402
+ Protected authorization, target/identity binding, lease, audit and approved recovery
403
+ remain caller prerequisites, exactly as for service publication. Local content
404
+ validation precedes credentials; the same bounded upload and independent manifest
405
+ GET must pass. The receipt carries source_commit=None, not dependency provenance.
406
+ """
407
+ session = _publication_session(
408
+ registry, repository, transport, total_timeout, call_timeout, max_requests
409
+ )
410
+ image = validate_dependency_oci_archive(
411
+ path,
412
+ expected_archive_sha256=expected_archive_sha256,
413
+ expected_manifest_digest=expected_manifest_digest,
414
+ expected_platform_tag=expected_platform_tag,
415
+ )
416
+ return _publish_image(session, repository, image, credential_provider)
417
+
418
+
419
+ def _publication_session(
420
+ registry: str,
421
+ repository: str,
422
+ transport: RegistryTransport | None,
423
+ total_timeout: float,
424
+ call_timeout: float,
425
+ max_requests: int,
426
+ ) -> _Session:
427
+ deadline = time.monotonic() + total_timeout
428
+ if (
429
+ not math.isfinite(total_timeout)
430
+ or not 0 < total_timeout <= 3600
431
+ or not math.isfinite(call_timeout)
432
+ or not 0 < call_timeout <= 120
433
+ or type(max_requests) is not int
434
+ or not 1 <= max_requests <= 60_003
435
+ ):
436
+ raise AcrPublishError("target", "invalid-budget")
437
+ if (
438
+ not _HOST.fullmatch(registry)
439
+ or len(repository) > 255
440
+ or not _REPOSITORY.fullmatch(repository)
441
+ ):
442
+ raise AcrPublishError("target", "invalid-target")
443
+ return _Session(
444
+ registry, transport or HttpsRegistryTransport(), deadline, call_timeout, max_requests
445
+ )
446
+
447
+
448
+ def _publish_image[Revision: (str, None)](
449
+ session: _Session,
450
+ repository: str,
451
+ image: VerifiedOciImage[Revision],
452
+ credential_provider: Callable[[], str],
453
+ ) -> PublishedImage[Revision]:
454
+ if time.monotonic() >= session.deadline:
455
+ raise AcrPublishError("credentials", "timeout")
456
+ try:
457
+ refresh_token = credential_provider()
458
+ except (OSError, RuntimeError, ValueError):
459
+ raise AcrPublishError("credentials", "unavailable") from None
460
+ if (
461
+ not isinstance(refresh_token, str)
462
+ or not 0 < len(refresh_token) <= 65536
463
+ or not _TOKEN.fullmatch(refresh_token)
464
+ ):
465
+ raise AcrPublishError("credentials", "invalid")
466
+ form = urlencode(
467
+ {
468
+ "grant_type": "refresh_token",
469
+ "service": session.host,
470
+ "scope": f"repository:{repository}:pull,push",
471
+ "refresh_token": refresh_token,
472
+ }
473
+ ).encode("ascii")
474
+ response = session.request(
475
+ "token",
476
+ "POST",
477
+ "/oauth2/token",
478
+ {200},
479
+ body=(form,),
480
+ size=len(form),
481
+ media_type="application/x-www-form-urlencoded",
482
+ )
483
+ try:
484
+ payload = json.loads(response.body, object_pairs_hook=_unique_token_fields)
485
+ token = payload["access_token"]
486
+ if not isinstance(token, str) or len(token) > 65536 or not _TOKEN.fullmatch(token):
487
+ raise ValueError
488
+ except (ValueError, KeyError, TypeError, RecursionError):
489
+ raise AcrPublishError("token", "invalid-response") from None
490
+ session.authorization = "Bearer " + token
491
+ try:
492
+ return _upload(session, repository, image)
493
+ finally:
494
+ session.authorization = ""
495
+
496
+
497
+ def _unique_token_fields(pairs: list[tuple[str, object]]) -> dict[str, object]:
498
+ result = dict(pairs)
499
+ if len(result) != len(pairs):
500
+ raise ValueError
501
+ return result
502
+
503
+
504
+ def _upload[Revision: (str, None)](
505
+ session: _Session, repository: str, image: VerifiedOciImage[Revision]
506
+ ) -> PublishedImage[Revision]:
507
+ base = f"/v2/{repository}"
508
+ uploaded: set[str] = set()
509
+ for blob in (image.config, *image.layers):
510
+ if blob.digest in uploaded:
511
+ continue
512
+ observed = session.request("blob-head", "HEAD", f"{base}/blobs/{blob.digest}", {200, 404})
513
+ if observed.status == 404:
514
+ started = session.request("blob-start", "POST", f"{base}/blobs/uploads/", {202})
515
+ location = _upload_location(started.header("Location"), session.host, repository)
516
+ separator = "&" if "?" in location else "?"
517
+ target = location + separator + urlencode({"digest": blob.digest})
518
+ observed = session.request(
519
+ "blob-upload", "PUT", target, {201}, body=image.iter_bytes(blob), size=blob.size
520
+ )
521
+ if observed.header("Docker-Content-Digest") != blob.digest:
522
+ raise AcrPublishError("blob", "digest-mismatch")
523
+ uploaded.add(blob.digest)
524
+ manifest = image.manifest
525
+ target = f"{base}/manifests/{manifest.digest}"
526
+ session.request(
527
+ "manifest-put",
528
+ "PUT",
529
+ target,
530
+ {201},
531
+ body=image.iter_bytes(manifest),
532
+ size=manifest.size,
533
+ media_type=manifest.media_type,
534
+ )
535
+ readback = session.request("manifest-readback", "GET", target, {200})
536
+ if (
537
+ readback.header("Docker-Content-Digest") != manifest.digest
538
+ or "sha256:" + hashlib.sha256(readback.body).hexdigest() != manifest.digest
539
+ ):
540
+ raise AcrPublishError("manifest-readback", "digest-mismatch")
541
+ return PublishedImage(
542
+ manifest.digest, image.source_commit, image.platform_tag, session.requests
543
+ )
544
+
545
+
546
+ def _request_target(value: str) -> bool:
547
+ return (
548
+ value.startswith("/")
549
+ and not value.startswith("//")
550
+ and len(value) <= 8192
551
+ and all(32 < ord(character) < 127 for character in value)
552
+ and "\\" not in value
553
+ and "#" not in value
554
+ )
555
+
556
+
557
+ def _upload_location(value: str | None, host: str, repository: str) -> str:
558
+ try:
559
+ if (
560
+ value is None
561
+ or len(value) > 4096
562
+ or any(ord(character) <= 32 or ord(character) >= 127 for character in value)
563
+ or "\\" in value
564
+ or "#" in value
565
+ ):
566
+ raise ValueError
567
+ parsed = urlsplit(value)
568
+ if (parsed.scheme or parsed.netloc) and (parsed.scheme != "https" or parsed.netloc != host):
569
+ raise ValueError
570
+ prefix = f"/v2/{repository}/blobs/uploads/"
571
+ upload_id = parsed.path.removeprefix(prefix)
572
+ if (
573
+ not parsed.path.startswith(prefix)
574
+ or upload_id in {".", ".."}
575
+ or not re.fullmatch(r"[A-Za-z0-9._~-]+", upload_id)
576
+ ):
577
+ raise ValueError
578
+ query = parse_qsl(
579
+ parsed.query, keep_blank_values=True, strict_parsing=True, max_num_fields=1
580
+ )
581
+ if any(key != "_state" or not value for key, value in query):
582
+ raise ValueError
583
+ return parsed.path + ("?" + urlencode(query) if query else "")
584
+ except (ValueError, TypeError):
585
+ raise AcrPublishError("blob-start", "invalid-location") from None