fdai-deployment-cli 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fdai_deployment_cli/__about__.py +3 -0
- fdai_deployment_cli/__init__.py +5 -0
- fdai_deployment_cli/__main__.py +5 -0
- fdai_deployment_cli/acr_publish.py +585 -0
- fdai_deployment_cli/aks_catalog_review.py +137 -0
- fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
- fdai_deployment_cli/aks_job_environment.py +59 -0
- fdai_deployment_cli/aks_job_execution.py +794 -0
- fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
- fdai_deployment_cli/aks_preflight.py +422 -0
- fdai_deployment_cli/aks_readiness.py +220 -0
- fdai_deployment_cli/aks_service_update.py +180 -0
- fdai_deployment_cli/aks_workload_jobs.py +281 -0
- fdai_deployment_cli/application_state_adoption.py +347 -0
- fdai_deployment_cli/azure_actor.py +61 -0
- fdai_deployment_cli/azure_naming.py +252 -0
- fdai_deployment_cli/azure_retail_prices.py +173 -0
- fdai_deployment_cli/bootstrap_approval.py +99 -0
- fdai_deployment_cli/bootstrap_channel.py +321 -0
- fdai_deployment_cli/bootstrap_executor.py +311 -0
- fdai_deployment_cli/bootstrap_plan.py +237 -0
- fdai_deployment_cli/bootstrap_reconcile.py +402 -0
- fdai_deployment_cli/bootstrap_support.py +138 -0
- fdai_deployment_cli/bootstrap_worker.py +448 -0
- fdai_deployment_cli/bundle.py +352 -0
- fdai_deployment_cli/capacity.py +143 -0
- fdai_deployment_cli/catalog_review_profile.py +306 -0
- fdai_deployment_cli/cli.py +798 -0
- fdai_deployment_cli/cli_help.py +214 -0
- fdai_deployment_cli/cli_parser.py +791 -0
- fdai_deployment_cli/cli_plan.py +450 -0
- fdai_deployment_cli/compiler.py +99 -0
- fdai_deployment_cli/console_artifact.py +218 -0
- fdai_deployment_cli/console_config.py +214 -0
- fdai_deployment_cli/console_update.py +832 -0
- fdai_deployment_cli/contracts.py +307 -0
- fdai_deployment_cli/control_package.py +140 -0
- fdai_deployment_cli/deadline_transport.py +70 -0
- fdai_deployment_cli/deployment_cost.py +131 -0
- fdai_deployment_cli/deployment_deadline.py +28 -0
- fdai_deployment_cli/deployment_kit.py +790 -0
- fdai_deployment_cli/deployment_kit_cache.py +191 -0
- fdai_deployment_cli/deployment_progress.py +648 -0
- fdai_deployment_cli/development_authority_cli.py +56 -0
- fdai_deployment_cli/development_authority_profile.py +319 -0
- fdai_deployment_cli/doctor.py +136 -0
- fdai_deployment_cli/entitlement_preflight.py +68 -0
- fdai_deployment_cli/entitlement_transport.py +49 -0
- fdai_deployment_cli/entra_bootstrap.py +579 -0
- fdai_deployment_cli/entra_control_profile.py +214 -0
- fdai_deployment_cli/entra_graph.py +349 -0
- fdai_deployment_cli/entra_profiles.py +215 -0
- fdai_deployment_cli/entra_source.py +244 -0
- fdai_deployment_cli/execution_bundle.py +478 -0
- fdai_deployment_cli/execution_copies.py +63 -0
- fdai_deployment_cli/foundation_adoption.py +438 -0
- fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
- fdai_deployment_cli/foundation_adoption_host.py +157 -0
- fdai_deployment_cli/foundation_adoption_transport.py +51 -0
- fdai_deployment_cli/foundation_failure.py +72 -0
- fdai_deployment_cli/foundation_image.py +168 -0
- fdai_deployment_cli/foundation_input.py +235 -0
- fdai_deployment_cli/foundation_output.py +154 -0
- fdai_deployment_cli/foundation_plan.py +381 -0
- fdai_deployment_cli/foundation_process.py +64 -0
- fdai_deployment_cli/foundation_progress.py +231 -0
- fdai_deployment_cli/github_actions.py +171 -0
- fdai_deployment_cli/github_apply_artifact.py +259 -0
- fdai_deployment_cli/github_artifact_io.py +48 -0
- fdai_deployment_cli/github_dispatch.py +248 -0
- fdai_deployment_cli/github_plan_artifact.py +181 -0
- fdai_deployment_cli/github_provider_artifact.py +170 -0
- fdai_deployment_cli/github_transport.py +46 -0
- fdai_deployment_cli/github_workflow_values.py +312 -0
- fdai_deployment_cli/hub_image_deploy.py +354 -0
- fdai_deployment_cli/hub_image_stage.py +162 -0
- fdai_deployment_cli/hub_image_trust.py +281 -0
- fdai_deployment_cli/hub_ownership.py +161 -0
- fdai_deployment_cli/hub_workload_handoff.py +264 -0
- fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
- fdai_deployment_cli/identity_profile.py +227 -0
- fdai_deployment_cli/image_targets.py +197 -0
- fdai_deployment_cli/installation_scope.py +280 -0
- fdai_deployment_cli/license.py +311 -0
- fdai_deployment_cli/license_issue.py +239 -0
- fdai_deployment_cli/lifecycle_authorization.py +252 -0
- fdai_deployment_cli/lifecycle_commands.py +173 -0
- fdai_deployment_cli/lifecycle_configuration.py +722 -0
- fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
- fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
- fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
- fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
- fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
- fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
- fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
- fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
- fdai_deployment_cli/lifecycle_enrollment.py +45 -0
- fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
- fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
- fdai_deployment_cli/lifecycle_native.py +247 -0
- fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
- fdai_deployment_cli/lifecycle_native_transition.py +339 -0
- fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
- fdai_deployment_cli/lifecycle_plan.py +800 -0
- fdai_deployment_cli/lifecycle_promotion.py +185 -0
- fdai_deployment_cli/lifecycle_release.py +308 -0
- fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
- fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
- fdai_deployment_cli/notification_recipients_input.py +222 -0
- fdai_deployment_cli/observer_artifact.py +106 -0
- fdai_deployment_cli/oci_archive.py +353 -0
- fdai_deployment_cli/offline_kit.py +797 -0
- fdai_deployment_cli/offline_prepare.py +135 -0
- fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
- fdai_deployment_cli/plan_input.py +101 -0
- fdai_deployment_cli/private_output.py +192 -0
- fdai_deployment_cli/profile.py +70 -0
- fdai_deployment_cli/progress.py +183 -0
- fdai_deployment_cli/readiness.py +286 -0
- fdai_deployment_cli/run_command_receiver.py +418 -0
- fdai_deployment_cli/runtime_build.py +386 -0
- fdai_deployment_cli/runtime_profile.py +287 -0
- fdai_deployment_cli/runtime_release.py +745 -0
- fdai_deployment_cli/runtime_stage.py +82 -0
- fdai_deployment_cli/runtime_support_installation.py +215 -0
- fdai_deployment_cli/simulation.py +80 -0
- fdai_deployment_cli/source_application_inputs.py +397 -0
- fdai_deployment_cli/source_azure.py +586 -0
- fdai_deployment_cli/source_change_cli.py +107 -0
- fdai_deployment_cli/source_change_parser.py +75 -0
- fdai_deployment_cli/source_change_plan.py +181 -0
- fdai_deployment_cli/source_changed_update.py +196 -0
- fdai_deployment_cli/source_deploy.py +170 -0
- fdai_deployment_cli/source_foundation.py +174 -0
- fdai_deployment_cli/source_host_tools.py +163 -0
- fdai_deployment_cli/source_image_stage.py +400 -0
- fdai_deployment_cli/source_input.py +173 -0
- fdai_deployment_cli/source_projection.py +164 -0
- fdai_deployment_cli/source_receiver.py +76 -0
- fdai_deployment_cli/source_recovery.py +264 -0
- fdai_deployment_cli/source_runtime.py +81 -0
- fdai_deployment_cli/source_runtime_support.py +368 -0
- fdai_deployment_cli/source_service_update.py +726 -0
- fdai_deployment_cli/source_service_update_cli.py +47 -0
- fdai_deployment_cli/source_snapshot.py +178 -0
- fdai_deployment_cli/source_teardown.py +574 -0
- fdai_deployment_cli/source_terraform.py +85 -0
- fdai_deployment_cli/source_transport.py +399 -0
- fdai_deployment_cli/standalone_aks_inventory.py +558 -0
- fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
- fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
- fdai_deployment_cli/standalone_application.py +977 -0
- fdai_deployment_cli/standalone_application_completion.py +151 -0
- fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
- fdai_deployment_cli/standalone_application_recovery.py +116 -0
- fdai_deployment_cli/standalone_approval_input.py +24 -0
- fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
- fdai_deployment_cli/standalone_catalog_review.py +251 -0
- fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
- fdai_deployment_cli/standalone_console_publish.py +174 -0
- fdai_deployment_cli/standalone_database_principals.py +41 -0
- fdai_deployment_cli/standalone_deploy.py +638 -0
- fdai_deployment_cli/standalone_development_authority.py +166 -0
- fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
- fdai_deployment_cli/standalone_foundation_transition.py +767 -0
- fdai_deployment_cli/standalone_host.py +4608 -0
- fdai_deployment_cli/standalone_host_rebind.py +226 -0
- fdai_deployment_cli/standalone_host_residual.py +104 -0
- fdai_deployment_cli/standalone_host_state.py +145 -0
- fdai_deployment_cli/standalone_host_values.py +563 -0
- fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
- fdai_deployment_cli/standalone_license_installation.py +177 -0
- fdai_deployment_cli/standalone_management_egress.py +83 -0
- fdai_deployment_cli/standalone_migration_evidence.py +38 -0
- fdai_deployment_cli/standalone_operational_evidence.py +356 -0
- fdai_deployment_cli/standalone_planned_outputs.py +147 -0
- fdai_deployment_cli/standalone_private_access_probe.py +100 -0
- fdai_deployment_cli/standalone_product_profile.py +110 -0
- fdai_deployment_cli/standalone_remote_prepare.py +442 -0
- fdai_deployment_cli/standalone_residual_apply.py +540 -0
- fdai_deployment_cli/standalone_residual_successor.py +431 -0
- fdai_deployment_cli/standalone_review.py +266 -0
- fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
- fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
- fdai_deployment_cli/standalone_stage_targets.py +325 -0
- fdai_deployment_cli/standalone_status.py +67 -0
- fdai_deployment_cli/standalone_terraform_environment.py +237 -0
- fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
- fdai_deployment_cli/standalone_trial_activation.py +193 -0
- fdai_deployment_cli/state.py +434 -0
- fdai_deployment_cli/state_handoff.py +253 -0
- fdai_deployment_cli/status_projection.py +97 -0
- fdai_deployment_cli/support_install.py +257 -0
- fdai_deployment_cli/target.py +46 -0
- fdai_deployment_cli/trust/capabilities.json +62 -0
- fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
- fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
- fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
- fdai_deployment_cli/trust_roots.py +50 -0
- fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
- fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
- fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
- fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Private target and reviewed-control profiles for bounded Entra convergence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import stat
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fdai_deployment_cli.contracts import canonical_digest, load_json_object
|
|
14
|
+
from fdai_deployment_cli.entra_control_profile import validate_control_profile
|
|
15
|
+
|
|
16
|
+
_GUID = re.compile(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")
|
|
17
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
|
18
|
+
_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._ -]{0,127}")
|
|
19
|
+
_MAX_PROFILE_BYTES = 262_144
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
23
|
+
class EntraTargetProfile:
|
|
24
|
+
"""Private exact target and approved executor identity."""
|
|
25
|
+
|
|
26
|
+
environment: str
|
|
27
|
+
target_binding: str
|
|
28
|
+
executor_object_id: str = field(repr=False)
|
|
29
|
+
executor_client_id: str = field(repr=False)
|
|
30
|
+
executor_display_name: str = field(repr=False)
|
|
31
|
+
executor_azure_config_dir: Path = field(repr=False)
|
|
32
|
+
control_profile_digest: str
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_mapping(cls, value: dict[str, Any]) -> EntraTargetProfile:
|
|
36
|
+
if (
|
|
37
|
+
set(value)
|
|
38
|
+
!= {
|
|
39
|
+
"schema_version",
|
|
40
|
+
"environment",
|
|
41
|
+
"target_binding",
|
|
42
|
+
"executor_object_id",
|
|
43
|
+
"executor_client_id",
|
|
44
|
+
"executor_display_name",
|
|
45
|
+
"executor_azure_config_dir",
|
|
46
|
+
"control_profile_digest",
|
|
47
|
+
}
|
|
48
|
+
or value.get("schema_version") != "fdai.entra-target-profile.v2"
|
|
49
|
+
):
|
|
50
|
+
raise ValueError("Entra target profile fields are invalid")
|
|
51
|
+
environment = _text(value, "environment")
|
|
52
|
+
target_binding = _text(value, "target_binding")
|
|
53
|
+
executor_object_id = _text(value, "executor_object_id")
|
|
54
|
+
executor_client_id = _text(value, "executor_client_id")
|
|
55
|
+
executor_display_name = _text(value, "executor_display_name")
|
|
56
|
+
executor_azure_config_dir = Path(_text(value, "executor_azure_config_dir"))
|
|
57
|
+
control_profile_digest = _text(value, "control_profile_digest")
|
|
58
|
+
if (
|
|
59
|
+
environment not in {"dev", "staging", "prod"}
|
|
60
|
+
or _DIGEST.fullmatch(target_binding) is None
|
|
61
|
+
or _GUID.fullmatch(executor_object_id) is None
|
|
62
|
+
or _GUID.fullmatch(executor_client_id) is None
|
|
63
|
+
or _NAME.fullmatch(executor_display_name) is None
|
|
64
|
+
or not executor_azure_config_dir.is_absolute()
|
|
65
|
+
or ".." in executor_azure_config_dir.parts
|
|
66
|
+
or _DIGEST.fullmatch(control_profile_digest) is None
|
|
67
|
+
):
|
|
68
|
+
raise ValueError("Entra target profile values are invalid")
|
|
69
|
+
return cls(
|
|
70
|
+
environment=environment,
|
|
71
|
+
target_binding=target_binding,
|
|
72
|
+
executor_object_id=executor_object_id,
|
|
73
|
+
executor_client_id=executor_client_id,
|
|
74
|
+
executor_display_name=executor_display_name,
|
|
75
|
+
executor_azure_config_dir=executor_azure_config_dir,
|
|
76
|
+
control_profile_digest=control_profile_digest,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def digest(self) -> str:
|
|
81
|
+
return canonical_digest(self.to_mapping())
|
|
82
|
+
|
|
83
|
+
def to_mapping(self) -> dict[str, object]:
|
|
84
|
+
return {
|
|
85
|
+
"schema_version": "fdai.entra-target-profile.v2",
|
|
86
|
+
"environment": self.environment,
|
|
87
|
+
"target_binding": self.target_binding,
|
|
88
|
+
"executor_object_id": self.executor_object_id,
|
|
89
|
+
"executor_client_id": self.executor_client_id,
|
|
90
|
+
"executor_display_name": self.executor_display_name,
|
|
91
|
+
"executor_azure_config_dir": str(self.executor_azure_config_dir),
|
|
92
|
+
"control_profile_digest": self.control_profile_digest,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
97
|
+
class EntraControlProfile:
|
|
98
|
+
value: dict[str, Any] = field(repr=False)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_mapping(cls, value: dict[str, Any]) -> EntraControlProfile:
|
|
102
|
+
normalized = json.loads(json.dumps(value))
|
|
103
|
+
if not isinstance(normalized, dict):
|
|
104
|
+
raise ValueError("Entra control profile is invalid")
|
|
105
|
+
validate_control_profile(normalized)
|
|
106
|
+
return cls(normalized)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def digest(self) -> str:
|
|
110
|
+
return canonical_digest(self.value)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def premium_service_plan(self) -> str:
|
|
114
|
+
return str(self.value["premium_service_plan"])
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def role_groups(self) -> dict[str, str]:
|
|
118
|
+
groups = self.value["role_groups"]
|
|
119
|
+
assert isinstance(groups, dict)
|
|
120
|
+
return {str(key): str(item) for key, item in groups.items()}
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def conditional_access_policies(self) -> list[dict[str, Any]]:
|
|
124
|
+
return _dict_list(self.value["conditional_access_policies"])
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def access_reviews(self) -> list[dict[str, Any]]:
|
|
128
|
+
return _dict_list(self.value["access_reviews"])
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def authentication_methods(self) -> list[dict[str, Any]]:
|
|
132
|
+
return _dict_list(self.value["authentication_methods"])
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def azure_policy_assignments(self) -> list[dict[str, Any]]:
|
|
136
|
+
return _dict_list(self.value["azure_policy_assignments"])
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_target_profile(path: Path) -> EntraTargetProfile:
|
|
140
|
+
descriptor = open_private_profile(path)
|
|
141
|
+
try:
|
|
142
|
+
return load_target_profile_descriptor(descriptor)
|
|
143
|
+
finally:
|
|
144
|
+
os.close(descriptor)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def load_control_profile(path: Path) -> EntraControlProfile:
|
|
148
|
+
descriptor = open_private_profile(path)
|
|
149
|
+
try:
|
|
150
|
+
return load_control_profile_descriptor(descriptor)
|
|
151
|
+
finally:
|
|
152
|
+
os.close(descriptor)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def load_target_profile_descriptor(descriptor: int) -> EntraTargetProfile:
|
|
156
|
+
return EntraTargetProfile.from_mapping(_read_private_object(descriptor, "Entra target profile"))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def load_control_profile_descriptor(descriptor: int) -> EntraControlProfile:
|
|
160
|
+
return EntraControlProfile.from_mapping(
|
|
161
|
+
_read_private_object(descriptor, "Entra control profile")
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def open_private_profile(path: Path) -> int:
|
|
166
|
+
if not path.is_absolute():
|
|
167
|
+
raise ValueError("Entra profile path MUST be absolute")
|
|
168
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
|
|
169
|
+
try:
|
|
170
|
+
_validate_private_descriptor(descriptor)
|
|
171
|
+
return descriptor
|
|
172
|
+
except BaseException:
|
|
173
|
+
os.close(descriptor)
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _read_private_object(descriptor: int, label: str) -> dict[str, Any]:
|
|
178
|
+
_validate_private_descriptor(descriptor)
|
|
179
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
180
|
+
before = os.fstat(descriptor)
|
|
181
|
+
payload = os.read(descriptor, _MAX_PROFILE_BYTES + 1)
|
|
182
|
+
after = os.fstat(descriptor)
|
|
183
|
+
if (
|
|
184
|
+
not 0 < len(payload) <= _MAX_PROFILE_BYTES
|
|
185
|
+
or len(payload) != before.st_size
|
|
186
|
+
or before.st_size != after.st_size
|
|
187
|
+
or before.st_mtime_ns != after.st_mtime_ns
|
|
188
|
+
or before.st_ctime_ns != after.st_ctime_ns
|
|
189
|
+
):
|
|
190
|
+
raise ValueError(f"{label} changed or exceeded its bound")
|
|
191
|
+
return load_json_object(payload, label=label, max_bytes=_MAX_PROFILE_BYTES)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _validate_private_descriptor(descriptor: int) -> None:
|
|
195
|
+
details = os.fstat(descriptor)
|
|
196
|
+
if (
|
|
197
|
+
not stat.S_ISREG(details.st_mode)
|
|
198
|
+
or stat.S_IMODE(details.st_mode) != 0o600
|
|
199
|
+
or details.st_uid != os.geteuid()
|
|
200
|
+
or details.st_nlink != 1
|
|
201
|
+
):
|
|
202
|
+
raise PermissionError("Entra profile MUST be a current-UID mode-0600 single-link file")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _dict_list(value: object) -> list[dict[str, Any]]:
|
|
206
|
+
if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
|
|
207
|
+
raise ValueError("Entra control profile collection is invalid")
|
|
208
|
+
return value
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _text(value: dict[str, Any], key: str) -> str:
|
|
212
|
+
item = value.get(key)
|
|
213
|
+
if not isinstance(item, str):
|
|
214
|
+
raise ValueError(f"Entra profile {key} is invalid")
|
|
215
|
+
return item
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Launch bounded Entra convergence from a private tracked-only source snapshot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from fdai_deployment_cli.contracts import load_json_object
|
|
14
|
+
from fdai_deployment_cli.doctor import azure_active_target_binding
|
|
15
|
+
from fdai_deployment_cli.entra_profiles import (
|
|
16
|
+
load_control_profile,
|
|
17
|
+
load_control_profile_descriptor,
|
|
18
|
+
load_target_profile,
|
|
19
|
+
load_target_profile_descriptor,
|
|
20
|
+
open_private_profile,
|
|
21
|
+
)
|
|
22
|
+
from fdai_deployment_cli.private_output import read_private_bytes, write_private_output
|
|
23
|
+
from fdai_deployment_cli.source_input import SourceDeploymentInput, inspect_source
|
|
24
|
+
from fdai_deployment_cli.source_snapshot import materialize_source, verify_source_snapshot
|
|
25
|
+
|
|
26
|
+
_SAFE_ENVIRONMENT_KEYS = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"AZURE_CONFIG_DIR",
|
|
29
|
+
"CURL_CA_BUNDLE",
|
|
30
|
+
"HOME",
|
|
31
|
+
"LANG",
|
|
32
|
+
"LC_ALL",
|
|
33
|
+
"LC_CTYPE",
|
|
34
|
+
"LOGNAME",
|
|
35
|
+
"PATH",
|
|
36
|
+
"REQUESTS_CA_BUNDLE",
|
|
37
|
+
"SSL_CERT_FILE",
|
|
38
|
+
"TERM",
|
|
39
|
+
"TZ",
|
|
40
|
+
"USER",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
_SNAPSHOT_CONTEXT = "entra-source-context.json"
|
|
44
|
+
_BOOTSTRAP = """
|
|
45
|
+
import os, runpy, sys
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from fdai_deployment_cli.source_snapshot import verify_source_snapshot
|
|
48
|
+
root = os.environ["FDAI_ENTRA_SNAPSHOT_ROOT"]
|
|
49
|
+
verify_source_snapshot(
|
|
50
|
+
Path(os.environ["FDAI_ENTRA_SNAPSHOT_DIRECTORY"]),
|
|
51
|
+
expected_digest=os.environ["FDAI_ENTRA_SNAPSHOT_DIGEST"],
|
|
52
|
+
)
|
|
53
|
+
sys.path[:0] = [
|
|
54
|
+
root + "/scripts/deployment/azure",
|
|
55
|
+
root + "/packages/deployment-cli/src",
|
|
56
|
+
]
|
|
57
|
+
runpy.run_path(
|
|
58
|
+
root + "/scripts/deployment/azure/genesis_entra_operation.py",
|
|
59
|
+
run_name="__main__",
|
|
60
|
+
)
|
|
61
|
+
""".strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_source_entra_operation(
|
|
65
|
+
*,
|
|
66
|
+
source: Path,
|
|
67
|
+
work_dir: Path,
|
|
68
|
+
target_profile_path: Path,
|
|
69
|
+
control_profile_path: Path,
|
|
70
|
+
apply: bool,
|
|
71
|
+
output: str,
|
|
72
|
+
timeout_seconds: int,
|
|
73
|
+
) -> int:
|
|
74
|
+
"""Verify the target before creating state, then execute only snapshotted bytes."""
|
|
75
|
+
|
|
76
|
+
target_profile = load_target_profile(target_profile_path)
|
|
77
|
+
control_profile = load_control_profile(control_profile_path)
|
|
78
|
+
if target_profile.control_profile_digest != control_profile.digest:
|
|
79
|
+
raise ValueError("Entra target profile does not bind the reviewed control profile")
|
|
80
|
+
if apply and target_profile.environment != "dev":
|
|
81
|
+
raise ValueError("Entra apply is supported only for a profile-bound dev target")
|
|
82
|
+
active_binding = azure_active_target_binding()
|
|
83
|
+
if active_binding is None or active_binding != target_profile.target_binding:
|
|
84
|
+
raise ValueError("active Azure target does not match the private Entra target profile")
|
|
85
|
+
if not work_dir.is_absolute():
|
|
86
|
+
raise ValueError("Entra work directory MUST be absolute")
|
|
87
|
+
if output not in {"text", "json"}:
|
|
88
|
+
raise ValueError("Entra output format is invalid")
|
|
89
|
+
if not 30 <= timeout_seconds <= 1800:
|
|
90
|
+
raise ValueError("Entra operation timeout MUST be between 30 and 1800 seconds")
|
|
91
|
+
source_input = inspect_source(source)
|
|
92
|
+
_ensure_private_directory(work_dir)
|
|
93
|
+
snapshot_directory, snapshot_digest = _prepare_snapshot(
|
|
94
|
+
work_dir=work_dir,
|
|
95
|
+
source=source_input,
|
|
96
|
+
target_profile_digest=target_profile.digest,
|
|
97
|
+
control_profile_digest=control_profile.digest,
|
|
98
|
+
)
|
|
99
|
+
snapshot_tree = snapshot_directory / "tree"
|
|
100
|
+
entrypoint = snapshot_tree / "scripts/deployment/azure/genesis_entra_operation.py"
|
|
101
|
+
if not entrypoint.is_file() or entrypoint.is_symlink():
|
|
102
|
+
raise ValueError("verified Entra source snapshot is missing its identity entrypoint")
|
|
103
|
+
|
|
104
|
+
target_descriptor = open_private_profile(target_profile_path)
|
|
105
|
+
control_descriptor = open_private_profile(control_profile_path)
|
|
106
|
+
try:
|
|
107
|
+
if load_target_profile_descriptor(target_descriptor).digest != target_profile.digest:
|
|
108
|
+
raise ValueError("Entra target profile changed after target validation")
|
|
109
|
+
if load_control_profile_descriptor(control_descriptor).digest != control_profile.digest:
|
|
110
|
+
raise ValueError("Entra control profile changed after target validation")
|
|
111
|
+
command = [
|
|
112
|
+
sys.executable,
|
|
113
|
+
"-I",
|
|
114
|
+
"-c",
|
|
115
|
+
_BOOTSTRAP,
|
|
116
|
+
"--work-dir",
|
|
117
|
+
str(work_dir),
|
|
118
|
+
"--output",
|
|
119
|
+
output,
|
|
120
|
+
]
|
|
121
|
+
if apply:
|
|
122
|
+
command.append("--apply")
|
|
123
|
+
process_environment = {
|
|
124
|
+
key: value for key, value in os.environ.items() if key in _SAFE_ENVIRONMENT_KEYS
|
|
125
|
+
}
|
|
126
|
+
process_environment.update(
|
|
127
|
+
{
|
|
128
|
+
"PYTHONNOUSERSITE": "1",
|
|
129
|
+
"FDAI_ENTRA_TARGET_PROFILE_FD": str(target_descriptor),
|
|
130
|
+
"FDAI_ENTRA_CONTROL_PROFILE_FD": str(control_descriptor),
|
|
131
|
+
"FDAI_ENTRA_SNAPSHOT_DIRECTORY": str(snapshot_directory),
|
|
132
|
+
"FDAI_ENTRA_SNAPSHOT_ROOT": str(snapshot_tree),
|
|
133
|
+
"FDAI_ENTRA_SNAPSHOT_DIGEST": snapshot_digest,
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
verify_source_snapshot(snapshot_directory, expected_digest=snapshot_digest)
|
|
137
|
+
return _run_identity_process(
|
|
138
|
+
command,
|
|
139
|
+
cwd=snapshot_tree,
|
|
140
|
+
environment=process_environment,
|
|
141
|
+
timeout_seconds=timeout_seconds,
|
|
142
|
+
pass_fds=(target_descriptor, control_descriptor),
|
|
143
|
+
)
|
|
144
|
+
finally:
|
|
145
|
+
os.close(target_descriptor)
|
|
146
|
+
os.close(control_descriptor)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _prepare_snapshot(
|
|
150
|
+
*,
|
|
151
|
+
work_dir: Path,
|
|
152
|
+
source: SourceDeploymentInput,
|
|
153
|
+
target_profile_digest: str,
|
|
154
|
+
control_profile_digest: str,
|
|
155
|
+
) -> tuple[Path, str]:
|
|
156
|
+
snapshot_directory = work_dir / "source-snapshot"
|
|
157
|
+
context_path = work_dir / _SNAPSHOT_CONTEXT
|
|
158
|
+
if context_path.exists():
|
|
159
|
+
context = load_json_object(
|
|
160
|
+
read_private_bytes(context_path, max_bytes=65_536),
|
|
161
|
+
label="Entra source context",
|
|
162
|
+
max_bytes=65_536,
|
|
163
|
+
)
|
|
164
|
+
if (
|
|
165
|
+
set(context)
|
|
166
|
+
!= {
|
|
167
|
+
"schema_version",
|
|
168
|
+
"source_digest",
|
|
169
|
+
"snapshot_digest",
|
|
170
|
+
"target_profile_digest",
|
|
171
|
+
"control_profile_digest",
|
|
172
|
+
}
|
|
173
|
+
or context.get("schema_version") != "fdai.entra-source-context.v1"
|
|
174
|
+
or context.get("source_digest") != source.digest
|
|
175
|
+
or context.get("target_profile_digest") != target_profile_digest
|
|
176
|
+
or context.get("control_profile_digest") != control_profile_digest
|
|
177
|
+
or not isinstance(context.get("snapshot_digest"), str)
|
|
178
|
+
):
|
|
179
|
+
raise ValueError("retained Entra source context differs from current inputs")
|
|
180
|
+
snapshot_digest = str(context["snapshot_digest"])
|
|
181
|
+
verify_source_snapshot(snapshot_directory, expected_digest=snapshot_digest)
|
|
182
|
+
return snapshot_directory, snapshot_digest
|
|
183
|
+
if snapshot_directory.exists() or snapshot_directory.is_symlink():
|
|
184
|
+
raise ValueError("unbound Entra source snapshot already exists")
|
|
185
|
+
snapshot_digest = materialize_source(source, snapshot_directory)
|
|
186
|
+
context = {
|
|
187
|
+
"schema_version": "fdai.entra-source-context.v1",
|
|
188
|
+
"source_digest": source.digest,
|
|
189
|
+
"snapshot_digest": snapshot_digest,
|
|
190
|
+
"target_profile_digest": target_profile_digest,
|
|
191
|
+
"control_profile_digest": control_profile_digest,
|
|
192
|
+
}
|
|
193
|
+
write_private_output(
|
|
194
|
+
context_path,
|
|
195
|
+
json.dumps(context, sort_keys=True, separators=(",", ":")) + "\n",
|
|
196
|
+
)
|
|
197
|
+
return snapshot_directory, snapshot_digest
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _ensure_private_directory(path: Path) -> None:
|
|
201
|
+
if path.is_symlink():
|
|
202
|
+
raise ValueError("Entra work directory MUST NOT be a symbolic link")
|
|
203
|
+
path.mkdir(parents=True, mode=0o700, exist_ok=True)
|
|
204
|
+
details = path.stat()
|
|
205
|
+
if (
|
|
206
|
+
not stat.S_ISDIR(details.st_mode)
|
|
207
|
+
or details.st_uid != os.geteuid()
|
|
208
|
+
or stat.S_IMODE(details.st_mode) != 0o700
|
|
209
|
+
):
|
|
210
|
+
raise PermissionError("Entra work directory MUST be current-UID mode 0700")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _run_identity_process(
|
|
214
|
+
command: list[str],
|
|
215
|
+
*,
|
|
216
|
+
cwd: Path,
|
|
217
|
+
environment: dict[str, str],
|
|
218
|
+
timeout_seconds: int,
|
|
219
|
+
pass_fds: tuple[int, ...],
|
|
220
|
+
) -> int:
|
|
221
|
+
"""Bound the identity coordinator and terminate its complete process group."""
|
|
222
|
+
|
|
223
|
+
process = subprocess.Popen( # noqa: S603 - fixed verified-snapshot bootstrap.
|
|
224
|
+
command,
|
|
225
|
+
cwd=cwd,
|
|
226
|
+
env=environment,
|
|
227
|
+
pass_fds=pass_fds,
|
|
228
|
+
start_new_session=True,
|
|
229
|
+
)
|
|
230
|
+
try:
|
|
231
|
+
return process.wait(timeout=timeout_seconds)
|
|
232
|
+
except BaseException:
|
|
233
|
+
for selected_signal, grace in ((signal.SIGTERM, 5), (signal.SIGKILL, 1)):
|
|
234
|
+
try:
|
|
235
|
+
os.killpg(process.pid, selected_signal)
|
|
236
|
+
except ProcessLookupError:
|
|
237
|
+
break
|
|
238
|
+
try:
|
|
239
|
+
process.wait(timeout=grace)
|
|
240
|
+
if selected_signal == signal.SIGKILL:
|
|
241
|
+
break
|
|
242
|
+
except subprocess.TimeoutExpired:
|
|
243
|
+
continue
|
|
244
|
+
raise
|