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,386 @@
|
|
|
1
|
+
"""Assemble a complete runtime release from prebuilt, locally verified artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import stat
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
from tempfile import TemporaryDirectory
|
|
11
|
+
from typing import cast
|
|
12
|
+
|
|
13
|
+
from fdai_deployment_cli.contracts import canonical_bytes
|
|
14
|
+
from fdai_deployment_cli.oci_archive import (
|
|
15
|
+
validate_dependency_oci_archive,
|
|
16
|
+
validate_oci_archive,
|
|
17
|
+
)
|
|
18
|
+
from fdai_deployment_cli.offline_kit import (
|
|
19
|
+
_MAX_FILE_BYTES,
|
|
20
|
+
_copy_verified_file,
|
|
21
|
+
_sha256_nofollow,
|
|
22
|
+
)
|
|
23
|
+
from fdai_deployment_cli.private_output import (
|
|
24
|
+
_open_private_parent,
|
|
25
|
+
read_private_bytes,
|
|
26
|
+
write_private_bytes,
|
|
27
|
+
)
|
|
28
|
+
from fdai_deployment_cli.runtime_release import (
|
|
29
|
+
RUNTIME_RELEASE_PATH,
|
|
30
|
+
RUNTIME_SERVICES,
|
|
31
|
+
RUNTIME_SIDECARS,
|
|
32
|
+
load_runtime_release,
|
|
33
|
+
validate_runtime_images,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_SCHEMA = "fdai.runtime-release-build.v1"
|
|
37
|
+
_OUTPUT_SCHEMA = "fdai.runtime-release-build-result.v1"
|
|
38
|
+
_MAX_DESCRIPTOR_BYTES = 1024 * 1024
|
|
39
|
+
_COMMIT = re.compile(r"[0-9a-f]{40}")
|
|
40
|
+
_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
|
|
41
|
+
_SHA256 = re.compile(r"[0-9a-f]{64}")
|
|
42
|
+
_PLATFORMS = frozenset({"linux-x86_64", "linux-aarch64"})
|
|
43
|
+
_IMAGE_FIELDS = frozenset(
|
|
44
|
+
{
|
|
45
|
+
"archive",
|
|
46
|
+
"archive_sha256",
|
|
47
|
+
"sbom",
|
|
48
|
+
"sbom_sha256",
|
|
49
|
+
"provenance",
|
|
50
|
+
"provenance_sha256",
|
|
51
|
+
"image_digest",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
_ARCHIVE_FIELDS = frozenset({"archive", "archive_sha256", "sbom", "sbom_sha256"})
|
|
55
|
+
_ROOT_FIELDS = frozenset(
|
|
56
|
+
{
|
|
57
|
+
"schema_version",
|
|
58
|
+
"source_commit",
|
|
59
|
+
"platform_tag",
|
|
60
|
+
"services",
|
|
61
|
+
"sidecars",
|
|
62
|
+
"console",
|
|
63
|
+
"deployment_support",
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
_OUTPUT_NAMES = {
|
|
67
|
+
"archive": "image.oci.tar",
|
|
68
|
+
"sbom": "sbom.cdx.json",
|
|
69
|
+
"provenance": "provenance.jsonl",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_runtime_release(
|
|
74
|
+
source_root: Path,
|
|
75
|
+
descriptor_path: Path,
|
|
76
|
+
deployment_bundle: Path,
|
|
77
|
+
output_root: Path,
|
|
78
|
+
) -> dict[str, object]:
|
|
79
|
+
"""Build one exact runtime v2 tree without network access or artifact execution.
|
|
80
|
+
|
|
81
|
+
The private descriptor names only relative files below ``source_root``. Every
|
|
82
|
+
payload is copied through no-follow digest checks into a new private output.
|
|
83
|
+
Service OCI archives must carry the declared FDAI revision; the ClamAV archive
|
|
84
|
+
is revision-neutral. This function does not sign artifacts, attest provenance,
|
|
85
|
+
publish an image, or authorize deployment.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
descriptor = _load_descriptor(
|
|
89
|
+
read_private_bytes(descriptor_path, max_bytes=_MAX_DESCRIPTOR_BYTES)
|
|
90
|
+
)
|
|
91
|
+
source_commit = _text(descriptor, "source_commit")
|
|
92
|
+
platform_tag = _text(descriptor, "platform_tag")
|
|
93
|
+
if _COMMIT.fullmatch(source_commit) is None:
|
|
94
|
+
raise ValueError("runtime build source_commit is invalid")
|
|
95
|
+
if platform_tag not in _PLATFORMS:
|
|
96
|
+
raise ValueError("runtime build platform_tag is unsupported")
|
|
97
|
+
_require_source_root(source_root)
|
|
98
|
+
if not output_root.is_absolute():
|
|
99
|
+
raise ValueError("runtime release output MUST be an absolute path")
|
|
100
|
+
parent = _open_private_parent(output_root)
|
|
101
|
+
os.close(parent)
|
|
102
|
+
if output_root.exists() or output_root.is_symlink():
|
|
103
|
+
raise ValueError("runtime release output already exists")
|
|
104
|
+
bundle_digest = _regular_digest(deployment_bundle, label="deployment bundle")
|
|
105
|
+
|
|
106
|
+
with TemporaryDirectory(prefix=".runtime-release-", dir=output_root.parent) as temporary:
|
|
107
|
+
temporary_root = Path(temporary)
|
|
108
|
+
temporary_root.chmod(0o700)
|
|
109
|
+
staging = temporary_root / "release"
|
|
110
|
+
staging.mkdir(mode=0o700)
|
|
111
|
+
services = _build_image_group(
|
|
112
|
+
source_root,
|
|
113
|
+
staging,
|
|
114
|
+
descriptor.get("services"),
|
|
115
|
+
section="services",
|
|
116
|
+
expected_names=RUNTIME_SERVICES,
|
|
117
|
+
source_commit=source_commit,
|
|
118
|
+
platform_tag=platform_tag,
|
|
119
|
+
)
|
|
120
|
+
sidecars = _build_image_group(
|
|
121
|
+
source_root,
|
|
122
|
+
staging,
|
|
123
|
+
descriptor.get("sidecars"),
|
|
124
|
+
section="sidecars",
|
|
125
|
+
expected_names=RUNTIME_SIDECARS,
|
|
126
|
+
source_commit=None,
|
|
127
|
+
platform_tag=platform_tag,
|
|
128
|
+
)
|
|
129
|
+
console = _build_archive_record(
|
|
130
|
+
source_root,
|
|
131
|
+
staging,
|
|
132
|
+
descriptor.get("console"),
|
|
133
|
+
section="console",
|
|
134
|
+
archive_name="console.tar.gz",
|
|
135
|
+
)
|
|
136
|
+
support = _build_archive_record(
|
|
137
|
+
source_root,
|
|
138
|
+
staging,
|
|
139
|
+
descriptor.get("deployment_support"),
|
|
140
|
+
section="deployment-support",
|
|
141
|
+
archive_name="deployment-support.tar.gz",
|
|
142
|
+
)
|
|
143
|
+
catalog = {
|
|
144
|
+
"schema_version": "fdai.runtime-release.v2",
|
|
145
|
+
"source_commit": source_commit,
|
|
146
|
+
"platform_tag": platform_tag,
|
|
147
|
+
"deployment_bundle_sha256": bundle_digest,
|
|
148
|
+
"services": services,
|
|
149
|
+
"sidecars": sidecars,
|
|
150
|
+
"console": console,
|
|
151
|
+
"deployment_support": support,
|
|
152
|
+
}
|
|
153
|
+
write_private_bytes(staging / RUNTIME_RELEASE_PATH, canonical_bytes(catalog))
|
|
154
|
+
release = load_runtime_release(
|
|
155
|
+
staging,
|
|
156
|
+
expected_source_commit=source_commit,
|
|
157
|
+
expected_platform_tag=platform_tag,
|
|
158
|
+
)
|
|
159
|
+
image_digests = validate_runtime_images(staging, release)
|
|
160
|
+
|
|
161
|
+
marker = staging / "runtime/.fdai-incomplete"
|
|
162
|
+
write_private_bytes(marker, b"fdai-runtime-release-build-v1\n")
|
|
163
|
+
os.mkdir(output_root, 0o700)
|
|
164
|
+
try:
|
|
165
|
+
os.rename(staging / "runtime", output_root / "runtime")
|
|
166
|
+
except BaseException:
|
|
167
|
+
os.rmdir(output_root)
|
|
168
|
+
raise
|
|
169
|
+
marker = output_root / "runtime/.fdai-incomplete"
|
|
170
|
+
try:
|
|
171
|
+
marker.unlink()
|
|
172
|
+
except OSError as exc:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
"runtime release publication is incomplete; remove the marked output after review"
|
|
175
|
+
) from exc
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
"schema_version": _OUTPUT_SCHEMA,
|
|
179
|
+
"runtime_release_digest": release.digest,
|
|
180
|
+
"deployment_bundle_sha256": bundle_digest,
|
|
181
|
+
"source_commit": source_commit,
|
|
182
|
+
"platform_tag": platform_tag,
|
|
183
|
+
"artifact_count": len(release.artifact_paths),
|
|
184
|
+
"image_content_digests": image_digests,
|
|
185
|
+
"azure_mutation_performed": False,
|
|
186
|
+
"production_release_eligibility": "unverified",
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _load_descriptor(raw: bytes) -> dict[str, object]:
|
|
191
|
+
try:
|
|
192
|
+
value = json.loads(raw, object_pairs_hook=_unique_object)
|
|
193
|
+
except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as exc:
|
|
194
|
+
raise ValueError("runtime build descriptor is not valid JSON") from exc
|
|
195
|
+
if not isinstance(value, dict) or set(value) != _ROOT_FIELDS:
|
|
196
|
+
raise ValueError("runtime build descriptor fields do not match the schema")
|
|
197
|
+
result = cast(dict[str, object], value)
|
|
198
|
+
if result["schema_version"] != _SCHEMA:
|
|
199
|
+
raise ValueError("runtime build descriptor schema_version is unsupported")
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
204
|
+
result: dict[str, object] = {}
|
|
205
|
+
for key, value in pairs:
|
|
206
|
+
if key in result:
|
|
207
|
+
raise ValueError("runtime build descriptor contains duplicate JSON keys")
|
|
208
|
+
result[key] = value
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _build_image_group(
|
|
213
|
+
source_root: Path,
|
|
214
|
+
staging: Path,
|
|
215
|
+
value: object,
|
|
216
|
+
*,
|
|
217
|
+
section: str,
|
|
218
|
+
expected_names: frozenset[str],
|
|
219
|
+
source_commit: str | None,
|
|
220
|
+
platform_tag: str,
|
|
221
|
+
) -> dict[str, object]:
|
|
222
|
+
records = _object(value, set(expected_names), label=section)
|
|
223
|
+
result: dict[str, object] = {}
|
|
224
|
+
for name in sorted(expected_names):
|
|
225
|
+
record = _string_record(records[name], _IMAGE_FIELDS, label=f"{section} record")
|
|
226
|
+
image_digest = record["image_digest"]
|
|
227
|
+
if _DIGEST.fullmatch(image_digest) is None:
|
|
228
|
+
raise ValueError("runtime build image digest is invalid")
|
|
229
|
+
output: dict[str, str] = {"image_digest": image_digest}
|
|
230
|
+
for field in ("archive", "sbom", "provenance"):
|
|
231
|
+
relative = f"runtime/{section}/{name}/{_OUTPUT_NAMES[field]}"
|
|
232
|
+
digest = _copy_source(
|
|
233
|
+
source_root,
|
|
234
|
+
record[field],
|
|
235
|
+
staging / relative,
|
|
236
|
+
output_root=staging,
|
|
237
|
+
expected_digest=record[f"{field}_sha256"],
|
|
238
|
+
label=f"{section} {field}",
|
|
239
|
+
)
|
|
240
|
+
output[field] = relative
|
|
241
|
+
output[f"{field}_sha256"] = digest
|
|
242
|
+
arguments = {
|
|
243
|
+
"expected_archive_sha256": output["archive_sha256"],
|
|
244
|
+
"expected_manifest_digest": image_digest,
|
|
245
|
+
"expected_platform_tag": platform_tag,
|
|
246
|
+
}
|
|
247
|
+
archive = staging / output["archive"]
|
|
248
|
+
if source_commit is None:
|
|
249
|
+
validate_dependency_oci_archive(archive, **arguments)
|
|
250
|
+
else:
|
|
251
|
+
validate_oci_archive(
|
|
252
|
+
archive,
|
|
253
|
+
expected_source_commit=source_commit,
|
|
254
|
+
**arguments,
|
|
255
|
+
)
|
|
256
|
+
result[name] = output
|
|
257
|
+
return result
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _build_archive_record(
|
|
261
|
+
source_root: Path,
|
|
262
|
+
staging: Path,
|
|
263
|
+
value: object,
|
|
264
|
+
*,
|
|
265
|
+
section: str,
|
|
266
|
+
archive_name: str,
|
|
267
|
+
) -> dict[str, str]:
|
|
268
|
+
record = _string_record(value, _ARCHIVE_FIELDS, label=f"{section} record")
|
|
269
|
+
output: dict[str, str] = {}
|
|
270
|
+
names = {"archive": archive_name, "sbom": "sbom.cdx.json"}
|
|
271
|
+
for field in ("archive", "sbom"):
|
|
272
|
+
relative = f"runtime/{section}/{names[field]}"
|
|
273
|
+
digest = _copy_source(
|
|
274
|
+
source_root,
|
|
275
|
+
record[field],
|
|
276
|
+
staging / relative,
|
|
277
|
+
output_root=staging,
|
|
278
|
+
expected_digest=record[f"{field}_sha256"],
|
|
279
|
+
label=f"{section} {field}",
|
|
280
|
+
)
|
|
281
|
+
output[field] = relative
|
|
282
|
+
output[f"{field}_sha256"] = digest
|
|
283
|
+
return output
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _copy_source(
|
|
287
|
+
source_root: Path,
|
|
288
|
+
relative: str,
|
|
289
|
+
target: Path,
|
|
290
|
+
*,
|
|
291
|
+
output_root: Path,
|
|
292
|
+
expected_digest: str,
|
|
293
|
+
label: str,
|
|
294
|
+
) -> str:
|
|
295
|
+
if _SHA256.fullmatch(expected_digest) is None:
|
|
296
|
+
raise ValueError(f"runtime build {label} digest is invalid")
|
|
297
|
+
try:
|
|
298
|
+
source = _source_path(source_root, relative)
|
|
299
|
+
details = source.lstat()
|
|
300
|
+
except OSError as exc:
|
|
301
|
+
raise ValueError(f"runtime build {label} is unavailable") from exc
|
|
302
|
+
if not stat.S_ISREG(details.st_mode) or not 0 < details.st_size <= _MAX_FILE_BYTES:
|
|
303
|
+
raise ValueError(f"runtime build {label} MUST be a non-empty bounded regular file")
|
|
304
|
+
digest = _sha256_nofollow(source, expected=details)
|
|
305
|
+
if digest != expected_digest:
|
|
306
|
+
raise ValueError(f"runtime build {label} digest does not match")
|
|
307
|
+
_ensure_private_parent(target, root=output_root)
|
|
308
|
+
_copy_verified_file(
|
|
309
|
+
source,
|
|
310
|
+
target,
|
|
311
|
+
expected_digest=digest,
|
|
312
|
+
expected_size=details.st_size,
|
|
313
|
+
)
|
|
314
|
+
return digest
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _ensure_private_parent(path: Path, *, root: Path) -> None:
|
|
318
|
+
current = root
|
|
319
|
+
for part in path.parent.relative_to(root).parts:
|
|
320
|
+
current /= part
|
|
321
|
+
try:
|
|
322
|
+
os.mkdir(current, 0o700)
|
|
323
|
+
except FileExistsError:
|
|
324
|
+
details = current.lstat()
|
|
325
|
+
if not stat.S_ISDIR(details.st_mode):
|
|
326
|
+
raise ValueError(
|
|
327
|
+
"runtime release output directories MUST NOT be symlinks"
|
|
328
|
+
) from None
|
|
329
|
+
current.chmod(0o700)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _source_path(root: Path, value: str) -> Path:
|
|
333
|
+
path = PurePosixPath(value)
|
|
334
|
+
if (
|
|
335
|
+
not value
|
|
336
|
+
or value.startswith("/")
|
|
337
|
+
or "\\" in value
|
|
338
|
+
or any(part in {"", ".", ".."} for part in path.parts)
|
|
339
|
+
):
|
|
340
|
+
raise ValueError("runtime build source path is invalid")
|
|
341
|
+
current = root
|
|
342
|
+
for part in path.parts[:-1]:
|
|
343
|
+
current /= part
|
|
344
|
+
if not stat.S_ISDIR(current.lstat().st_mode):
|
|
345
|
+
raise ValueError("runtime build source directories MUST NOT be symlinks")
|
|
346
|
+
return root / path.as_posix()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _require_source_root(path: Path) -> None:
|
|
350
|
+
if not path.is_absolute() or not stat.S_ISDIR(path.lstat().st_mode):
|
|
351
|
+
raise ValueError("runtime build source root MUST be an absolute real directory")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _regular_digest(path: Path, *, label: str) -> str:
|
|
355
|
+
if not path.is_absolute():
|
|
356
|
+
raise ValueError(f"{label} path MUST be absolute")
|
|
357
|
+
try:
|
|
358
|
+
details = path.lstat()
|
|
359
|
+
except OSError as exc:
|
|
360
|
+
raise ValueError(f"{label} is unavailable") from exc
|
|
361
|
+
if not stat.S_ISREG(details.st_mode) or not 0 < details.st_size <= _MAX_FILE_BYTES:
|
|
362
|
+
raise ValueError(f"{label} MUST be a non-empty bounded regular file")
|
|
363
|
+
return _sha256_nofollow(path, expected=details)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _object(value: object, fields: set[str], *, label: str) -> dict[str, object]:
|
|
367
|
+
if not isinstance(value, dict) or set(value) != fields:
|
|
368
|
+
raise ValueError(f"runtime build {label} fields do not match the schema")
|
|
369
|
+
return cast(dict[str, object], value)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _string_record(value: object, fields: frozenset[str], *, label: str) -> dict[str, str]:
|
|
373
|
+
record = _object(value, set(fields), label=label)
|
|
374
|
+
if not all(isinstance(item, str) for item in record.values()):
|
|
375
|
+
raise ValueError(f"runtime build {label} values MUST be strings")
|
|
376
|
+
return cast(dict[str, str], record)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _text(value: dict[str, object], field: str) -> str:
|
|
380
|
+
item = value[field]
|
|
381
|
+
if not isinstance(item, str):
|
|
382
|
+
raise TypeError(f"runtime build {field} MUST be a string")
|
|
383
|
+
return item
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
__all__ = ["build_runtime_release"]
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Validate and seal the runtime substrate selected for a new deployment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
|
|
9
|
+
from fdai_service_contracts.product_profile import (
|
|
10
|
+
ObservationDataSource,
|
|
11
|
+
ObservationReadPermissions,
|
|
12
|
+
ProductAddOn,
|
|
13
|
+
ProductProfile,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RuntimePlatform(StrEnum):
|
|
20
|
+
"""Supported Azure runtime substrates for a new FDAI installation."""
|
|
21
|
+
|
|
22
|
+
CONTAINER_APPS = "container-apps"
|
|
23
|
+
AKS = "aks"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DatabasePlacement(StrEnum):
|
|
27
|
+
"""Supported PostgreSQL placement choices for a new installation."""
|
|
28
|
+
|
|
29
|
+
POSTGRES_FLEX = "postgres-flex"
|
|
30
|
+
POSTGRES_AKS = "postgres-aks"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Explicit PostgreSQL Flexible Server sizes. Unset keeps the infrastructure default,
|
|
34
|
+
# GP_Standard_D4ads_v5, and leaves the profile mapping and digest unchanged.
|
|
35
|
+
DATABASE_SKUS = (
|
|
36
|
+
"B_Standard_B1ms",
|
|
37
|
+
"B_Standard_B2s",
|
|
38
|
+
"B_Standard_B2ms",
|
|
39
|
+
"GP_Standard_D2ds_v5",
|
|
40
|
+
"GP_Standard_D4ds_v5",
|
|
41
|
+
"GP_Standard_D4ads_v5",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
LEGACY_FULL_PRODUCT_ADD_ONS = (
|
|
45
|
+
ProductAddOn.ENTERPRISE_IDENTITY_GOVERNANCE,
|
|
46
|
+
ProductAddOn.GOVERNED_EXECUTION,
|
|
47
|
+
ProductAddOn.NOTIFICATIONS,
|
|
48
|
+
ProductAddOn.READ_ONLY_CONSOLE,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class RuntimeDeploymentProfile:
|
|
54
|
+
"""Secret-free runtime and node-pool intent bound to exact deployment plans."""
|
|
55
|
+
|
|
56
|
+
runtime_platform: RuntimePlatform
|
|
57
|
+
database_placement: DatabasePlacement
|
|
58
|
+
system_node_count: int = 3
|
|
59
|
+
system_node_sku: str = "Standard_D4ds_v4"
|
|
60
|
+
user_node_min_count: int = 3
|
|
61
|
+
user_node_max_count: int = 5
|
|
62
|
+
user_node_sku: str = "Standard_D4ds_v4"
|
|
63
|
+
product_profile: ProductProfile = field(default_factory=ProductProfile)
|
|
64
|
+
database_sku: str | None = None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def console_selected(self) -> bool:
|
|
68
|
+
"""Report the explicitly selected read-only Console surface."""
|
|
69
|
+
|
|
70
|
+
return self.product_profile.selects(ProductAddOn.READ_ONLY_CONSOLE)
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
if self.database_sku is not None:
|
|
74
|
+
if self.database_sku not in DATABASE_SKUS:
|
|
75
|
+
raise ValueError("database SKU is unsupported")
|
|
76
|
+
if self.database_placement is not DatabasePlacement.POSTGRES_FLEX:
|
|
77
|
+
raise ValueError("database SKU requires postgres-flex placement")
|
|
78
|
+
if self.runtime_platform is RuntimePlatform.CONTAINER_APPS:
|
|
79
|
+
if self.database_placement is not DatabasePlacement.POSTGRES_FLEX:
|
|
80
|
+
raise ValueError("Container Apps requires postgres-flex")
|
|
81
|
+
return
|
|
82
|
+
if not 2 <= self.system_node_count <= 100:
|
|
83
|
+
raise ValueError("AKS system node count MUST be in [2, 100]")
|
|
84
|
+
minimum_user_nodes = 4 if self.database_placement is DatabasePlacement.POSTGRES_AKS else 3
|
|
85
|
+
if not minimum_user_nodes <= self.user_node_min_count <= 100:
|
|
86
|
+
raise ValueError(f"AKS user node minimum MUST be in [{minimum_user_nodes}, 100]")
|
|
87
|
+
if not self.user_node_min_count <= self.user_node_max_count <= 100:
|
|
88
|
+
raise ValueError("AKS user node maximum MUST cover the minimum and be at most 100")
|
|
89
|
+
for label, value in (
|
|
90
|
+
("system node SKU", self.system_node_sku),
|
|
91
|
+
("user node SKU", self.user_node_sku),
|
|
92
|
+
):
|
|
93
|
+
if not value.startswith("Standard_") or any(character.isspace() for character in value):
|
|
94
|
+
raise ValueError(f"AKS {label} is invalid")
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def digest(self) -> str:
|
|
98
|
+
"""Return a replay-stable digest over the complete runtime choice."""
|
|
99
|
+
|
|
100
|
+
return canonical_digest(self.to_mapping())
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def create(
|
|
104
|
+
cls,
|
|
105
|
+
*,
|
|
106
|
+
runtime_platform: str,
|
|
107
|
+
database_placement: str,
|
|
108
|
+
system_node_count: int = 3,
|
|
109
|
+
system_node_sku: str | None = None,
|
|
110
|
+
user_node_min_count: int = 3,
|
|
111
|
+
user_node_max_count: int = 5,
|
|
112
|
+
user_node_sku: str = "Standard_D4ds_v4",
|
|
113
|
+
product_add_ons: tuple[str, ...] = (),
|
|
114
|
+
observation_data_sources: tuple[str, ...] = (),
|
|
115
|
+
database_sku: str | None = None,
|
|
116
|
+
) -> RuntimeDeploymentProfile:
|
|
117
|
+
"""Parse command values and reject unsupported runtime or database names."""
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
runtime = RuntimePlatform(runtime_platform)
|
|
121
|
+
except ValueError as exc:
|
|
122
|
+
raise ValueError("runtime platform is unsupported") from exc
|
|
123
|
+
try:
|
|
124
|
+
database = DatabasePlacement(database_placement)
|
|
125
|
+
except ValueError as exc:
|
|
126
|
+
raise ValueError("database placement is unsupported") from exc
|
|
127
|
+
try:
|
|
128
|
+
add_ons = tuple(sorted({ProductAddOn(value) for value in product_add_ons}, key=str))
|
|
129
|
+
except ValueError as exc:
|
|
130
|
+
raise ValueError("product add-on is unsupported") from exc
|
|
131
|
+
if len(add_ons) != len(product_add_ons):
|
|
132
|
+
raise ValueError("product add-ons MUST NOT contain duplicates")
|
|
133
|
+
try:
|
|
134
|
+
data_sources = tuple(
|
|
135
|
+
sorted(
|
|
136
|
+
{ObservationDataSource(value) for value in observation_data_sources},
|
|
137
|
+
key=str,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
except ValueError as exc:
|
|
141
|
+
raise ValueError("observation data source is unsupported") from exc
|
|
142
|
+
if len(data_sources) != len(observation_data_sources):
|
|
143
|
+
raise ValueError("observation data sources MUST NOT contain duplicates")
|
|
144
|
+
return cls(
|
|
145
|
+
runtime_platform=runtime,
|
|
146
|
+
database_placement=database,
|
|
147
|
+
system_node_count=system_node_count,
|
|
148
|
+
system_node_sku=system_node_sku
|
|
149
|
+
if system_node_sku is not None
|
|
150
|
+
else ("Standard_D4ds_v4" if runtime is RuntimePlatform.AKS else "Standard_D2as_v5"),
|
|
151
|
+
user_node_min_count=user_node_min_count,
|
|
152
|
+
user_node_max_count=user_node_max_count,
|
|
153
|
+
user_node_sku=user_node_sku,
|
|
154
|
+
product_profile=ProductProfile(
|
|
155
|
+
add_ons=add_ons,
|
|
156
|
+
observation_permissions=ObservationReadPermissions(
|
|
157
|
+
selected_sources=data_sources,
|
|
158
|
+
),
|
|
159
|
+
),
|
|
160
|
+
database_sku=database_sku,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def from_prepare_arguments(cls, args: argparse.Namespace) -> RuntimeDeploymentProfile:
|
|
165
|
+
"""Rebuild the profile from managed-host prepare arguments with the same checks."""
|
|
166
|
+
|
|
167
|
+
return cls.create(
|
|
168
|
+
runtime_platform=str(args.runtime_platform),
|
|
169
|
+
database_placement=str(args.database_placement),
|
|
170
|
+
system_node_count=int(args.system_node_count),
|
|
171
|
+
system_node_sku=args.system_node_sku,
|
|
172
|
+
user_node_min_count=int(args.user_node_min_count),
|
|
173
|
+
user_node_max_count=int(args.user_node_max_count),
|
|
174
|
+
user_node_sku=str(args.user_node_sku),
|
|
175
|
+
product_add_ons=tuple(args.product_add_on),
|
|
176
|
+
observation_data_sources=tuple(args.observation_source),
|
|
177
|
+
database_sku=args.database_sku,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def from_cli_arguments(cls, args: argparse.Namespace) -> RuntimeDeploymentProfile:
|
|
182
|
+
"""Build the selected profile from the public Azure command arguments."""
|
|
183
|
+
|
|
184
|
+
return cls.create(
|
|
185
|
+
runtime_platform=args.runtime,
|
|
186
|
+
database_placement=args.database,
|
|
187
|
+
system_node_count=args.system_nodes,
|
|
188
|
+
system_node_sku=args.system_node_sku,
|
|
189
|
+
user_node_min_count=args.user_nodes,
|
|
190
|
+
user_node_max_count=args.max_user_nodes,
|
|
191
|
+
user_node_sku=args.user_node_sku,
|
|
192
|
+
product_add_ons=tuple(args.product_add_ons),
|
|
193
|
+
observation_data_sources=tuple(args.observation_data_sources),
|
|
194
|
+
database_sku=args.database_sku,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def to_mapping(self) -> dict[str, object]:
|
|
198
|
+
"""Return the canonical machine representation stored with deployment evidence."""
|
|
199
|
+
|
|
200
|
+
mapping: dict[str, object] = {
|
|
201
|
+
"schema_version": "fdai.runtime-deployment-profile.v2",
|
|
202
|
+
"runtime_platform": self.runtime_platform.value,
|
|
203
|
+
"database_placement": self.database_placement.value,
|
|
204
|
+
"system_node_count": self.system_node_count,
|
|
205
|
+
"system_node_sku": self.system_node_sku,
|
|
206
|
+
"user_node_min_count": self.user_node_min_count,
|
|
207
|
+
"user_node_max_count": self.user_node_max_count,
|
|
208
|
+
"user_node_sku": self.user_node_sku,
|
|
209
|
+
"product_profile": self.product_profile.model_dump(mode="json"),
|
|
210
|
+
}
|
|
211
|
+
if self.database_sku is not None:
|
|
212
|
+
mapping["database_sku"] = self.database_sku
|
|
213
|
+
return mapping
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def from_mapping(cls, value: dict[str, object]) -> RuntimeDeploymentProfile:
|
|
217
|
+
"""Read current profiles and preserve explicit legacy full-product behavior."""
|
|
218
|
+
|
|
219
|
+
schema_version = value.get("schema_version")
|
|
220
|
+
if schema_version not in {
|
|
221
|
+
"fdai.runtime-deployment-profile.v1",
|
|
222
|
+
"fdai.runtime-deployment-profile.v2",
|
|
223
|
+
}:
|
|
224
|
+
raise ValueError("runtime deployment profile schema is unsupported")
|
|
225
|
+
product = value.get("product_profile")
|
|
226
|
+
if schema_version == "fdai.runtime-deployment-profile.v1":
|
|
227
|
+
add_ons = tuple(item.value for item in LEGACY_FULL_PRODUCT_ADD_ONS)
|
|
228
|
+
data_sources = tuple(item.value for item in ObservationDataSource)
|
|
229
|
+
else:
|
|
230
|
+
if not isinstance(product, dict):
|
|
231
|
+
raise ValueError("runtime deployment product profile is missing")
|
|
232
|
+
parsed = ProductProfile.model_validate(product)
|
|
233
|
+
add_ons = tuple(item.value for item in parsed.add_ons)
|
|
234
|
+
data_sources = tuple(
|
|
235
|
+
item.value for item in parsed.observation_permissions.selected_sources
|
|
236
|
+
)
|
|
237
|
+
return cls.create(
|
|
238
|
+
runtime_platform=str(value.get("runtime_platform", "")),
|
|
239
|
+
database_placement=str(value.get("database_placement", "")),
|
|
240
|
+
system_node_count=value.get("system_node_count", 0), # type: ignore[arg-type]
|
|
241
|
+
system_node_sku=(
|
|
242
|
+
str(value["system_node_sku"]) if value.get("system_node_sku") is not None else None
|
|
243
|
+
),
|
|
244
|
+
user_node_min_count=value.get("user_node_min_count", 0), # type: ignore[arg-type]
|
|
245
|
+
user_node_max_count=value.get("user_node_max_count", 0), # type: ignore[arg-type]
|
|
246
|
+
user_node_sku=str(value.get("user_node_sku", "")),
|
|
247
|
+
product_add_ons=add_ons,
|
|
248
|
+
observation_data_sources=data_sources,
|
|
249
|
+
database_sku=(
|
|
250
|
+
str(value["database_sku"]) if value.get("database_sku") is not None else None
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def matches_mapping(self, value: dict[str, object]) -> bool:
|
|
255
|
+
"""Compare current mappings exactly and legacy mappings without invented fields."""
|
|
256
|
+
|
|
257
|
+
if value.get("schema_version") == "fdai.runtime-deployment-profile.v1":
|
|
258
|
+
return legacy_runtime_profile_mapping(self) == value
|
|
259
|
+
return self.to_mapping() == value
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def legacy_runtime_profile_mapping(
|
|
263
|
+
profile: RuntimeDeploymentProfile,
|
|
264
|
+
) -> dict[str, object]:
|
|
265
|
+
"""Project the pre-product-axis mapping for immutable legacy evidence."""
|
|
266
|
+
|
|
267
|
+
current = profile.to_mapping()
|
|
268
|
+
current.pop("product_profile")
|
|
269
|
+
current["schema_version"] = "fdai.runtime-deployment-profile.v1"
|
|
270
|
+
return current
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def legacy_runtime_profile_digest(profile: RuntimeDeploymentProfile) -> str:
|
|
274
|
+
"""Return the digest retained by a context created before profile v2."""
|
|
275
|
+
|
|
276
|
+
return canonical_digest(legacy_runtime_profile_mapping(profile))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def legacy_full_product_matches(profile: RuntimeDeploymentProfile, retained_digest: str) -> bool:
|
|
280
|
+
"""Match a pre-product-axis record only to the explicit legacy full-product selection."""
|
|
281
|
+
|
|
282
|
+
selected = profile.product_profile
|
|
283
|
+
return (
|
|
284
|
+
set(selected.add_ons) == set(LEGACY_FULL_PRODUCT_ADD_ONS)
|
|
285
|
+
and len(selected.observation_permissions.selected_sources) == len(ObservationDataSource)
|
|
286
|
+
and retained_digest == legacy_runtime_profile_digest(profile)
|
|
287
|
+
)
|