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,264 @@
|
|
|
1
|
+
"""Exact Terraform import, state-release, and rollback contract for Hub workload handoff."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest
|
|
11
|
+
from fdai_deployment_cli.hub_workload_handoff_inputs import (
|
|
12
|
+
INVENTORY_SCHEMA,
|
|
13
|
+
WorkloadStateObject,
|
|
14
|
+
build_handoff_inventory,
|
|
15
|
+
read_handoff_inventory,
|
|
16
|
+
write_handoff_operator_inputs,
|
|
17
|
+
)
|
|
18
|
+
from fdai_deployment_cli.private_output import write_private_bytes
|
|
19
|
+
|
|
20
|
+
TRANSITION_SCHEMA = "fdai.hub-workload-state-transition.v1"
|
|
21
|
+
RECEIPT_SCHEMA = "fdai.hub-workload-state-transition-receipt.v1"
|
|
22
|
+
ADOPTION_RELEASE_SCHEMA = "fdai.runtime-adoption-release.v1"
|
|
23
|
+
PLAN_RECEIPT_SCHEMA = "fdai.hub-workload-plan-verification.v1"
|
|
24
|
+
_DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z", re.ASCII)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"WorkloadStateObject",
|
|
28
|
+
"build_handoff_inventory",
|
|
29
|
+
"read_handoff_inventory",
|
|
30
|
+
"verify_handoff_plan",
|
|
31
|
+
"verify_handoff_plans",
|
|
32
|
+
"write_handoff_operator_inputs",
|
|
33
|
+
"write_handoff_terraform_files",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def verify_handoff_plan(
|
|
38
|
+
plan: Mapping[str, object],
|
|
39
|
+
objects: Sequence[WorkloadStateObject],
|
|
40
|
+
*,
|
|
41
|
+
phase: str,
|
|
42
|
+
inventory_digest: str,
|
|
43
|
+
) -> dict[str, object]:
|
|
44
|
+
"""Verify an exact import, forget-only release, or post-import zero-change plan."""
|
|
45
|
+
|
|
46
|
+
if (
|
|
47
|
+
phase not in {"import", "release", "zero-change"}
|
|
48
|
+
or _DIGEST.fullmatch(inventory_digest) is None
|
|
49
|
+
):
|
|
50
|
+
raise ValueError("Hub handoff plan verification inputs are invalid")
|
|
51
|
+
expected = {item.address: item for item in objects}
|
|
52
|
+
if not expected or len(expected) != len(objects):
|
|
53
|
+
raise ValueError("Hub handoff plan objects must be unique")
|
|
54
|
+
_plan_header(plan, applyable=phase != "zero-change")
|
|
55
|
+
changes = _plan_changes(plan)
|
|
56
|
+
if set(changes) != set(expected):
|
|
57
|
+
raise ValueError(f"Hub handoff {phase} plan inventory differs")
|
|
58
|
+
for address, change in changes.items():
|
|
59
|
+
actions = change.get("actions")
|
|
60
|
+
importing = change.get("importing")
|
|
61
|
+
if phase == "import":
|
|
62
|
+
before = change.get("before")
|
|
63
|
+
if (
|
|
64
|
+
actions != ["no-op"]
|
|
65
|
+
or not isinstance(importing, Mapping)
|
|
66
|
+
or importing.get("id") != expected[address].import_id
|
|
67
|
+
or (before is not None and before != change.get("after"))
|
|
68
|
+
):
|
|
69
|
+
raise ValueError("Hub handoff import plan is not exact import-only")
|
|
70
|
+
elif phase == "release":
|
|
71
|
+
if (
|
|
72
|
+
actions != ["forget"]
|
|
73
|
+
or importing is not None
|
|
74
|
+
or change.get("before") is None
|
|
75
|
+
or change.get("after") is not None
|
|
76
|
+
):
|
|
77
|
+
raise ValueError("Hub handoff release plan is not exact forget-only")
|
|
78
|
+
elif (
|
|
79
|
+
actions != ["no-op"]
|
|
80
|
+
or importing is not None
|
|
81
|
+
or "before" not in change
|
|
82
|
+
or change.get("before") != change.get("after")
|
|
83
|
+
):
|
|
84
|
+
raise ValueError("Hub handoff plan is not zero-change")
|
|
85
|
+
body: dict[str, object] = {
|
|
86
|
+
"schema_version": PLAN_RECEIPT_SCHEMA,
|
|
87
|
+
"phase": phase,
|
|
88
|
+
"inventory_digest": inventory_digest,
|
|
89
|
+
"plan_digest": "sha256:" + canonical_digest(plan),
|
|
90
|
+
"object_count": len(expected),
|
|
91
|
+
"live_mutation_authorized": False,
|
|
92
|
+
"state_effect": {
|
|
93
|
+
"import": "import-only",
|
|
94
|
+
"release": "forgotten-without-destroy",
|
|
95
|
+
"zero-change": "zero-change",
|
|
96
|
+
}[phase],
|
|
97
|
+
}
|
|
98
|
+
return body | {"receipt_digest": "sha256:" + canonical_digest(body)}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def write_handoff_terraform_files(
|
|
102
|
+
destination: Path,
|
|
103
|
+
objects: Sequence[WorkloadStateObject],
|
|
104
|
+
) -> tuple[Path, Path]:
|
|
105
|
+
"""Write exact removed/import blocks into a fresh private execution directory."""
|
|
106
|
+
|
|
107
|
+
if destination.exists() or destination.is_symlink():
|
|
108
|
+
raise ValueError("Hub handoff Terraform destination already exists")
|
|
109
|
+
by_address = {item.address: item for item in objects}
|
|
110
|
+
if not by_address or len(by_address) != len(objects):
|
|
111
|
+
raise ValueError("Hub handoff Terraform objects must be unique")
|
|
112
|
+
destination.mkdir(mode=0o700, parents=True)
|
|
113
|
+
release_path = destination / "hub-release.tf.json"
|
|
114
|
+
rollback_path = destination / "hub-rollback.tf.json"
|
|
115
|
+
write_private_bytes(
|
|
116
|
+
release_path,
|
|
117
|
+
canonical_bytes(
|
|
118
|
+
{
|
|
119
|
+
"removed": [
|
|
120
|
+
{
|
|
121
|
+
"from": f"${{{address}}}",
|
|
122
|
+
"lifecycle": {"destroy": False},
|
|
123
|
+
}
|
|
124
|
+
for address in sorted(by_address)
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
+ b"\n",
|
|
129
|
+
)
|
|
130
|
+
write_private_bytes(
|
|
131
|
+
rollback_path,
|
|
132
|
+
canonical_bytes(
|
|
133
|
+
{
|
|
134
|
+
"import": [
|
|
135
|
+
{
|
|
136
|
+
"to": f"${{{address}}}",
|
|
137
|
+
"id": by_address[address].import_id,
|
|
138
|
+
}
|
|
139
|
+
for address in sorted(by_address)
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
+ b"\n",
|
|
144
|
+
)
|
|
145
|
+
return release_path, rollback_path
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def verify_handoff_plans(
|
|
149
|
+
release_plan: Mapping[str, object],
|
|
150
|
+
rollback_plan: Mapping[str, object],
|
|
151
|
+
objects: Sequence[WorkloadStateObject],
|
|
152
|
+
*,
|
|
153
|
+
adoption_release: Mapping[str, object],
|
|
154
|
+
) -> dict[str, object]:
|
|
155
|
+
"""Require exact forget-only release and zero-change rollback plans."""
|
|
156
|
+
|
|
157
|
+
adoption_release_digest = _adoption_release_digest(adoption_release)
|
|
158
|
+
inventory_body = {
|
|
159
|
+
"schema_version": INVENTORY_SCHEMA,
|
|
160
|
+
"objects": [
|
|
161
|
+
{
|
|
162
|
+
"address": item.address,
|
|
163
|
+
"import_id": item.import_id,
|
|
164
|
+
"render_digest": item.render_digest,
|
|
165
|
+
}
|
|
166
|
+
for item in sorted(objects, key=lambda value: value.address)
|
|
167
|
+
],
|
|
168
|
+
}
|
|
169
|
+
inventory_digest = "sha256:" + canonical_digest(inventory_body)
|
|
170
|
+
release_verification = verify_handoff_plan(
|
|
171
|
+
release_plan,
|
|
172
|
+
objects,
|
|
173
|
+
phase="release",
|
|
174
|
+
inventory_digest=inventory_digest,
|
|
175
|
+
)
|
|
176
|
+
rollback_verification = verify_handoff_plan(
|
|
177
|
+
rollback_plan,
|
|
178
|
+
objects,
|
|
179
|
+
phase="zero-change",
|
|
180
|
+
inventory_digest=inventory_digest,
|
|
181
|
+
)
|
|
182
|
+
body: dict[str, object] = {
|
|
183
|
+
"schema_version": RECEIPT_SCHEMA,
|
|
184
|
+
"objects": [
|
|
185
|
+
{
|
|
186
|
+
"address": item.address,
|
|
187
|
+
"import_id": item.import_id,
|
|
188
|
+
"render_digest": item.render_digest,
|
|
189
|
+
}
|
|
190
|
+
for item in sorted(objects, key=lambda value: value.address)
|
|
191
|
+
],
|
|
192
|
+
"release_plan_digest": "sha256:" + canonical_digest(release_plan),
|
|
193
|
+
"rollback_plan_digest": "sha256:" + canonical_digest(rollback_plan),
|
|
194
|
+
"release_verification_digest": release_verification["receipt_digest"],
|
|
195
|
+
"rollback_verification_digest": rollback_verification["receipt_digest"],
|
|
196
|
+
"adoption_release_receipt_digest": adoption_release_digest,
|
|
197
|
+
"release_state": "forgotten-without-destroy",
|
|
198
|
+
"rollback_state": "zero-change",
|
|
199
|
+
}
|
|
200
|
+
return body | {"receipt_digest": "sha256:" + canonical_digest(body)}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _adoption_release_digest(receipt: Mapping[str, object]) -> str:
|
|
204
|
+
digest = receipt.get("receipt_digest")
|
|
205
|
+
body = {key: value for key, value in receipt.items() if key != "receipt_digest"}
|
|
206
|
+
if (
|
|
207
|
+
body.get("schema_version") != ADOPTION_RELEASE_SCHEMA
|
|
208
|
+
or body.get("reconciliation_released") is not True
|
|
209
|
+
or not isinstance(body.get("installation_id"), str)
|
|
210
|
+
or not isinstance(body.get("release_id"), str)
|
|
211
|
+
or _DIGEST.fullmatch(str(body.get("adoption_receipt_digest"))) is None
|
|
212
|
+
or not isinstance(digest, str)
|
|
213
|
+
or digest != "sha256:" + canonical_digest(body)
|
|
214
|
+
):
|
|
215
|
+
raise ValueError("Agent reconciliation release receipt is invalid")
|
|
216
|
+
return digest
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _plan_header(plan: Mapping[str, object], *, applyable: bool) -> None:
|
|
220
|
+
if (
|
|
221
|
+
plan.get("format_version") not in {"1.1", "1.2"}
|
|
222
|
+
or plan.get("complete") is not True
|
|
223
|
+
or plan.get("errored") is not False
|
|
224
|
+
or plan.get("applyable") is not applyable
|
|
225
|
+
or plan.get("resource_drift", []) != []
|
|
226
|
+
or plan.get("deferred_changes", []) != []
|
|
227
|
+
):
|
|
228
|
+
raise ValueError("Hub handoff plan metadata is invalid")
|
|
229
|
+
output_changes = plan.get("output_changes", {})
|
|
230
|
+
if not isinstance(output_changes, Mapping):
|
|
231
|
+
raise ValueError( # noqa: TRY004 - normalize untrusted plan JSON
|
|
232
|
+
"Hub handoff plan outputs are invalid"
|
|
233
|
+
)
|
|
234
|
+
if phase_changes := [
|
|
235
|
+
value
|
|
236
|
+
for value in output_changes.values()
|
|
237
|
+
if not isinstance(value, Mapping) or value.get("actions") != ["no-op"]
|
|
238
|
+
]:
|
|
239
|
+
raise ValueError(f"Hub handoff plan contains {len(phase_changes)} output changes")
|
|
240
|
+
checks = plan.get("checks", [])
|
|
241
|
+
if not isinstance(checks, list) or any(
|
|
242
|
+
not isinstance(check, Mapping) or check.get("status") != "pass" for check in checks
|
|
243
|
+
):
|
|
244
|
+
raise ValueError("Hub handoff plan checks must pass")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _plan_changes(plan: Mapping[str, object]) -> dict[str, dict[str, Any]]:
|
|
248
|
+
changes = plan.get("resource_changes")
|
|
249
|
+
if not isinstance(changes, list):
|
|
250
|
+
raise ValueError( # noqa: TRY004 - normalize untrusted plan JSON
|
|
251
|
+
"Terraform plan resource changes are invalid"
|
|
252
|
+
)
|
|
253
|
+
result: dict[str, dict[str, Any]] = {}
|
|
254
|
+
for item in changes:
|
|
255
|
+
if (
|
|
256
|
+
not isinstance(item, Mapping)
|
|
257
|
+
or not isinstance(address := item.get("address"), str)
|
|
258
|
+
or item.get("previous_address") is not None
|
|
259
|
+
or not isinstance(change := item.get("change"), Mapping)
|
|
260
|
+
or address in result
|
|
261
|
+
):
|
|
262
|
+
raise ValueError("Terraform plan resource change is invalid")
|
|
263
|
+
result[address] = dict(change)
|
|
264
|
+
return result
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Private full-topology inputs for the Hub workload Terraform state handoff."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest
|
|
11
|
+
from fdai_deployment_cli.private_output import write_private_bytes
|
|
12
|
+
|
|
13
|
+
INVENTORY_SCHEMA = "fdai.hub-workload-state-inventory.v1"
|
|
14
|
+
_ADDRESS = re.compile(
|
|
15
|
+
r"kubernetes_(?:deployment_v1|service_v1|cron_job_v1|horizontal_pod_autoscaler_v2)"
|
|
16
|
+
r"\.[a-z][a-z0-9_]*(?:\[[\"'][a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?[\"']\])?\Z",
|
|
17
|
+
re.ASCII,
|
|
18
|
+
)
|
|
19
|
+
_IMPORT_ID = re.compile(r"[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?/[A-Za-z0-9._/-]{1,253}\Z")
|
|
20
|
+
_DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z", re.ASCII)
|
|
21
|
+
_KIND_RESOURCES = {
|
|
22
|
+
"Deployment": ("kubernetes_deployment_v1", "workload", "deployments"),
|
|
23
|
+
"Service": ("kubernetes_service_v1", "workload", "services"),
|
|
24
|
+
"CronJob": ("kubernetes_cron_job_v1", "job", "cron_jobs"),
|
|
25
|
+
"HorizontalPodAutoscaler": (
|
|
26
|
+
"kubernetes_horizontal_pod_autoscaler_v2",
|
|
27
|
+
"workload",
|
|
28
|
+
"horizontal_pod_autoscalers",
|
|
29
|
+
),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class WorkloadStateObject:
|
|
35
|
+
address: str
|
|
36
|
+
import_id: str
|
|
37
|
+
render_digest: str
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if (
|
|
41
|
+
_ADDRESS.fullmatch(self.address) is None
|
|
42
|
+
or _IMPORT_ID.fullmatch(self.import_id) is None
|
|
43
|
+
or _DIGEST.fullmatch(self.render_digest) is None
|
|
44
|
+
):
|
|
45
|
+
raise ValueError("Hub workload state object is invalid")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_handoff_inventory(
|
|
49
|
+
native_scope: Mapping[str, object],
|
|
50
|
+
rendered_manifests: Sequence[Mapping[str, object]],
|
|
51
|
+
) -> dict[str, object]:
|
|
52
|
+
"""Bind one exact native scope to Terraform addresses, import IDs, and render digests."""
|
|
53
|
+
|
|
54
|
+
namespace = native_scope.get("namespace")
|
|
55
|
+
if not isinstance(namespace, str):
|
|
56
|
+
raise ValueError( # noqa: TRY004 - normalize untrusted JSON into one operator error
|
|
57
|
+
"Hub workload native scope namespace is invalid"
|
|
58
|
+
)
|
|
59
|
+
expected = _native_resources(native_scope)
|
|
60
|
+
objects: list[WorkloadStateObject] = []
|
|
61
|
+
actual: set[tuple[str, str]] = set()
|
|
62
|
+
tfvars: dict[str, dict[str, str]] = {
|
|
63
|
+
"deployments": {},
|
|
64
|
+
"services": {},
|
|
65
|
+
"cron_jobs": {},
|
|
66
|
+
"horizontal_pod_autoscalers": {},
|
|
67
|
+
}
|
|
68
|
+
for manifest in rendered_manifests:
|
|
69
|
+
kind = manifest.get("kind")
|
|
70
|
+
metadata = manifest.get("metadata")
|
|
71
|
+
if (
|
|
72
|
+
not isinstance(kind, str)
|
|
73
|
+
or kind not in _KIND_RESOURCES
|
|
74
|
+
or not isinstance(metadata, Mapping)
|
|
75
|
+
or not isinstance(name := metadata.get("name"), str)
|
|
76
|
+
or metadata.get("namespace") != namespace
|
|
77
|
+
):
|
|
78
|
+
raise ValueError("Hub workload rendered manifest is invalid")
|
|
79
|
+
key = (kind, name)
|
|
80
|
+
if key in actual:
|
|
81
|
+
raise ValueError("Hub workload rendered manifests must be unique")
|
|
82
|
+
actual.add(key)
|
|
83
|
+
resource_type, resource_name, variable_name = _KIND_RESOURCES[kind]
|
|
84
|
+
address = f'{resource_type}.{resource_name}["{name}"]'
|
|
85
|
+
import_id = f"{namespace}/{name}"
|
|
86
|
+
objects.append(
|
|
87
|
+
WorkloadStateObject(
|
|
88
|
+
address=address,
|
|
89
|
+
import_id=import_id,
|
|
90
|
+
render_digest="sha256:" + canonical_digest(manifest),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
tfvars[variable_name][name] = import_id
|
|
94
|
+
if actual != expected:
|
|
95
|
+
raise ValueError("Hub workload rendered inventory differs from the native scope")
|
|
96
|
+
ordered = sorted(objects, key=lambda item: item.address)
|
|
97
|
+
body: dict[str, object] = {
|
|
98
|
+
"schema_version": INVENTORY_SCHEMA,
|
|
99
|
+
"namespace": namespace,
|
|
100
|
+
"objects": [
|
|
101
|
+
{
|
|
102
|
+
"address": item.address,
|
|
103
|
+
"import_id": item.import_id,
|
|
104
|
+
"render_digest": item.render_digest,
|
|
105
|
+
}
|
|
106
|
+
for item in ordered
|
|
107
|
+
],
|
|
108
|
+
"terraform_variables": {
|
|
109
|
+
"namespace": namespace,
|
|
110
|
+
**{key: dict(sorted(value.items())) for key, value in tfvars.items()},
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
return body | {"inventory_digest": "sha256:" + canonical_digest(body)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def write_handoff_operator_inputs(
|
|
117
|
+
destination: Path,
|
|
118
|
+
native_scope: Mapping[str, object],
|
|
119
|
+
rendered_manifests: Sequence[Mapping[str, object]],
|
|
120
|
+
) -> tuple[Path, Path, Path]:
|
|
121
|
+
"""Write exact Terraform variables, verified inventory, and phase commands."""
|
|
122
|
+
|
|
123
|
+
inventory = build_handoff_inventory(native_scope, rendered_manifests)
|
|
124
|
+
inventory_path = destination / "workload-handoff-inventory.json"
|
|
125
|
+
tfvars_path = destination / "workload-handoff.tfvars.json"
|
|
126
|
+
commands_path = destination / "workload-handoff-commands.txt"
|
|
127
|
+
write_private_bytes(inventory_path, canonical_bytes(inventory) + b"\n")
|
|
128
|
+
write_private_bytes(
|
|
129
|
+
tfvars_path,
|
|
130
|
+
canonical_bytes(inventory["terraform_variables"]) + b"\n",
|
|
131
|
+
)
|
|
132
|
+
write_private_bytes(commands_path, _commands().encode())
|
|
133
|
+
return inventory_path, tfvars_path, commands_path
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def read_handoff_inventory(document: Mapping[str, object]) -> tuple[WorkloadStateObject, ...]:
|
|
137
|
+
"""Verify and load a generated handoff inventory without trusting its Terraform variables."""
|
|
138
|
+
|
|
139
|
+
digest = document.get("inventory_digest")
|
|
140
|
+
body = {key: value for key, value in document.items() if key != "inventory_digest"}
|
|
141
|
+
objects = body.get("objects")
|
|
142
|
+
if (
|
|
143
|
+
body.get("schema_version") != INVENTORY_SCHEMA
|
|
144
|
+
or not isinstance(body.get("namespace"), str)
|
|
145
|
+
or not isinstance(objects, list)
|
|
146
|
+
or not objects
|
|
147
|
+
or not isinstance(digest, str)
|
|
148
|
+
or digest != "sha256:" + canonical_digest(body)
|
|
149
|
+
):
|
|
150
|
+
raise ValueError("Hub workload state inventory is invalid")
|
|
151
|
+
result: list[WorkloadStateObject] = []
|
|
152
|
+
for item in objects:
|
|
153
|
+
if not isinstance(item, Mapping):
|
|
154
|
+
raise ValueError( # noqa: TRY004 - normalize untrusted JSON into one operator error
|
|
155
|
+
"Hub workload state inventory is invalid"
|
|
156
|
+
)
|
|
157
|
+
result.append(
|
|
158
|
+
WorkloadStateObject(
|
|
159
|
+
address=str(item.get("address", "")),
|
|
160
|
+
import_id=str(item.get("import_id", "")),
|
|
161
|
+
render_digest=str(item.get("render_digest", "")),
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
if len({item.address for item in result}) != len(result):
|
|
165
|
+
raise ValueError("Hub workload state inventory is invalid")
|
|
166
|
+
return tuple(result)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _native_resources(scope: Mapping[str, object]) -> set[tuple[str, str]]:
|
|
170
|
+
components = _names(scope.get("components"), required=True)
|
|
171
|
+
services = _names(scope.get("service_components"), required=False)
|
|
172
|
+
schedules = _names(scope.get("scheduled_components"), required=False)
|
|
173
|
+
autoscalers = _names(scope.get("autoscaled_components"), required=False)
|
|
174
|
+
if not services and scope.get("service_components") is None:
|
|
175
|
+
services = components
|
|
176
|
+
if not services <= components or not autoscalers <= components or components & schedules:
|
|
177
|
+
raise ValueError("Hub workload native scope is invalid")
|
|
178
|
+
return (
|
|
179
|
+
{("Deployment", name) for name in components}
|
|
180
|
+
| {("Service", name) for name in services}
|
|
181
|
+
| {("CronJob", name) for name in schedules}
|
|
182
|
+
| {("HorizontalPodAutoscaler", name) for name in autoscalers}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _names(value: object, *, required: bool) -> set[str]:
|
|
187
|
+
if value is None and not required:
|
|
188
|
+
return set()
|
|
189
|
+
if (
|
|
190
|
+
not isinstance(value, (list, tuple))
|
|
191
|
+
or (required and not value)
|
|
192
|
+
or any(not isinstance(item, str) for item in value)
|
|
193
|
+
or len(set(value)) != len(value)
|
|
194
|
+
):
|
|
195
|
+
raise ValueError("Hub workload native scope is invalid")
|
|
196
|
+
return set(value)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _commands() -> str:
|
|
200
|
+
return """\
|
|
201
|
+
# Replace angle-bracket values with private operator paths. Reuse one absolute backend path.
|
|
202
|
+
umask 077
|
|
203
|
+
terraform -chdir=infra/hub-workload-state-handoff/import init -reconfigure -backend-config=path=<absolute-private-state-path>
|
|
204
|
+
terraform -chdir=infra/hub-workload-state-handoff/import plan -var-file=<workload-handoff.tfvars.json> -var=kubeconfig_path=<private-kubeconfig> -var=kubeconfig_context=<kube-context> -out=<import-plan>
|
|
205
|
+
terraform show -json <import-plan> > <import-plan.json>
|
|
206
|
+
fdai-lifecycle-manager verify-workload-handoff-plan --phase import --inventory <workload-handoff-inventory.json> --plan-json <import-plan.json> --destination <import-verification.json>
|
|
207
|
+
terraform -chdir=infra/hub-workload-state-handoff/import apply <import-plan>
|
|
208
|
+
terraform -chdir=infra/hub-workload-state-handoff/import plan -detailed-exitcode -var-file=<workload-handoff.tfvars.json> -var=kubeconfig_path=<private-kubeconfig> -var=kubeconfig_context=<kube-context> -out=<import-zero-plan>
|
|
209
|
+
terraform show -json <import-zero-plan> > <import-zero-plan.json>
|
|
210
|
+
fdai-lifecycle-manager verify-workload-handoff-plan --phase zero-change --inventory <workload-handoff-inventory.json> --plan-json <import-zero-plan.json> --destination <import-zero-verification.json>
|
|
211
|
+
|
|
212
|
+
terraform -chdir=infra/hub-workload-state-handoff/release init -reconfigure -backend-config=path=<absolute-private-state-path>
|
|
213
|
+
terraform -chdir=infra/hub-workload-state-handoff/release plan -var-file=<workload-handoff.tfvars.json> -var=kubeconfig_path=<private-kubeconfig> -var=kubeconfig_context=<kube-context> -out=<release-plan>
|
|
214
|
+
terraform show -json <release-plan> > <release-plan.json>
|
|
215
|
+
fdai-lifecycle-manager verify-workload-handoff-plan --phase release --inventory <workload-handoff-inventory.json> --plan-json <release-plan.json> --destination <release-verification.json>
|
|
216
|
+
terraform -chdir=infra/hub-workload-state-handoff/release apply <release-plan>
|
|
217
|
+
|
|
218
|
+
# Rollback only after the Agent reconciliation-release receipt is retained.
|
|
219
|
+
terraform -chdir=infra/hub-workload-state-handoff/import init -reconfigure -backend-config=path=<absolute-private-state-path>
|
|
220
|
+
terraform -chdir=infra/hub-workload-state-handoff/import plan -var-file=<workload-handoff.tfvars.json> -var=kubeconfig_path=<private-kubeconfig> -var=kubeconfig_context=<kube-context> -out=<rollback-import-plan>
|
|
221
|
+
terraform show -json <rollback-import-plan> > <rollback-import-plan.json>
|
|
222
|
+
fdai-lifecycle-manager verify-workload-handoff-plan --phase import --inventory <workload-handoff-inventory.json> --plan-json <rollback-import-plan.json> --destination <rollback-import-verification.json>
|
|
223
|
+
terraform -chdir=infra/hub-workload-state-handoff/import apply <rollback-import-plan>
|
|
224
|
+
terraform -chdir=infra/hub-workload-state-handoff/import plan -detailed-exitcode -var-file=<workload-handoff.tfvars.json> -var=kubeconfig_path=<private-kubeconfig> -var=kubeconfig_context=<kube-context> -out=<rollback-zero-plan>
|
|
225
|
+
terraform show -json <rollback-zero-plan> > <rollback-zero-plan.json>
|
|
226
|
+
fdai-lifecycle-manager verify-workload-handoff-plan --phase zero-change --inventory <workload-handoff-inventory.json> --plan-json <rollback-zero-plan.json> --destination <rollback-zero-verification.json>
|
|
227
|
+
"""
|