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,450 @@
|
|
|
1
|
+
"""Offline and source Foundation planning for fdaictl."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from contextlib import nullcontext
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from tempfile import TemporaryDirectory
|
|
16
|
+
from typing import cast
|
|
17
|
+
from fdai_deployment_cli.__about__ import __version__
|
|
18
|
+
from fdai_deployment_cli.bundle import (
|
|
19
|
+
extract_bundle_archive,
|
|
20
|
+
verify_bundle,
|
|
21
|
+
)
|
|
22
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
23
|
+
from fdai_deployment_cli.doctor import (
|
|
24
|
+
azure_active_target_binding,
|
|
25
|
+
)
|
|
26
|
+
from fdai_deployment_cli.foundation_image import verify_foundation_runner_image
|
|
27
|
+
from fdai_deployment_cli.foundation_input import snapshot_foundation_input
|
|
28
|
+
from fdai_deployment_cli.foundation_plan import (
|
|
29
|
+
foundation_plan_context,
|
|
30
|
+
save_foundation_plan,
|
|
31
|
+
)
|
|
32
|
+
from fdai_deployment_cli.offline_kit import materialize_verified_artifacts, verify_offline_kit
|
|
33
|
+
from fdai_deployment_cli.plan_input import read_plan_input as read_plan_input
|
|
34
|
+
from fdai_deployment_cli.plan_input import snapshot_plan_input
|
|
35
|
+
from fdai_deployment_cli.profile import load_profile
|
|
36
|
+
from fdai_deployment_cli.source_foundation import prepare_source_foundation_plan
|
|
37
|
+
from fdai_deployment_cli.target import compute_target_binding
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _provision_plan(args: argparse.Namespace) -> int:
|
|
41
|
+
work_dir = _absolute_work_dir(args.work_dir)
|
|
42
|
+
profile = load_profile(args.profile)
|
|
43
|
+
foundation = args.stage == "foundation"
|
|
44
|
+
source_mode = args.source_snapshot is not None
|
|
45
|
+
if source_mode:
|
|
46
|
+
if (
|
|
47
|
+
not foundation
|
|
48
|
+
or not args.save_plan
|
|
49
|
+
or args.source_snapshot_digest is None
|
|
50
|
+
or args.terraform is None
|
|
51
|
+
):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
"source plans require foundation, --save-plan, snapshot digest and Terraform"
|
|
54
|
+
)
|
|
55
|
+
if args.release_root is not None or args.bundle_public_key is not None:
|
|
56
|
+
raise ValueError("source plans cannot accept kit verification keys")
|
|
57
|
+
elif args.release_root is None or args.bundle_public_key is None:
|
|
58
|
+
raise ValueError("kit plans require release and bundle verification keys")
|
|
59
|
+
elif args.source_snapshot_digest is not None or args.terraform is not None:
|
|
60
|
+
raise ValueError("kit plans cannot accept source execution inputs")
|
|
61
|
+
if args.save_plan and not foundation:
|
|
62
|
+
raise ValueError("saved local plans are supported only for the foundation stage")
|
|
63
|
+
if foundation and (profile.host != "managed-vm" or profile.monthly_cost_ceiling <= 0):
|
|
64
|
+
raise ValueError("foundation planning requires a managed-vm profile and cost ceiling")
|
|
65
|
+
active_binding = azure_active_target_binding()
|
|
66
|
+
if foundation and active_binding != profile.target_binding:
|
|
67
|
+
raise ValueError("foundation planning requires the authenticated operator target")
|
|
68
|
+
if foundation and os.environ.get("ARM_USE_MSI", ""):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"foundation planning uses the authenticated operator, not managed identity"
|
|
71
|
+
)
|
|
72
|
+
_validate_plan_target(
|
|
73
|
+
profile_binding=profile.target_binding,
|
|
74
|
+
active_binding=active_binding,
|
|
75
|
+
use_managed_identity=os.environ.get("ARM_USE_MSI", "").casefold() == "true",
|
|
76
|
+
)
|
|
77
|
+
if source_mode:
|
|
78
|
+
_create_private_work_dir(work_dir)
|
|
79
|
+
result = prepare_source_foundation_plan(
|
|
80
|
+
snapshot=_absolute_work_dir(args.source_snapshot),
|
|
81
|
+
snapshot_digest=cast(str, args.source_snapshot_digest),
|
|
82
|
+
terraform=_absolute_work_dir(cast(Path, args.terraform)),
|
|
83
|
+
profile=profile,
|
|
84
|
+
variables_file=args.variables_file,
|
|
85
|
+
work_dir=work_dir,
|
|
86
|
+
environment_builder=_terraform_environment,
|
|
87
|
+
)
|
|
88
|
+
_print_mapping(
|
|
89
|
+
result,
|
|
90
|
+
output=args.output,
|
|
91
|
+
text="source Foundation plan saved; exact human approval remains required",
|
|
92
|
+
)
|
|
93
|
+
return 0
|
|
94
|
+
verification = verify_offline_kit(
|
|
95
|
+
args.offline_kit,
|
|
96
|
+
release_root_pem=_read_public_key(cast(Path, args.release_root)),
|
|
97
|
+
cli_version=__version__,
|
|
98
|
+
platform_tag=_runtime_platform_tag(),
|
|
99
|
+
)
|
|
100
|
+
_create_private_work_dir(work_dir)
|
|
101
|
+
artifacts = materialize_verified_artifacts(
|
|
102
|
+
args.offline_kit,
|
|
103
|
+
verification,
|
|
104
|
+
work_dir / "artifacts",
|
|
105
|
+
)
|
|
106
|
+
terraform = artifacts.terraform_binary
|
|
107
|
+
mirror = artifacts.provider_mirror
|
|
108
|
+
bundle_root = extract_bundle_archive(
|
|
109
|
+
artifacts.deployment_bundle,
|
|
110
|
+
work_dir / "bundle",
|
|
111
|
+
)
|
|
112
|
+
bundle_verification = verify_bundle(
|
|
113
|
+
bundle_root,
|
|
114
|
+
public_key_pem=_read_public_key(cast(Path, args.bundle_public_key)),
|
|
115
|
+
cli_version=__version__,
|
|
116
|
+
)
|
|
117
|
+
_require_bundle_version(
|
|
118
|
+
kit_version=verification.bundle_version,
|
|
119
|
+
bundle_version=bundle_verification.bundle_version,
|
|
120
|
+
)
|
|
121
|
+
infra_dir = bundle_root / "infra"
|
|
122
|
+
if not infra_dir.is_dir():
|
|
123
|
+
raise ValueError("verified deployment bundle does not contain infra")
|
|
124
|
+
if foundation:
|
|
125
|
+
infra_dir = infra_dir / "genesis-foundation"
|
|
126
|
+
if not infra_dir.is_dir() or not (infra_dir / ".terraform.lock.hcl").is_file():
|
|
127
|
+
raise ValueError(
|
|
128
|
+
"verified deployment bundle does not contain the locked foundation root"
|
|
129
|
+
)
|
|
130
|
+
config = work_dir / "offline.tfrc"
|
|
131
|
+
_write_private_text(
|
|
132
|
+
config,
|
|
133
|
+
"provider_installation {\n"
|
|
134
|
+
" filesystem_mirror {\n"
|
|
135
|
+
f' path = "{mirror}"\n'
|
|
136
|
+
' include = ["*/*"]\n'
|
|
137
|
+
" }\n"
|
|
138
|
+
" direct {\n"
|
|
139
|
+
' exclude = ["*/*"]\n'
|
|
140
|
+
" }\n"
|
|
141
|
+
"}\n",
|
|
142
|
+
)
|
|
143
|
+
variables_file = work_dir / "plan.auto.tfvars.json"
|
|
144
|
+
snapshot_input = snapshot_foundation_input if foundation else snapshot_plan_input
|
|
145
|
+
plan_context = snapshot_input(
|
|
146
|
+
args.variables_file,
|
|
147
|
+
variables_file,
|
|
148
|
+
expected_target_binding=profile.target_binding,
|
|
149
|
+
expected_region=profile.region,
|
|
150
|
+
expected_environment=profile.environment,
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
environment = _terraform_environment(
|
|
154
|
+
work_dir=work_dir,
|
|
155
|
+
config=config,
|
|
156
|
+
source=os.environ,
|
|
157
|
+
subscription_id=plan_context.subscription_id,
|
|
158
|
+
tenant_id=plan_context.tenant_id,
|
|
159
|
+
azure_cli_path=Path(azure_cli) if (azure_cli := shutil.which("az")) else None,
|
|
160
|
+
)
|
|
161
|
+
variables = read_plan_input(variables_file) if args.save_plan else {}
|
|
162
|
+
if (
|
|
163
|
+
args.save_plan
|
|
164
|
+
and foundation
|
|
165
|
+
and profile.access_method == "bastion"
|
|
166
|
+
and variables.get("enable_bastion") is not True
|
|
167
|
+
):
|
|
168
|
+
raise ValueError("Foundation Bastion profile requires Bastion in the exact plan")
|
|
169
|
+
runner_image_observation_digest = (
|
|
170
|
+
verify_foundation_runner_image(variables, expected_region=profile.region)
|
|
171
|
+
if args.save_plan and foundation
|
|
172
|
+
else ""
|
|
173
|
+
)
|
|
174
|
+
provider_lock = (infra_dir / ".terraform.lock.hcl").read_bytes() if args.save_plan else b""
|
|
175
|
+
saved_context = (
|
|
176
|
+
foundation_plan_context(
|
|
177
|
+
profile=profile,
|
|
178
|
+
variables=variables,
|
|
179
|
+
offline_manifest_digest=verification.manifest_digest,
|
|
180
|
+
deployment_bundle_digest=bundle_verification.manifest_digest,
|
|
181
|
+
terraform_digest=dict(verification.file_digests)[verification.terraform_binary],
|
|
182
|
+
provider_lock=provider_lock,
|
|
183
|
+
runner_image_observation_digest=runner_image_observation_digest,
|
|
184
|
+
)
|
|
185
|
+
if args.save_plan
|
|
186
|
+
else {}
|
|
187
|
+
)
|
|
188
|
+
initialized = subprocess.run(
|
|
189
|
+
[str(terraform), "init", "-backend=false", "-input=false", "-lockfile=readonly"],
|
|
190
|
+
cwd=infra_dir,
|
|
191
|
+
env=environment,
|
|
192
|
+
check=False,
|
|
193
|
+
capture_output=True,
|
|
194
|
+
text=True,
|
|
195
|
+
timeout=300,
|
|
196
|
+
)
|
|
197
|
+
if initialized.returncode != 0:
|
|
198
|
+
raise ValueError("terraform initialization failed with the locked offline providers")
|
|
199
|
+
saved_review = None
|
|
200
|
+
with (
|
|
201
|
+
TemporaryDirectory(prefix="foundation-plan-", dir=work_dir)
|
|
202
|
+
if args.save_plan
|
|
203
|
+
else nullcontext(None)
|
|
204
|
+
) as temporary:
|
|
205
|
+
command = [
|
|
206
|
+
str(terraform),
|
|
207
|
+
"plan",
|
|
208
|
+
"-input=false",
|
|
209
|
+
"-no-color",
|
|
210
|
+
f"-var-file={variables_file}",
|
|
211
|
+
]
|
|
212
|
+
if temporary is not None:
|
|
213
|
+
command.append(f"-out={Path(temporary) / 'plan.tfplan'}")
|
|
214
|
+
completed = subprocess.run(
|
|
215
|
+
command,
|
|
216
|
+
cwd=infra_dir,
|
|
217
|
+
env=environment,
|
|
218
|
+
check=False,
|
|
219
|
+
capture_output=True,
|
|
220
|
+
text=True,
|
|
221
|
+
timeout=300,
|
|
222
|
+
umask=0o077,
|
|
223
|
+
)
|
|
224
|
+
if completed.returncode != 0:
|
|
225
|
+
raise ValueError(_safe_plan_error(completed.stdout + completed.stderr))
|
|
226
|
+
if temporary is not None:
|
|
227
|
+
if (infra_dir / ".terraform.lock.hcl").read_bytes() != provider_lock:
|
|
228
|
+
raise ValueError("foundation provider lock changed during planning")
|
|
229
|
+
saved_review = save_foundation_plan(
|
|
230
|
+
plan=Path(temporary) / "plan.tfplan",
|
|
231
|
+
destination=work_dir,
|
|
232
|
+
terraform=terraform,
|
|
233
|
+
root=infra_dir,
|
|
234
|
+
environment=environment,
|
|
235
|
+
context=saved_context,
|
|
236
|
+
variables=variables,
|
|
237
|
+
)
|
|
238
|
+
finally:
|
|
239
|
+
variables_file.unlink(missing_ok=True)
|
|
240
|
+
result = {
|
|
241
|
+
"schema_version": "fdai.provision-plan.v1",
|
|
242
|
+
"offline_manifest_digest": verification.manifest_digest,
|
|
243
|
+
"mutation_performed": False,
|
|
244
|
+
}
|
|
245
|
+
if foundation:
|
|
246
|
+
result.update(
|
|
247
|
+
{
|
|
248
|
+
"stage": "foundation",
|
|
249
|
+
"state": "review",
|
|
250
|
+
"subscription_ready": False,
|
|
251
|
+
"apply_authorized": False,
|
|
252
|
+
"deployment_bundle_digest": bundle_verification.manifest_digest,
|
|
253
|
+
"profile_digest": canonical_digest(profile.to_mapping()),
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
if saved_review is not None:
|
|
257
|
+
result["saved_plan"] = saved_review
|
|
258
|
+
text = (
|
|
259
|
+
f"foundation plan saved; review digest: {saved_review['review_digest']}; no apply authorized"
|
|
260
|
+
if saved_review is not None
|
|
261
|
+
else "foundation dry run completed; approval and installation remain required"
|
|
262
|
+
if foundation
|
|
263
|
+
else "plan completed"
|
|
264
|
+
)
|
|
265
|
+
print(
|
|
266
|
+
json.dumps(result, sort_keys=True, separators=(",", ":")) if args.output == "json" else text
|
|
267
|
+
)
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _safe_plan_error(output: str) -> str:
|
|
272
|
+
"""Map Terraform output to bounded stable errors without echoing provider values."""
|
|
273
|
+
|
|
274
|
+
if "No value for required variable" in output:
|
|
275
|
+
return "terraform plan requires deployment input: No value for required variable"
|
|
276
|
+
authentication_markers = (
|
|
277
|
+
"please run 'az login'",
|
|
278
|
+
"could not configure azurecli authorizer",
|
|
279
|
+
"managedidentitycredential authentication failed",
|
|
280
|
+
"error building arm config",
|
|
281
|
+
"unable to build authorizer",
|
|
282
|
+
)
|
|
283
|
+
normalized = output.casefold()
|
|
284
|
+
if any(marker in normalized for marker in authentication_markers):
|
|
285
|
+
return "terraform_provider_authentication_unavailable"
|
|
286
|
+
return "terraform plan failed after offline provider initialization"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _require_bundle_version(*, kit_version: str, bundle_version: str) -> None:
|
|
290
|
+
if kit_version != bundle_version:
|
|
291
|
+
raise ValueError("offline kit and deployment bundle versions do not match")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _validate_plan_target(
|
|
295
|
+
*,
|
|
296
|
+
profile_binding: str,
|
|
297
|
+
active_binding: str | None,
|
|
298
|
+
use_managed_identity: bool,
|
|
299
|
+
) -> None:
|
|
300
|
+
if use_managed_identity:
|
|
301
|
+
return
|
|
302
|
+
if active_binding is not None and active_binding != profile_binding:
|
|
303
|
+
raise ValueError("active Azure target does not match the provision profile")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _terraform_environment(
|
|
307
|
+
*,
|
|
308
|
+
work_dir: Path,
|
|
309
|
+
config: Path,
|
|
310
|
+
source: Mapping[str, str],
|
|
311
|
+
subscription_id: str,
|
|
312
|
+
tenant_id: str,
|
|
313
|
+
azure_cli_path: Path | None,
|
|
314
|
+
) -> dict[str, str]:
|
|
315
|
+
"""Build a minimal Terraform environment and reject ambient control injection."""
|
|
316
|
+
|
|
317
|
+
forbidden = {
|
|
318
|
+
key
|
|
319
|
+
for key in source
|
|
320
|
+
if key.startswith("TF_CLI_ARGS")
|
|
321
|
+
or key in {"TF_WORKSPACE", "TF_DATA_DIR", "TF_CLI_CONFIG_FILE"}
|
|
322
|
+
}
|
|
323
|
+
if forbidden:
|
|
324
|
+
raise ValueError("ambient Terraform control variables are not accepted")
|
|
325
|
+
allowed = ("HOME", "TMPDIR", "TEMP", "TMP", "SSL_CERT_FILE", "SSL_CERT_DIR")
|
|
326
|
+
environment = {key: source[key] for key in allowed if key in source}
|
|
327
|
+
azure_config_value = source.get("AZURE_CONFIG_DIR")
|
|
328
|
+
if azure_config_value is not None:
|
|
329
|
+
azure_config = Path(azure_config_value)
|
|
330
|
+
if not azure_config.is_absolute():
|
|
331
|
+
raise ValueError("AZURE_CONFIG_DIR MUST be absolute")
|
|
332
|
+
details = azure_config.lstat()
|
|
333
|
+
if not azure_config.is_dir() or azure_config.is_symlink() or details.st_mode & 0o077:
|
|
334
|
+
raise ValueError("AZURE_CONFIG_DIR MUST be a private regular directory")
|
|
335
|
+
environment["AZURE_CONFIG_DIR"] = str(azure_config)
|
|
336
|
+
use_msi = source.get("ARM_USE_MSI", "").casefold()
|
|
337
|
+
if use_msi not in {"", "true"}:
|
|
338
|
+
raise ValueError("ARM_USE_MSI MUST be true when supplied")
|
|
339
|
+
tenant_value = source.get("ARM_TENANT_ID")
|
|
340
|
+
if tenant_value is not None and tenant_value.casefold() != tenant_id.casefold():
|
|
341
|
+
raise ValueError("ARM_TENANT_ID does not match the verified plan target")
|
|
342
|
+
client_id = source.get("ARM_CLIENT_ID")
|
|
343
|
+
if client_id is not None:
|
|
344
|
+
compute_target_binding(tenant_id=tenant_id, subscription_id=client_id)
|
|
345
|
+
if azure_cli_path is not None:
|
|
346
|
+
resolved_cli = azure_cli_path.resolve(strict=True)
|
|
347
|
+
details = resolved_cli.stat()
|
|
348
|
+
if not resolved_cli.is_file() or details.st_mode & 0o022:
|
|
349
|
+
raise ValueError("Azure CLI executable is not trusted")
|
|
350
|
+
path_entries = tuple(dict.fromkeys((str(resolved_cli.parent), "/usr/bin", "/bin")))
|
|
351
|
+
environment["PATH"] = os.pathsep.join(path_entries)
|
|
352
|
+
elif use_msi != "true":
|
|
353
|
+
raise ValueError("Terraform plan requires Azure CLI or managed identity")
|
|
354
|
+
if use_msi == "true":
|
|
355
|
+
environment["ARM_USE_MSI"] = "true"
|
|
356
|
+
environment["ARM_TENANT_ID"] = tenant_id
|
|
357
|
+
if client_id is not None:
|
|
358
|
+
environment["ARM_CLIENT_ID"] = client_id
|
|
359
|
+
data_dir = work_dir / "terraform-data"
|
|
360
|
+
data_dir.mkdir(mode=0o700)
|
|
361
|
+
data_dir.chmod(0o700)
|
|
362
|
+
environment.update(
|
|
363
|
+
{
|
|
364
|
+
"TF_CLI_CONFIG_FILE": str(config),
|
|
365
|
+
"TF_DATA_DIR": str(data_dir),
|
|
366
|
+
"TF_IN_AUTOMATION": "1",
|
|
367
|
+
"ARM_SUBSCRIPTION_ID": subscription_id,
|
|
368
|
+
"ARM_RESOURCE_PROVIDER_REGISTRATIONS": "none",
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
return environment
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _create_private_work_dir(path: Path) -> None:
|
|
375
|
+
"""Create a new private work directory and reject every existing destination."""
|
|
376
|
+
|
|
377
|
+
_require_nonreplaceable_parent_chain(path)
|
|
378
|
+
os.mkdir(path, 0o700)
|
|
379
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
380
|
+
try:
|
|
381
|
+
details = os.fstat(descriptor)
|
|
382
|
+
if details.st_uid != os.geteuid():
|
|
383
|
+
raise PermissionError("plan work directory MUST be owned by the current UID")
|
|
384
|
+
os.fchmod(descriptor, 0o700)
|
|
385
|
+
finally:
|
|
386
|
+
os.close(descriptor)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _require_nonreplaceable_parent_chain(path: Path) -> None:
|
|
390
|
+
trusted_owners = {0, os.geteuid(), Path("/").lstat().st_uid}
|
|
391
|
+
current = path.parent
|
|
392
|
+
while True:
|
|
393
|
+
details = current.lstat()
|
|
394
|
+
if not stat.S_ISDIR(details.st_mode):
|
|
395
|
+
raise PermissionError("plan work directory parent chain MUST contain only directories")
|
|
396
|
+
if details.st_uid not in trusted_owners:
|
|
397
|
+
raise PermissionError("plan work directory parent chain has an unsafe owner")
|
|
398
|
+
mode = stat.S_IMODE(details.st_mode)
|
|
399
|
+
if mode & 0o022 and not (details.st_mode & stat.S_ISVTX):
|
|
400
|
+
raise PermissionError("plan work directory parent chain is replaceable")
|
|
401
|
+
if current == current.parent:
|
|
402
|
+
return
|
|
403
|
+
current = current.parent
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _absolute_work_dir(path: Path) -> Path:
|
|
407
|
+
"""Make a path absolute without resolving a potentially hostile symlink."""
|
|
408
|
+
|
|
409
|
+
return path if path.is_absolute() else Path.cwd() / path
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _write_private_text(path: Path, content: str) -> None:
|
|
413
|
+
descriptor = os.open(
|
|
414
|
+
path,
|
|
415
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
416
|
+
0o600,
|
|
417
|
+
)
|
|
418
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
419
|
+
stream.write(content)
|
|
420
|
+
stream.flush()
|
|
421
|
+
os.fsync(stream.fileno())
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _runtime_platform_tag() -> str:
|
|
425
|
+
"""Return the supported runtime platform identity without caller input."""
|
|
426
|
+
|
|
427
|
+
machine = platform.machine().casefold()
|
|
428
|
+
architectures = {
|
|
429
|
+
"x86_64": "x86_64",
|
|
430
|
+
"amd64": "x86_64",
|
|
431
|
+
"aarch64": "aarch64",
|
|
432
|
+
"arm64": "aarch64",
|
|
433
|
+
}
|
|
434
|
+
architecture = architectures.get(machine)
|
|
435
|
+
if sys.platform != "linux" or architecture is None:
|
|
436
|
+
raise ValueError("this deployment CLI build supports linux x86_64 or aarch64")
|
|
437
|
+
return f"linux-{architecture}"
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _read_public_key(path: Path) -> bytes:
|
|
441
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
442
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
443
|
+
details = os.fstat(stream.fileno())
|
|
444
|
+
if not stat.S_ISREG(details.st_mode) or details.st_size > 65_536:
|
|
445
|
+
raise ValueError("public key MUST be a regular file within 65536 bytes")
|
|
446
|
+
return stream.read(65_537)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _print_mapping(result: Mapping[str, object], *, output: str, text: str) -> None:
|
|
450
|
+
print(json.dumps(result, sort_keys=True, separators=(",", ":")) if output == "json" else text)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Compile the fixed subscription-genesis dependency graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fdai_deployment_cli.contracts import (
|
|
6
|
+
ApprovalClass,
|
|
7
|
+
ManifestEntry,
|
|
8
|
+
ProvisionProfile,
|
|
9
|
+
SubscriptionProvisioningManifest,
|
|
10
|
+
canonical_digest,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
_GENESIS_V1_ENTRIES: tuple[tuple[str, ApprovalClass], ...] = (
|
|
14
|
+
("inspect-context", ApprovalClass.STANDARD),
|
|
15
|
+
("reconcile-current-state", ApprovalClass.STANDARD),
|
|
16
|
+
("provider-registrations", ApprovalClass.HIGH_IMPACT),
|
|
17
|
+
("ops-resource-group", ApprovalClass.HIGH_IMPACT),
|
|
18
|
+
("application-resource-group", ApprovalClass.HIGH_IMPACT),
|
|
19
|
+
("state-storage", ApprovalClass.HIGH_IMPACT),
|
|
20
|
+
("ops-network", ApprovalClass.HIGH_IMPACT),
|
|
21
|
+
("deploy-identity", ApprovalClass.HIGH_IMPACT),
|
|
22
|
+
("foundation", ApprovalClass.HIGH_IMPACT),
|
|
23
|
+
("runner", ApprovalClass.HIGH_IMPACT),
|
|
24
|
+
("attest-runner", ApprovalClass.STANDARD),
|
|
25
|
+
("application-network", ApprovalClass.HIGH_IMPACT),
|
|
26
|
+
("key-vault", ApprovalClass.HIGH_IMPACT),
|
|
27
|
+
("postgres", ApprovalClass.HIGH_IMPACT),
|
|
28
|
+
("event-hubs-primary", ApprovalClass.HIGH_IMPACT),
|
|
29
|
+
("event-hubs-operational", ApprovalClass.HIGH_IMPACT),
|
|
30
|
+
("container-registry", ApprovalClass.HIGH_IMPACT),
|
|
31
|
+
("log-analytics", ApprovalClass.HIGH_IMPACT),
|
|
32
|
+
("application-insights", ApprovalClass.HIGH_IMPACT),
|
|
33
|
+
("container-apps-environment", ApprovalClass.HIGH_IMPACT),
|
|
34
|
+
("database", ApprovalClass.HIGH_IMPACT),
|
|
35
|
+
("semantic-defaults", ApprovalClass.STANDARD),
|
|
36
|
+
("model-account-openai", ApprovalClass.HIGH_IMPACT),
|
|
37
|
+
("model-account-foundry", ApprovalClass.HIGH_IMPACT),
|
|
38
|
+
("model-deployments", ApprovalClass.HIGH_IMPACT),
|
|
39
|
+
("runtime-identities", ApprovalClass.HIGH_IMPACT),
|
|
40
|
+
("core-control-plane", ApprovalClass.HIGH_IMPACT),
|
|
41
|
+
("operator-service", ApprovalClass.HIGH_IMPACT),
|
|
42
|
+
("document-ingestion-api", ApprovalClass.HIGH_IMPACT),
|
|
43
|
+
("document-processing-worker", ApprovalClass.HIGH_IMPACT),
|
|
44
|
+
("isolated-executor", ApprovalClass.HIGH_IMPACT),
|
|
45
|
+
("inventory-job", ApprovalClass.STANDARD),
|
|
46
|
+
("canary-job", ApprovalClass.STANDARD),
|
|
47
|
+
("initial-inventory", ApprovalClass.STANDARD),
|
|
48
|
+
("console", ApprovalClass.STANDARD),
|
|
49
|
+
("monitoring", ApprovalClass.STANDARD),
|
|
50
|
+
("system-readiness", ApprovalClass.STANDARD),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
_ENTRIES: tuple[tuple[str, ApprovalClass], ...] = (
|
|
54
|
+
*_GENESIS_V1_ENTRIES[:33],
|
|
55
|
+
("console", ApprovalClass.STANDARD),
|
|
56
|
+
("initial-inventory", ApprovalClass.STANDARD),
|
|
57
|
+
*_GENESIS_V1_ENTRIES[35:],
|
|
58
|
+
)
|
|
59
|
+
GENESIS_MANIFEST_VERSION = "genesis.v2"
|
|
60
|
+
GENESIS_ENTRY_IDS = tuple(entry_id for entry_id, _approval in _ENTRIES)
|
|
61
|
+
GENESIS_ENTRY_IDS_BY_VERSION = {
|
|
62
|
+
"genesis.v1": tuple(entry_id for entry_id, _approval in _GENESIS_V1_ENTRIES),
|
|
63
|
+
GENESIS_MANIFEST_VERSION: GENESIS_ENTRY_IDS,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def compile_manifest(
|
|
68
|
+
profile: ProvisionProfile,
|
|
69
|
+
*,
|
|
70
|
+
source_commit: str,
|
|
71
|
+
) -> SubscriptionProvisioningManifest:
|
|
72
|
+
"""Compile the finite stage chain and bind every idempotency key to the profile."""
|
|
73
|
+
|
|
74
|
+
profile_digest = canonical_digest(profile.to_mapping())
|
|
75
|
+
entries: list[ManifestEntry] = []
|
|
76
|
+
previous: str | None = None
|
|
77
|
+
for stage, approval in _ENTRIES:
|
|
78
|
+
timeout = 3_600 if approval is ApprovalClass.HIGH_IMPACT else 900
|
|
79
|
+
no_progress = 300 if approval is ApprovalClass.HIGH_IMPACT else 120
|
|
80
|
+
entries.append(
|
|
81
|
+
ManifestEntry(
|
|
82
|
+
entry_id=stage,
|
|
83
|
+
owner="deployment",
|
|
84
|
+
desired_state="ready",
|
|
85
|
+
prerequisites=() if previous is None else (previous,),
|
|
86
|
+
approval_class=approval,
|
|
87
|
+
idempotency_key=(f"genesis.{profile_digest[:16]}.{source_commit}.{stage}"),
|
|
88
|
+
timeout_seconds=timeout,
|
|
89
|
+
no_progress_seconds=no_progress,
|
|
90
|
+
rollback_ref=f"rollback.{stage}",
|
|
91
|
+
observer=f"observer.{stage}",
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
previous = stage
|
|
95
|
+
return SubscriptionProvisioningManifest(
|
|
96
|
+
source_commit=source_commit,
|
|
97
|
+
profile_digest=profile_digest,
|
|
98
|
+
entries=tuple(entries),
|
|
99
|
+
)
|