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.
- fdai_deployment_cli/__about__.py +3 -0
- fdai_deployment_cli/__init__.py +5 -0
- fdai_deployment_cli/__main__.py +5 -0
- fdai_deployment_cli/acr_publish.py +585 -0
- fdai_deployment_cli/aks_catalog_review.py +137 -0
- fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
- fdai_deployment_cli/aks_job_environment.py +59 -0
- fdai_deployment_cli/aks_job_execution.py +794 -0
- fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
- fdai_deployment_cli/aks_preflight.py +422 -0
- fdai_deployment_cli/aks_readiness.py +220 -0
- fdai_deployment_cli/aks_service_update.py +180 -0
- fdai_deployment_cli/aks_workload_jobs.py +281 -0
- fdai_deployment_cli/application_state_adoption.py +347 -0
- fdai_deployment_cli/azure_actor.py +61 -0
- fdai_deployment_cli/azure_naming.py +252 -0
- fdai_deployment_cli/azure_retail_prices.py +173 -0
- fdai_deployment_cli/bootstrap_approval.py +99 -0
- fdai_deployment_cli/bootstrap_channel.py +321 -0
- fdai_deployment_cli/bootstrap_executor.py +311 -0
- fdai_deployment_cli/bootstrap_plan.py +237 -0
- fdai_deployment_cli/bootstrap_reconcile.py +402 -0
- fdai_deployment_cli/bootstrap_support.py +138 -0
- fdai_deployment_cli/bootstrap_worker.py +448 -0
- fdai_deployment_cli/bundle.py +352 -0
- fdai_deployment_cli/capacity.py +143 -0
- fdai_deployment_cli/catalog_review_profile.py +306 -0
- fdai_deployment_cli/cli.py +798 -0
- fdai_deployment_cli/cli_help.py +214 -0
- fdai_deployment_cli/cli_parser.py +791 -0
- fdai_deployment_cli/cli_plan.py +450 -0
- fdai_deployment_cli/compiler.py +99 -0
- fdai_deployment_cli/console_artifact.py +218 -0
- fdai_deployment_cli/console_config.py +214 -0
- fdai_deployment_cli/console_update.py +832 -0
- fdai_deployment_cli/contracts.py +307 -0
- fdai_deployment_cli/control_package.py +140 -0
- fdai_deployment_cli/deadline_transport.py +70 -0
- fdai_deployment_cli/deployment_cost.py +131 -0
- fdai_deployment_cli/deployment_deadline.py +28 -0
- fdai_deployment_cli/deployment_kit.py +790 -0
- fdai_deployment_cli/deployment_kit_cache.py +191 -0
- fdai_deployment_cli/deployment_progress.py +648 -0
- fdai_deployment_cli/development_authority_cli.py +56 -0
- fdai_deployment_cli/development_authority_profile.py +319 -0
- fdai_deployment_cli/doctor.py +136 -0
- fdai_deployment_cli/entitlement_preflight.py +68 -0
- fdai_deployment_cli/entitlement_transport.py +49 -0
- fdai_deployment_cli/entra_bootstrap.py +579 -0
- fdai_deployment_cli/entra_control_profile.py +214 -0
- fdai_deployment_cli/entra_graph.py +349 -0
- fdai_deployment_cli/entra_profiles.py +215 -0
- fdai_deployment_cli/entra_source.py +244 -0
- fdai_deployment_cli/execution_bundle.py +478 -0
- fdai_deployment_cli/execution_copies.py +63 -0
- fdai_deployment_cli/foundation_adoption.py +438 -0
- fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
- fdai_deployment_cli/foundation_adoption_host.py +157 -0
- fdai_deployment_cli/foundation_adoption_transport.py +51 -0
- fdai_deployment_cli/foundation_failure.py +72 -0
- fdai_deployment_cli/foundation_image.py +168 -0
- fdai_deployment_cli/foundation_input.py +235 -0
- fdai_deployment_cli/foundation_output.py +154 -0
- fdai_deployment_cli/foundation_plan.py +381 -0
- fdai_deployment_cli/foundation_process.py +64 -0
- fdai_deployment_cli/foundation_progress.py +231 -0
- fdai_deployment_cli/github_actions.py +171 -0
- fdai_deployment_cli/github_apply_artifact.py +259 -0
- fdai_deployment_cli/github_artifact_io.py +48 -0
- fdai_deployment_cli/github_dispatch.py +248 -0
- fdai_deployment_cli/github_plan_artifact.py +181 -0
- fdai_deployment_cli/github_provider_artifact.py +170 -0
- fdai_deployment_cli/github_transport.py +46 -0
- fdai_deployment_cli/github_workflow_values.py +312 -0
- fdai_deployment_cli/hub_image_deploy.py +354 -0
- fdai_deployment_cli/hub_image_stage.py +162 -0
- fdai_deployment_cli/hub_image_trust.py +281 -0
- fdai_deployment_cli/hub_ownership.py +161 -0
- fdai_deployment_cli/hub_workload_handoff.py +264 -0
- fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
- fdai_deployment_cli/identity_profile.py +227 -0
- fdai_deployment_cli/image_targets.py +197 -0
- fdai_deployment_cli/installation_scope.py +280 -0
- fdai_deployment_cli/license.py +311 -0
- fdai_deployment_cli/license_issue.py +239 -0
- fdai_deployment_cli/lifecycle_authorization.py +252 -0
- fdai_deployment_cli/lifecycle_commands.py +173 -0
- fdai_deployment_cli/lifecycle_configuration.py +722 -0
- fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
- fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
- fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
- fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
- fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
- fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
- fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
- fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
- fdai_deployment_cli/lifecycle_enrollment.py +45 -0
- fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
- fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
- fdai_deployment_cli/lifecycle_native.py +247 -0
- fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
- fdai_deployment_cli/lifecycle_native_transition.py +339 -0
- fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
- fdai_deployment_cli/lifecycle_plan.py +800 -0
- fdai_deployment_cli/lifecycle_promotion.py +185 -0
- fdai_deployment_cli/lifecycle_release.py +308 -0
- fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
- fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
- fdai_deployment_cli/notification_recipients_input.py +222 -0
- fdai_deployment_cli/observer_artifact.py +106 -0
- fdai_deployment_cli/oci_archive.py +353 -0
- fdai_deployment_cli/offline_kit.py +797 -0
- fdai_deployment_cli/offline_prepare.py +135 -0
- fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
- fdai_deployment_cli/plan_input.py +101 -0
- fdai_deployment_cli/private_output.py +192 -0
- fdai_deployment_cli/profile.py +70 -0
- fdai_deployment_cli/progress.py +183 -0
- fdai_deployment_cli/readiness.py +286 -0
- fdai_deployment_cli/run_command_receiver.py +418 -0
- fdai_deployment_cli/runtime_build.py +386 -0
- fdai_deployment_cli/runtime_profile.py +287 -0
- fdai_deployment_cli/runtime_release.py +745 -0
- fdai_deployment_cli/runtime_stage.py +82 -0
- fdai_deployment_cli/runtime_support_installation.py +215 -0
- fdai_deployment_cli/simulation.py +80 -0
- fdai_deployment_cli/source_application_inputs.py +397 -0
- fdai_deployment_cli/source_azure.py +586 -0
- fdai_deployment_cli/source_change_cli.py +107 -0
- fdai_deployment_cli/source_change_parser.py +75 -0
- fdai_deployment_cli/source_change_plan.py +181 -0
- fdai_deployment_cli/source_changed_update.py +196 -0
- fdai_deployment_cli/source_deploy.py +170 -0
- fdai_deployment_cli/source_foundation.py +174 -0
- fdai_deployment_cli/source_host_tools.py +163 -0
- fdai_deployment_cli/source_image_stage.py +400 -0
- fdai_deployment_cli/source_input.py +173 -0
- fdai_deployment_cli/source_projection.py +164 -0
- fdai_deployment_cli/source_receiver.py +76 -0
- fdai_deployment_cli/source_recovery.py +264 -0
- fdai_deployment_cli/source_runtime.py +81 -0
- fdai_deployment_cli/source_runtime_support.py +368 -0
- fdai_deployment_cli/source_service_update.py +726 -0
- fdai_deployment_cli/source_service_update_cli.py +47 -0
- fdai_deployment_cli/source_snapshot.py +178 -0
- fdai_deployment_cli/source_teardown.py +574 -0
- fdai_deployment_cli/source_terraform.py +85 -0
- fdai_deployment_cli/source_transport.py +399 -0
- fdai_deployment_cli/standalone_aks_inventory.py +558 -0
- fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
- fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
- fdai_deployment_cli/standalone_application.py +977 -0
- fdai_deployment_cli/standalone_application_completion.py +151 -0
- fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
- fdai_deployment_cli/standalone_application_recovery.py +116 -0
- fdai_deployment_cli/standalone_approval_input.py +24 -0
- fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
- fdai_deployment_cli/standalone_catalog_review.py +251 -0
- fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
- fdai_deployment_cli/standalone_console_publish.py +174 -0
- fdai_deployment_cli/standalone_database_principals.py +41 -0
- fdai_deployment_cli/standalone_deploy.py +638 -0
- fdai_deployment_cli/standalone_development_authority.py +166 -0
- fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
- fdai_deployment_cli/standalone_foundation_transition.py +767 -0
- fdai_deployment_cli/standalone_host.py +4608 -0
- fdai_deployment_cli/standalone_host_rebind.py +226 -0
- fdai_deployment_cli/standalone_host_residual.py +104 -0
- fdai_deployment_cli/standalone_host_state.py +145 -0
- fdai_deployment_cli/standalone_host_values.py +563 -0
- fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
- fdai_deployment_cli/standalone_license_installation.py +177 -0
- fdai_deployment_cli/standalone_management_egress.py +83 -0
- fdai_deployment_cli/standalone_migration_evidence.py +38 -0
- fdai_deployment_cli/standalone_operational_evidence.py +356 -0
- fdai_deployment_cli/standalone_planned_outputs.py +147 -0
- fdai_deployment_cli/standalone_private_access_probe.py +100 -0
- fdai_deployment_cli/standalone_product_profile.py +110 -0
- fdai_deployment_cli/standalone_remote_prepare.py +442 -0
- fdai_deployment_cli/standalone_residual_apply.py +540 -0
- fdai_deployment_cli/standalone_residual_successor.py +431 -0
- fdai_deployment_cli/standalone_review.py +266 -0
- fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
- fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
- fdai_deployment_cli/standalone_stage_targets.py +325 -0
- fdai_deployment_cli/standalone_status.py +67 -0
- fdai_deployment_cli/standalone_terraform_environment.py +237 -0
- fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
- fdai_deployment_cli/standalone_trial_activation.py +193 -0
- fdai_deployment_cli/state.py +434 -0
- fdai_deployment_cli/state_handoff.py +253 -0
- fdai_deployment_cli/status_projection.py +97 -0
- fdai_deployment_cli/support_install.py +257 -0
- fdai_deployment_cli/target.py +46 -0
- fdai_deployment_cli/trust/capabilities.json +62 -0
- fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
- fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
- fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
- fdai_deployment_cli/trust_roots.py +50 -0
- fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
- fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
- fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
- fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
|
@@ -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
|