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,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
|