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,176 @@
|
|
|
1
|
+
"""Signed offline upgrade bundle builder and Target Hub import verifier."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from fdai_deployment_cli.contracts import canonical_bytes
|
|
11
|
+
from fdai_deployment_cli.lifecycle_promotion import BoundaryRegistryMirror
|
|
12
|
+
|
|
13
|
+
_IMAGE_DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z", re.ASCII)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
17
|
+
class SignedConfigurationTransferItem:
|
|
18
|
+
"""Customer-authored signed configuration package carried alongside an upgrade bundle."""
|
|
19
|
+
|
|
20
|
+
package_id: str
|
|
21
|
+
payload: bytes
|
|
22
|
+
signature: bytes
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
if not self.package_id.strip():
|
|
26
|
+
raise ValueError("package_id MUST NOT be empty")
|
|
27
|
+
if not self.payload:
|
|
28
|
+
raise ValueError("payload MUST NOT be empty")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
32
|
+
class SignedUpgradeBundle:
|
|
33
|
+
"""Offline upgrade bundle carrying catalog metadata, minimal image layers, and checksums."""
|
|
34
|
+
|
|
35
|
+
bundle_id: str
|
|
36
|
+
extends_bundle_id: str | None
|
|
37
|
+
catalog_export_bytes: bytes
|
|
38
|
+
image_layers: Mapping[str, bytes] = field(default_factory=dict)
|
|
39
|
+
checksums: Mapping[str, str] = field(default_factory=dict)
|
|
40
|
+
checksum_signature: bytes = b""
|
|
41
|
+
configuration_packages: tuple[SignedConfigurationTransferItem, ...] = ()
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
if not self.bundle_id.strip():
|
|
45
|
+
raise ValueError("bundle_id MUST NOT be empty")
|
|
46
|
+
if not self.catalog_export_bytes:
|
|
47
|
+
raise ValueError("catalog_export_bytes MUST NOT be empty")
|
|
48
|
+
for digest in self.image_layers:
|
|
49
|
+
if _IMAGE_DIGEST.fullmatch(digest) is None:
|
|
50
|
+
raise ValueError("image_layers keys MUST be sha256:<64 hex> digests")
|
|
51
|
+
|
|
52
|
+
def canonical_checksum_payload(self) -> bytes:
|
|
53
|
+
return canonical_bytes(
|
|
54
|
+
{
|
|
55
|
+
"schema_version": "fdai.upgrade-bundle-checksums.v1",
|
|
56
|
+
"bundle_id": self.bundle_id,
|
|
57
|
+
"extends_bundle_id": self.extends_bundle_id,
|
|
58
|
+
"checksums": dict(sorted(self.checksums.items())),
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
64
|
+
class UpgradeBundleImportResult:
|
|
65
|
+
"""Verified Target Hub import result for one signed upgrade bundle."""
|
|
66
|
+
|
|
67
|
+
bundle_id: str
|
|
68
|
+
extends_bundle_id: str | None
|
|
69
|
+
catalog_export_sha256: str
|
|
70
|
+
mirror: BoundaryRegistryMirror
|
|
71
|
+
verified_configuration_package_digests: tuple[str, ...]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def compute_upgrade_bundle_checksums(
|
|
75
|
+
*,
|
|
76
|
+
catalog_export_bytes: bytes,
|
|
77
|
+
image_layers: Mapping[str, bytes],
|
|
78
|
+
) -> dict[str, str]:
|
|
79
|
+
"""Compute the canonical checksum map for a catalog export and minimal image layers."""
|
|
80
|
+
|
|
81
|
+
checksums: dict[str, str] = {
|
|
82
|
+
"catalog_export": hashlib.sha256(catalog_export_bytes).hexdigest(),
|
|
83
|
+
}
|
|
84
|
+
for digest, layer_bytes in sorted(image_layers.items()):
|
|
85
|
+
if _IMAGE_DIGEST.fullmatch(digest) is None:
|
|
86
|
+
raise ValueError("image_layers keys MUST be sha256:<64 hex> digests")
|
|
87
|
+
checksums[f"layer:{digest}"] = hashlib.sha256(layer_bytes).hexdigest()
|
|
88
|
+
return checksums
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_signed_upgrade_bundle(
|
|
92
|
+
*,
|
|
93
|
+
bundle_id: str,
|
|
94
|
+
extends_bundle_id: str | None,
|
|
95
|
+
catalog_export_bytes: bytes,
|
|
96
|
+
image_layers: Mapping[str, bytes],
|
|
97
|
+
sign_checksums: Callable[[bytes], bytes],
|
|
98
|
+
configuration_packages: tuple[SignedConfigurationTransferItem, ...] = (),
|
|
99
|
+
) -> SignedUpgradeBundle:
|
|
100
|
+
"""Build and sign an offline upgrade bundle."""
|
|
101
|
+
|
|
102
|
+
checksums = compute_upgrade_bundle_checksums(
|
|
103
|
+
catalog_export_bytes=catalog_export_bytes,
|
|
104
|
+
image_layers=image_layers,
|
|
105
|
+
)
|
|
106
|
+
unsigned = SignedUpgradeBundle(
|
|
107
|
+
bundle_id=bundle_id,
|
|
108
|
+
extends_bundle_id=extends_bundle_id,
|
|
109
|
+
catalog_export_bytes=catalog_export_bytes,
|
|
110
|
+
image_layers=dict(image_layers),
|
|
111
|
+
checksums=checksums,
|
|
112
|
+
configuration_packages=configuration_packages,
|
|
113
|
+
)
|
|
114
|
+
signature = sign_checksums(unsigned.canonical_checksum_payload())
|
|
115
|
+
return SignedUpgradeBundle(
|
|
116
|
+
bundle_id=bundle_id,
|
|
117
|
+
extends_bundle_id=extends_bundle_id,
|
|
118
|
+
catalog_export_bytes=catalog_export_bytes,
|
|
119
|
+
image_layers=dict(image_layers),
|
|
120
|
+
checksums=checksums,
|
|
121
|
+
checksum_signature=signature,
|
|
122
|
+
configuration_packages=configuration_packages,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def verify_and_import_upgrade_bundle(
|
|
127
|
+
bundle: SignedUpgradeBundle,
|
|
128
|
+
*,
|
|
129
|
+
verify_vendor_signature: Callable[[bytes, bytes], bool],
|
|
130
|
+
verify_configuration_signature: Callable[[bytes, bytes], bool] | None = None,
|
|
131
|
+
mirror: BoundaryRegistryMirror | None = None,
|
|
132
|
+
expected_previous_bundle_id: str | None = None,
|
|
133
|
+
) -> UpgradeBundleImportResult:
|
|
134
|
+
"""Verify the vendor checksum signature, every checksum, and each configuration signature."""
|
|
135
|
+
|
|
136
|
+
if expected_previous_bundle_id is not None and (
|
|
137
|
+
bundle.extends_bundle_id != expected_previous_bundle_id
|
|
138
|
+
):
|
|
139
|
+
raise ValueError("upgrade_bundle_lineage_mismatch")
|
|
140
|
+
if not bundle.checksum_signature or not bool(
|
|
141
|
+
verify_vendor_signature(bundle.canonical_checksum_payload(), bundle.checksum_signature)
|
|
142
|
+
):
|
|
143
|
+
raise ValueError("upgrade_bundle_signature_invalid")
|
|
144
|
+
|
|
145
|
+
expected_checksums = compute_upgrade_bundle_checksums(
|
|
146
|
+
catalog_export_bytes=bundle.catalog_export_bytes,
|
|
147
|
+
image_layers=bundle.image_layers,
|
|
148
|
+
)
|
|
149
|
+
if dict(bundle.checksums) != expected_checksums:
|
|
150
|
+
raise ValueError("upgrade_bundle_checksum_mismatch")
|
|
151
|
+
for digest, layer_bytes in bundle.image_layers.items():
|
|
152
|
+
actual_layer_digest = f"sha256:{hashlib.sha256(layer_bytes).hexdigest()}"
|
|
153
|
+
if actual_layer_digest != digest:
|
|
154
|
+
raise ValueError("upgrade_bundle_checksum_mismatch")
|
|
155
|
+
|
|
156
|
+
verified_config_digests: list[str] = []
|
|
157
|
+
for item in bundle.configuration_packages:
|
|
158
|
+
if not item.signature:
|
|
159
|
+
raise ValueError("upgrade_bundle_configuration_package_unsigned")
|
|
160
|
+
if verify_configuration_signature is None or not bool(
|
|
161
|
+
verify_configuration_signature(item.payload, item.signature)
|
|
162
|
+
):
|
|
163
|
+
raise ValueError("upgrade_bundle_configuration_package_signature_invalid")
|
|
164
|
+
verified_config_digests.append(hashlib.sha256(item.payload).hexdigest())
|
|
165
|
+
|
|
166
|
+
current_mirror = mirror or BoundaryRegistryMirror()
|
|
167
|
+
updated_mirror = BoundaryRegistryMirror(
|
|
168
|
+
mirrored_digests=current_mirror.mirrored_digests | frozenset(bundle.image_layers.keys())
|
|
169
|
+
)
|
|
170
|
+
return UpgradeBundleImportResult(
|
|
171
|
+
bundle_id=bundle.bundle_id,
|
|
172
|
+
extends_bundle_id=bundle.extends_bundle_id,
|
|
173
|
+
catalog_export_sha256=expected_checksums["catalog_export"],
|
|
174
|
+
mirror=updated_mirror,
|
|
175
|
+
verified_configuration_package_digests=tuple(verified_config_digests),
|
|
176
|
+
)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Private operator-selected email recipients for the source AKS notifications add-on.
|
|
2
|
+
|
|
3
|
+
Addresses are personal data, so they are accepted only from a private local file and travel to
|
|
4
|
+
the managed host as one private staged file. They never enter the runtime profile, its digest,
|
|
5
|
+
plan reviews, receipts or logs; only the digest of the canonical recipient list is retained in the
|
|
6
|
+
managed host's private context so a later run can detect drift.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
18
|
+
from fdai_deployment_cli.private_output import read_private_bytes
|
|
19
|
+
from fdai_deployment_cli.standalone_host_state import replace_or_verify_private_json
|
|
20
|
+
|
|
21
|
+
_STAGED_SCHEMA = "fdai.notification-recipients-staged-input.v1"
|
|
22
|
+
_MAX_INPUT_BYTES = 16 * 1024
|
|
23
|
+
_MAX_RECIPIENTS = 20
|
|
24
|
+
_MAX_ADDRESS_LENGTH = 254
|
|
25
|
+
# Mirrors the `notification_email_recipients` validation in `infra/variables.tf`.
|
|
26
|
+
_ADDRESS = re.compile(r"[^@\s]+@[^@\s]+[.][^@\s]+")
|
|
27
|
+
_PLACEHOLDER_DOMAINS = frozenset({"example.com", "example.org", "example.net", "invalid", "test"})
|
|
28
|
+
_CONTEXT_KEY = "notification_recipients_digest"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class NotificationRecipientsDeploymentInput:
|
|
33
|
+
"""Validated, deduplicated recipient list and its content digest."""
|
|
34
|
+
|
|
35
|
+
recipients: tuple[str, ...]
|
|
36
|
+
input_digest: str
|
|
37
|
+
|
|
38
|
+
def __repr__(self) -> str:
|
|
39
|
+
return f"NotificationRecipientsDeploymentInput(count={len(self.recipients)})"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_notification_recipients(path: Path) -> NotificationRecipientsDeploymentInput:
|
|
43
|
+
"""Load one private recipients file from the operator-selected path."""
|
|
44
|
+
|
|
45
|
+
return _from_mapping(_load_json_object(path, "notification recipients input"), staged=False)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def staged_notification_recipients_from_path(
|
|
49
|
+
path: Path | None,
|
|
50
|
+
) -> NotificationRecipientsDeploymentInput | None:
|
|
51
|
+
"""Load a staged managed-host input, or report that none was supplied."""
|
|
52
|
+
|
|
53
|
+
if path is None:
|
|
54
|
+
return None
|
|
55
|
+
return _from_mapping(
|
|
56
|
+
_load_json_object(path, "staged notification recipients input"), staged=True
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def stage_notification_recipients(
|
|
61
|
+
value: NotificationRecipientsDeploymentInput | None,
|
|
62
|
+
*,
|
|
63
|
+
tunnel: Any,
|
|
64
|
+
prepared_root: Path,
|
|
65
|
+
remote_root: str,
|
|
66
|
+
) -> tuple[str, ...]:
|
|
67
|
+
"""Copy the private recipient list to the managed host and return CLI arguments."""
|
|
68
|
+
|
|
69
|
+
if value is None:
|
|
70
|
+
return ()
|
|
71
|
+
local_path = prepared_root / "notification-recipients-input.json"
|
|
72
|
+
remote_path = f"{remote_root}/notification-recipients-input.json"
|
|
73
|
+
replace_or_verify_private_json(
|
|
74
|
+
local_path,
|
|
75
|
+
{
|
|
76
|
+
"schema_version": _STAGED_SCHEMA,
|
|
77
|
+
"input_digest": value.input_digest,
|
|
78
|
+
"recipients": list(value.recipients),
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
tunnel.copy_to(local_path, remote_path, timeout=120)
|
|
82
|
+
return ("--notification-recipients", remote_path)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def apply_notification_recipients(
|
|
86
|
+
values: dict[str, object],
|
|
87
|
+
value: NotificationRecipientsDeploymentInput | None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Populate the Terraform recipients only when the input is supplied."""
|
|
90
|
+
|
|
91
|
+
if value is None:
|
|
92
|
+
return
|
|
93
|
+
values["notification_email_recipients"] = list(value.recipients)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def require_recipients_for_notifications(
|
|
97
|
+
*, notifications_selected: bool, value: NotificationRecipientsDeploymentInput | None
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Fail closed before any Azure effect when the add-on and its input disagree."""
|
|
100
|
+
|
|
101
|
+
if notifications_selected and value is None:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"the notifications add-on requires --notification-recipients with at least one address"
|
|
104
|
+
)
|
|
105
|
+
if value is not None and not notifications_selected:
|
|
106
|
+
raise ValueError("--notification-recipients requires the notifications add-on")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def reconcile_retained_notification_recipients(
|
|
110
|
+
value: NotificationRecipientsDeploymentInput | None,
|
|
111
|
+
*,
|
|
112
|
+
retained: dict[str, object],
|
|
113
|
+
values: dict[str, object],
|
|
114
|
+
) -> bool:
|
|
115
|
+
"""Bind a recipient list onto a retained host context, or fail on drift."""
|
|
116
|
+
|
|
117
|
+
previous = retained.get(_CONTEXT_KEY, "")
|
|
118
|
+
if not isinstance(previous, str):
|
|
119
|
+
raise ValueError("standalone host retained context differs")
|
|
120
|
+
current = value.input_digest if value is not None else ""
|
|
121
|
+
if previous and previous != current:
|
|
122
|
+
raise ValueError("standalone host retained context differs")
|
|
123
|
+
if value is None:
|
|
124
|
+
return False
|
|
125
|
+
apply_notification_recipients(values, value)
|
|
126
|
+
if previous == current:
|
|
127
|
+
return False
|
|
128
|
+
retained[_CONTEXT_KEY] = current
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def selected_notification_recipients(
|
|
133
|
+
args: Any, *, runtime_profile: Any, cwd: Path
|
|
134
|
+
) -> NotificationRecipientsDeploymentInput | None:
|
|
135
|
+
"""Load the operator's recipient file and require it to match the add-on selection."""
|
|
136
|
+
|
|
137
|
+
from fdai_service_contracts.product_profile import ProductAddOn
|
|
138
|
+
|
|
139
|
+
selected = getattr(args, "notification_recipients", None)
|
|
140
|
+
value = (
|
|
141
|
+
None
|
|
142
|
+
if selected is None
|
|
143
|
+
else load_notification_recipients(selected if selected.is_absolute() else cwd / selected)
|
|
144
|
+
)
|
|
145
|
+
if value is not None and (args.runtime != "aks" or args.source is None):
|
|
146
|
+
raise ValueError(
|
|
147
|
+
"--notification-recipients requires a source deployment with --runtime aks"
|
|
148
|
+
)
|
|
149
|
+
if args.source is not None and not (args.prepare_only or args.preflight_only or args.teardown):
|
|
150
|
+
require_recipients_for_notifications(
|
|
151
|
+
notifications_selected=runtime_profile.product_profile.selects(
|
|
152
|
+
ProductAddOn.NOTIFICATIONS
|
|
153
|
+
),
|
|
154
|
+
value=value,
|
|
155
|
+
)
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def context_digest(value: NotificationRecipientsDeploymentInput | None) -> str:
|
|
160
|
+
"""Return the digest retained in the private managed-host context."""
|
|
161
|
+
|
|
162
|
+
return value.input_digest if value is not None else ""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _from_mapping(value: dict[str, Any], *, staged: bool) -> NotificationRecipientsDeploymentInput:
|
|
166
|
+
expected = {"schema_version", "input_digest", "recipients"} if staged else {"recipients"}
|
|
167
|
+
if set(value) != expected:
|
|
168
|
+
raise ValueError("notification recipients input fields are invalid")
|
|
169
|
+
recipients = _recipients(value["recipients"])
|
|
170
|
+
digest = canonical_digest({"notification_email_recipients": list(recipients)})
|
|
171
|
+
if staged:
|
|
172
|
+
if value["schema_version"] != _STAGED_SCHEMA:
|
|
173
|
+
raise ValueError("staged notification recipients input schema is unsupported")
|
|
174
|
+
if value["input_digest"] != digest:
|
|
175
|
+
raise ValueError("staged notification recipients input digest differs")
|
|
176
|
+
return NotificationRecipientsDeploymentInput(recipients=recipients, input_digest=digest)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _recipients(value: object) -> tuple[str, ...]:
|
|
180
|
+
if not isinstance(value, list) or not value:
|
|
181
|
+
raise ValueError("notification recipients MUST be a non-empty list")
|
|
182
|
+
seen: dict[str, str] = {}
|
|
183
|
+
for item in value:
|
|
184
|
+
if not isinstance(item, str):
|
|
185
|
+
raise ValueError("notification recipients MUST be strings")
|
|
186
|
+
address = item.strip()
|
|
187
|
+
if not address or len(address) > _MAX_ADDRESS_LENGTH or _ADDRESS.fullmatch(address) is None:
|
|
188
|
+
raise ValueError("a notification recipient address is invalid")
|
|
189
|
+
if address.rsplit("@", 1)[1].casefold() in _PLACEHOLDER_DOMAINS:
|
|
190
|
+
raise ValueError("a notification recipient address uses a placeholder domain")
|
|
191
|
+
seen.setdefault(address.casefold(), address)
|
|
192
|
+
if len(seen) > _MAX_RECIPIENTS:
|
|
193
|
+
raise ValueError(f"notification recipients exceed the limit of {_MAX_RECIPIENTS}")
|
|
194
|
+
return tuple(seen[key] for key in sorted(seen))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _load_json_object(path: Path, label: str) -> dict[str, Any]:
|
|
198
|
+
data = read_private_bytes(path, max_bytes=_MAX_INPUT_BYTES)
|
|
199
|
+
if not data:
|
|
200
|
+
raise ValueError(f"{label} is empty")
|
|
201
|
+
try:
|
|
202
|
+
value = json.loads(data.decode("utf-8"))
|
|
203
|
+
except UnicodeDecodeError as exc:
|
|
204
|
+
raise ValueError(f"{label} is not valid UTF-8") from exc
|
|
205
|
+
except json.JSONDecodeError as exc:
|
|
206
|
+
raise ValueError(f"{label} is not valid JSON") from exc
|
|
207
|
+
if not isinstance(value, dict):
|
|
208
|
+
raise ValueError(f"{label} MUST be a JSON object") # noqa: TRY004
|
|
209
|
+
return value
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
__all__ = [
|
|
213
|
+
"NotificationRecipientsDeploymentInput",
|
|
214
|
+
"apply_notification_recipients",
|
|
215
|
+
"context_digest",
|
|
216
|
+
"load_notification_recipients",
|
|
217
|
+
"reconcile_retained_notification_recipients",
|
|
218
|
+
"require_recipients_for_notifications",
|
|
219
|
+
"selected_notification_recipients",
|
|
220
|
+
"stage_notification_recipients",
|
|
221
|
+
"staged_notification_recipients_from_path",
|
|
222
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Read-only observer image evidence from the existing pinned deployment-kit trust chain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from fdai_deployment_cli.contracts import canonical_bytes
|
|
13
|
+
from fdai_deployment_cli.deployment_kit import acquire_deployment_kit
|
|
14
|
+
from fdai_deployment_cli.private_output import read_private_bytes
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def inspect_observer_artifact(
|
|
18
|
+
*, offline_kit: Path, work_dir: Path, source_commit: str, image_digest: str
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
"""Reverify signed local release bytes and exact Core image, never install or import them.
|
|
21
|
+
|
|
22
|
+
Trust roots and all kit verification remain deployment-owned. Output is process-local read
|
|
23
|
+
evidence, not a transferable authorization; an enrolled preflight producer signs it separately.
|
|
24
|
+
"""
|
|
25
|
+
if re.fullmatch(r"[a-f0-9]{40}", source_commit) is None:
|
|
26
|
+
raise ValueError("observer artifact source revision is invalid")
|
|
27
|
+
if re.fullmatch(r"sha256:[a-f0-9]{64}", image_digest) is None:
|
|
28
|
+
raise ValueError("observer artifact image digest is invalid")
|
|
29
|
+
kit = acquire_deployment_kit(work_dir=work_dir, online=False, offline_kit=offline_kit)
|
|
30
|
+
catalog = kit.runtime.to_mapping()
|
|
31
|
+
if kit.source_commit != source_commit:
|
|
32
|
+
raise ValueError("observer artifact source revision differs from the selected release")
|
|
33
|
+
services = catalog.get("services")
|
|
34
|
+
core = services.get("core-control-plane") if isinstance(services, dict) else None
|
|
35
|
+
if not isinstance(core, dict) or core.get("image_digest") != image_digest:
|
|
36
|
+
raise ValueError("observer image differs from the signed Core release")
|
|
37
|
+
value: dict[str, Any] = {
|
|
38
|
+
"schema_version": "1.0.0",
|
|
39
|
+
"source_commit": source_commit,
|
|
40
|
+
"image_digest": image_digest,
|
|
41
|
+
"platform_tag": kit.runtime.platform_tag,
|
|
42
|
+
"runtime_digest": "sha256:" + kit.runtime.digest,
|
|
43
|
+
"bundle_digest": "sha256:" + kit.bundle_manifest_digest,
|
|
44
|
+
"release_signature_verified": True,
|
|
45
|
+
"execution_authority": False,
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
**value,
|
|
49
|
+
"evidence_digest": "sha256:" + hashlib.sha256(canonical_bytes(value)).hexdigest(),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _unique(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
54
|
+
result: dict[str, object] = {}
|
|
55
|
+
for key, value in pairs:
|
|
56
|
+
if key in result:
|
|
57
|
+
raise ValueError("observer artifact request contains duplicate fields")
|
|
58
|
+
result[key] = value
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main() -> int:
|
|
63
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
64
|
+
parser.add_argument("--request", type=Path, required=True)
|
|
65
|
+
args = parser.parse_args()
|
|
66
|
+
try:
|
|
67
|
+
request = json.loads(
|
|
68
|
+
read_private_bytes(args.request, max_bytes=16384), object_pairs_hook=_unique
|
|
69
|
+
)
|
|
70
|
+
if not isinstance(request, dict) or set(request) != {
|
|
71
|
+
"offline_kit",
|
|
72
|
+
"work_dir",
|
|
73
|
+
"source_commit",
|
|
74
|
+
"image_digest",
|
|
75
|
+
}:
|
|
76
|
+
raise ValueError("observer artifact request is invalid")
|
|
77
|
+
if not all(isinstance(value, str) and value for value in request.values()):
|
|
78
|
+
raise ValueError("observer artifact request values are invalid")
|
|
79
|
+
if (
|
|
80
|
+
not Path(request["offline_kit"]).is_absolute()
|
|
81
|
+
or not Path(request["work_dir"]).is_absolute()
|
|
82
|
+
):
|
|
83
|
+
raise ValueError("observer artifact paths must be absolute")
|
|
84
|
+
result = inspect_observer_artifact(
|
|
85
|
+
offline_kit=Path(request["offline_kit"]),
|
|
86
|
+
work_dir=Path(request["work_dir"]),
|
|
87
|
+
source_commit=request["source_commit"],
|
|
88
|
+
image_digest=request["image_digest"],
|
|
89
|
+
)
|
|
90
|
+
print(json.dumps(result, sort_keys=True))
|
|
91
|
+
return 0
|
|
92
|
+
except (OSError, ValueError, RuntimeError):
|
|
93
|
+
print(
|
|
94
|
+
json.dumps(
|
|
95
|
+
{
|
|
96
|
+
"status": "unavailable",
|
|
97
|
+
"reason": "observer_artifact_verification_failed",
|
|
98
|
+
"execution_authority": False,
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
return 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
raise SystemExit(main())
|