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,56 @@
1
+ """CLI selection rules for the source development-authority input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from fdai_deployment_cli.development_authority_profile import (
9
+ DevelopmentAuthorityDeploymentProfile,
10
+ load_development_authority_profile,
11
+ )
12
+
13
+
14
+ def selected_development_authority(
15
+ args: argparse.Namespace,
16
+ *,
17
+ cwd: Path,
18
+ ) -> tuple[DevelopmentAuthorityDeploymentProfile | None, bool]:
19
+ """Load and validate the mutually exclusive select/clear operation."""
20
+
21
+ selected_path = args.development_authority_profile
22
+ profile = (
23
+ None
24
+ if selected_path is None
25
+ else load_development_authority_profile(
26
+ selected_path if selected_path.is_absolute() else cwd / selected_path
27
+ )
28
+ )
29
+ clear = bool(args.clear_development_authority_profile)
30
+ if profile is not None and clear:
31
+ raise ValueError(
32
+ "--development-authority-profile and --clear-development-profile are mutually exclusive"
33
+ )
34
+ return profile, clear
35
+
36
+
37
+ def validate_development_authority_command(
38
+ args: argparse.Namespace,
39
+ *,
40
+ profile: DevelopmentAuthorityDeploymentProfile | None,
41
+ clear: bool,
42
+ ) -> None:
43
+ """Restrict authority changes to one source-AKS application run."""
44
+
45
+ if profile is None and not clear:
46
+ return
47
+ if (args.source is None and getattr(args, "hub", None) is None) or args.runtime != "aks":
48
+ raise ValueError("development authority changes require source AKS deployment")
49
+ if args.prepare_only or args.preflight_only or args.teardown:
50
+ raise ValueError("development authority changes require an application deployment run")
51
+
52
+
53
+ __all__ = [
54
+ "selected_development_authority",
55
+ "validate_development_authority_command",
56
+ ]
@@ -0,0 +1,319 @@
1
+ """Reviewed full-authority development profile input for source deployment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from fdai_deployment_cli.contracts import canonical_digest
14
+ from fdai_deployment_cli.private_output import read_private_bytes
15
+ from fdai_deployment_cli.standalone_host_state import replace_or_verify_private_json
16
+
17
+ _STAGED_SCHEMA = "fdai.development-authority-staged-input.v1"
18
+ _MAX_INPUT_BYTES = 256 * 1024
19
+ _DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
20
+ _IDENTIFIER = re.compile(r"[a-z0-9][a-z0-9._:-]{0,127}")
21
+ _SEMVER = re.compile(r"\d+\.\d+\.\d+")
22
+ _PROFILE_FIELDS = frozenset(
23
+ {
24
+ "schema_version",
25
+ "profile_id",
26
+ "scope",
27
+ "classification",
28
+ "owner_principal",
29
+ "executor_principal",
30
+ "valid_from",
31
+ "valid_until",
32
+ "source_revision",
33
+ "catalog_revision",
34
+ "policy_revision",
35
+ "registered_actions",
36
+ }
37
+ )
38
+ _SCOPE_FIELDS = frozenset({"tenant_digest", "subscription_digest", "resource_group_digests"})
39
+ _ACTION_FIELDS = frozenset({"action_type", "version", "action_type_digest"})
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class DevelopmentAuthorityDeploymentProfile:
44
+ """Validated deployment input and exact executor binding."""
45
+
46
+ profile: dict[str, object]
47
+ profile_json: str
48
+ executor_principal: str
49
+ input_digest: str
50
+
51
+
52
+ def load_development_authority_profile(path: Path) -> DevelopmentAuthorityDeploymentProfile:
53
+ """Load one operator-reviewed profile from a private local file."""
54
+
55
+ return _from_mapping(_load_json_object(path, "development authority profile"), staged=False)
56
+
57
+
58
+ def staged_development_authority_profile_from_path(
59
+ path: Path | None,
60
+ ) -> DevelopmentAuthorityDeploymentProfile | None:
61
+ """Load one digest-bound managed-host profile."""
62
+
63
+ if path is None:
64
+ return None
65
+ return _from_mapping(
66
+ _load_json_object(path, "staged development authority profile"),
67
+ staged=True,
68
+ )
69
+
70
+
71
+ def stage_development_authority_profile(
72
+ value: DevelopmentAuthorityDeploymentProfile | None,
73
+ *,
74
+ tunnel: Any,
75
+ prepared_root: Path,
76
+ remote_root: str,
77
+ ) -> tuple[str, ...]:
78
+ """Copy one reviewed non-secret profile to the managed host."""
79
+
80
+ if value is None:
81
+ return ()
82
+ local_path = prepared_root / "development-authority-profile.json"
83
+ remote_path = f"{remote_root}/development-authority-profile.json"
84
+ replace_or_verify_private_json(
85
+ local_path,
86
+ {
87
+ "schema_version": _STAGED_SCHEMA,
88
+ "input_digest": value.input_digest,
89
+ "profile": value.profile,
90
+ },
91
+ )
92
+ tunnel.copy_to(local_path, remote_path, timeout=120)
93
+ return ("--development-authority-profile", remote_path)
94
+
95
+
96
+ def apply_development_authority_profile(
97
+ values: dict[str, object],
98
+ profile: DevelopmentAuthorityDeploymentProfile | None,
99
+ ) -> None:
100
+ """Bind a selected profile without revoking a retained profile on omission."""
101
+
102
+ if profile is None:
103
+ return
104
+ values["development_authority_profile_json"] = profile.profile_json
105
+ values["development_authority_executor_principal"] = profile.executor_principal
106
+
107
+
108
+ def clear_development_authority_profile(values: dict[str, object]) -> bool:
109
+ """Remove a retained profile only for an explicit clear operation."""
110
+
111
+ removed_profile = values.pop("development_authority_profile_json", None)
112
+ removed_executor = values.pop("development_authority_executor_principal", None)
113
+ return removed_profile is not None or removed_executor is not None
114
+
115
+
116
+ def validate_deployment_target(
117
+ profile: DevelopmentAuthorityDeploymentProfile,
118
+ *,
119
+ tenant_id: str,
120
+ subscription_id: str,
121
+ resource_groups: tuple[str, ...],
122
+ source_revision: str,
123
+ ) -> None:
124
+ """Require the reviewed profile to cover only the exact selected deployment target."""
125
+
126
+ scope = _mapping(profile.profile["scope"], "development authority scope")
127
+ expected_tenant = _scope_digest(tenant_id)
128
+ expected_subscription = _scope_digest(subscription_id)
129
+ if scope["tenant_digest"] != expected_tenant:
130
+ raise ValueError("development authority profile tenant differs from the deployment target")
131
+ if scope["subscription_digest"] != expected_subscription:
132
+ raise ValueError(
133
+ "development authority profile subscription differs from the deployment target"
134
+ )
135
+ allowed_groups = {_scope_digest(value) for value in resource_groups}
136
+ selected_groups = set(_digest_list(scope["resource_group_digests"], "resource_group_digests"))
137
+ if selected_groups and not selected_groups <= allowed_groups:
138
+ raise ValueError(
139
+ "development authority profile resource groups exceed the deployment target"
140
+ )
141
+ if profile.profile["source_revision"] != source_revision:
142
+ raise ValueError("development authority profile source revision differs")
143
+
144
+
145
+ def _from_mapping(
146
+ value: Mapping[str, Any], *, staged: bool
147
+ ) -> DevelopmentAuthorityDeploymentProfile:
148
+ if staged:
149
+ if set(value) != {"schema_version", "input_digest", "profile"}:
150
+ raise ValueError("staged development authority profile fields are invalid")
151
+ if value.get("schema_version") != _STAGED_SCHEMA:
152
+ raise ValueError("staged development authority profile schema is unsupported")
153
+ profile = _profile(_mapping(value["profile"], "development authority profile"))
154
+ input_digest = _input_digest(profile)
155
+ if value.get("input_digest") != input_digest:
156
+ raise ValueError("staged development authority profile digest differs")
157
+ else:
158
+ profile = _profile(value)
159
+ input_digest = _input_digest(profile)
160
+ profile_json = json.dumps(
161
+ profile,
162
+ allow_nan=False,
163
+ ensure_ascii=True,
164
+ separators=(",", ":"),
165
+ sort_keys=True,
166
+ )
167
+ return DevelopmentAuthorityDeploymentProfile(
168
+ profile=profile,
169
+ profile_json=profile_json,
170
+ executor_principal=str(profile["executor_principal"]),
171
+ input_digest=input_digest,
172
+ )
173
+
174
+
175
+ def _profile(value: Mapping[str, Any]) -> dict[str, object]:
176
+ if set(value) != _PROFILE_FIELDS:
177
+ raise ValueError("development authority profile fields are invalid")
178
+ if value.get("schema_version") != "1.0.0":
179
+ raise ValueError("development authority profile schema is unsupported")
180
+ if value.get("classification") != "disposable_development":
181
+ raise ValueError("development authority profile classification is invalid")
182
+ profile_id = _identifier(value.get("profile_id"), "profile_id")
183
+ owner = _text(value.get("owner_principal"), "owner_principal")
184
+ executor = _text(value.get("executor_principal"), "executor_principal")
185
+ if owner.strip().casefold() == executor.strip().casefold():
186
+ raise ValueError("development Owner and executor principals MUST be distinct")
187
+ valid_from = _aware_time(value.get("valid_from"), "valid_from")
188
+ valid_until = _aware_time(value.get("valid_until"), "valid_until")
189
+ if valid_from >= valid_until:
190
+ raise ValueError("development authority profile interval is invalid")
191
+ scope = _scope(_mapping(value.get("scope"), "development authority scope"))
192
+ actions = _actions(value.get("registered_actions"))
193
+ return {
194
+ "schema_version": "1.0.0",
195
+ "profile_id": profile_id,
196
+ "scope": scope,
197
+ "classification": "disposable_development",
198
+ "owner_principal": owner,
199
+ "executor_principal": executor,
200
+ "valid_from": str(value["valid_from"]),
201
+ "valid_until": str(value["valid_until"]),
202
+ "source_revision": _revision(value.get("source_revision"), "source_revision"),
203
+ "catalog_revision": _revision(value.get("catalog_revision"), "catalog_revision"),
204
+ "policy_revision": _revision(value.get("policy_revision"), "policy_revision"),
205
+ "registered_actions": actions,
206
+ }
207
+
208
+
209
+ def _scope(value: Mapping[str, Any]) -> dict[str, object]:
210
+ if set(value) != _SCOPE_FIELDS:
211
+ raise ValueError("development authority scope fields are invalid")
212
+ groups = _digest_list(value.get("resource_group_digests"), "resource_group_digests")
213
+ if tuple(sorted(set(groups))) != groups:
214
+ raise ValueError("development authority resource groups MUST be sorted and unique")
215
+ return {
216
+ "tenant_digest": _digest(value.get("tenant_digest"), "tenant_digest"),
217
+ "subscription_digest": _digest(value.get("subscription_digest"), "subscription_digest"),
218
+ "resource_group_digests": list(groups),
219
+ }
220
+
221
+
222
+ def _actions(value: object) -> list[dict[str, str]]:
223
+ if not isinstance(value, list) or not value:
224
+ raise ValueError("development authority registered actions are required")
225
+ actions: list[dict[str, str]] = []
226
+ identities: list[tuple[str, str, str]] = []
227
+ for item in value:
228
+ action = _mapping(item, "registered development action")
229
+ if set(action) != _ACTION_FIELDS:
230
+ raise ValueError("registered development action fields are invalid")
231
+ name = _identifier(action.get("action_type"), "action_type")
232
+ version = action.get("version")
233
+ if not isinstance(version, str) or _SEMVER.fullmatch(version) is None:
234
+ raise ValueError("registered development action version is invalid")
235
+ digest = _digest(action.get("action_type_digest"), "action_type_digest")
236
+ actions.append({"action_type": name, "version": version, "action_type_digest": digest})
237
+ identities.append((name, version, digest))
238
+ if len(set(identities)) != len(identities) or tuple(sorted(identities)) != tuple(identities):
239
+ raise ValueError("registered development actions MUST be sorted and unique")
240
+ return actions
241
+
242
+
243
+ def _scope_digest(value: str) -> str:
244
+ normalized = value.strip().lower()
245
+ if not normalized:
246
+ raise ValueError("development deployment scope identifier is empty")
247
+ return "sha256:" + canonical_digest({"value": normalized})
248
+
249
+
250
+ def _input_digest(profile: Mapping[str, object]) -> str:
251
+ return canonical_digest({"profile": profile})
252
+
253
+
254
+ def _load_json_object(path: Path, description: str) -> dict[str, Any]:
255
+ raw = read_private_bytes(path, max_bytes=_MAX_INPUT_BYTES)
256
+ try:
257
+ value = json.loads(raw)
258
+ except json.JSONDecodeError as exc:
259
+ raise ValueError(f"{description} must contain valid JSON") from exc
260
+ return _mapping(value, description)
261
+
262
+
263
+ def _mapping(value: object, description: str) -> dict[str, Any]:
264
+ if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
265
+ raise ValueError(f"{description} must contain a JSON object")
266
+ return dict(value)
267
+
268
+
269
+ def _digest(value: object, name: str) -> str:
270
+ if not isinstance(value, str) or _DIGEST.fullmatch(value) is None:
271
+ raise ValueError(f"development authority {name} is invalid")
272
+ return value
273
+
274
+
275
+ def _digest_list(value: object, name: str) -> tuple[str, ...]:
276
+ if not isinstance(value, list):
277
+ raise ValueError(f"development authority {name} must be a list")
278
+ return tuple(_digest(item, name) for item in value)
279
+
280
+
281
+ def _identifier(value: object, name: str) -> str:
282
+ if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None:
283
+ raise ValueError(f"development authority {name} is invalid")
284
+ return value
285
+
286
+
287
+ def _text(value: object, name: str) -> str:
288
+ if not isinstance(value, str) or not value.strip() or len(value) > 256:
289
+ raise ValueError(f"development authority {name} is invalid")
290
+ return value
291
+
292
+
293
+ def _revision(value: object, name: str) -> str:
294
+ if not isinstance(value, str) or not 1 <= len(value) <= 256:
295
+ raise ValueError(f"development authority {name} is invalid")
296
+ return value
297
+
298
+
299
+ def _aware_time(value: object, name: str) -> datetime:
300
+ if not isinstance(value, str):
301
+ raise ValueError(f"development authority {name} is invalid")
302
+ try:
303
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
304
+ except ValueError:
305
+ raise ValueError(f"development authority {name} is invalid") from None
306
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
307
+ raise ValueError(f"development authority {name} must be timezone-aware")
308
+ return parsed
309
+
310
+
311
+ __all__ = [
312
+ "DevelopmentAuthorityDeploymentProfile",
313
+ "apply_development_authority_profile",
314
+ "clear_development_authority_profile",
315
+ "load_development_authority_profile",
316
+ "stage_development_authority_profile",
317
+ "staged_development_authority_profile_from_path",
318
+ "validate_deployment_target",
319
+ ]
@@ -0,0 +1,136 @@
1
+ """Read-only local deployment prerequisite inspection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+
10
+ from fdai_deployment_cli.target import compute_target_binding
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class ToolCheck:
15
+ """One non-secret tool availability result."""
16
+
17
+ name: str
18
+ available: bool
19
+ version: str | None
20
+
21
+
22
+ def inspect_tools(names: tuple[str, ...] = ("az",)) -> tuple[ToolCheck, ...]:
23
+ """Inspect required executables without installing or authenticating."""
24
+
25
+ results: list[ToolCheck] = []
26
+ for name in names:
27
+ executable = shutil.which(name)
28
+ if executable is None:
29
+ results.append(ToolCheck(name=name, available=False, version=None))
30
+ continue
31
+ completed = subprocess.run(
32
+ [executable, "version" if name == "terraform" else "--version"],
33
+ check=False,
34
+ capture_output=True,
35
+ text=True,
36
+ timeout=10,
37
+ )
38
+ output = (completed.stdout or completed.stderr).splitlines()
39
+ version = output[0][:256] if completed.returncode == 0 and output else None
40
+ results.append(ToolCheck(name=name, available=completed.returncode == 0, version=version))
41
+ return tuple(results)
42
+
43
+
44
+ def doctor_json(
45
+ checks: tuple[ToolCheck, ...],
46
+ *,
47
+ azure_authenticated: bool,
48
+ ) -> str:
49
+ """Return stable doctor output."""
50
+
51
+ return json.dumps(
52
+ {
53
+ "schema_version": "fdai.doctor.v1",
54
+ "ready": all(check.available for check in checks) and azure_authenticated,
55
+ "azure_authenticated": azure_authenticated,
56
+ "reason_codes": ([] if azure_authenticated else ["azure_authentication_missing"]),
57
+ "mutation_performed": False,
58
+ "tools": [
59
+ {
60
+ "name": check.name,
61
+ "available": check.available,
62
+ "version": check.version,
63
+ }
64
+ for check in checks
65
+ ],
66
+ },
67
+ sort_keys=True,
68
+ separators=(",", ":"),
69
+ )
70
+
71
+
72
+ def azure_cli_authenticated() -> bool:
73
+ """Return whether Azure CLI has one active account without exposing its identifiers."""
74
+
75
+ executable = shutil.which("az")
76
+ if executable is None:
77
+ return False
78
+ try:
79
+ completed = subprocess.run(
80
+ [
81
+ executable,
82
+ "account",
83
+ "get-access-token",
84
+ "--resource-type",
85
+ "arm",
86
+ "--output",
87
+ "none",
88
+ "--only-show-errors",
89
+ ],
90
+ check=False,
91
+ capture_output=True,
92
+ timeout=10,
93
+ )
94
+ except (OSError, subprocess.SubprocessError):
95
+ return False
96
+ return completed.returncode == 0
97
+
98
+
99
+ def azure_active_target_binding() -> str | None:
100
+ """Return a digest of the active tenant and subscription, never their raw values."""
101
+
102
+ executable = shutil.which("az")
103
+ if executable is None:
104
+ return None
105
+ try:
106
+ completed = subprocess.run(
107
+ [
108
+ executable,
109
+ "account",
110
+ "show",
111
+ "--query",
112
+ "{subscription:id,tenant:tenantId}",
113
+ "--output",
114
+ "json",
115
+ "--only-show-errors",
116
+ ],
117
+ check=False,
118
+ capture_output=True,
119
+ text=True,
120
+ timeout=10,
121
+ )
122
+ if completed.returncode != 0:
123
+ return None
124
+ value = json.loads(completed.stdout)
125
+ except (OSError, subprocess.SubprocessError, json.JSONDecodeError):
126
+ return None
127
+ if not isinstance(value, dict):
128
+ return None
129
+ subscription = value.get("subscription")
130
+ tenant = value.get("tenant")
131
+ if not isinstance(subscription, str) or not isinstance(tenant, str):
132
+ return None
133
+ try:
134
+ return compute_target_binding(tenant_id=tenant, subscription_id=subscription)
135
+ except ValueError:
136
+ return None
@@ -0,0 +1,68 @@
1
+ """Workstation checks that run before the first Azure call of a deployment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+
9
+ from fdai_deployment_cli.license_issue import discover_license_signing_key
10
+
11
+ _TRIAL = "trial"
12
+ _KEY_HOLDER = "key-holder"
13
+ _DESCRIPTIONS = {
14
+ _TRIAL: "30-day Trial (no secrets/integrity-signing-key.pem)",
15
+ _KEY_HOLDER: "key holder (secrets/integrity-signing-key.pem)",
16
+ }
17
+
18
+
19
+ def select_entitlement_mode() -> str:
20
+ """Return the entitlement mode, stopping on a present but unusable key.
21
+
22
+ The decision reads only the working checkout's fixed integrity key, so it
23
+ needs no Azure access and runs before any.
24
+ """
25
+
26
+ try:
27
+ key = discover_license_signing_key()
28
+ except (OSError, ValueError) as exc:
29
+ raise ValueError(
30
+ "secrets/integrity-signing-key.pem is present but unusable "
31
+ f"({exc}); fix its custody or remove it to deploy the 30-day Trial"
32
+ ) from None
33
+ return _TRIAL if key is None else _KEY_HOLDER
34
+
35
+
36
+ def describe_entitlement_mode(mode: str) -> str:
37
+ """Return the operator-facing description of one entitlement mode."""
38
+
39
+ return _DESCRIPTIONS[mode]
40
+
41
+
42
+ def ensure_azure_session(*, timeout_seconds: int = 900) -> None:
43
+ """Start the interactive Azure CLI login when no session exists."""
44
+
45
+ azure_cli = shutil.which("az")
46
+ if azure_cli is None:
47
+ raise ValueError("the Azure CLI (az) is required")
48
+ probe = subprocess.run( # noqa: S603 - resolved Azure CLI with fixed arguments
49
+ (azure_cli, "account", "show", "--only-show-errors", "--output", "none"),
50
+ capture_output=True,
51
+ check=False,
52
+ timeout=60,
53
+ )
54
+ if probe.returncode == 0:
55
+ return
56
+ if not sys.stdin.isatty():
57
+ raise ValueError("no Azure CLI session; run az login first")
58
+ print("fdaictl: no Azure CLI session; starting az login", file=sys.stderr)
59
+ login = subprocess.run( # noqa: S603 - resolved Azure CLI with fixed arguments
60
+ (azure_cli, "login", "--only-show-errors", "--output", "none"),
61
+ check=False,
62
+ timeout=timeout_seconds,
63
+ )
64
+ if login.returncode != 0:
65
+ raise ValueError("az login did not complete; no deployment was performed")
66
+
67
+
68
+ __all__ = ["describe_entitlement_mode", "ensure_azure_session", "select_entitlement_mode"]
@@ -0,0 +1,49 @@
1
+ """Private offline carrier output; sealing original bytes grants no entitlement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+
9
+ from cryptography.hazmat.primitives import serialization
10
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey
11
+ from fdai_service_contracts.installation_entitlement_transport import (
12
+ MAX_ENTITLEMENT_TOKEN_BYTES,
13
+ EntitlementTransportError,
14
+ entitlement_recipient,
15
+ seal_entitlement_token,
16
+ )
17
+
18
+ from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
19
+
20
+
21
+ def seal_entitlement_command(args: argparse.Namespace) -> int:
22
+ """Seal one existing private token to separately supplied recipient material."""
23
+ token = read_private_bytes(args.token, max_bytes=MAX_ENTITLEMENT_TOKEN_BYTES)
24
+ public = serialization.load_pem_public_key(
25
+ read_private_bytes(args.recipient_public_key, max_bytes=16_384)
26
+ )
27
+ if not isinstance(public, X25519PublicKey):
28
+ raise EntitlementTransportError("entitlement transport requires an X25519 recipient key")
29
+ recipient = entitlement_recipient(
30
+ public,
31
+ installation_binding=args.installation_binding,
32
+ deployment_binding=args.deployment_binding,
33
+ )
34
+ carrier = seal_entitlement_token(token, recipient=recipient, public_key=public)
35
+ write_private_bytes(args.destination, carrier)
36
+ print(
37
+ json.dumps(
38
+ {
39
+ "schema_version": "fdai.entitlement-carrier-output.v1",
40
+ "carrier_digest": hashlib.sha256(carrier).hexdigest(),
41
+ "recipient_fingerprint": recipient.fingerprint,
42
+ "execution_authority": False,
43
+ "license_verified": False,
44
+ },
45
+ sort_keys=True,
46
+ separators=(",", ":"),
47
+ )
48
+ )
49
+ return 0