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,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
|