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,280 @@
|
|
|
1
|
+
"""Collect immutable initial installation preferences without granting apply authority."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import select
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from fdai_service_contracts.product_profile import ProductAddOn
|
|
15
|
+
|
|
16
|
+
from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest, load_json_object
|
|
17
|
+
from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
|
|
18
|
+
from fdai_deployment_cli.runtime_profile import RuntimeDeploymentProfile
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class InstallationOptions:
|
|
23
|
+
"""Requested cost, exposure and retention limits; no execution authorization."""
|
|
24
|
+
|
|
25
|
+
setup_cost_ceiling: int | None = None
|
|
26
|
+
console_access: str = "none"
|
|
27
|
+
allow_dedicated_identities: bool = False
|
|
28
|
+
cleanup_temporary_resources: bool = False
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
if self.setup_cost_ceiling is not None:
|
|
32
|
+
_budget(self.setup_cost_ceiling)
|
|
33
|
+
if self.console_access not in {"none", "public-https-entra", "private-https-entra"}:
|
|
34
|
+
raise ValueError("installation Console access is invalid")
|
|
35
|
+
if any(
|
|
36
|
+
type(value) is not bool
|
|
37
|
+
for value in (self.allow_dedicated_identities, self.cleanup_temporary_resources)
|
|
38
|
+
):
|
|
39
|
+
raise ValueError("installation scope flags must be booleans")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def confirm_installation_scope(
|
|
43
|
+
*,
|
|
44
|
+
work_dir: Path,
|
|
45
|
+
binding: dict[str, object],
|
|
46
|
+
runtime_profile: RuntimeDeploymentProfile,
|
|
47
|
+
options: InstallationOptions,
|
|
48
|
+
interactive: bool,
|
|
49
|
+
timeout_seconds: int,
|
|
50
|
+
now: datetime | None = None,
|
|
51
|
+
) -> dict[str, object]:
|
|
52
|
+
"""Confirm once before execution, or verify an unchanged unexpired retained scope.
|
|
53
|
+
|
|
54
|
+
Noninteractive, denied, legacy-started, changed and expired runs never prompt
|
|
55
|
+
later or renew consent. The record is not accepted as a Genesis approval.
|
|
56
|
+
"""
|
|
57
|
+
_validate_binding(binding)
|
|
58
|
+
_validate_product_options(runtime_profile, options)
|
|
59
|
+
if runtime_profile.digest != binding["runtime_profile_digest"]:
|
|
60
|
+
raise ValueError("installation scope changed: runtime profile digest differs")
|
|
61
|
+
if type(timeout_seconds) is not int or not 1 <= timeout_seconds <= 86400:
|
|
62
|
+
raise ValueError("installation scope lifetime must be between 1 and 86400 seconds")
|
|
63
|
+
moment = now or datetime.now(UTC)
|
|
64
|
+
_utc(moment)
|
|
65
|
+
path = work_dir / "installation-scope.json"
|
|
66
|
+
try:
|
|
67
|
+
retained = load_json_object(
|
|
68
|
+
read_private_bytes(path, max_bytes=16384), label="installation scope"
|
|
69
|
+
)
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
retained = None
|
|
72
|
+
if retained is not None:
|
|
73
|
+
expected = {
|
|
74
|
+
"schema_version",
|
|
75
|
+
"scope",
|
|
76
|
+
"confirmed_at",
|
|
77
|
+
"expires_at",
|
|
78
|
+
"apply_authorized",
|
|
79
|
+
"digest",
|
|
80
|
+
}
|
|
81
|
+
unsigned = {key: value for key, value in retained.items() if key != "digest"}
|
|
82
|
+
if (
|
|
83
|
+
set(retained) != expected
|
|
84
|
+
or retained["schema_version"] != "fdai.installation-scope.v1"
|
|
85
|
+
or retained["apply_authorized"] is not False
|
|
86
|
+
or retained["digest"] != canonical_digest(unsigned)
|
|
87
|
+
):
|
|
88
|
+
raise ValueError("retained installation scope is invalid")
|
|
89
|
+
scope = retained["scope"]
|
|
90
|
+
if not isinstance(scope, dict):
|
|
91
|
+
raise ValueError("retained installation scope is invalid")
|
|
92
|
+
setup_cost = options.setup_cost_ceiling
|
|
93
|
+
if setup_cost is None:
|
|
94
|
+
setup_cost = _budget(scope.get("setup_cost_ceiling"))
|
|
95
|
+
if canonical_bytes(scope) != canonical_bytes(
|
|
96
|
+
_scope(binding, runtime_profile, options, setup_cost)
|
|
97
|
+
):
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"installation scope changed; preserve the run and review before restarting"
|
|
100
|
+
)
|
|
101
|
+
confirmed = _timestamp(retained["confirmed_at"])
|
|
102
|
+
expires = _timestamp(retained["expires_at"])
|
|
103
|
+
valid_window = confirmed <= moment < expires and timedelta(
|
|
104
|
+
0
|
|
105
|
+
) < expires - confirmed <= timedelta(days=1)
|
|
106
|
+
if not valid_window and not started_installation_matches(work_dir, binding):
|
|
107
|
+
return _result("installation_scope_expired")
|
|
108
|
+
return _result("installation_scope_confirmed", str(retained["digest"]))
|
|
109
|
+
if (work_dir / "foundation/status.json").exists():
|
|
110
|
+
return _result("initial_confirmation_missing_for_started_run")
|
|
111
|
+
if not interactive:
|
|
112
|
+
return _result("initial_confirmation_required")
|
|
113
|
+
deadline = time.monotonic() + min(600, timeout_seconds)
|
|
114
|
+
setup_cost = options.setup_cost_ceiling
|
|
115
|
+
if setup_cost is None:
|
|
116
|
+
print(
|
|
117
|
+
"Initial setup cost estimate ceiling (USD, whole number): ",
|
|
118
|
+
end="",
|
|
119
|
+
file=sys.stderr,
|
|
120
|
+
flush=True,
|
|
121
|
+
)
|
|
122
|
+
answer = _read_initial_answer(deadline)
|
|
123
|
+
if re.fullmatch(r"[1-9][0-9]{0,6}", answer) is None:
|
|
124
|
+
return _result("initial_confirmation_denied")
|
|
125
|
+
setup_cost = _budget(int(answer))
|
|
126
|
+
scope = _scope(binding, runtime_profile, options, setup_cost)
|
|
127
|
+
print("Initial installation scope (not an exact-plan approval):", file=sys.stderr)
|
|
128
|
+
for key, value in scope.items():
|
|
129
|
+
print(f" {key}: {value}", file=sys.stderr)
|
|
130
|
+
print(
|
|
131
|
+
"Estimates are not billing caps. Existing resources and runtime authority are excluded.\n"
|
|
132
|
+
"Later missing authority or evidence stops the run without another prompt.\n"
|
|
133
|
+
"Type install to confirm these settings, or anything else to stop: ",
|
|
134
|
+
end="",
|
|
135
|
+
file=sys.stderr,
|
|
136
|
+
flush=True,
|
|
137
|
+
)
|
|
138
|
+
if _read_initial_answer(deadline) != "install":
|
|
139
|
+
return _result("initial_confirmation_denied")
|
|
140
|
+
elapsed = min(600, timeout_seconds) - max(0.0, deadline - time.monotonic())
|
|
141
|
+
confirmed_at = moment + timedelta(seconds=elapsed)
|
|
142
|
+
if confirmed_at >= moment + timedelta(seconds=timeout_seconds):
|
|
143
|
+
return _result("installation_scope_expired")
|
|
144
|
+
record: dict[str, object] = {
|
|
145
|
+
"schema_version": "fdai.installation-scope.v1",
|
|
146
|
+
"scope": scope,
|
|
147
|
+
"confirmed_at": confirmed_at.isoformat(),
|
|
148
|
+
"expires_at": (moment + timedelta(seconds=timeout_seconds)).isoformat(),
|
|
149
|
+
"apply_authorized": False,
|
|
150
|
+
}
|
|
151
|
+
record["digest"] = canonical_digest(record)
|
|
152
|
+
write_private_bytes(path, canonical_bytes(record))
|
|
153
|
+
return _result("installation_scope_confirmed", str(record["digest"]))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _scope(
|
|
157
|
+
binding: dict[str, object],
|
|
158
|
+
runtime_profile: RuntimeDeploymentProfile,
|
|
159
|
+
options: InstallationOptions,
|
|
160
|
+
setup_cost: int,
|
|
161
|
+
) -> dict[str, object]:
|
|
162
|
+
return {
|
|
163
|
+
**binding,
|
|
164
|
+
"runtime_profile": runtime_profile.to_mapping(),
|
|
165
|
+
**asdict(options),
|
|
166
|
+
"setup_cost_ceiling": setup_cost,
|
|
167
|
+
"resource_scope": "new-installation-only",
|
|
168
|
+
"retain_services": True,
|
|
169
|
+
"runtime_authority_change": False,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _result(reason: str, digest: str | None = None) -> dict[str, object]:
|
|
174
|
+
return {
|
|
175
|
+
"schema_version": "fdai.installation-scope-result.v1",
|
|
176
|
+
"state": "confirmed" if digest else "review",
|
|
177
|
+
"stage": "initial-confirmation",
|
|
178
|
+
"reason_code": reason,
|
|
179
|
+
"scope_digest": digest,
|
|
180
|
+
"apply_authorized": False,
|
|
181
|
+
"mutation_performed": False,
|
|
182
|
+
"deployment_ready": False,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def started_installation_matches(
|
|
187
|
+
work_dir: Path,
|
|
188
|
+
binding: dict[str, object],
|
|
189
|
+
) -> bool:
|
|
190
|
+
"""Let an effected installation reuse its immutable preferences after their initial window."""
|
|
191
|
+
try:
|
|
192
|
+
status = load_json_object(
|
|
193
|
+
read_private_bytes(work_dir / "foundation/status.json", max_bytes=1024 * 1024),
|
|
194
|
+
label="Foundation status",
|
|
195
|
+
)
|
|
196
|
+
profile = load_json_object(
|
|
197
|
+
read_private_bytes(work_dir / "foundation/profile.json", max_bytes=16384),
|
|
198
|
+
label="Foundation profile",
|
|
199
|
+
)
|
|
200
|
+
except (FileNotFoundError, OSError, ValueError):
|
|
201
|
+
return False
|
|
202
|
+
return (
|
|
203
|
+
status.get("schema_version") == "fdai.genesis-orchestration-status.v2"
|
|
204
|
+
and status.get("mutation_performed") is True
|
|
205
|
+
and status.get("source_commit") == binding["source_commit"]
|
|
206
|
+
and profile.get("schema_version") == "fdai.provision-profile.v1"
|
|
207
|
+
and profile.get("target_binding") == binding["target_binding"]
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _validate_product_options(
|
|
212
|
+
runtime_profile: RuntimeDeploymentProfile,
|
|
213
|
+
options: InstallationOptions,
|
|
214
|
+
) -> None:
|
|
215
|
+
console_selected = runtime_profile.product_profile.selects(ProductAddOn.READ_ONLY_CONSOLE)
|
|
216
|
+
if console_selected and options.console_access == "none":
|
|
217
|
+
raise ValueError("selected read-only Console requires an explicit access profile")
|
|
218
|
+
if not console_selected and options.console_access != "none":
|
|
219
|
+
raise ValueError("Console access requires the read-only-console product add-on")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _validate_binding(binding: dict[str, object]) -> None:
|
|
223
|
+
if set(binding) != {
|
|
224
|
+
"source_commit",
|
|
225
|
+
"target_binding",
|
|
226
|
+
"preparation_digest",
|
|
227
|
+
"runtime_profile_digest",
|
|
228
|
+
"region",
|
|
229
|
+
"monthly_cost_ceiling",
|
|
230
|
+
}:
|
|
231
|
+
raise ValueError("installation scope binding fields are invalid")
|
|
232
|
+
for key in ("source_commit", "target_binding", "preparation_digest", "runtime_profile_digest"):
|
|
233
|
+
value = binding[key]
|
|
234
|
+
length = 40 if key == "source_commit" else 64
|
|
235
|
+
if not isinstance(value, str) or re.fullmatch(rf"[0-9a-f]{{{length}}}", value) is None:
|
|
236
|
+
raise ValueError("installation scope binding digest is invalid")
|
|
237
|
+
region = binding["region"]
|
|
238
|
+
if not isinstance(region, str) or re.fullmatch(r"[a-z][a-z0-9]{1,40}", region) is None:
|
|
239
|
+
raise ValueError("installation scope region is invalid")
|
|
240
|
+
_budget(binding["monthly_cost_ceiling"])
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _budget(value: object) -> int:
|
|
244
|
+
if type(value) is not int or not 1 <= value <= 1000000:
|
|
245
|
+
raise ValueError(
|
|
246
|
+
"installation cost ceiling must be a whole USD amount between 1 and 1000000"
|
|
247
|
+
)
|
|
248
|
+
return value
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _utc(value: datetime) -> None:
|
|
252
|
+
if value.tzinfo is None or value.utcoffset() != timedelta(0):
|
|
253
|
+
raise ValueError("installation scope timestamp must be UTC")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _timestamp(value: object) -> datetime:
|
|
257
|
+
if not isinstance(value, str):
|
|
258
|
+
raise TypeError("installation scope timestamp is invalid")
|
|
259
|
+
result = datetime.fromisoformat(value)
|
|
260
|
+
_utc(result)
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _read_initial_answer(deadline: float) -> str:
|
|
265
|
+
"""Read one bounded TTY line without an unbounded readline after partial input."""
|
|
266
|
+
if not sys.stdin.isatty():
|
|
267
|
+
raise ValueError("initial confirmation requires an interactive terminal")
|
|
268
|
+
descriptor = sys.stdin.fileno()
|
|
269
|
+
answer = bytearray()
|
|
270
|
+
while len(answer) <= 64:
|
|
271
|
+
remaining = deadline - time.monotonic()
|
|
272
|
+
if remaining <= 0 or not select.select([descriptor], [], [], remaining)[0]:
|
|
273
|
+
raise TimeoutError("initial confirmation timed out; no consent was recorded")
|
|
274
|
+
chunk = os.read(descriptor, 1)
|
|
275
|
+
if not chunk:
|
|
276
|
+
raise ValueError("initial confirmation input closed; no consent was recorded")
|
|
277
|
+
if chunk == b"\n":
|
|
278
|
+
return answer.decode("ascii").strip()
|
|
279
|
+
answer.extend(chunk)
|
|
280
|
+
raise ValueError("initial confirmation input exceeds its limit")
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Offline capability-license inspection without runtime dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime, timedelta
|
|
11
|
+
|
|
12
|
+
from cryptography.exceptions import InvalidSignature
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
14
|
+
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
|
15
|
+
|
|
16
|
+
from fdai_deployment_cli.contracts import canonical_bytes, load_json_object
|
|
17
|
+
|
|
18
|
+
_B64URL = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
19
|
+
_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{2,127}$")
|
|
20
|
+
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
|
21
|
+
_MAX_CAPABILITIES = 512
|
|
22
|
+
_MAX_VALIDITY = timedelta(days=30)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LicenseInspectionError(ValueError):
|
|
26
|
+
"""A license token is malformed, untrusted, or inactive."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class LicenseInspection:
|
|
31
|
+
"""Sanitized active-license result."""
|
|
32
|
+
|
|
33
|
+
license_id: str
|
|
34
|
+
distribution_id: str
|
|
35
|
+
capability_ids: tuple[str, ...]
|
|
36
|
+
not_before: str
|
|
37
|
+
not_after: str
|
|
38
|
+
active: bool
|
|
39
|
+
|
|
40
|
+
def to_json(self) -> str:
|
|
41
|
+
"""Return stable JSON without the bearer token."""
|
|
42
|
+
|
|
43
|
+
return json.dumps(
|
|
44
|
+
{
|
|
45
|
+
"schema_version": "fdai.license-inspection.v1",
|
|
46
|
+
"license_id": self.license_id,
|
|
47
|
+
"distribution_id": self.distribution_id,
|
|
48
|
+
"capability_ids": list(self.capability_ids),
|
|
49
|
+
"not_before": self.not_before,
|
|
50
|
+
"not_after": self.not_after,
|
|
51
|
+
"active": self.active,
|
|
52
|
+
},
|
|
53
|
+
sort_keys=True,
|
|
54
|
+
separators=(",", ":"),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def inspect_license(
|
|
59
|
+
token: str,
|
|
60
|
+
*,
|
|
61
|
+
public_key_pem: bytes,
|
|
62
|
+
now: datetime | None = None,
|
|
63
|
+
expected_image_digest: str | None = None,
|
|
64
|
+
expected_tenant_binding: str | None = None,
|
|
65
|
+
) -> LicenseInspection:
|
|
66
|
+
"""Verify one signed canonical token and report active entitlement."""
|
|
67
|
+
|
|
68
|
+
if not token or len(token) > 8192:
|
|
69
|
+
raise LicenseInspectionError("license token is empty or exceeds its size limit")
|
|
70
|
+
parts = token.split(".")
|
|
71
|
+
if len(parts) != 2:
|
|
72
|
+
raise LicenseInspectionError("license token MUST contain document and signature")
|
|
73
|
+
document = _decode(parts[0], "document")
|
|
74
|
+
signature = _decode(parts[1], "signature")
|
|
75
|
+
if len(signature) != 64:
|
|
76
|
+
raise LicenseInspectionError("license signature MUST be 64 bytes")
|
|
77
|
+
_verify(public_key_pem, document, signature)
|
|
78
|
+
payload = load_json_object(document, label="license document", max_bytes=6144)
|
|
79
|
+
expected = {
|
|
80
|
+
"schema_version",
|
|
81
|
+
"license_id",
|
|
82
|
+
"distribution_id",
|
|
83
|
+
"capability_ids",
|
|
84
|
+
"not_before",
|
|
85
|
+
"not_after",
|
|
86
|
+
"image_digest",
|
|
87
|
+
"tenant_binding",
|
|
88
|
+
}
|
|
89
|
+
if set(payload) != expected or payload["schema_version"] != "fdai.license.v1":
|
|
90
|
+
raise LicenseInspectionError("license document schema does not match")
|
|
91
|
+
if canonical_bytes(payload) != document:
|
|
92
|
+
raise LicenseInspectionError("license document is not canonical")
|
|
93
|
+
not_before = _moment(payload, "not_before")
|
|
94
|
+
not_after = _moment(payload, "not_after")
|
|
95
|
+
if _text(payload, "not_before") != _canonical_moment(not_before) or _text(
|
|
96
|
+
payload, "not_after"
|
|
97
|
+
) != _canonical_moment(not_after):
|
|
98
|
+
raise LicenseInspectionError("license timestamps are not canonically encoded")
|
|
99
|
+
if not_after <= not_before:
|
|
100
|
+
raise LicenseInspectionError("license validity window is invalid")
|
|
101
|
+
if not_after.astimezone(UTC) - not_before.astimezone(UTC) > _MAX_VALIDITY:
|
|
102
|
+
raise LicenseInspectionError("license validity MUST NOT exceed 30 elapsed UTC days")
|
|
103
|
+
evaluated = now or datetime.now(UTC)
|
|
104
|
+
if evaluated.tzinfo is None:
|
|
105
|
+
raise LicenseInspectionError("license evaluation time MUST be timezone-aware")
|
|
106
|
+
capabilities = payload["capability_ids"]
|
|
107
|
+
if (
|
|
108
|
+
not isinstance(capabilities, list)
|
|
109
|
+
or not capabilities
|
|
110
|
+
or not all(isinstance(item, str) and item for item in capabilities)
|
|
111
|
+
):
|
|
112
|
+
raise LicenseInspectionError("license capability_ids are invalid")
|
|
113
|
+
if capabilities != sorted(set(capabilities)):
|
|
114
|
+
raise LicenseInspectionError("license capability_ids MUST be unique and sorted")
|
|
115
|
+
if len(capabilities) > _MAX_CAPABILITIES:
|
|
116
|
+
raise LicenseInspectionError("license capability_ids exceeds the supported count")
|
|
117
|
+
license_id = _text(payload, "license_id")
|
|
118
|
+
distribution_id = _text(payload, "distribution_id")
|
|
119
|
+
if _ID.fullmatch(license_id) is None or _ID.fullmatch(distribution_id) is None:
|
|
120
|
+
raise LicenseInspectionError("license identifiers MUST be lowercase stable identifiers")
|
|
121
|
+
if any(_ID.fullmatch(item) is None for item in capabilities):
|
|
122
|
+
raise LicenseInspectionError("license capability_ids MUST be lowercase stable identifiers")
|
|
123
|
+
_verify_optional_binding(
|
|
124
|
+
payload,
|
|
125
|
+
field="image_digest",
|
|
126
|
+
expected=expected_image_digest,
|
|
127
|
+
)
|
|
128
|
+
_verify_optional_binding(
|
|
129
|
+
payload,
|
|
130
|
+
field="tenant_binding",
|
|
131
|
+
expected=expected_tenant_binding,
|
|
132
|
+
)
|
|
133
|
+
active = not_before <= evaluated < not_after
|
|
134
|
+
if not active:
|
|
135
|
+
raise LicenseInspectionError("license is not active")
|
|
136
|
+
return LicenseInspection(
|
|
137
|
+
license_id=license_id,
|
|
138
|
+
distribution_id=distribution_id,
|
|
139
|
+
capability_ids=tuple(capabilities),
|
|
140
|
+
not_before=not_before.isoformat(),
|
|
141
|
+
not_after=not_after.isoformat(),
|
|
142
|
+
active=True,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
INSTALLATION_ENTITLEMENT_SCHEMA = "fdai.installation-entitlement.v1"
|
|
147
|
+
_ENTITLEMENT_FIELDS = frozenset(
|
|
148
|
+
{
|
|
149
|
+
"schema_version",
|
|
150
|
+
"entitlement_id",
|
|
151
|
+
"distribution_id",
|
|
152
|
+
"installation_binding",
|
|
153
|
+
"deployment_binding",
|
|
154
|
+
"issued_at",
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True)
|
|
160
|
+
class InstallationEntitlementInspection:
|
|
161
|
+
"""Sanitized result for an exactly bound installation entitlement."""
|
|
162
|
+
|
|
163
|
+
entitlement_id: str
|
|
164
|
+
distribution_id: str
|
|
165
|
+
issued_at: str
|
|
166
|
+
|
|
167
|
+
def to_json(self) -> str:
|
|
168
|
+
"""Return stable JSON without the bearer token or either binding digest."""
|
|
169
|
+
|
|
170
|
+
return json.dumps(
|
|
171
|
+
{
|
|
172
|
+
"schema_version": "fdai.installation-entitlement-inspection.v1",
|
|
173
|
+
"entitlement_id": self.entitlement_id,
|
|
174
|
+
"distribution_id": self.distribution_id,
|
|
175
|
+
"issued_at": self.issued_at,
|
|
176
|
+
"complete_catalog": True,
|
|
177
|
+
"expires": False,
|
|
178
|
+
"active": True,
|
|
179
|
+
},
|
|
180
|
+
sort_keys=True,
|
|
181
|
+
separators=(",", ":"),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def signed_token_schema(token: str) -> str | None:
|
|
186
|
+
"""Return the schema a token's document declares, without establishing trust."""
|
|
187
|
+
|
|
188
|
+
parts = token.split(".")
|
|
189
|
+
if len(parts) != 2 or len(token) > 8192:
|
|
190
|
+
return None
|
|
191
|
+
try:
|
|
192
|
+
payload = json.loads(_decode(parts[0], "document"))
|
|
193
|
+
except (LicenseInspectionError, UnicodeDecodeError, json.JSONDecodeError):
|
|
194
|
+
return None
|
|
195
|
+
schema = payload.get("schema_version") if isinstance(payload, dict) else None
|
|
196
|
+
return schema if isinstance(schema, str) else None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def inspect_installation_entitlement(
|
|
200
|
+
token: str,
|
|
201
|
+
*,
|
|
202
|
+
public_key_pem: bytes,
|
|
203
|
+
expected_installation_binding: str,
|
|
204
|
+
expected_deployment_binding: str,
|
|
205
|
+
) -> InstallationEntitlementInspection:
|
|
206
|
+
"""Verify one installation entitlement against the exact bindings it must carry."""
|
|
207
|
+
|
|
208
|
+
for label, expected in (
|
|
209
|
+
("installation binding", expected_installation_binding),
|
|
210
|
+
("deployment binding", expected_deployment_binding),
|
|
211
|
+
):
|
|
212
|
+
if _DIGEST.fullmatch(expected) is None:
|
|
213
|
+
raise LicenseInspectionError(f"expected {label} MUST be a lowercase SHA-256")
|
|
214
|
+
if not token or len(token) > 8192:
|
|
215
|
+
raise LicenseInspectionError("entitlement is empty or exceeds its size limit")
|
|
216
|
+
parts = token.split(".")
|
|
217
|
+
if len(parts) != 2:
|
|
218
|
+
raise LicenseInspectionError("entitlement MUST contain document and signature")
|
|
219
|
+
document = _decode(parts[0], "document")
|
|
220
|
+
signature = _decode(parts[1], "signature")
|
|
221
|
+
if len(signature) != 64:
|
|
222
|
+
raise LicenseInspectionError("entitlement signature MUST be 64 bytes")
|
|
223
|
+
_verify(public_key_pem, document, signature)
|
|
224
|
+
payload = load_json_object(document, label="entitlement document", max_bytes=2048)
|
|
225
|
+
if (
|
|
226
|
+
set(payload) != _ENTITLEMENT_FIELDS
|
|
227
|
+
or payload["schema_version"] != INSTALLATION_ENTITLEMENT_SCHEMA
|
|
228
|
+
):
|
|
229
|
+
raise LicenseInspectionError("entitlement document schema does not match")
|
|
230
|
+
if canonical_bytes(payload) != document:
|
|
231
|
+
raise LicenseInspectionError("entitlement document is not canonical")
|
|
232
|
+
entitlement_id = _text(payload, "entitlement_id")
|
|
233
|
+
distribution_id = _text(payload, "distribution_id")
|
|
234
|
+
if _ID.fullmatch(entitlement_id) is None or _ID.fullmatch(distribution_id) is None:
|
|
235
|
+
raise LicenseInspectionError("entitlement identifiers MUST be lowercase stable identifiers")
|
|
236
|
+
issued_at = _moment(payload, "issued_at")
|
|
237
|
+
if _text(payload, "issued_at") != _canonical_moment(issued_at):
|
|
238
|
+
raise LicenseInspectionError("entitlement issued_at is not canonically encoded")
|
|
239
|
+
if _text(payload, "installation_binding") != expected_installation_binding:
|
|
240
|
+
raise LicenseInspectionError("entitlement installation binding does not match")
|
|
241
|
+
if _text(payload, "deployment_binding") != expected_deployment_binding:
|
|
242
|
+
raise LicenseInspectionError("entitlement deployment binding does not match")
|
|
243
|
+
return InstallationEntitlementInspection(
|
|
244
|
+
entitlement_id=entitlement_id,
|
|
245
|
+
distribution_id=distribution_id,
|
|
246
|
+
issued_at=issued_at.isoformat(),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _decode(value: str, label: str) -> bytes:
|
|
251
|
+
if _B64URL.fullmatch(value) is None:
|
|
252
|
+
raise LicenseInspectionError(f"license {label} is not canonical base64url")
|
|
253
|
+
try:
|
|
254
|
+
decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
|
255
|
+
except (ValueError, binascii.Error) as exc:
|
|
256
|
+
raise LicenseInspectionError(f"license {label} is not base64url") from exc
|
|
257
|
+
if base64.urlsafe_b64encode(decoded).rstrip(b"=").decode() != value:
|
|
258
|
+
raise LicenseInspectionError(f"license {label} is not canonical base64url")
|
|
259
|
+
return decoded
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _verify_optional_binding(
|
|
263
|
+
payload: dict[str, object],
|
|
264
|
+
*,
|
|
265
|
+
field: str,
|
|
266
|
+
expected: str | None,
|
|
267
|
+
) -> None:
|
|
268
|
+
value = payload[field]
|
|
269
|
+
if value is not None and (not isinstance(value, str) or _DIGEST.fullmatch(value) is None):
|
|
270
|
+
raise LicenseInspectionError(f"license {field} MUST be a lowercase SHA-256 or null")
|
|
271
|
+
if expected is not None and _DIGEST.fullmatch(expected) is None:
|
|
272
|
+
raise LicenseInspectionError(f"expected {field} MUST be a lowercase SHA-256")
|
|
273
|
+
if value is not None and expected is None:
|
|
274
|
+
raise LicenseInspectionError(f"license {field} requires an expected binding")
|
|
275
|
+
if expected is not None and value != expected:
|
|
276
|
+
raise LicenseInspectionError(f"license {field} does not match")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _verify(public_pem: bytes, document: bytes, signature: bytes) -> None:
|
|
280
|
+
try:
|
|
281
|
+
key = load_pem_public_key(public_pem)
|
|
282
|
+
except (TypeError, ValueError) as exc:
|
|
283
|
+
raise LicenseInspectionError("license public key is invalid") from exc
|
|
284
|
+
if not isinstance(key, Ed25519PublicKey):
|
|
285
|
+
raise LicenseInspectionError("license public key MUST be Ed25519")
|
|
286
|
+
try:
|
|
287
|
+
key.verify(signature, document)
|
|
288
|
+
except InvalidSignature as exc:
|
|
289
|
+
raise LicenseInspectionError("license signature is invalid") from exc
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _moment(value: dict[str, object], field: str) -> datetime:
|
|
293
|
+
raw = _text(value, field)
|
|
294
|
+
try:
|
|
295
|
+
moment = datetime.fromisoformat(raw)
|
|
296
|
+
except ValueError as exc:
|
|
297
|
+
raise LicenseInspectionError(f"license {field} MUST be ISO 8601") from exc
|
|
298
|
+
if moment.tzinfo is None:
|
|
299
|
+
raise LicenseInspectionError(f"license {field} MUST be timezone-aware")
|
|
300
|
+
return moment
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _canonical_moment(moment: datetime) -> str:
|
|
304
|
+
return moment.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _text(value: dict[str, object], field: str) -> str:
|
|
308
|
+
item = value[field]
|
|
309
|
+
if not isinstance(item, str) or not item:
|
|
310
|
+
raise LicenseInspectionError(f"license {field} MUST be text")
|
|
311
|
+
return item
|