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,312 @@
|
|
|
1
|
+
"""Validated immutable values for protected GitHub workflow operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
|
|
10
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
11
|
+
|
|
12
|
+
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}$")
|
|
13
|
+
|
|
14
|
+
_COMMIT = re.compile(r"^[0-9a-f]{40}$")
|
|
15
|
+
|
|
16
|
+
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
|
17
|
+
|
|
18
|
+
_OCI_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
19
|
+
|
|
20
|
+
_PROVIDER_SOURCE_REVISION = re.compile(r"^[0-9a-f]{40,64}$")
|
|
21
|
+
|
|
22
|
+
_PLAN_ID = re.compile(r"^plan-[1-9][0-9]*-[1-9][0-9]*$")
|
|
23
|
+
|
|
24
|
+
_REQUEST_ID = re.compile(
|
|
25
|
+
r"^(?:plan|apply)-(?:cost-|history-|identity-|provider-cost-|provider-|rca-)?"
|
|
26
|
+
r"[0-9a-f]{48}$"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_ENVIRONMENTS = frozenset({"dev", "staging", "prod"})
|
|
30
|
+
|
|
31
|
+
_RUNTIME_IMAGE_PROFILES = frozenset({"core-control-plane", "cost-governance"})
|
|
32
|
+
|
|
33
|
+
_BOOL_INPUTS = (
|
|
34
|
+
"deploy_console",
|
|
35
|
+
"deploy_dev_operations_gateway",
|
|
36
|
+
"deploy_document_ingestion",
|
|
37
|
+
"deploy_identity_migration",
|
|
38
|
+
"deploy_isolated_executor",
|
|
39
|
+
"deploy_monitoring",
|
|
40
|
+
"deploy_operational_history",
|
|
41
|
+
"deploy_operator_api",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_EXPIRES_AT_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class CommandResult:
|
|
49
|
+
"""Bounded process result from one fixed GitHub CLI invocation."""
|
|
50
|
+
|
|
51
|
+
returncode: int
|
|
52
|
+
stdout: str
|
|
53
|
+
stderr: str = ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
CommandRunner = Callable[[tuple[str, ...]], CommandResult]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class DeploymentSelection:
|
|
61
|
+
"""Feature selection sealed into both protected plan and apply requests."""
|
|
62
|
+
|
|
63
|
+
deploy_console: bool = True
|
|
64
|
+
deploy_dev_operations_gateway: bool = False
|
|
65
|
+
deploy_document_ingestion: bool = False
|
|
66
|
+
deploy_identity_migration: bool = False
|
|
67
|
+
deploy_isolated_executor: bool = False
|
|
68
|
+
deploy_monitoring: bool = False
|
|
69
|
+
deploy_operational_history: bool = False
|
|
70
|
+
deploy_operator_api: bool = True
|
|
71
|
+
deploy_operator_channel_edge: bool = False
|
|
72
|
+
deploy_provider_schema: bool = False
|
|
73
|
+
deploy_rca_reader_identity: bool = False
|
|
74
|
+
runtime_image_revision: str = ""
|
|
75
|
+
runtime_image_profile: str = "core-control-plane"
|
|
76
|
+
|
|
77
|
+
def __post_init__(self) -> None:
|
|
78
|
+
application_targets = (
|
|
79
|
+
self.deploy_console,
|
|
80
|
+
self.deploy_dev_operations_gateway,
|
|
81
|
+
self.deploy_document_ingestion,
|
|
82
|
+
self.deploy_isolated_executor,
|
|
83
|
+
self.deploy_operational_history,
|
|
84
|
+
self.deploy_operator_api,
|
|
85
|
+
self.deploy_operator_channel_edge,
|
|
86
|
+
self.deploy_provider_schema,
|
|
87
|
+
)
|
|
88
|
+
if self.deploy_monitoring and not any(application_targets) and self.runtime_image_revision:
|
|
89
|
+
raise ValueError("monitoring-only deployment cannot specify runtime_image_revision")
|
|
90
|
+
if self.runtime_image_revision and _COMMIT.fullmatch(self.runtime_image_revision) is None:
|
|
91
|
+
raise ValueError("runtime_image_revision MUST be a lowercase 40-character git SHA")
|
|
92
|
+
if self.runtime_image_profile not in _RUNTIME_IMAGE_PROFILES:
|
|
93
|
+
raise ValueError("runtime_image_profile is unsupported")
|
|
94
|
+
if self.runtime_image_profile != "core-control-plane" and not self.runtime_image_revision:
|
|
95
|
+
raise ValueError("non-default runtime_image_profile requires runtime_image_revision")
|
|
96
|
+
if self.runtime_image_profile != "core-control-plane" and (
|
|
97
|
+
self.deploy_identity_migration
|
|
98
|
+
or self.deploy_operational_history
|
|
99
|
+
or self.deploy_rca_reader_identity
|
|
100
|
+
):
|
|
101
|
+
raise ValueError(
|
|
102
|
+
"non-default runtime_image_profile cannot be combined with a bounded operation"
|
|
103
|
+
)
|
|
104
|
+
if self.deploy_provider_schema:
|
|
105
|
+
provider_schema_mixed = (
|
|
106
|
+
self.deploy_console,
|
|
107
|
+
self.deploy_dev_operations_gateway,
|
|
108
|
+
self.deploy_document_ingestion,
|
|
109
|
+
self.deploy_identity_migration,
|
|
110
|
+
self.deploy_isolated_executor,
|
|
111
|
+
self.deploy_monitoring,
|
|
112
|
+
self.deploy_operational_history,
|
|
113
|
+
self.deploy_operator_api,
|
|
114
|
+
self.deploy_operator_channel_edge,
|
|
115
|
+
self.deploy_rca_reader_identity,
|
|
116
|
+
)
|
|
117
|
+
if any(provider_schema_mixed):
|
|
118
|
+
raise ValueError(
|
|
119
|
+
"deploy_provider_schema cannot be combined with another deployment target"
|
|
120
|
+
)
|
|
121
|
+
if not self.runtime_image_revision:
|
|
122
|
+
raise ValueError("deploy_provider_schema requires runtime_image_revision")
|
|
123
|
+
if self.deploy_rca_reader_identity and (
|
|
124
|
+
any(application_targets) or self.deploy_monitoring or self.runtime_image_revision
|
|
125
|
+
):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
"deploy_rca_reader_identity cannot be combined with another deployment target"
|
|
128
|
+
)
|
|
129
|
+
if self.deploy_identity_migration and (
|
|
130
|
+
any(application_targets)
|
|
131
|
+
or self.deploy_monitoring
|
|
132
|
+
or self.deploy_operational_history
|
|
133
|
+
or self.deploy_rca_reader_identity
|
|
134
|
+
or self.runtime_image_revision
|
|
135
|
+
):
|
|
136
|
+
raise ValueError(
|
|
137
|
+
"deploy_identity_migration cannot be combined with another bounded operation"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def to_mapping(self) -> dict[str, bool | str]:
|
|
141
|
+
"""Return workflow input names in stable order."""
|
|
142
|
+
|
|
143
|
+
result: dict[str, bool | str] = {name: bool(getattr(self, name)) for name in _BOOL_INPUTS}
|
|
144
|
+
if self.deploy_operator_channel_edge:
|
|
145
|
+
result["deploy_operator_channel_edge"] = True
|
|
146
|
+
if self.deploy_provider_schema:
|
|
147
|
+
result["deploy_provider_schema"] = True
|
|
148
|
+
result["deploy_rca_reader_identity"] = self.deploy_rca_reader_identity
|
|
149
|
+
result["document_ocr_action"] = "preserve"
|
|
150
|
+
result["runtime_call_evidence_transition"] = False
|
|
151
|
+
result["runtime_image_revision"] = self.runtime_image_revision
|
|
152
|
+
result["runtime_image_profile"] = self.runtime_image_profile
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True, slots=True)
|
|
157
|
+
class WorkflowDispatch:
|
|
158
|
+
"""Sanitized receipt for one accepted workflow dispatch."""
|
|
159
|
+
|
|
160
|
+
request_id: str
|
|
161
|
+
run_name: str
|
|
162
|
+
context_digest: str
|
|
163
|
+
mode: str
|
|
164
|
+
|
|
165
|
+
def to_mapping(self) -> dict[str, object]:
|
|
166
|
+
"""Return stable dispatch output without credentials or target identifiers."""
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
"schema_version": "fdai.workflow-dispatch.v1",
|
|
170
|
+
"request_id": self.request_id,
|
|
171
|
+
"run_name": self.run_name,
|
|
172
|
+
"context_digest": self.context_digest,
|
|
173
|
+
"mode": self.mode,
|
|
174
|
+
"mutation_performed": True,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def parse_plan_expiry(expires_at: str) -> datetime:
|
|
179
|
+
"""Parse a strict ISO-8601 UTC timestamp or raise fail-closed."""
|
|
180
|
+
|
|
181
|
+
if _EXPIRES_AT_RE.fullmatch(expires_at) is None:
|
|
182
|
+
raise ValueError("expires_at MUST be an ISO-8601 UTC timestamp ending in Z")
|
|
183
|
+
try:
|
|
184
|
+
return datetime.fromisoformat(expires_at)
|
|
185
|
+
except ValueError as exc:
|
|
186
|
+
raise ValueError("expires_at contains an invalid date or time") from exc
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def enforce_plan_not_expired(
|
|
190
|
+
expires_at: str,
|
|
191
|
+
*,
|
|
192
|
+
now: datetime | None = None,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Fail-closed: reject expired plans before apply dispatch."""
|
|
195
|
+
|
|
196
|
+
deadline = parse_plan_expiry(expires_at)
|
|
197
|
+
current = now if now is not None else datetime.now(UTC)
|
|
198
|
+
if current >= deadline:
|
|
199
|
+
raise ValueError("protected plan has expired; replan before applying")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def deployment_context_digest(
|
|
203
|
+
*,
|
|
204
|
+
environment: str,
|
|
205
|
+
commit_sha: str,
|
|
206
|
+
selection: DeploymentSelection,
|
|
207
|
+
) -> str:
|
|
208
|
+
"""Seal the non-secret deployment inputs shared by plan and apply."""
|
|
209
|
+
|
|
210
|
+
if environment not in _ENVIRONMENTS:
|
|
211
|
+
raise ValueError("environment is unsupported")
|
|
212
|
+
if _COMMIT.fullmatch(commit_sha) is None:
|
|
213
|
+
raise ValueError("commit_sha MUST be a lowercase git SHA")
|
|
214
|
+
return canonical_digest(
|
|
215
|
+
{
|
|
216
|
+
"schema_version": "fdai.deployment-context.v1",
|
|
217
|
+
"environment": environment,
|
|
218
|
+
"commit_sha": commit_sha,
|
|
219
|
+
"selection": selection.to_mapping(),
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def request_id(
|
|
225
|
+
mode: str,
|
|
226
|
+
*,
|
|
227
|
+
run_id: str,
|
|
228
|
+
context_digest: str,
|
|
229
|
+
target_binding: str,
|
|
230
|
+
region: str,
|
|
231
|
+
attempt: int = 1,
|
|
232
|
+
) -> str:
|
|
233
|
+
"""Derive one bounded request id without exposing the local run id."""
|
|
234
|
+
|
|
235
|
+
if mode not in {"plan", "apply", "resume"}:
|
|
236
|
+
raise ValueError("workflow request mode is unsupported")
|
|
237
|
+
if not run_id or len(run_id) > 128:
|
|
238
|
+
raise ValueError("run_id MUST be from 1 through 128 characters")
|
|
239
|
+
if _DIGEST.fullmatch(context_digest) is None:
|
|
240
|
+
raise ValueError("context_digest MUST be a lowercase SHA-256")
|
|
241
|
+
if _DIGEST.fullmatch(target_binding) is None:
|
|
242
|
+
raise ValueError("target_binding MUST be a lowercase SHA-256")
|
|
243
|
+
if not region or len(region) > 64:
|
|
244
|
+
raise ValueError("region is invalid")
|
|
245
|
+
if not 1 <= attempt <= 9_999:
|
|
246
|
+
raise ValueError("workflow attempt MUST be from 1 through 9999")
|
|
247
|
+
binding = request_binding_prefix(
|
|
248
|
+
target_binding=target_binding,
|
|
249
|
+
context_digest=context_digest,
|
|
250
|
+
mode=mode,
|
|
251
|
+
region=region,
|
|
252
|
+
)
|
|
253
|
+
run_key = canonical_digest({"run_id": run_id})[:20]
|
|
254
|
+
suffix = f"{binding}{run_key}{attempt:04x}"
|
|
255
|
+
prefix = "apply" if mode == "resume" else mode
|
|
256
|
+
return f"{prefix}-{suffix}"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _request_binding_from_id(request_id_value: str) -> str:
|
|
260
|
+
"""Return the target/context binding embedded in one validated request id."""
|
|
261
|
+
|
|
262
|
+
for prefix in (
|
|
263
|
+
"plan-history-",
|
|
264
|
+
"apply-history-",
|
|
265
|
+
"plan-identity-",
|
|
266
|
+
"apply-identity-",
|
|
267
|
+
"plan-provider-cost-",
|
|
268
|
+
"apply-provider-cost-",
|
|
269
|
+
"plan-provider-",
|
|
270
|
+
"apply-provider-",
|
|
271
|
+
"plan-rca-",
|
|
272
|
+
"apply-rca-",
|
|
273
|
+
"plan-cost-",
|
|
274
|
+
"apply-cost-",
|
|
275
|
+
"plan-",
|
|
276
|
+
"apply-",
|
|
277
|
+
):
|
|
278
|
+
if request_id_value.startswith(prefix):
|
|
279
|
+
return request_id_value.removeprefix(prefix)[:24]
|
|
280
|
+
raise ValueError("request_id is invalid")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def request_binding_prefix(
|
|
284
|
+
*,
|
|
285
|
+
target_binding: str,
|
|
286
|
+
context_digest: str,
|
|
287
|
+
mode: str,
|
|
288
|
+
region: str,
|
|
289
|
+
) -> str:
|
|
290
|
+
"""Return the target-bound prefix independently recomputed by the workflow."""
|
|
291
|
+
|
|
292
|
+
if _DIGEST.fullmatch(target_binding) is None:
|
|
293
|
+
raise ValueError("target_binding MUST be a lowercase SHA-256")
|
|
294
|
+
if _DIGEST.fullmatch(context_digest) is None:
|
|
295
|
+
raise ValueError("context_digest MUST be a lowercase SHA-256")
|
|
296
|
+
if mode not in {"plan", "apply", "resume"}:
|
|
297
|
+
raise ValueError("workflow request mode is unsupported")
|
|
298
|
+
if not region or len(region) > 64:
|
|
299
|
+
raise ValueError("region is invalid")
|
|
300
|
+
return canonical_digest(
|
|
301
|
+
{
|
|
302
|
+
"target_binding": target_binding,
|
|
303
|
+
"context_digest": context_digest,
|
|
304
|
+
"mode": mode,
|
|
305
|
+
"region": region.casefold(),
|
|
306
|
+
}
|
|
307
|
+
)[:24]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _validate_repository(repository: str) -> None:
|
|
311
|
+
if _REPOSITORY.fullmatch(repository) is None or ".." in repository:
|
|
312
|
+
raise ValueError("repository MUST be owner/name")
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Connected image-bootstrap inputs and public, self-reported request intake."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import http.client
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import ssl
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
from uuid import UUID
|
|
17
|
+
|
|
18
|
+
from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest, load_json_object
|
|
19
|
+
from fdai_deployment_cli.hub_image_trust import (
|
|
20
|
+
MAX_EXPORT_BYTES,
|
|
21
|
+
MAX_SELECTION_BYTES,
|
|
22
|
+
HubImageSelection,
|
|
23
|
+
load_selection,
|
|
24
|
+
public_key,
|
|
25
|
+
selection_bytes,
|
|
26
|
+
validate_channel,
|
|
27
|
+
verify_export,
|
|
28
|
+
)
|
|
29
|
+
from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
|
|
30
|
+
from fdai_deployment_cli.source_input import inspect_source
|
|
31
|
+
|
|
32
|
+
DEFAULT_HUB_URL = "https://hub.msfdai.com"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def hub_source_inputs(args: argparse.Namespace) -> tuple[bool, Path | None, Path | None]:
|
|
36
|
+
hub_mode = args.hub is not None
|
|
37
|
+
source_root = args.hub if hub_mode else args.source
|
|
38
|
+
if not hub_mode and any(
|
|
39
|
+
getattr(args, name) is not None
|
|
40
|
+
for name in ("hub_url", "hub_public_key", "hub_release", "hub_channel")
|
|
41
|
+
):
|
|
42
|
+
raise ValueError("Hub options require --hub and cannot modify source or offline mode")
|
|
43
|
+
hub_key = args.hub_public_key
|
|
44
|
+
if hub_mode and hub_key is None and os.environ.get("FDAI_HUB_PUBLIC_KEY"):
|
|
45
|
+
hub_key = Path(os.environ["FDAI_HUB_PUBLIC_KEY"])
|
|
46
|
+
if hub_mode and hub_key is None:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"--hub requires --hub-public-key PATH or FDAI_HUB_PUBLIC_KEY; provision the "
|
|
49
|
+
"Ed25519 public key independently, never download a TOFU key from the Hub"
|
|
50
|
+
)
|
|
51
|
+
if hub_mode and args.runtime != "aks":
|
|
52
|
+
raise ValueError("Hub image bootstrap currently requires AKS")
|
|
53
|
+
return hub_mode, source_root, hub_key
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def deployment_work_dir(args: argparse.Namespace) -> Path:
|
|
57
|
+
selected_dir: Path | None = args.work_dir
|
|
58
|
+
if selected_dir is None:
|
|
59
|
+
selected_dir = Path.home() / (
|
|
60
|
+
".local/state/fdai/azure-hub"
|
|
61
|
+
if args.hub is not None
|
|
62
|
+
else ".local/state/fdai/azure-source"
|
|
63
|
+
if args.source is not None
|
|
64
|
+
else ".local/state/fdai/azure"
|
|
65
|
+
)
|
|
66
|
+
return selected_dir if selected_dir.is_absolute() else Path.cwd() / selected_dir
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def prepare_cli_selection(
|
|
70
|
+
args: argparse.Namespace, work_dir: Path, source_root: Path | None, hub_key: Path | None
|
|
71
|
+
) -> HubImageSelection | None:
|
|
72
|
+
"""Seal Hub inputs without permitting source/offline reuse of a retained Hub run."""
|
|
73
|
+
if args.hub is not None:
|
|
74
|
+
assert source_root is not None and hub_key is not None
|
|
75
|
+
if args.teardown:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"Hub image bootstrap does not authorize teardown; retain the run for review"
|
|
78
|
+
)
|
|
79
|
+
return prepare_hub_selection(
|
|
80
|
+
source_root=source_root,
|
|
81
|
+
work_dir=work_dir,
|
|
82
|
+
public_key_path=hub_key,
|
|
83
|
+
hub_url=DEFAULT_HUB_URL if args.hub_url is None else args.hub_url,
|
|
84
|
+
channel="RELEASE" if args.hub_channel is None else args.hub_channel,
|
|
85
|
+
release_id=args.hub_release,
|
|
86
|
+
)
|
|
87
|
+
if (work_dir / "hub-images").exists():
|
|
88
|
+
raise ValueError("source or offline artifact mode cannot reuse a Hub-image work directory")
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def bind_support_receipt(
|
|
93
|
+
result: dict[str, Any], selection: HubImageSelection | None, *, require_receipt: bool = False
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Preserve the support receipt identity before binding signed image provenance."""
|
|
96
|
+
if selection is not None:
|
|
97
|
+
result.update(selection.metadata())
|
|
98
|
+
if require_receipt or "receipt_digest" in result:
|
|
99
|
+
result["support_receipt_digest"] = result.pop("receipt_digest")
|
|
100
|
+
result["receipt_digest"] = canonical_digest(result)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def application_selection(
|
|
104
|
+
selection: HubImageSelection | None, source_mode: bool, prepared: Any
|
|
105
|
+
) -> HubImageSelection | None:
|
|
106
|
+
"""Reverify Hub provenance without adding source/offline preparation prerequisites."""
|
|
107
|
+
if selection is not None:
|
|
108
|
+
selection = load_selection(selection.path, selection.digest)
|
|
109
|
+
if not source_mode or selection.source_commit != prepared.source_commit:
|
|
110
|
+
raise ValueError("Hub images require the matching checkout support snapshot")
|
|
111
|
+
return selection
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def source_binding(
|
|
115
|
+
snapshot_digest: str | None, selection: HubImageSelection | None
|
|
116
|
+
) -> dict[str, object]:
|
|
117
|
+
return {
|
|
118
|
+
"source_snapshot_digest": snapshot_digest,
|
|
119
|
+
"provenance": "operator-selected-source",
|
|
120
|
+
**(selection.metadata() if selection is not None else {}),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def require_retained_hub_recovery(
|
|
125
|
+
selection: HubImageSelection, *, work_dir: Path, target_binding: str
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Check the original mode and human target before recovery can advance any effect."""
|
|
128
|
+
intent = load_json_object(
|
|
129
|
+
read_private_bytes(work_dir / "source-intent.json", max_bytes=65_536),
|
|
130
|
+
label="retained Hub support intent",
|
|
131
|
+
strict=True,
|
|
132
|
+
)
|
|
133
|
+
marker = load_json_object(
|
|
134
|
+
read_private_bytes(work_dir / "foundation/source-genesis.json", max_bytes=65_536),
|
|
135
|
+
label="retained Hub Foundation binding",
|
|
136
|
+
strict=True,
|
|
137
|
+
)
|
|
138
|
+
digest = marker.pop("receipt_digest", None)
|
|
139
|
+
if (
|
|
140
|
+
intent.get("hub_selection_digest") != selection.digest
|
|
141
|
+
or intent.get("artifact_source") != "hub-signed-images"
|
|
142
|
+
or canonical_digest(marker) != digest
|
|
143
|
+
or marker.get("target_binding") != target_binding
|
|
144
|
+
or marker.get("source_commit") != selection.source_commit
|
|
145
|
+
):
|
|
146
|
+
raise ValueError("Hub recovery mode, Release or selected Azure target drifted")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def validate_hub_url(url: str) -> str:
|
|
150
|
+
parsed = urlsplit(url)
|
|
151
|
+
if (
|
|
152
|
+
len(url) > 512
|
|
153
|
+
or parsed.scheme != "https"
|
|
154
|
+
or not parsed.hostname
|
|
155
|
+
or parsed.username is not None
|
|
156
|
+
or parsed.password is not None
|
|
157
|
+
or parsed.path not in {"", "/"}
|
|
158
|
+
or parsed.query
|
|
159
|
+
or parsed.fragment
|
|
160
|
+
or re.fullmatch(r"https://[a-z0-9][a-z0-9.-]*(?::[1-9][0-9]{0,4})?/?", url) is None
|
|
161
|
+
):
|
|
162
|
+
raise ValueError("Hub URL must be one explicit HTTPS origin without credentials or path")
|
|
163
|
+
if parsed.port is not None and parsed.port > 65535:
|
|
164
|
+
raise ValueError("Hub HTTPS port is invalid")
|
|
165
|
+
return url.rstrip("/")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def hub_request(
|
|
169
|
+
hub_url: str, path: str, *, body: bytes | None = None
|
|
170
|
+
) -> tuple[int, dict[str, str], bytes]:
|
|
171
|
+
"""One bounded TLS request, without proxies, redirects, tokens or automatic retries."""
|
|
172
|
+
origin = urlsplit(validate_hub_url(hub_url))
|
|
173
|
+
assert origin.hostname is not None
|
|
174
|
+
if path not in {"/v1/catalog/export", "/v1/deployment-requests"}:
|
|
175
|
+
raise ValueError("Hub request path is not allowed")
|
|
176
|
+
limit = MAX_EXPORT_BYTES if body is None else 16_384
|
|
177
|
+
connection = http.client.HTTPSConnection(
|
|
178
|
+
origin.hostname, origin.port or 443, timeout=15, context=ssl.create_default_context()
|
|
179
|
+
)
|
|
180
|
+
deadline = time.monotonic() + 30
|
|
181
|
+
try:
|
|
182
|
+
headers = {"Accept": "application/json", "Connection": "close"}
|
|
183
|
+
if body is not None:
|
|
184
|
+
headers["Content-Type"] = "application/json"
|
|
185
|
+
connection.request("GET" if body is None else "POST", path, body=body, headers=headers)
|
|
186
|
+
response = connection.getresponse()
|
|
187
|
+
if response.status not in ({200} if body is None else {200, 201}):
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"Hub request was refused (HTTP {response.status}); redirects and "
|
|
190
|
+
"source-build fallback are disabled"
|
|
191
|
+
)
|
|
192
|
+
if response.getheader("Content-Encoding", "identity") != "identity":
|
|
193
|
+
raise ValueError("Hub response encoding is unsupported")
|
|
194
|
+
length = response.getheader("Content-Length")
|
|
195
|
+
if length is not None and (not length.isdecimal() or int(length) > limit):
|
|
196
|
+
raise ValueError("Hub response exceeds its size bound")
|
|
197
|
+
result = bytearray()
|
|
198
|
+
while True:
|
|
199
|
+
remaining = deadline - time.monotonic()
|
|
200
|
+
if remaining <= 0:
|
|
201
|
+
raise ValueError("Hub request deadline exceeded")
|
|
202
|
+
if connection.sock is not None:
|
|
203
|
+
connection.sock.settimeout(min(15, remaining))
|
|
204
|
+
chunk = response.read1(min(65_536, limit + 1 - len(result)))
|
|
205
|
+
if not chunk:
|
|
206
|
+
break
|
|
207
|
+
result.extend(chunk)
|
|
208
|
+
if len(result) > limit:
|
|
209
|
+
raise ValueError("Hub response exceeds its size bound")
|
|
210
|
+
if length is not None and len(result) != int(length):
|
|
211
|
+
raise ValueError("Hub response body is incomplete")
|
|
212
|
+
signature_headers: dict[str, str] = {}
|
|
213
|
+
for name in ("X-FDAI-Export-Signature", "X-FDAI-Export-Key-Id"):
|
|
214
|
+
values = response.headers.get_all(name, [])
|
|
215
|
+
if len(values) > 1:
|
|
216
|
+
raise ValueError("Hub response has ambiguous signature headers")
|
|
217
|
+
signature_headers[name.lower()] = values[0] if values else ""
|
|
218
|
+
return response.status, signature_headers, bytes(result)
|
|
219
|
+
except (TimeoutError, OSError, http.client.HTTPException) as error:
|
|
220
|
+
raise ValueError(
|
|
221
|
+
"Hub network request failed; preserve retained inputs and inspect any pending "
|
|
222
|
+
"deployment request before retrying. No automatic retry or source-build fallback."
|
|
223
|
+
) from error
|
|
224
|
+
finally:
|
|
225
|
+
connection.close()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def prepare_hub_selection(
|
|
229
|
+
*,
|
|
230
|
+
source_root: Path,
|
|
231
|
+
work_dir: Path,
|
|
232
|
+
public_key_path: Path,
|
|
233
|
+
hub_url: str = DEFAULT_HUB_URL,
|
|
234
|
+
channel: str = "RELEASE",
|
|
235
|
+
release_id: str | None = None,
|
|
236
|
+
) -> HubImageSelection:
|
|
237
|
+
"""Verify before any Azure call and seal a selection that reruns cannot retarget."""
|
|
238
|
+
hub_url = validate_hub_url(hub_url)
|
|
239
|
+
validate_channel(channel)
|
|
240
|
+
with public_key_path.open("rb") as stream:
|
|
241
|
+
pem = stream.read(4097)
|
|
242
|
+
public_key(pem)
|
|
243
|
+
source = inspect_source(source_root)
|
|
244
|
+
if work_dir.resolve().is_relative_to(source.root):
|
|
245
|
+
raise ValueError("Hub work directory must be outside the selected checkout")
|
|
246
|
+
inputs = work_dir / "hub-images"
|
|
247
|
+
path = inputs / "selection.json"
|
|
248
|
+
previous = None
|
|
249
|
+
if path.exists():
|
|
250
|
+
raw = read_private_bytes(path, max_bytes=MAX_SELECTION_BYTES)
|
|
251
|
+
previous = load_selection(path, hashlib.sha256(raw).hexdigest())
|
|
252
|
+
if (
|
|
253
|
+
previous.document["hub_url"] != hub_url
|
|
254
|
+
or previous.document["channel"] != channel
|
|
255
|
+
or previous.document["public_key"] != pem.decode("ascii")
|
|
256
|
+
or release_id not in {None, previous.release_id}
|
|
257
|
+
):
|
|
258
|
+
raise ValueError(
|
|
259
|
+
"Hub selection mode, origin, channel, key or Release drift; use a new work directory"
|
|
260
|
+
)
|
|
261
|
+
release_id = previous.release_id
|
|
262
|
+
elif work_dir.exists() and any(work_dir.iterdir()):
|
|
263
|
+
raise ValueError("Hub images cannot adopt source, kit, or partial work state")
|
|
264
|
+
_, headers, raw = hub_request(hub_url, "/v1/catalog/export")
|
|
265
|
+
verified = verify_export(
|
|
266
|
+
raw,
|
|
267
|
+
signature=headers["x-fdai-export-signature"],
|
|
268
|
+
export_key_id=headers["x-fdai-export-key-id"],
|
|
269
|
+
trusted_pem=pem,
|
|
270
|
+
channel=channel,
|
|
271
|
+
release_id=release_id,
|
|
272
|
+
minimum_revision=previous.document["catalog_revision"] if previous else 0,
|
|
273
|
+
previous_digest=previous.document["catalog_digest"] if previous else None,
|
|
274
|
+
)
|
|
275
|
+
if source.commit != verified["source_commit"]:
|
|
276
|
+
raise ValueError(
|
|
277
|
+
"Hub Release source_commit differs from checkout migrations and Console. "
|
|
278
|
+
f"Check out the exact approved commit {verified['source_commit']} and rerun; "
|
|
279
|
+
"no Azure deployment effect or source-build fallback was attempted."
|
|
280
|
+
)
|
|
281
|
+
selected = selection_bytes(
|
|
282
|
+
raw=raw,
|
|
283
|
+
signature=headers["x-fdai-export-signature"],
|
|
284
|
+
export_key_id=headers["x-fdai-export-key-id"],
|
|
285
|
+
pem=pem,
|
|
286
|
+
hub_url=hub_url,
|
|
287
|
+
channel=channel,
|
|
288
|
+
verified=verified,
|
|
289
|
+
)
|
|
290
|
+
digest = hashlib.sha256(selected).hexdigest()
|
|
291
|
+
if previous is not None:
|
|
292
|
+
if digest != previous.digest:
|
|
293
|
+
raise ValueError(
|
|
294
|
+
"Hub immutable catalog selection changed; preserve this run and use a new work directory"
|
|
295
|
+
)
|
|
296
|
+
return previous
|
|
297
|
+
inputs.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
298
|
+
work_dir.chmod(0o700)
|
|
299
|
+
write_private_bytes(path, selected)
|
|
300
|
+
return load_selection(path, digest)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def record_deployment_request(
|
|
304
|
+
selection: HubImageSelection, *, subscription_id: str, region: str
|
|
305
|
+
) -> dict[str, object]:
|
|
306
|
+
"""Record self-reported metadata once; uncertain mutations remain an explicit stop."""
|
|
307
|
+
subscription_id = str(UUID(subscription_id))
|
|
308
|
+
if re.fullmatch(r"[a-z][a-z0-9]{0,63}", region) is None:
|
|
309
|
+
raise ValueError("Hub deployment request region is invalid")
|
|
310
|
+
payload = {
|
|
311
|
+
"subscription_id": subscription_id,
|
|
312
|
+
"release_id": selection.release_id,
|
|
313
|
+
"channel": selection.document["channel"],
|
|
314
|
+
"region": region,
|
|
315
|
+
"source_commit": selection.source_commit,
|
|
316
|
+
"catalog_digest": selection.document["catalog_digest"],
|
|
317
|
+
}
|
|
318
|
+
payload["request_id"] = hashlib.sha256(canonical_bytes(payload)).hexdigest()
|
|
319
|
+
root = selection.path.parent
|
|
320
|
+
pending, receipt = root / "request-pending.json", root / "request-receipt.json"
|
|
321
|
+
if pending.exists():
|
|
322
|
+
retained = load_json_object(
|
|
323
|
+
read_private_bytes(pending, max_bytes=16_384), label="Hub request", strict=True
|
|
324
|
+
)
|
|
325
|
+
if retained != payload:
|
|
326
|
+
raise ValueError("Hub deployment request target or inputs drifted")
|
|
327
|
+
if not receipt.exists():
|
|
328
|
+
raise ValueError(
|
|
329
|
+
"Hub deployment request has an uncertain outcome. Inspect its retained "
|
|
330
|
+
"request_id with a Hub operator; do not automatically resubmit."
|
|
331
|
+
)
|
|
332
|
+
result = load_json_object(
|
|
333
|
+
read_private_bytes(receipt, max_bytes=16_384), label="Hub request receipt", strict=True
|
|
334
|
+
)
|
|
335
|
+
else:
|
|
336
|
+
if receipt.exists():
|
|
337
|
+
raise ValueError("Hub request pending evidence is missing")
|
|
338
|
+
write_private_bytes(pending, canonical_bytes(payload))
|
|
339
|
+
_, _, raw = hub_request(
|
|
340
|
+
selection.document["hub_url"], "/v1/deployment-requests", body=canonical_bytes(payload)
|
|
341
|
+
)
|
|
342
|
+
result = load_json_object(raw, label="Hub request response", max_bytes=16_384, strict=True)
|
|
343
|
+
if (
|
|
344
|
+
set(result) != set(payload) | {"status", "trust", "recorded_at"}
|
|
345
|
+
or any(result.get(name) != value for name, value in payload.items())
|
|
346
|
+
or result["status"] != "requested"
|
|
347
|
+
or result["trust"] != "self-reported"
|
|
348
|
+
or not isinstance(result["recorded_at"], str)
|
|
349
|
+
or datetime.fromisoformat(result["recorded_at"]).tzinfo is None
|
|
350
|
+
):
|
|
351
|
+
raise ValueError("Hub request receipt differs; deployment is stopped")
|
|
352
|
+
if not receipt.exists():
|
|
353
|
+
write_private_bytes(receipt, canonical_bytes(result))
|
|
354
|
+
return result
|