fdai-deployment-cli 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. fdai_deployment_cli/__about__.py +3 -0
  2. fdai_deployment_cli/__init__.py +5 -0
  3. fdai_deployment_cli/__main__.py +5 -0
  4. fdai_deployment_cli/acr_publish.py +585 -0
  5. fdai_deployment_cli/aks_catalog_review.py +137 -0
  6. fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
  7. fdai_deployment_cli/aks_job_environment.py +59 -0
  8. fdai_deployment_cli/aks_job_execution.py +794 -0
  9. fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
  10. fdai_deployment_cli/aks_preflight.py +422 -0
  11. fdai_deployment_cli/aks_readiness.py +220 -0
  12. fdai_deployment_cli/aks_service_update.py +180 -0
  13. fdai_deployment_cli/aks_workload_jobs.py +281 -0
  14. fdai_deployment_cli/application_state_adoption.py +347 -0
  15. fdai_deployment_cli/azure_actor.py +61 -0
  16. fdai_deployment_cli/azure_naming.py +252 -0
  17. fdai_deployment_cli/azure_retail_prices.py +173 -0
  18. fdai_deployment_cli/bootstrap_approval.py +99 -0
  19. fdai_deployment_cli/bootstrap_channel.py +321 -0
  20. fdai_deployment_cli/bootstrap_executor.py +311 -0
  21. fdai_deployment_cli/bootstrap_plan.py +237 -0
  22. fdai_deployment_cli/bootstrap_reconcile.py +402 -0
  23. fdai_deployment_cli/bootstrap_support.py +138 -0
  24. fdai_deployment_cli/bootstrap_worker.py +448 -0
  25. fdai_deployment_cli/bundle.py +352 -0
  26. fdai_deployment_cli/capacity.py +143 -0
  27. fdai_deployment_cli/catalog_review_profile.py +306 -0
  28. fdai_deployment_cli/cli.py +798 -0
  29. fdai_deployment_cli/cli_help.py +214 -0
  30. fdai_deployment_cli/cli_parser.py +791 -0
  31. fdai_deployment_cli/cli_plan.py +450 -0
  32. fdai_deployment_cli/compiler.py +99 -0
  33. fdai_deployment_cli/console_artifact.py +218 -0
  34. fdai_deployment_cli/console_config.py +214 -0
  35. fdai_deployment_cli/console_update.py +832 -0
  36. fdai_deployment_cli/contracts.py +307 -0
  37. fdai_deployment_cli/control_package.py +140 -0
  38. fdai_deployment_cli/deadline_transport.py +70 -0
  39. fdai_deployment_cli/deployment_cost.py +131 -0
  40. fdai_deployment_cli/deployment_deadline.py +28 -0
  41. fdai_deployment_cli/deployment_kit.py +790 -0
  42. fdai_deployment_cli/deployment_kit_cache.py +191 -0
  43. fdai_deployment_cli/deployment_progress.py +648 -0
  44. fdai_deployment_cli/development_authority_cli.py +56 -0
  45. fdai_deployment_cli/development_authority_profile.py +319 -0
  46. fdai_deployment_cli/doctor.py +136 -0
  47. fdai_deployment_cli/entitlement_preflight.py +68 -0
  48. fdai_deployment_cli/entitlement_transport.py +49 -0
  49. fdai_deployment_cli/entra_bootstrap.py +579 -0
  50. fdai_deployment_cli/entra_control_profile.py +214 -0
  51. fdai_deployment_cli/entra_graph.py +349 -0
  52. fdai_deployment_cli/entra_profiles.py +215 -0
  53. fdai_deployment_cli/entra_source.py +244 -0
  54. fdai_deployment_cli/execution_bundle.py +478 -0
  55. fdai_deployment_cli/execution_copies.py +63 -0
  56. fdai_deployment_cli/foundation_adoption.py +438 -0
  57. fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
  58. fdai_deployment_cli/foundation_adoption_host.py +157 -0
  59. fdai_deployment_cli/foundation_adoption_transport.py +51 -0
  60. fdai_deployment_cli/foundation_failure.py +72 -0
  61. fdai_deployment_cli/foundation_image.py +168 -0
  62. fdai_deployment_cli/foundation_input.py +235 -0
  63. fdai_deployment_cli/foundation_output.py +154 -0
  64. fdai_deployment_cli/foundation_plan.py +381 -0
  65. fdai_deployment_cli/foundation_process.py +64 -0
  66. fdai_deployment_cli/foundation_progress.py +231 -0
  67. fdai_deployment_cli/github_actions.py +171 -0
  68. fdai_deployment_cli/github_apply_artifact.py +259 -0
  69. fdai_deployment_cli/github_artifact_io.py +48 -0
  70. fdai_deployment_cli/github_dispatch.py +248 -0
  71. fdai_deployment_cli/github_plan_artifact.py +181 -0
  72. fdai_deployment_cli/github_provider_artifact.py +170 -0
  73. fdai_deployment_cli/github_transport.py +46 -0
  74. fdai_deployment_cli/github_workflow_values.py +312 -0
  75. fdai_deployment_cli/hub_image_deploy.py +354 -0
  76. fdai_deployment_cli/hub_image_stage.py +162 -0
  77. fdai_deployment_cli/hub_image_trust.py +281 -0
  78. fdai_deployment_cli/hub_ownership.py +161 -0
  79. fdai_deployment_cli/hub_workload_handoff.py +264 -0
  80. fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
  81. fdai_deployment_cli/identity_profile.py +227 -0
  82. fdai_deployment_cli/image_targets.py +197 -0
  83. fdai_deployment_cli/installation_scope.py +280 -0
  84. fdai_deployment_cli/license.py +311 -0
  85. fdai_deployment_cli/license_issue.py +239 -0
  86. fdai_deployment_cli/lifecycle_authorization.py +252 -0
  87. fdai_deployment_cli/lifecycle_commands.py +173 -0
  88. fdai_deployment_cli/lifecycle_configuration.py +722 -0
  89. fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
  90. fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
  91. fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
  92. fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
  93. fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
  94. fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
  95. fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
  96. fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
  97. fdai_deployment_cli/lifecycle_enrollment.py +45 -0
  98. fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
  99. fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
  100. fdai_deployment_cli/lifecycle_native.py +247 -0
  101. fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
  102. fdai_deployment_cli/lifecycle_native_transition.py +339 -0
  103. fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
  104. fdai_deployment_cli/lifecycle_plan.py +800 -0
  105. fdai_deployment_cli/lifecycle_promotion.py +185 -0
  106. fdai_deployment_cli/lifecycle_release.py +308 -0
  107. fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
  108. fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
  109. fdai_deployment_cli/notification_recipients_input.py +222 -0
  110. fdai_deployment_cli/observer_artifact.py +106 -0
  111. fdai_deployment_cli/oci_archive.py +353 -0
  112. fdai_deployment_cli/offline_kit.py +797 -0
  113. fdai_deployment_cli/offline_prepare.py +135 -0
  114. fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
  115. fdai_deployment_cli/plan_input.py +101 -0
  116. fdai_deployment_cli/private_output.py +192 -0
  117. fdai_deployment_cli/profile.py +70 -0
  118. fdai_deployment_cli/progress.py +183 -0
  119. fdai_deployment_cli/readiness.py +286 -0
  120. fdai_deployment_cli/run_command_receiver.py +418 -0
  121. fdai_deployment_cli/runtime_build.py +386 -0
  122. fdai_deployment_cli/runtime_profile.py +287 -0
  123. fdai_deployment_cli/runtime_release.py +745 -0
  124. fdai_deployment_cli/runtime_stage.py +82 -0
  125. fdai_deployment_cli/runtime_support_installation.py +215 -0
  126. fdai_deployment_cli/simulation.py +80 -0
  127. fdai_deployment_cli/source_application_inputs.py +397 -0
  128. fdai_deployment_cli/source_azure.py +586 -0
  129. fdai_deployment_cli/source_change_cli.py +107 -0
  130. fdai_deployment_cli/source_change_parser.py +75 -0
  131. fdai_deployment_cli/source_change_plan.py +181 -0
  132. fdai_deployment_cli/source_changed_update.py +196 -0
  133. fdai_deployment_cli/source_deploy.py +170 -0
  134. fdai_deployment_cli/source_foundation.py +174 -0
  135. fdai_deployment_cli/source_host_tools.py +163 -0
  136. fdai_deployment_cli/source_image_stage.py +400 -0
  137. fdai_deployment_cli/source_input.py +173 -0
  138. fdai_deployment_cli/source_projection.py +164 -0
  139. fdai_deployment_cli/source_receiver.py +76 -0
  140. fdai_deployment_cli/source_recovery.py +264 -0
  141. fdai_deployment_cli/source_runtime.py +81 -0
  142. fdai_deployment_cli/source_runtime_support.py +368 -0
  143. fdai_deployment_cli/source_service_update.py +726 -0
  144. fdai_deployment_cli/source_service_update_cli.py +47 -0
  145. fdai_deployment_cli/source_snapshot.py +178 -0
  146. fdai_deployment_cli/source_teardown.py +574 -0
  147. fdai_deployment_cli/source_terraform.py +85 -0
  148. fdai_deployment_cli/source_transport.py +399 -0
  149. fdai_deployment_cli/standalone_aks_inventory.py +558 -0
  150. fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
  151. fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
  152. fdai_deployment_cli/standalone_application.py +977 -0
  153. fdai_deployment_cli/standalone_application_completion.py +151 -0
  154. fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
  155. fdai_deployment_cli/standalone_application_recovery.py +116 -0
  156. fdai_deployment_cli/standalone_approval_input.py +24 -0
  157. fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
  158. fdai_deployment_cli/standalone_catalog_review.py +251 -0
  159. fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
  160. fdai_deployment_cli/standalone_console_publish.py +174 -0
  161. fdai_deployment_cli/standalone_database_principals.py +41 -0
  162. fdai_deployment_cli/standalone_deploy.py +638 -0
  163. fdai_deployment_cli/standalone_development_authority.py +166 -0
  164. fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
  165. fdai_deployment_cli/standalone_foundation_transition.py +767 -0
  166. fdai_deployment_cli/standalone_host.py +4608 -0
  167. fdai_deployment_cli/standalone_host_rebind.py +226 -0
  168. fdai_deployment_cli/standalone_host_residual.py +104 -0
  169. fdai_deployment_cli/standalone_host_state.py +145 -0
  170. fdai_deployment_cli/standalone_host_values.py +563 -0
  171. fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
  172. fdai_deployment_cli/standalone_license_installation.py +177 -0
  173. fdai_deployment_cli/standalone_management_egress.py +83 -0
  174. fdai_deployment_cli/standalone_migration_evidence.py +38 -0
  175. fdai_deployment_cli/standalone_operational_evidence.py +356 -0
  176. fdai_deployment_cli/standalone_planned_outputs.py +147 -0
  177. fdai_deployment_cli/standalone_private_access_probe.py +100 -0
  178. fdai_deployment_cli/standalone_product_profile.py +110 -0
  179. fdai_deployment_cli/standalone_remote_prepare.py +442 -0
  180. fdai_deployment_cli/standalone_residual_apply.py +540 -0
  181. fdai_deployment_cli/standalone_residual_successor.py +431 -0
  182. fdai_deployment_cli/standalone_review.py +266 -0
  183. fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
  184. fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
  185. fdai_deployment_cli/standalone_stage_targets.py +325 -0
  186. fdai_deployment_cli/standalone_status.py +67 -0
  187. fdai_deployment_cli/standalone_terraform_environment.py +237 -0
  188. fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
  189. fdai_deployment_cli/standalone_trial_activation.py +193 -0
  190. fdai_deployment_cli/state.py +434 -0
  191. fdai_deployment_cli/state_handoff.py +253 -0
  192. fdai_deployment_cli/status_projection.py +97 -0
  193. fdai_deployment_cli/support_install.py +257 -0
  194. fdai_deployment_cli/target.py +46 -0
  195. fdai_deployment_cli/trust/capabilities.json +62 -0
  196. fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
  197. fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
  198. fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
  199. fdai_deployment_cli/trust_roots.py +50 -0
  200. fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
  201. fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
  202. fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
  203. fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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())