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,162 @@
1
+ """Mirror all signed FDAI images, independently observing every imported digest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest, load_json_object
10
+ from fdai_deployment_cli.hub_image_trust import HubImageSelection, load_selection
11
+ from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
12
+ from fdai_deployment_cli.source_application_inputs import absolute_input_path
13
+ from fdai_deployment_cli.source_image_stage import (
14
+ RegistryBuildService,
15
+ SourceImageStageStopped,
16
+ SourceImageTarget,
17
+ )
18
+
19
+
20
+ def prepare_host_inputs(args: argparse.Namespace, work_dir: Path) -> HubImageSelection | None:
21
+ selected_hub_path = getattr(args, "hub_selection", None)
22
+ selection = prepare_host_selection(
23
+ path=absolute_input_path(selected_hub_path) if selected_hub_path is not None else None,
24
+ digest=getattr(args, "hub_selection_digest", None),
25
+ work_dir=work_dir,
26
+ )
27
+ if selection is not None and not hasattr(args, "source_snapshot"):
28
+ raise ValueError("Hub images require checkout support, not offline-kit mode")
29
+ return selection
30
+
31
+
32
+ def require_foundation_source(selection: HubImageSelection | None, source_commit: str) -> None:
33
+ if selection is not None and selection.source_commit != source_commit:
34
+ raise ValueError("Hub Release and Foundation source revisions differ")
35
+
36
+
37
+ def require_retained_artifact_mode(
38
+ retained: dict[str, object], source_mode: bool, selection: HubImageSelection | None
39
+ ) -> None:
40
+ """Reject any retained artifact mode or exact signed selection drift."""
41
+ expected_source = (
42
+ "hub-signed-images"
43
+ if selection is not None
44
+ else "operator-selected-source"
45
+ if source_mode
46
+ else "signed-kit"
47
+ )
48
+ if retained.get("artifact_source") != expected_source or retained.get(
49
+ "hub_selection_digest"
50
+ ) != (selection.digest if selection is not None else None):
51
+ raise ValueError("standalone host artifact mode or Hub selection drifted")
52
+
53
+
54
+ def bind_host_selection(context: dict[str, object], selection: HubImageSelection | None) -> None:
55
+ if selection is not None:
56
+ context.update(selection.metadata())
57
+ context["hub_selection"] = str(selection.path)
58
+
59
+
60
+ def prepare_host_selection(
61
+ *, path: Path | None, digest: str | None, work_dir: Path
62
+ ) -> HubImageSelection | None:
63
+ """Retain the independently verified workstation pin, never a host-fetched TOFU key."""
64
+ retained = work_dir / "hub-selection.json"
65
+ if path is None and digest is None:
66
+ if retained.exists():
67
+ raise ValueError("Hub artifact mode cannot change to source or offline kit")
68
+ return None
69
+ if path is None or digest is None:
70
+ raise ValueError("Hub selection and exact digest are both required")
71
+ selection = load_selection(path, digest)
72
+ content = read_private_bytes(path, max_bytes=12 * 1024 * 1024)
73
+ if retained.exists():
74
+ if read_private_bytes(retained, max_bytes=12 * 1024 * 1024) != content:
75
+ raise ValueError("Hub host immutable image selection changed")
76
+ else:
77
+ write_private_bytes(retained, content)
78
+ return load_selection(retained, selection.digest)
79
+
80
+
81
+ def run_hub_image_stage(
82
+ *,
83
+ selection: HubImageSelection,
84
+ target: SourceImageTarget,
85
+ registry: RegistryBuildService,
86
+ work_dir: Path,
87
+ ) -> dict[str, object]:
88
+ if (
89
+ re.fullmatch(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", target.subscription_id) is None
90
+ or re.fullmatch(r"[a-z0-9]{5,50}", target.registry_name) is None
91
+ ):
92
+ raise ValueError("Hub image target is malformed")
93
+ selection = load_selection(selection.path, selection.digest)
94
+ claim = {
95
+ "schema_version": "fdai.hub-image-stage-claim.v1",
96
+ "hub_selection_digest": selection.digest,
97
+ "subscription_id": target.subscription_id,
98
+ "registry_login_server": target.login_server,
99
+ "image_sources": selection.image_sources,
100
+ "artifact_source": "hub-signed-images",
101
+ }
102
+ work_dir.mkdir(mode=0o700, exist_ok=True)
103
+ claim_path, receipt_path = work_dir / "claim.json", work_dir / "receipt.json"
104
+ if claim_path.exists():
105
+ existing = load_json_object(
106
+ read_private_bytes(claim_path, max_bytes=65_536), label="Hub image claim", strict=True
107
+ )
108
+ if existing != claim:
109
+ raise ValueError("Hub image stage target, mode or selection changed")
110
+ else:
111
+ if any(work_dir.iterdir()):
112
+ raise ValueError("Hub image stage cannot adopt another artifact mode")
113
+ write_private_bytes(claim_path, canonical_bytes(claim))
114
+ digests = {name: ref.rsplit("@", 1)[1] for name, ref in selection.image_sources.items()}
115
+ tag = "hub-" + selection.digest[:32]
116
+ existing_receipt = None
117
+ if receipt_path.exists():
118
+ existing_receipt = load_json_object(
119
+ read_private_bytes(receipt_path, max_bytes=65_536),
120
+ label="Hub image receipt",
121
+ strict=True,
122
+ )
123
+ unsigned = {
124
+ key: value for key, value in existing_receipt.items() if key != "receipt_digest"
125
+ }
126
+ if (
127
+ existing_receipt.get("claim_digest") != canonical_digest(claim)
128
+ or existing_receipt.get("receipt_digest") != canonical_digest(unsigned)
129
+ or existing_receipt.get("image_digests") != digests
130
+ ):
131
+ raise ValueError("Hub image receipt binding differs")
132
+ try:
133
+ if existing_receipt is None:
134
+ registry.require_available(target)
135
+ for name, source in sorted(selection.image_sources.items()):
136
+ registry.import_image(target, source=source, image=f"{name}:{tag}")
137
+ for name, digest in sorted(digests.items()):
138
+ for reference in (f"{name}@{digest}", f"{name}:{tag}"):
139
+ if registry.read_digest(target, reference=reference) != digest:
140
+ raise ValueError("Hub imported image digest readback differs")
141
+ except SourceImageStageStopped as error:
142
+ raise ValueError("Hub image import or observation failed; no builds or fallback") from error
143
+ if existing_receipt is not None:
144
+ return existing_receipt
145
+ receipt = {
146
+ "schema_version": "fdai.hub-image-stage-receipt.v1",
147
+ **selection.metadata(),
148
+ "claim_digest": canonical_digest(claim),
149
+ "source_commit": selection.source_commit,
150
+ "image_sources": selection.image_sources,
151
+ "image_digests": digests,
152
+ "image_refs": {
153
+ name: f"{target.login_server}/{name}@{digest}" for name, digest in digests.items()
154
+ },
155
+ "builder": "none",
156
+ "effect_verified": True,
157
+ "mutation_performed": True,
158
+ "deployment_ready": False,
159
+ }
160
+ receipt["receipt_digest"] = canonical_digest(receipt)
161
+ write_private_bytes(receipt_path, canonical_bytes(receipt))
162
+ return receipt
@@ -0,0 +1,281 @@
1
+ """Verify a public signed export without importing the dependent lifecycle Hub package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from base64 import b64decode, b64encode
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from functools import cmp_to_key
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from cryptography.exceptions import InvalidSignature
15
+ from cryptography.hazmat.primitives import serialization
16
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
17
+
18
+ from fdai_deployment_cli.contracts import canonical_bytes, load_json_object
19
+ from fdai_deployment_cli.lifecycle_release import (
20
+ APPLICATION_COMPONENT_RELEASE_SCHEMA,
21
+ parse_lifecycle_release_manifest,
22
+ )
23
+ from fdai_deployment_cli.private_output import read_private_bytes
24
+ from fdai_deployment_cli.runtime_release import (
25
+ RecallRecord,
26
+ compare_release_ids,
27
+ evaluate_recall_candidate,
28
+ is_release_id,
29
+ )
30
+ from fdai_deployment_cli.source_image_stage import DEPENDENCY_IMAGES, SERVICE_ORDER
31
+
32
+ MAX_EXPORT_BYTES = 8 * 1024 * 1024
33
+ MAX_SELECTION_BYTES = 12 * 1024 * 1024
34
+ EXPORT_DOMAIN = b"fdai.catalog-export.v1\n"
35
+ PROVENANCE = "hub-signed-images-with-checkout-support"
36
+ INVENTORY = frozenset(SERVICE_ORDER) | DEPENDENCY_IMAGES.keys()
37
+
38
+
39
+ def public_key(pem: bytes) -> Ed25519PublicKey:
40
+ if not 0 < len(pem) <= 4096:
41
+ raise ValueError("Hub trust requires a bounded independently provisioned public PEM")
42
+ key = serialization.load_pem_public_key(pem)
43
+ if not isinstance(key, Ed25519PublicKey):
44
+ raise ValueError("Hub trust requires an Ed25519 public key") # noqa: TRY004
45
+ return key
46
+
47
+
48
+ def key_id(key: Ed25519PublicKey) -> str:
49
+ raw = key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
50
+ return "hub-" + hashlib.sha256(raw).hexdigest()[:16]
51
+
52
+
53
+ def validate_channel(channel: str) -> None:
54
+ if (
55
+ len(channel) > 64
56
+ or re.fullmatch(r"(?:DEV|RELEASE_CANDIDATE|RELEASE|custom-[a-z0-9][a-z0-9-]*)", channel)
57
+ is None
58
+ ):
59
+ raise ValueError("Hub channel must be DEV, RELEASE_CANDIDATE, RELEASE, or custom-*")
60
+
61
+
62
+ def _signature(key: Ed25519PublicKey, signature: object, payload: bytes) -> None:
63
+ if not isinstance(signature, str) or not 0 < len(signature) <= 128:
64
+ raise ValueError("Hub signature is missing or malformed")
65
+ try:
66
+ key.verify(b64decode(signature, validate=True), payload)
67
+ except (InvalidSignature, ValueError) as error:
68
+ raise ValueError("Hub signature verification failed; no source-build fallback") from error
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class HubImageSelection:
73
+ path: Path
74
+ digest: str
75
+ document: dict[str, Any]
76
+ release_id: str
77
+ source_commit: str
78
+ release_digest: str
79
+ image_sources: dict[str, str]
80
+
81
+ def metadata(self) -> dict[str, object]:
82
+ return {
83
+ "provenance": PROVENANCE,
84
+ "artifact_source": "hub-signed-images",
85
+ "hub_selection_digest": self.digest,
86
+ "hub_release_id": self.release_id,
87
+ "hub_channel": self.document["channel"],
88
+ "hub_catalog_digest": self.document["catalog_digest"],
89
+ "hub_catalog_revision": self.document["catalog_revision"],
90
+ "runtime_release_digest": self.release_digest,
91
+ "image_signature_verified": True,
92
+ "release_signature_verified": False,
93
+ "support_artifact_source": "operator-selected-source",
94
+ }
95
+
96
+
97
+ def verify_export(
98
+ raw: bytes,
99
+ *,
100
+ signature: str,
101
+ export_key_id: str,
102
+ trusted_pem: bytes,
103
+ channel: str,
104
+ release_id: str | None = None,
105
+ now: datetime | None = None,
106
+ minimum_revision: int = 0,
107
+ previous_digest: str | None = None,
108
+ ) -> dict[str, Any]:
109
+ """Authenticate membership, all manifests and recall before selecting one exact Release."""
110
+ if not 0 < len(raw) <= MAX_EXPORT_BYTES:
111
+ raise ValueError("Hub export exceeds its size bound")
112
+ validate_channel(channel)
113
+ key = public_key(trusted_pem)
114
+ if export_key_id != key_id(key):
115
+ raise ValueError("Hub export key id differs from the independent trust key")
116
+ _signature(key, signature, EXPORT_DOMAIN + raw)
117
+ doc = load_json_object(raw, label="Hub signed export", max_bytes=MAX_EXPORT_BYTES, strict=True)
118
+ if set(doc) != {"revision", "expires_at", "releases", "channels", "recalls"}:
119
+ raise ValueError("Hub signed export fields are invalid")
120
+ revision = doc["revision"]
121
+ if type(revision) is not int or not 0 <= revision <= 2**63 - 1:
122
+ raise ValueError("Hub signed export revision is invalid")
123
+ digest = hashlib.sha256(raw).hexdigest()
124
+ if revision < minimum_revision or (
125
+ revision == minimum_revision and previous_digest not in {None, digest}
126
+ ):
127
+ raise ValueError("Hub catalog rollback or same-revision substitution refused")
128
+ now = now or datetime.now(UTC)
129
+ try:
130
+ expires = datetime.fromisoformat(doc["expires_at"])
131
+ except (TypeError, ValueError) as error:
132
+ raise ValueError("Hub catalog expiry is invalid") from error
133
+ if now.tzinfo is None or expires.tzinfo is None or now >= expires:
134
+ raise ValueError("Hub catalog expired; no source-build fallback")
135
+ releases, channels, recalls = doc["releases"], doc["channels"], doc["recalls"]
136
+ if (
137
+ not isinstance(releases, dict)
138
+ or not 1 <= len(releases) <= 4096
139
+ or not isinstance(channels, dict)
140
+ or not 1 <= len(channels) <= 256
141
+ or not isinstance(recalls, list)
142
+ or len(recalls) > 8192
143
+ ):
144
+ raise ValueError("Hub catalog inventory is invalid")
145
+ parsed = {}
146
+ for version, artifact in releases.items():
147
+ if not isinstance(version, str) or len(version) > 64 or not is_release_id(version):
148
+ raise ValueError("Hub release id is invalid")
149
+ if (
150
+ not isinstance(artifact, dict)
151
+ or set(artifact) != {"manifest", "signature"}
152
+ or not isinstance(artifact["manifest"], str)
153
+ ):
154
+ raise ValueError("Hub release artifact is invalid")
155
+ manifest = artifact["manifest"].encode("utf-8")
156
+ _signature(key, artifact["signature"], manifest)
157
+ release = parse_lifecycle_release_manifest(manifest)
158
+ if release.to_mapping().get("release_version", version) != version or (
159
+ release.configuration_schema is not None
160
+ and release.configuration_schema.release_version != version
161
+ ):
162
+ raise ValueError("Hub manifest version differs from the catalog release id")
163
+ parsed[version] = release
164
+ for name, members in channels.items():
165
+ validate_channel(name)
166
+ if (
167
+ not isinstance(members, list)
168
+ or len(members) > 4096
169
+ or any(not isinstance(member, str) or member not in parsed for member in members)
170
+ or len(set(members)) != len(members)
171
+ ):
172
+ raise ValueError("Hub channel members are invalid")
173
+ active: dict[tuple[str, str], RecallRecord] = {}
174
+ records: list[RecallRecord] = []
175
+ try:
176
+ for value in recalls:
177
+ if not isinstance(value, dict):
178
+ raise ValueError("Hub recall is invalid") # noqa: TRY004
179
+ record = RecallRecord(**value)
180
+ identity = record.scope, record.target
181
+ prior = active.get(identity)
182
+ if prior is not None and record.sequence <= prior.sequence:
183
+ raise ValueError("Hub recall sequence did not advance")
184
+ active[identity] = record
185
+ records.append(record)
186
+ except (TypeError, ValueError) as error:
187
+ raise ValueError("Hub recall history is invalid") from error
188
+ members = channels.get(channel, [])
189
+ capabilities = tuple(record.target for record in records if record.scope == "capability")
190
+ eligible = [
191
+ version
192
+ for version in members
193
+ if evaluate_recall_candidate(
194
+ release_id=version, capability_ids=capabilities, recall_records=tuple(records)
195
+ ).allowed
196
+ ]
197
+ if release_id is not None:
198
+ if len(release_id) > 64 or not is_release_id(release_id) or release_id not in eligible:
199
+ raise ValueError("Hub requested Release is absent, outside channel, or recalled")
200
+ selected = release_id
201
+ else:
202
+ if not eligible:
203
+ raise ValueError("Hub channel has no non-recalled Release")
204
+ selected = max(eligible, key=cmp_to_key(compare_release_ids))
205
+ release = parsed[selected]
206
+ manifest_doc = release.to_mapping()
207
+ components = manifest_doc.get("components")
208
+ if (
209
+ release.schema_version != APPLICATION_COMPONENT_RELEASE_SCHEMA
210
+ or release.product != "fdai"
211
+ or release.platform_tag != "linux-x86_64"
212
+ or not isinstance(components, dict)
213
+ or set(components) != INVENTORY
214
+ or any(not isinstance(value.get("image_ref"), str) for value in components.values())
215
+ ):
216
+ raise ValueError(
217
+ "Hub bootstrap requires an fdai linux-x86_64 application-release.v2 with all "
218
+ "five services and two dependencies, each with signed digest-pinned image_ref"
219
+ )
220
+ return {
221
+ "release_id": selected,
222
+ "source_commit": release.source_commit,
223
+ "release_digest": release.digest,
224
+ "image_sources": {name: value["image_ref"] for name, value in components.items()},
225
+ "catalog_digest": digest,
226
+ "catalog_revision": revision,
227
+ }
228
+
229
+
230
+ def load_selection(path: Path, expected_digest: str) -> HubImageSelection:
231
+ raw = read_private_bytes(path, max_bytes=MAX_SELECTION_BYTES)
232
+ if hashlib.sha256(raw).hexdigest() != expected_digest:
233
+ raise ValueError("Hub immutable selection digest differs")
234
+ doc = load_json_object(raw, label="Hub selection", max_bytes=MAX_SELECTION_BYTES, strict=True)
235
+ if doc.get("schema_version") != "fdai.hub-image-selection.v1":
236
+ raise ValueError("Hub selection schema is invalid")
237
+ result = verify_export(
238
+ b64decode(doc["catalog"], validate=True),
239
+ signature=doc["export_signature"],
240
+ export_key_id=doc["export_key_id"],
241
+ trusted_pem=doc["public_key"].encode("ascii"),
242
+ channel=doc["channel"],
243
+ release_id=doc["release_id"],
244
+ minimum_revision=doc["catalog_revision"],
245
+ previous_digest=doc["catalog_digest"],
246
+ )
247
+ if any(doc.get(name) != value for name, value in result.items()):
248
+ raise ValueError("Hub selection disagrees with its authenticated catalog")
249
+ return HubImageSelection(
250
+ path=path,
251
+ digest=expected_digest,
252
+ document=doc,
253
+ **{
254
+ name: result[name]
255
+ for name in ("release_id", "source_commit", "release_digest", "image_sources")
256
+ },
257
+ )
258
+
259
+
260
+ def selection_bytes(
261
+ *,
262
+ raw: bytes,
263
+ signature: str,
264
+ export_key_id: str,
265
+ pem: bytes,
266
+ hub_url: str,
267
+ channel: str,
268
+ verified: dict[str, Any],
269
+ ) -> bytes:
270
+ return canonical_bytes(
271
+ {
272
+ "schema_version": "fdai.hub-image-selection.v1",
273
+ "hub_url": hub_url,
274
+ "channel": channel,
275
+ "catalog": b64encode(raw).decode("ascii"),
276
+ "export_signature": signature,
277
+ "export_key_id": export_key_id,
278
+ "public_key": pem.decode("ascii"),
279
+ **verified,
280
+ }
281
+ )
@@ -0,0 +1,161 @@
1
+ """Local Hub ownership marker: once the Lifecycle Agent owns the workloads, Terraform must not.
2
+
3
+ The marker is recorded after the Terraform release step of the ownership handoff. While it exists,
4
+ every entry point that would plan or apply the AKS workloads root refuses, so Terraform and the
5
+ agent never write the same object. A present but unreadable marker also refuses: a damaged marker
6
+ must not silently hand the objects back to Terraform. Rollback removes the marker only through the
7
+ explicit release function after the agent has stopped reconciling.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from datetime import UTC, datetime
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.standalone_host_state import private_json, replace_private_json
19
+
20
+ MARKER_NAME = "hub-ownership.json"
21
+ MARKER_SCHEMA = "fdai.hub-ownership-marker.v1"
22
+ ADOPTION_SCHEMA = "fdai.runtime-adoption-receipt.v1"
23
+ _ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
24
+ _DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
25
+
26
+
27
+ def marker_path(work_dir: Path) -> Path:
28
+ return work_dir / MARKER_NAME
29
+
30
+
31
+ def add_handoff_parser(subcommands: Any) -> None:
32
+ parser = subcommands.add_parser("record-hub-workload-handoff")
33
+ parser.add_argument("--adoption-receipt", type=Path, required=True)
34
+ parser.set_defaults(handler=_record_handoff_command)
35
+
36
+
37
+ def _record_handoff_command(args: Any, work_dir: Path) -> dict[str, object]:
38
+ return record_hub_ownership_from_adoption(
39
+ work_dir,
40
+ adoption_receipt=args.adoption_receipt,
41
+ )
42
+
43
+
44
+ def record_hub_ownership(
45
+ work_dir: Path,
46
+ *,
47
+ installation_id: str,
48
+ handoff_receipt_digest: str,
49
+ recorded_at: datetime | None = None,
50
+ ) -> dict[str, object]:
51
+ """Record the marker once; repeating the same handoff returns the retained record."""
52
+
53
+ if _ID.fullmatch(installation_id) is None or _DIGEST.fullmatch(handoff_receipt_digest) is None:
54
+ raise ValueError("hub ownership marker inputs are invalid")
55
+ path = marker_path(work_dir)
56
+ if path.exists():
57
+ retained = _read(path)
58
+ if (
59
+ retained["installation_id"] != installation_id
60
+ or retained["handoff_receipt_digest"] != handoff_receipt_digest
61
+ ):
62
+ raise ValueError("a different hub ownership marker is already recorded")
63
+ return retained
64
+ moment = recorded_at or datetime.now(tz=UTC)
65
+ if moment.tzinfo is None:
66
+ raise ValueError("hub ownership marker time MUST be timezone-aware")
67
+ body: dict[str, object] = {
68
+ "schema_version": MARKER_SCHEMA,
69
+ "installation_id": installation_id,
70
+ "handoff_receipt_digest": handoff_receipt_digest,
71
+ "recorded_at": moment.astimezone(UTC).isoformat(),
72
+ }
73
+ record = body | {"record_digest": "sha256:" + canonical_digest(body)}
74
+ replace_private_json(path, record)
75
+ return record
76
+
77
+
78
+ def record_hub_ownership_from_adoption(
79
+ work_dir: Path,
80
+ *,
81
+ adoption_receipt: Path,
82
+ ) -> dict[str, object]:
83
+ """Record the marker only from one complete, digest-valid per-object adoption receipt."""
84
+
85
+ receipt = private_json(adoption_receipt.absolute(), "runtime adoption receipt")
86
+ digest = receipt.pop("receipt_digest", None)
87
+ if (
88
+ receipt.get("schema_version") != ADOPTION_SCHEMA
89
+ or receipt.get("complete") is not True
90
+ or not isinstance(receipt.get("objects"), list)
91
+ or not receipt["objects"]
92
+ or digest != "sha256:" + canonical_digest(receipt)
93
+ or _ID.fullmatch(str(receipt.get("installation_id", ""))) is None
94
+ or _ID.fullmatch(str(receipt.get("release_id", ""))) is None
95
+ ):
96
+ raise ValueError("runtime adoption receipt is invalid")
97
+ required = {"kind", "name", "uid", "resource_version", "render_digest"}
98
+ if any(
99
+ not isinstance(item, dict)
100
+ or set(item) != required
101
+ or _ID.fullmatch(str(item["kind"])) is None
102
+ or _ID.fullmatch(str(item["name"])) is None
103
+ or _ID.fullmatch(str(item["uid"])) is None
104
+ or _ID.fullmatch(str(item["resource_version"])) is None
105
+ or _DIGEST.fullmatch(str(item["render_digest"])) is None
106
+ for item in receipt["objects"]
107
+ ):
108
+ raise ValueError("runtime adoption receipt is invalid")
109
+ return record_hub_ownership(
110
+ work_dir,
111
+ installation_id=str(receipt["installation_id"]),
112
+ handoff_receipt_digest=str(digest),
113
+ )
114
+
115
+
116
+ def release_hub_ownership(work_dir: Path, *, rollback_receipt_digest: str) -> None:
117
+ """Remove the marker for a verified rollback to Terraform; refuses without a receipt."""
118
+
119
+ if _DIGEST.fullmatch(rollback_receipt_digest) is None:
120
+ raise ValueError("hub ownership release requires a rollback receipt digest")
121
+ path = marker_path(work_dir)
122
+ if path.exists():
123
+ _read(path)
124
+ path.unlink()
125
+
126
+
127
+ def require_terraform_owned_workloads(work_dir: Path) -> None:
128
+ """Refuse a Terraform workloads operation while the Hub owns the workloads."""
129
+
130
+ path = marker_path(work_dir)
131
+ if not path.exists() and not path.is_symlink():
132
+ return
133
+ _read(path)
134
+ raise ValueError(
135
+ "the workloads are Hub-managed; plan the Hub variant instead of the workloads root"
136
+ )
137
+
138
+
139
+ def workloads_terraform_data(context: dict[str, object], work_dir: Path) -> object:
140
+ """Return the workloads Terraform data path only while Terraform owns the workloads."""
141
+
142
+ require_terraform_owned_workloads(work_dir)
143
+ return context.get("workloads_terraform_data")
144
+
145
+
146
+ def _read(path: Path) -> dict[str, object]:
147
+ try:
148
+ record = private_json(path, "hub ownership marker")
149
+ digest = record.pop("record_digest", None)
150
+ if (
151
+ record.get("schema_version") != MARKER_SCHEMA
152
+ or set(record)
153
+ != {"schema_version", "installation_id", "handoff_receipt_digest", "recorded_at"}
154
+ or digest != "sha256:" + canonical_digest(record)
155
+ or _ID.fullmatch(str(record["installation_id"])) is None
156
+ or _DIGEST.fullmatch(str(record["handoff_receipt_digest"])) is None
157
+ ):
158
+ raise ValueError("hub ownership marker is invalid")
159
+ except (OSError, ValueError):
160
+ raise ValueError("hub ownership marker is unreadable; workloads stay Hub-managed") from None
161
+ return record | {"record_digest": digest}