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.
Files changed (203) hide show
  1. fdai_deployment_cli/__about__.py +3 -0
  2. fdai_deployment_cli/__init__.py +5 -0
  3. fdai_deployment_cli/__main__.py +5 -0
  4. fdai_deployment_cli/acr_publish.py +585 -0
  5. fdai_deployment_cli/aks_catalog_review.py +137 -0
  6. fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
  7. fdai_deployment_cli/aks_job_environment.py +59 -0
  8. fdai_deployment_cli/aks_job_execution.py +794 -0
  9. fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
  10. fdai_deployment_cli/aks_preflight.py +422 -0
  11. fdai_deployment_cli/aks_readiness.py +220 -0
  12. fdai_deployment_cli/aks_service_update.py +180 -0
  13. fdai_deployment_cli/aks_workload_jobs.py +281 -0
  14. fdai_deployment_cli/application_state_adoption.py +347 -0
  15. fdai_deployment_cli/azure_actor.py +61 -0
  16. fdai_deployment_cli/azure_naming.py +252 -0
  17. fdai_deployment_cli/azure_retail_prices.py +173 -0
  18. fdai_deployment_cli/bootstrap_approval.py +99 -0
  19. fdai_deployment_cli/bootstrap_channel.py +321 -0
  20. fdai_deployment_cli/bootstrap_executor.py +311 -0
  21. fdai_deployment_cli/bootstrap_plan.py +237 -0
  22. fdai_deployment_cli/bootstrap_reconcile.py +402 -0
  23. fdai_deployment_cli/bootstrap_support.py +138 -0
  24. fdai_deployment_cli/bootstrap_worker.py +448 -0
  25. fdai_deployment_cli/bundle.py +352 -0
  26. fdai_deployment_cli/capacity.py +143 -0
  27. fdai_deployment_cli/catalog_review_profile.py +306 -0
  28. fdai_deployment_cli/cli.py +798 -0
  29. fdai_deployment_cli/cli_help.py +214 -0
  30. fdai_deployment_cli/cli_parser.py +791 -0
  31. fdai_deployment_cli/cli_plan.py +450 -0
  32. fdai_deployment_cli/compiler.py +99 -0
  33. fdai_deployment_cli/console_artifact.py +218 -0
  34. fdai_deployment_cli/console_config.py +214 -0
  35. fdai_deployment_cli/console_update.py +832 -0
  36. fdai_deployment_cli/contracts.py +307 -0
  37. fdai_deployment_cli/control_package.py +140 -0
  38. fdai_deployment_cli/deadline_transport.py +70 -0
  39. fdai_deployment_cli/deployment_cost.py +131 -0
  40. fdai_deployment_cli/deployment_deadline.py +28 -0
  41. fdai_deployment_cli/deployment_kit.py +790 -0
  42. fdai_deployment_cli/deployment_kit_cache.py +191 -0
  43. fdai_deployment_cli/deployment_progress.py +648 -0
  44. fdai_deployment_cli/development_authority_cli.py +56 -0
  45. fdai_deployment_cli/development_authority_profile.py +319 -0
  46. fdai_deployment_cli/doctor.py +136 -0
  47. fdai_deployment_cli/entitlement_preflight.py +68 -0
  48. fdai_deployment_cli/entitlement_transport.py +49 -0
  49. fdai_deployment_cli/entra_bootstrap.py +579 -0
  50. fdai_deployment_cli/entra_control_profile.py +214 -0
  51. fdai_deployment_cli/entra_graph.py +349 -0
  52. fdai_deployment_cli/entra_profiles.py +215 -0
  53. fdai_deployment_cli/entra_source.py +244 -0
  54. fdai_deployment_cli/execution_bundle.py +478 -0
  55. fdai_deployment_cli/execution_copies.py +63 -0
  56. fdai_deployment_cli/foundation_adoption.py +438 -0
  57. fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
  58. fdai_deployment_cli/foundation_adoption_host.py +157 -0
  59. fdai_deployment_cli/foundation_adoption_transport.py +51 -0
  60. fdai_deployment_cli/foundation_failure.py +72 -0
  61. fdai_deployment_cli/foundation_image.py +168 -0
  62. fdai_deployment_cli/foundation_input.py +235 -0
  63. fdai_deployment_cli/foundation_output.py +154 -0
  64. fdai_deployment_cli/foundation_plan.py +381 -0
  65. fdai_deployment_cli/foundation_process.py +64 -0
  66. fdai_deployment_cli/foundation_progress.py +231 -0
  67. fdai_deployment_cli/github_actions.py +171 -0
  68. fdai_deployment_cli/github_apply_artifact.py +259 -0
  69. fdai_deployment_cli/github_artifact_io.py +48 -0
  70. fdai_deployment_cli/github_dispatch.py +248 -0
  71. fdai_deployment_cli/github_plan_artifact.py +181 -0
  72. fdai_deployment_cli/github_provider_artifact.py +170 -0
  73. fdai_deployment_cli/github_transport.py +46 -0
  74. fdai_deployment_cli/github_workflow_values.py +312 -0
  75. fdai_deployment_cli/hub_image_deploy.py +354 -0
  76. fdai_deployment_cli/hub_image_stage.py +162 -0
  77. fdai_deployment_cli/hub_image_trust.py +281 -0
  78. fdai_deployment_cli/hub_ownership.py +161 -0
  79. fdai_deployment_cli/hub_workload_handoff.py +264 -0
  80. fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
  81. fdai_deployment_cli/identity_profile.py +227 -0
  82. fdai_deployment_cli/image_targets.py +197 -0
  83. fdai_deployment_cli/installation_scope.py +280 -0
  84. fdai_deployment_cli/license.py +311 -0
  85. fdai_deployment_cli/license_issue.py +239 -0
  86. fdai_deployment_cli/lifecycle_authorization.py +252 -0
  87. fdai_deployment_cli/lifecycle_commands.py +173 -0
  88. fdai_deployment_cli/lifecycle_configuration.py +722 -0
  89. fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
  90. fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
  91. fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
  92. fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
  93. fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
  94. fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
  95. fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
  96. fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
  97. fdai_deployment_cli/lifecycle_enrollment.py +45 -0
  98. fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
  99. fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
  100. fdai_deployment_cli/lifecycle_native.py +247 -0
  101. fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
  102. fdai_deployment_cli/lifecycle_native_transition.py +339 -0
  103. fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
  104. fdai_deployment_cli/lifecycle_plan.py +800 -0
  105. fdai_deployment_cli/lifecycle_promotion.py +185 -0
  106. fdai_deployment_cli/lifecycle_release.py +308 -0
  107. fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
  108. fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
  109. fdai_deployment_cli/notification_recipients_input.py +222 -0
  110. fdai_deployment_cli/observer_artifact.py +106 -0
  111. fdai_deployment_cli/oci_archive.py +353 -0
  112. fdai_deployment_cli/offline_kit.py +797 -0
  113. fdai_deployment_cli/offline_prepare.py +135 -0
  114. fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
  115. fdai_deployment_cli/plan_input.py +101 -0
  116. fdai_deployment_cli/private_output.py +192 -0
  117. fdai_deployment_cli/profile.py +70 -0
  118. fdai_deployment_cli/progress.py +183 -0
  119. fdai_deployment_cli/readiness.py +286 -0
  120. fdai_deployment_cli/run_command_receiver.py +418 -0
  121. fdai_deployment_cli/runtime_build.py +386 -0
  122. fdai_deployment_cli/runtime_profile.py +287 -0
  123. fdai_deployment_cli/runtime_release.py +745 -0
  124. fdai_deployment_cli/runtime_stage.py +82 -0
  125. fdai_deployment_cli/runtime_support_installation.py +215 -0
  126. fdai_deployment_cli/simulation.py +80 -0
  127. fdai_deployment_cli/source_application_inputs.py +397 -0
  128. fdai_deployment_cli/source_azure.py +586 -0
  129. fdai_deployment_cli/source_change_cli.py +107 -0
  130. fdai_deployment_cli/source_change_parser.py +75 -0
  131. fdai_deployment_cli/source_change_plan.py +181 -0
  132. fdai_deployment_cli/source_changed_update.py +196 -0
  133. fdai_deployment_cli/source_deploy.py +170 -0
  134. fdai_deployment_cli/source_foundation.py +174 -0
  135. fdai_deployment_cli/source_host_tools.py +163 -0
  136. fdai_deployment_cli/source_image_stage.py +400 -0
  137. fdai_deployment_cli/source_input.py +173 -0
  138. fdai_deployment_cli/source_projection.py +164 -0
  139. fdai_deployment_cli/source_receiver.py +76 -0
  140. fdai_deployment_cli/source_recovery.py +264 -0
  141. fdai_deployment_cli/source_runtime.py +81 -0
  142. fdai_deployment_cli/source_runtime_support.py +368 -0
  143. fdai_deployment_cli/source_service_update.py +726 -0
  144. fdai_deployment_cli/source_service_update_cli.py +47 -0
  145. fdai_deployment_cli/source_snapshot.py +178 -0
  146. fdai_deployment_cli/source_teardown.py +574 -0
  147. fdai_deployment_cli/source_terraform.py +85 -0
  148. fdai_deployment_cli/source_transport.py +399 -0
  149. fdai_deployment_cli/standalone_aks_inventory.py +558 -0
  150. fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
  151. fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
  152. fdai_deployment_cli/standalone_application.py +977 -0
  153. fdai_deployment_cli/standalone_application_completion.py +151 -0
  154. fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
  155. fdai_deployment_cli/standalone_application_recovery.py +116 -0
  156. fdai_deployment_cli/standalone_approval_input.py +24 -0
  157. fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
  158. fdai_deployment_cli/standalone_catalog_review.py +251 -0
  159. fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
  160. fdai_deployment_cli/standalone_console_publish.py +174 -0
  161. fdai_deployment_cli/standalone_database_principals.py +41 -0
  162. fdai_deployment_cli/standalone_deploy.py +638 -0
  163. fdai_deployment_cli/standalone_development_authority.py +166 -0
  164. fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
  165. fdai_deployment_cli/standalone_foundation_transition.py +767 -0
  166. fdai_deployment_cli/standalone_host.py +4608 -0
  167. fdai_deployment_cli/standalone_host_rebind.py +226 -0
  168. fdai_deployment_cli/standalone_host_residual.py +104 -0
  169. fdai_deployment_cli/standalone_host_state.py +145 -0
  170. fdai_deployment_cli/standalone_host_values.py +563 -0
  171. fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
  172. fdai_deployment_cli/standalone_license_installation.py +177 -0
  173. fdai_deployment_cli/standalone_management_egress.py +83 -0
  174. fdai_deployment_cli/standalone_migration_evidence.py +38 -0
  175. fdai_deployment_cli/standalone_operational_evidence.py +356 -0
  176. fdai_deployment_cli/standalone_planned_outputs.py +147 -0
  177. fdai_deployment_cli/standalone_private_access_probe.py +100 -0
  178. fdai_deployment_cli/standalone_product_profile.py +110 -0
  179. fdai_deployment_cli/standalone_remote_prepare.py +442 -0
  180. fdai_deployment_cli/standalone_residual_apply.py +540 -0
  181. fdai_deployment_cli/standalone_residual_successor.py +431 -0
  182. fdai_deployment_cli/standalone_review.py +266 -0
  183. fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
  184. fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
  185. fdai_deployment_cli/standalone_stage_targets.py +325 -0
  186. fdai_deployment_cli/standalone_status.py +67 -0
  187. fdai_deployment_cli/standalone_terraform_environment.py +237 -0
  188. fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
  189. fdai_deployment_cli/standalone_trial_activation.py +193 -0
  190. fdai_deployment_cli/state.py +434 -0
  191. fdai_deployment_cli/state_handoff.py +253 -0
  192. fdai_deployment_cli/status_projection.py +97 -0
  193. fdai_deployment_cli/support_install.py +257 -0
  194. fdai_deployment_cli/target.py +46 -0
  195. fdai_deployment_cli/trust/capabilities.json +62 -0
  196. fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
  197. fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
  198. fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
  199. fdai_deployment_cli/trust_roots.py +50 -0
  200. fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
  201. fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
  202. fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
  203. fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,75 @@
1
+ """Register changed-source CLI commands without growing the top-level parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from collections.abc import Callable, Mapping
7
+ from pathlib import Path
8
+
9
+
10
+ def register(
11
+ provision_commands: argparse._SubParsersAction[argparse.ArgumentParser],
12
+ *,
13
+ command: Callable[..., argparse.ArgumentParser],
14
+ handlers: Mapping[str, Callable[[argparse.Namespace], int]],
15
+ ) -> None:
16
+ plan = command(
17
+ provision_commands,
18
+ "source-change-plan",
19
+ "Classify one exact Git diff into the narrowest safe deployment lane",
20
+ )
21
+ plan.add_argument("--source", type=Path, required=True, help="Clean Git checkout to inspect")
22
+ plan.add_argument("--base", required=True, help="Base Git revision for the exact diff")
23
+ plan.add_argument(
24
+ "--head",
25
+ default="HEAD",
26
+ help="Head Git revision for the exact diff (default: %(default)s)",
27
+ )
28
+ plan.add_argument(
29
+ "--output",
30
+ choices=("text", "json"),
31
+ default="text",
32
+ help="Output format (default: %(default)s)",
33
+ )
34
+ plan.set_defaults(handler=handlers["provision_source_change_plan"])
35
+
36
+ update = command(
37
+ provision_commands,
38
+ "source-changed-update",
39
+ "Deploy every affected baseline service from one exact Git diff",
40
+ )
41
+ update.add_argument(
42
+ "--source",
43
+ type=Path,
44
+ required=True,
45
+ help="Clean checkout whose HEAD supplies the update",
46
+ )
47
+ update.add_argument("--base", required=True, help="Base Git revision for affected services")
48
+ update.add_argument(
49
+ "--application-work-dir",
50
+ type=Path,
51
+ required=True,
52
+ help="Retained existing-development application work directory",
53
+ )
54
+ update.add_argument(
55
+ "--work-dir",
56
+ type=Path,
57
+ required=True,
58
+ help="Fresh or retained private changed-update work directory",
59
+ )
60
+ update.add_argument(
61
+ "--timeout-seconds",
62
+ type=int,
63
+ default=14400,
64
+ help="Total update deadline in seconds (default: %(default)s)",
65
+ )
66
+ update.add_argument(
67
+ "--output",
68
+ choices=("text", "json"),
69
+ default="text",
70
+ help="Output format (default: %(default)s)",
71
+ )
72
+ update.set_defaults(handler=handlers["provision_source_changed_update"])
73
+
74
+
75
+ __all__ = ["register"]
@@ -0,0 +1,181 @@
1
+ """Classify a clean Git diff into the narrowest safe source deployment lane."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from collections.abc import Iterable
8
+ from pathlib import Path
9
+
10
+ from fdai_deployment_cli.contracts import canonical_digest
11
+ from fdai_deployment_cli.image_targets import IMAGE_TARGETS, select_image_targets
12
+ from fdai_deployment_cli.runtime_release import RUNTIME_SERVICES
13
+
14
+ _COMMIT = re.compile(r"[0-9a-f]{40}")
15
+ _MAX_CHANGED_PATHS = 20_000
16
+ _HIGHER_LANE_PREFIXES = (
17
+ "alembic/",
18
+ "infra/",
19
+ "lifecycle/",
20
+ "packages/deployment-cli/",
21
+ "scripts/deployment/azure/",
22
+ "service-migrations/",
23
+ )
24
+ _HIGHER_LANE_EXACT = frozenset({"alembic.ini", "azure.yaml"})
25
+ _NO_DEPLOY_PREFIXES = (
26
+ ".github/",
27
+ ".vscode/",
28
+ "docs/",
29
+ "examples/",
30
+ "mocks/",
31
+ "site/",
32
+ "tests/",
33
+ "tools/",
34
+ )
35
+ _RUNTIME_EXCEPTION_PATHS = frozenset({"docs/internals/sregym-absorption-ledger.json"})
36
+ _RUNTIME_EXCEPTION_PREFIXES = ("services/core-control-plane/tests/scenarios/",)
37
+ _SERVICE_BUILD_CONTRACTS = frozenset(
38
+ {
39
+ *(target.dockerfile for target in IMAGE_TARGETS),
40
+ *(f"services/{target.service}/pyproject.toml" for target in IMAGE_TARGETS),
41
+ }
42
+ )
43
+
44
+
45
+ def source_change_plan(
46
+ changed_paths: Iterable[str],
47
+ *,
48
+ base_commit: str,
49
+ head_commit: str,
50
+ deleted_paths: Iterable[str] = (),
51
+ ) -> dict[str, object]:
52
+ """Return a no-effect deployment route for one exact commit range."""
53
+ if _COMMIT.fullmatch(base_commit) is None or _COMMIT.fullmatch(head_commit) is None:
54
+ raise ValueError("source change plan requires exact commit ids")
55
+ paths = tuple(sorted({path.removeprefix("./") for path in changed_paths if path}))
56
+ if len(paths) > _MAX_CHANGED_PATHS or any(
57
+ path.startswith("/") or ".." in Path(path).parts or "\x00" in path for path in paths
58
+ ):
59
+ raise ValueError("source change plan paths are invalid or exceed their bound")
60
+ deleted = tuple(sorted({path.removeprefix("./") for path in deleted_paths if path}))
61
+ if any(path not in paths for path in deleted):
62
+ raise ValueError("source change plan deletions must belong to the exact diff")
63
+ runtime_paths = tuple(path for path in paths if not _is_no_deploy_path(path))
64
+
65
+ higher_lane = tuple(
66
+ path
67
+ for path in runtime_paths
68
+ if path in _HIGHER_LANE_EXACT or path.startswith(_HIGHER_LANE_PREFIXES)
69
+ )
70
+ removed_contracts = tuple(path for path in deleted if path in _SERVICE_BUILD_CONTRACTS)
71
+ console_paths = tuple(path for path in runtime_paths if path.startswith("console/"))
72
+ image_targets = select_image_targets(runtime_paths)
73
+ unsupported = tuple(
74
+ target.target
75
+ for target in image_targets
76
+ if target.service not in RUNTIME_SERVICES or target.target != target.service
77
+ )
78
+ services = tuple(
79
+ target.service
80
+ for target in image_targets
81
+ if target.service in RUNTIME_SERVICES and target.target == target.service
82
+ )
83
+ services = tuple(dict.fromkeys(services))
84
+
85
+ if removed_contracts:
86
+ lane, reason = "higher-lane-required", "service_build_contract_removed"
87
+ higher_lane = tuple(sorted({*higher_lane, *removed_contracts}))
88
+ elif higher_lane:
89
+ lane, reason = "higher-lane-required", "deployment_contract_changed"
90
+ elif unsupported:
91
+ lane, reason = "full-release-required", "unsupported_hot_path_target"
92
+ elif console_paths and services:
93
+ lane, reason = "split-update-required", "console_and_service_changed"
94
+ elif console_paths:
95
+ lane, reason = "console-update", "console_changed"
96
+ elif len(services) == 1:
97
+ lane, reason = "single-service-update", "one_service_changed"
98
+ elif len(services) > 1:
99
+ lane, reason = "multi-service-update", "multiple_services_changed"
100
+ elif paths and not runtime_paths:
101
+ lane, reason = "no-deploy", "non_runtime_files_changed"
102
+ elif paths:
103
+ lane, reason = "higher-lane-required", "unclassified_runtime_impact"
104
+ else:
105
+ lane, reason = "no-deploy", "no_files_changed"
106
+
107
+ result: dict[str, object] = {
108
+ "schema_version": "fdai.source-change-plan.v1",
109
+ "base_commit": base_commit,
110
+ "head_commit": head_commit,
111
+ "lane": lane,
112
+ "reason_code": reason,
113
+ "changed_path_count": len(paths),
114
+ "changed_paths_digest": canonical_digest({"paths": list(paths)}),
115
+ "services": list(services),
116
+ "image_targets": [target.target for target in image_targets],
117
+ "unsupported_hot_path_targets": list(unsupported),
118
+ "console_changed": bool(console_paths),
119
+ "higher_lane_paths": list(higher_lane),
120
+ "mutation_performed": False,
121
+ "deployment_ready": False,
122
+ }
123
+ result["plan_digest"] = canonical_digest(result)
124
+ return result
125
+
126
+
127
+ def plan_git_changes(source_root: Path, base: str, head: str = "HEAD") -> dict[str, object]:
128
+ """Resolve an exact local diff and classify it without contacting Azure."""
129
+ root = source_root.resolve(strict=True)
130
+ if root.is_symlink():
131
+ raise ValueError("source change plan requires a non-symlink checkout")
132
+ base_commit = _git(root, "rev-parse", "--verify", f"{base}^{{commit}}").strip()
133
+ head_commit = _git(root, "rev-parse", "--verify", f"{head}^{{commit}}").strip()
134
+ if _COMMIT.fullmatch(base_commit) is None or _COMMIT.fullmatch(head_commit) is None:
135
+ raise ValueError("source change plan could not resolve exact commits")
136
+ output = subprocess.run(
137
+ [
138
+ "git",
139
+ "-C",
140
+ str(root),
141
+ "diff",
142
+ "--no-renames",
143
+ "--name-status",
144
+ "-z",
145
+ "--diff-filter=ACMRD",
146
+ base_commit,
147
+ head_commit,
148
+ ],
149
+ check=True,
150
+ capture_output=True,
151
+ timeout=30,
152
+ ).stdout
153
+ fields = [value for value in output.split(b"\0") if value]
154
+ if len(fields) % 2:
155
+ raise ValueError("source change plan received invalid Git diff status output")
156
+ changes = [
157
+ (fields[index].decode("ascii"), fields[index + 1].decode("utf-8"))
158
+ for index in range(0, len(fields), 2)
159
+ ]
160
+ return source_change_plan(
161
+ (path for _status, path in changes),
162
+ base_commit=base_commit,
163
+ head_commit=head_commit,
164
+ deleted_paths=(path for status, path in changes if status == "D"),
165
+ )
166
+
167
+
168
+ def _git(root: Path, *arguments: str) -> str:
169
+ return subprocess.run(
170
+ ["git", "-C", str(root), *arguments],
171
+ check=True,
172
+ capture_output=True,
173
+ text=True,
174
+ timeout=30,
175
+ ).stdout
176
+
177
+
178
+ def _is_no_deploy_path(path: str) -> bool:
179
+ if path in _RUNTIME_EXCEPTION_PATHS or path.startswith(_RUNTIME_EXCEPTION_PREFIXES):
180
+ return False
181
+ return path.startswith(_NO_DEPLOY_PREFIXES) or "/tests/" in path or "/docs/" in path
@@ -0,0 +1,196 @@
1
+ """Apply the service-only portion of one exact changed-source plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest, load_json_object
11
+ from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
12
+ from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
13
+ from fdai_deployment_cli.source_change_plan import plan_git_changes
14
+ from fdai_deployment_cli.source_service_update import (
15
+ _require_private_directory,
16
+ deploy_source_service_update,
17
+ )
18
+
19
+ _SUPPORTED_LANES = frozenset({"single-service-update", "multi-service-update"})
20
+ _DIGEST = re.compile(r"[0-9a-f]{64}")
21
+ _IMAGE_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
22
+
23
+
24
+ def deploy_changed_source(
25
+ *,
26
+ source_root: Path,
27
+ base: str,
28
+ application_work_dir: Path,
29
+ work_dir: Path,
30
+ timeout_seconds: int,
31
+ ) -> dict[str, object]:
32
+ """Deploy affected baseline services serially under their existing exact approvals."""
33
+
34
+ started = time.monotonic()
35
+ plan = plan_git_changes(source_root, base)
36
+ lane = str(plan["lane"])
37
+ services = plan.get("services")
38
+ if not isinstance(services, list) or any(not isinstance(item, str) for item in services):
39
+ raise TypeError("source change plan contains an invalid service list")
40
+ if lane == "no-deploy":
41
+ result: dict[str, object] = {
42
+ "schema_version": "fdai.source-changed-update.v1",
43
+ "state": "no-deploy",
44
+ "change_plan_digest": plan["plan_digest"],
45
+ "services": [],
46
+ "service_receipts": [],
47
+ "elapsed_ms": round((time.monotonic() - started) * 1000),
48
+ "mutation_performed": False,
49
+ "deployment_ready": False,
50
+ }
51
+ result["receipt_digest"] = canonical_digest(result)
52
+ return result
53
+ if lane not in _SUPPORTED_LANES:
54
+ result = {
55
+ "schema_version": "fdai.source-changed-update.v1",
56
+ "state": "review",
57
+ "lane": lane,
58
+ "reason_code": plan["reason_code"],
59
+ "change_plan_digest": plan["plan_digest"],
60
+ "services": services,
61
+ "next_action": "use_the_reported_deployment_lane",
62
+ "mutation_performed": False,
63
+ "deployment_ready": False,
64
+ }
65
+ result["receipt_digest"] = canonical_digest(result)
66
+ return result
67
+
68
+ deadline = DeploymentDeadline(timeout_seconds)
69
+ source = source_root.resolve(strict=True)
70
+ application = application_work_dir.absolute()
71
+ root = work_dir.absolute()
72
+ _require_private_directory(root, create=True)
73
+ intent: dict[str, object] = {
74
+ "schema_version": "fdai.source-changed-update-intent.v1",
75
+ "change_plan_digest": plan["plan_digest"],
76
+ "base_commit": plan["base_commit"],
77
+ "head_commit": plan["head_commit"],
78
+ "services": services,
79
+ "source_binding": canonical_digest({"path": str(source)}),
80
+ "application_work_binding": canonical_digest({"path": str(application)}),
81
+ }
82
+ intent["intent_digest"] = canonical_digest(intent)
83
+ _persist_exact(
84
+ root / "source-changed-update-intent.json",
85
+ intent,
86
+ label="source changed update intent",
87
+ )
88
+ receipts: list[dict[str, Any]] = []
89
+ for index, service in enumerate(services, start=1):
90
+ assert isinstance(service, str)
91
+ receipt = deploy_source_service_update(
92
+ source_root=source,
93
+ application_work_dir=application,
94
+ work_dir=root / service,
95
+ service=service,
96
+ timeout_seconds=deadline.remaining(),
97
+ expected_source_commit=str(plan["head_commit"]),
98
+ expected_current_source_commit=str(plan["base_commit"]),
99
+ )
100
+ summary = _service_receipt_summary(
101
+ receipt,
102
+ expected_service=service,
103
+ expected_source_commit=str(plan["head_commit"]),
104
+ )
105
+ receipts.append(summary)
106
+ progress: dict[str, object] = {
107
+ "schema_version": "fdai.source-changed-update-progress.v1",
108
+ "intent_digest": intent["intent_digest"],
109
+ "completed_service_count": index,
110
+ "service_receipts": list(receipts),
111
+ }
112
+ progress["progress_digest"] = canonical_digest(progress)
113
+ _persist_exact(
114
+ root / f"source-changed-update-progress-{index:02d}.json",
115
+ progress,
116
+ label="source changed update progress",
117
+ )
118
+ effect: dict[str, object] = {
119
+ "schema_version": "fdai.source-changed-update-effect.v1",
120
+ "state": "applied",
121
+ "intent_digest": intent["intent_digest"],
122
+ "change_plan_digest": plan["plan_digest"],
123
+ "head_commit": plan["head_commit"],
124
+ "services": services,
125
+ "service_receipts": receipts,
126
+ "effect_verified": True,
127
+ "mutation_performed": True,
128
+ "deployment_ready": False,
129
+ }
130
+ effect["receipt_digest"] = canonical_digest(effect)
131
+ _persist_exact(
132
+ root / "source-changed-update-effect.json",
133
+ effect,
134
+ label="source changed update effect",
135
+ )
136
+ result = {
137
+ "schema_version": "fdai.source-changed-update.v1",
138
+ "state": "applied",
139
+ "change_plan_digest": plan["plan_digest"],
140
+ "services": services,
141
+ "service_receipts": receipts,
142
+ "elapsed_ms": round((time.monotonic() - started) * 1000),
143
+ "effect_verified": True,
144
+ "effect_receipt_digest": effect["receipt_digest"],
145
+ "mutation_performed": True,
146
+ "deployment_ready": False,
147
+ }
148
+ result["receipt_digest"] = canonical_digest(result)
149
+ return result
150
+
151
+
152
+ def _service_receipt_summary(
153
+ receipt: dict[str, object],
154
+ *,
155
+ expected_service: str,
156
+ expected_source_commit: str,
157
+ ) -> dict[str, object]:
158
+ receipt_digest = receipt.get("receipt_digest")
159
+ image_digest = receipt.get("image_digest")
160
+ if (
161
+ receipt.get("schema_version") != "fdai.source-service-update.v1"
162
+ or receipt.get("state") != "applied"
163
+ or receipt.get("service") != expected_service
164
+ or receipt.get("source_commit") != expected_source_commit
165
+ or not isinstance(receipt_digest, str)
166
+ or _DIGEST.fullmatch(receipt_digest) is None
167
+ or not isinstance(image_digest, str)
168
+ or _IMAGE_DIGEST.fullmatch(image_digest) is None
169
+ or any(
170
+ receipt.get(key) is not True
171
+ for key in (
172
+ "effect_verified",
173
+ "peer_state_unchanged_verified",
174
+ "terraform_zero_change_verified",
175
+ )
176
+ )
177
+ ):
178
+ raise ValueError("source changed update received incomplete service effect evidence")
179
+ return {
180
+ "service": expected_service,
181
+ "source_commit": expected_source_commit,
182
+ "receipt_digest": receipt_digest,
183
+ "image_digest": image_digest,
184
+ }
185
+
186
+
187
+ def _persist_exact(path: Path, value: dict[str, object], *, label: str) -> None:
188
+ if path.exists() or path.is_symlink():
189
+ retained = load_json_object(
190
+ read_private_bytes(path, max_bytes=1024 * 1024),
191
+ label=label,
192
+ )
193
+ if retained != value:
194
+ raise ValueError(f"retained {label} differs")
195
+ return
196
+ write_private_bytes(path, canonical_bytes(value))
@@ -0,0 +1,170 @@
1
+ """Prepare connected source deployments without constructing or trusting a release kit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fcntl
6
+ import os
7
+ import re
8
+ import stat
9
+ from pathlib import Path
10
+
11
+ from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest, load_json_object
12
+ from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
13
+ from fdai_deployment_cli.runtime_profile import RuntimeDeploymentProfile
14
+ from fdai_deployment_cli.source_input import inspect_source
15
+ from fdai_deployment_cli.source_snapshot import materialize_source, verify_source_snapshot
16
+
17
+
18
+ def prepare_source_deployment(
19
+ *,
20
+ source_root: Path,
21
+ work_dir: Path,
22
+ runtime_profile: RuntimeDeploymentProfile,
23
+ region: str,
24
+ monthly_cost_ceiling: int,
25
+ hub_selection_digest: str | None = None,
26
+ ) -> dict[str, object]:
27
+ """Seal and reverify one connected dev source run without cloud access or execution.
28
+
29
+ A mode-specific work directory cannot adopt kit state, another source or runtime
30
+ profile. A partial snapshot is retained and blocked rather than silently replaced.
31
+ The receipt proves local preparation only and never grants apply or Trial authority.
32
+ """
33
+ if runtime_profile.runtime_platform.value != "aks" or (
34
+ runtime_profile.database_placement.value not in {"postgres-flex", "postgres-aks"}
35
+ ):
36
+ raise ValueError("source deployment currently requires AKS")
37
+ if re.fullmatch(r"[a-z][a-z0-9]+", region) is None:
38
+ raise ValueError("source deployment region is invalid")
39
+ if type(monthly_cost_ceiling) is not int or monthly_cost_ceiling <= 0:
40
+ raise ValueError("source deployment cost review ceiling must be positive")
41
+ source = inspect_source(source_root)
42
+ work_dir = work_dir.absolute()
43
+ if (work_dir / "hub-images").exists() and hub_selection_digest is None:
44
+ raise ValueError("source builds cannot reuse a Hub-image work directory")
45
+ if hub_selection_digest is not None:
46
+ from fdai_deployment_cli.hub_image_trust import load_selection
47
+
48
+ selection = load_selection(work_dir / "hub-images/selection.json", hub_selection_digest)
49
+ if selection.source_commit != source.commit:
50
+ raise ValueError("Hub images and source support revisions differ")
51
+ if work_dir.resolve().is_relative_to(source.root):
52
+ raise ValueError("source deployment work directory must be outside the selected checkout")
53
+ work_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
54
+ details = work_dir.lstat()
55
+ if (
56
+ not stat.S_ISDIR(details.st_mode)
57
+ or details.st_uid != os.geteuid()
58
+ or stat.S_IMODE(details.st_mode) != 0o700
59
+ ):
60
+ raise ValueError("source work directory must be a private owned directory")
61
+ from fdai_deployment_cli.private_output import _open_private_parent
62
+
63
+ parent = _open_private_parent(work_dir / "source.lock")
64
+ try:
65
+ lock = os.open(
66
+ "source.lock",
67
+ os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK,
68
+ 0o600,
69
+ dir_fd=parent,
70
+ )
71
+ finally:
72
+ os.close(parent)
73
+ try:
74
+ lock_stat = os.fstat(lock)
75
+ if (
76
+ not stat.S_ISREG(lock_stat.st_mode)
77
+ or lock_stat.st_uid != os.geteuid()
78
+ or lock_stat.st_nlink != 1
79
+ or stat.S_IMODE(lock_stat.st_mode) != 0o600
80
+ ):
81
+ raise ValueError("source deployment lock identity is invalid")
82
+ try:
83
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
84
+ except BlockingIOError:
85
+ raise ValueError("source deployment preparation is already active") from None
86
+ intent = {
87
+ "schema_version": "fdai.source-deployment-intent.v1",
88
+ "source": source.to_mapping(),
89
+ "source_input_digest": source.digest,
90
+ "runtime_profile": runtime_profile.to_mapping(),
91
+ "environment": "dev",
92
+ "region": region,
93
+ "monthly_cost_ceiling": monthly_cost_ceiling,
94
+ }
95
+ if hub_selection_digest is not None:
96
+ intent["hub_selection_digest"] = hub_selection_digest
97
+ intent["artifact_source"] = "hub-signed-images"
98
+ intent_path = work_dir / "source-intent.json"
99
+ if intent_path.exists():
100
+ prior = load_json_object(
101
+ read_private_bytes(intent_path, max_bytes=65_536), label="source intent"
102
+ )
103
+ if prior != intent:
104
+ changed = sorted(
105
+ key for key in set(prior) | set(intent) if prior.get(key) != intent.get(key)
106
+ )
107
+ raise ValueError(
108
+ "retained source deployment intent differs; preserve the existing run. "
109
+ f"Repeat the original values for: {', '.join(changed)}"
110
+ )
111
+ else:
112
+ expected_entries = {"source.lock"}
113
+ if hub_selection_digest is not None:
114
+ expected_entries.add("hub-images")
115
+ if {path.name for path in work_dir.iterdir()} != expected_entries:
116
+ raise ValueError("source deployment cannot adopt existing or partial work state")
117
+ write_private_bytes(intent_path, canonical_bytes(intent))
118
+ receipt_path = work_dir / "source-preparation.json"
119
+ snapshot = work_dir / "source-snapshot"
120
+ if receipt_path.exists():
121
+ receipt = load_json_object(
122
+ read_private_bytes(receipt_path, max_bytes=65_536), label="source preparation"
123
+ )
124
+ digest = receipt.pop("receipt_digest", None)
125
+ if canonical_digest(receipt) != digest or receipt.get(
126
+ "intent_digest"
127
+ ) != canonical_digest(intent):
128
+ raise ValueError("source preparation receipt binding is invalid")
129
+ snapshot_digest = receipt.get("source_snapshot_digest")
130
+ if not isinstance(snapshot_digest, str):
131
+ raise ValueError("source snapshot digest is unavailable")
132
+ if (
133
+ verify_source_snapshot(snapshot, expected_digest=snapshot_digest)
134
+ != source.to_mapping()
135
+ ):
136
+ raise ValueError("source snapshot differs from the selected checkout")
137
+ else:
138
+ if snapshot.exists() or snapshot.is_symlink():
139
+ raise ValueError(
140
+ "incomplete source snapshot requires review; no automatic replacement"
141
+ )
142
+ snapshot_digest = materialize_source(source, snapshot)
143
+ result: dict[str, object] = {
144
+ "schema_version": "fdai.source-deployment-preparation.v1",
145
+ "state": "prepared",
146
+ "provenance": "operator-selected-source",
147
+ "source_commit": source.commit,
148
+ "source_input_digest": source.digest,
149
+ "source_snapshot_digest": snapshot_digest,
150
+ "intent_digest": canonical_digest(intent),
151
+ "runtime_profile_digest": runtime_profile.digest,
152
+ "release_signature_verified": False,
153
+ "apply_authorized": False,
154
+ "mutation_performed": False,
155
+ "deployment_ready": False,
156
+ "subscription_ready": False,
157
+ }
158
+ if hub_selection_digest is not None:
159
+ result["hub_selection_digest"] = hub_selection_digest
160
+ result["image_artifact_source"] = "hub-signed-images"
161
+ result["image_signature_verified"] = True
162
+ result["receipt_digest"] = canonical_digest(result)
163
+ source.reverify()
164
+ if not receipt_path.exists():
165
+ write_private_bytes(receipt_path, canonical_bytes(result))
166
+ elif read_private_bytes(receipt_path, max_bytes=65_536) != canonical_bytes(result):
167
+ raise ValueError("retained source preparation receipt differs")
168
+ return result
169
+ finally:
170
+ os.close(lock)