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,478 @@
1
+ """Build one exact private execution bundle for a governed host transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ import hashlib
7
+ import io
8
+ import os
9
+ import re
10
+ import stat
11
+ import tarfile
12
+ import zipfile
13
+ from dataclasses import dataclass
14
+ from pathlib import Path, PurePosixPath
15
+
16
+ from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest, load_json_object
17
+ from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
18
+
19
+ _OPERATION_ID = re.compile(r"[a-z0-9][a-z0-9-]{0,127}")
20
+ _ALLOWED_SECTIONS = frozenset({"artifact", "evidence", "toolchain"})
21
+ _MAX_FILE_BYTES = 512 * 1024 * 1024
22
+ _MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
23
+ _MAX_FILES = 20_000
24
+ _ARCHIVE = "execution-bundle.tar.gz"
25
+ _RECEIPT = "execution-bundle-receipt.json"
26
+ _RECEIVER = "run-command-receiver.pyz"
27
+ _RECEIVER_MODULES = (
28
+ "__init__",
29
+ "__about__",
30
+ "contracts",
31
+ "private_output",
32
+ "run_command_receiver",
33
+ )
34
+ _RECEIVER_ENTRY = (
35
+ b"from fdai_deployment_cli.run_command_receiver import main\nraise SystemExit(main())\n"
36
+ )
37
+ _MAX_RECEIVER_BYTES = 4 * 1024 * 1024
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class ExecutionBundleSource:
42
+ """Map one private regular file or directory into an allowlisted bundle section."""
43
+
44
+ source: Path
45
+ destination: str
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class _File:
50
+ root_descriptor: int
51
+ relative_parts: tuple[str, ...]
52
+ destination: str
53
+ sha256: str
54
+ mode: str
55
+ size: int
56
+
57
+
58
+ def prepare_execution_bundle(
59
+ sources: tuple[ExecutionBundleSource, ...],
60
+ work_dir: Path,
61
+ *,
62
+ operation_id: str,
63
+ ) -> dict[str, object]:
64
+ """Create or reverify one deterministic bundle without granting execution authority."""
65
+
66
+ if not sources or _OPERATION_ID.fullmatch(operation_id) is None:
67
+ raise ValueError("execution bundle operation or source inventory is invalid")
68
+ details = work_dir.lstat()
69
+ if (
70
+ not stat.S_ISDIR(details.st_mode)
71
+ or stat.S_IMODE(details.st_mode) != 0o700
72
+ or details.st_uid != os.geteuid()
73
+ ):
74
+ raise PermissionError("execution bundle work directory must be current-UID mode 0700")
75
+ files, source_descriptors = _inventory(sources)
76
+ try:
77
+ return _prepare_execution_bundle_files(files, work_dir, operation_id=operation_id)
78
+ finally:
79
+ for source_descriptor in source_descriptors:
80
+ os.close(source_descriptor)
81
+
82
+
83
+ def _prepare_execution_bundle_files(
84
+ files: tuple[_File, ...], work_dir: Path, *, operation_id: str
85
+ ) -> dict[str, object]:
86
+ inventory = {item.destination: {"sha256": item.sha256, "mode": item.mode} for item in files}
87
+ inventory_digest = canonical_digest({"files": inventory})
88
+ archive = work_dir / _ARCHIVE
89
+ receipt_path = work_dir / _RECEIPT
90
+ if (
91
+ archive.exists()
92
+ or archive.is_symlink()
93
+ or receipt_path.exists()
94
+ or receipt_path.is_symlink()
95
+ ):
96
+ if not archive.is_file() or not receipt_path.is_file():
97
+ raise ValueError("execution bundle retained state is incomplete")
98
+ retained = load_json_object(
99
+ read_private_bytes(receipt_path, max_bytes=65_536),
100
+ label="execution bundle receipt",
101
+ max_bytes=65_536,
102
+ )
103
+ digest = _file_digest(archive, maximum=_MAX_FILE_BYTES)
104
+ expected = _receipt(
105
+ operation_id=operation_id,
106
+ bundle_digest=digest,
107
+ bundle_size=archive.stat().st_size,
108
+ file_count=len(files),
109
+ inventory_digest=inventory_digest,
110
+ )
111
+ if retained != expected:
112
+ raise ValueError("execution bundle retained receipt differs")
113
+ return expected
114
+
115
+ manifest = {
116
+ "schema_version": "fdai.execution-bundle-manifest.v1",
117
+ "operation_id": operation_id,
118
+ "files": inventory,
119
+ }
120
+ temporary = work_dir / f".{_ARCHIVE}.partial-{os.getpid()}"
121
+ descriptor = os.open(
122
+ temporary,
123
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
124
+ 0o600,
125
+ )
126
+ published = False
127
+ try:
128
+ with os.fdopen(descriptor, "wb", closefd=False) as raw:
129
+ with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed:
130
+ with tarfile.open(
131
+ fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT
132
+ ) as stream:
133
+ _add(stream, "fdai-execution/manifest.json", canonical_bytes(manifest), 0o600)
134
+ for item in files:
135
+ _add(
136
+ stream,
137
+ f"fdai-execution/{item.destination}",
138
+ _read_exact(item),
139
+ int(item.mode, 8),
140
+ )
141
+ raw.flush()
142
+ os.fsync(raw.fileno())
143
+ os.close(descriptor)
144
+ descriptor = -1
145
+ if temporary.stat().st_size > _MAX_FILE_BYTES:
146
+ raise ValueError("execution bundle archive exceeds its size limit")
147
+ os.rename(temporary, archive)
148
+ published = True
149
+ finally:
150
+ if descriptor >= 0:
151
+ os.close(descriptor)
152
+ if not published:
153
+ temporary.unlink(missing_ok=True)
154
+ digest = _file_digest(archive, maximum=_MAX_FILE_BYTES)
155
+ receipt = _receipt(
156
+ operation_id=operation_id,
157
+ bundle_digest=digest,
158
+ bundle_size=archive.stat().st_size,
159
+ file_count=len(files),
160
+ inventory_digest=inventory_digest,
161
+ )
162
+ write_private_bytes(receipt_path, canonical_bytes(receipt))
163
+ return receipt
164
+
165
+
166
+ def prepare_run_command_receiver(work_dir: Path) -> str:
167
+ """Build or reverify the fixed dependency-free WSL receiver zipapp."""
168
+
169
+ payload = io.BytesIO()
170
+ with zipfile.ZipFile(payload, "w", compression=zipfile.ZIP_STORED) as archive:
171
+ _zip_add(archive, "__main__.py", _RECEIVER_ENTRY)
172
+ module_root = Path(__file__).parent
173
+ for module in _RECEIVER_MODULES:
174
+ content = _module_content(module_root / f"{module}.py")
175
+ if payload.tell() + len(content) > _MAX_RECEIVER_BYTES:
176
+ raise ValueError("run command receiver exceeds its size limit")
177
+ _zip_add(archive, f"fdai_deployment_cli/{module}.py", content)
178
+ content = payload.getvalue()
179
+ if not 0 < len(content) <= _MAX_RECEIVER_BYTES:
180
+ raise ValueError("run command receiver size is invalid")
181
+ destination = work_dir / _RECEIVER
182
+ if destination.exists() or destination.is_symlink():
183
+ if read_private_bytes(destination, max_bytes=_MAX_RECEIVER_BYTES) != content:
184
+ raise ValueError("retained run command receiver differs")
185
+ else:
186
+ write_private_bytes(destination, content)
187
+ return hashlib.sha256(content).hexdigest()
188
+
189
+
190
+ def _inventory(
191
+ sources: tuple[ExecutionBundleSource, ...],
192
+ ) -> tuple[tuple[_File, ...], tuple[int, ...]]:
193
+ selected: dict[str, tuple[int, tuple[str, ...], os.stat_result]] = {}
194
+ source_descriptors: list[int] = []
195
+ try:
196
+ for selected_source in sources:
197
+ destination = _destination(selected_source.destination)
198
+ source = selected_source.source.absolute()
199
+ if source.resolve(strict=True) != source or source.is_symlink():
200
+ raise ValueError("execution bundle source must be a regular single-link file")
201
+ if source.is_dir():
202
+ root_descriptor = os.open(source, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
203
+ source_descriptors.append(root_descriptor)
204
+ _walk_source_directory(
205
+ selected,
206
+ root_descriptor=root_descriptor,
207
+ directory_descriptor=root_descriptor,
208
+ relative_parts=(),
209
+ destination=destination,
210
+ )
211
+ else:
212
+ root_descriptor = os.open(
213
+ source.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
214
+ )
215
+ source_descriptors.append(root_descriptor)
216
+ details = os.stat(source.name, dir_fd=root_descriptor, follow_symlinks=False)
217
+ _select(
218
+ selected,
219
+ destination,
220
+ root_descriptor,
221
+ (source.name,),
222
+ details,
223
+ )
224
+ if not 0 < len(selected) <= _MAX_FILES:
225
+ raise ValueError("execution bundle file count is invalid")
226
+ files: list[_File] = []
227
+ total = 0
228
+ for destination, (root_descriptor, relative_parts, details) in sorted(selected.items()):
229
+ _validate_source_details(details)
230
+ total += details.st_size
231
+ if total > _MAX_TOTAL_BYTES:
232
+ raise ValueError("execution bundle source inventory exceeds its size limit")
233
+ mode = "0700" if details.st_mode & stat.S_IXUSR else "0600"
234
+ files.append(
235
+ _File(
236
+ root_descriptor=root_descriptor,
237
+ relative_parts=relative_parts,
238
+ destination=destination,
239
+ sha256=_descriptor_digest(
240
+ root_descriptor, relative_parts, maximum=_MAX_FILE_BYTES
241
+ ),
242
+ mode=mode,
243
+ size=details.st_size,
244
+ )
245
+ )
246
+ return tuple(files), tuple(source_descriptors)
247
+ except BaseException:
248
+ for source_descriptor in source_descriptors:
249
+ os.close(source_descriptor)
250
+ raise
251
+
252
+
253
+ def _walk_source_directory(
254
+ selected: dict[str, tuple[int, tuple[str, ...], os.stat_result]],
255
+ *,
256
+ root_descriptor: int,
257
+ directory_descriptor: int,
258
+ relative_parts: tuple[str, ...],
259
+ destination: str,
260
+ ) -> None:
261
+ for name in sorted(os.listdir(directory_descriptor)):
262
+ details = os.stat(name, dir_fd=directory_descriptor, follow_symlinks=False)
263
+ child_parts = (*relative_parts, name)
264
+ if stat.S_ISDIR(details.st_mode):
265
+ child = os.open(
266
+ name,
267
+ os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
268
+ dir_fd=directory_descriptor,
269
+ )
270
+ try:
271
+ _walk_source_directory(
272
+ selected,
273
+ root_descriptor=root_descriptor,
274
+ directory_descriptor=child,
275
+ relative_parts=child_parts,
276
+ destination=destination,
277
+ )
278
+ finally:
279
+ os.close(child)
280
+ continue
281
+ relative = PurePosixPath(*child_parts).as_posix()
282
+ _select(
283
+ selected,
284
+ f"{destination}/{relative}",
285
+ root_descriptor,
286
+ child_parts,
287
+ details,
288
+ )
289
+
290
+
291
+ def _select(
292
+ selected: dict[str, tuple[int, tuple[str, ...], os.stat_result]],
293
+ destination: str,
294
+ root_descriptor: int,
295
+ relative_parts: tuple[str, ...],
296
+ details: os.stat_result,
297
+ ) -> None:
298
+ normalized = _destination(destination)
299
+ if normalized in selected:
300
+ raise ValueError("execution bundle destination is duplicated")
301
+ selected[normalized] = (root_descriptor, relative_parts, details)
302
+
303
+
304
+ def _destination(value: str) -> str:
305
+ path = PurePosixPath(value)
306
+ if (
307
+ value.startswith("/")
308
+ or "\\" in value
309
+ or not path.parts
310
+ or path.parts[0] not in _ALLOWED_SECTIONS
311
+ or any(part in {"", ".", ".."} for part in path.parts)
312
+ ):
313
+ raise ValueError("execution bundle destination is invalid")
314
+ return path.as_posix()
315
+
316
+
317
+ def _read_exact(item: _File) -> bytes:
318
+ descriptor = _open_relative(item.root_descriptor, item.relative_parts)
319
+ with os.fdopen(descriptor, "rb") as stream:
320
+ before = os.fstat(stream.fileno())
321
+ if (
322
+ not stat.S_ISREG(before.st_mode)
323
+ or stat.S_IMODE(before.st_mode) not in {0o600, 0o700}
324
+ or before.st_uid != os.geteuid()
325
+ or before.st_nlink != 1
326
+ or before.st_size != item.size
327
+ ):
328
+ raise PermissionError("execution bundle source permissions changed")
329
+ content = stream.read(_MAX_FILE_BYTES + 1)
330
+ after = os.fstat(stream.fileno())
331
+ if (
332
+ len(content) != item.size
333
+ or hashlib.sha256(content).hexdigest() != item.sha256
334
+ or after.st_size != before.st_size
335
+ or after.st_mtime_ns != before.st_mtime_ns
336
+ or after.st_ctime_ns != before.st_ctime_ns
337
+ ):
338
+ raise ValueError("execution bundle source changed during preparation")
339
+ return content
340
+
341
+
342
+ def _validate_source_details(details: os.stat_result) -> None:
343
+ if (
344
+ not stat.S_ISREG(details.st_mode)
345
+ or stat.S_IMODE(details.st_mode) not in {0o600, 0o700}
346
+ or details.st_nlink != 1
347
+ or details.st_uid != os.geteuid()
348
+ or not 0 < details.st_size <= _MAX_FILE_BYTES
349
+ ):
350
+ raise ValueError("execution bundle source must be a private regular single-link file")
351
+
352
+
353
+ def _open_relative(root_descriptor: int, relative_parts: tuple[str, ...]) -> int:
354
+ if not relative_parts:
355
+ raise ValueError("execution bundle source path is empty")
356
+ parent = os.dup(root_descriptor)
357
+ try:
358
+ for part in relative_parts[:-1]:
359
+ child = os.open(
360
+ part,
361
+ os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
362
+ dir_fd=parent,
363
+ )
364
+ os.close(parent)
365
+ parent = child
366
+ return os.open(
367
+ relative_parts[-1],
368
+ os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK,
369
+ dir_fd=parent,
370
+ )
371
+ finally:
372
+ os.close(parent)
373
+
374
+
375
+ def _descriptor_digest(
376
+ root_descriptor: int, relative_parts: tuple[str, ...], *, maximum: int
377
+ ) -> str:
378
+ descriptor = _open_relative(root_descriptor, relative_parts)
379
+ digest = hashlib.sha256()
380
+ with os.fdopen(descriptor, "rb") as stream:
381
+ before = os.fstat(stream.fileno())
382
+ _validate_source_details(before)
383
+ while chunk := stream.read(1024 * 1024):
384
+ digest.update(chunk)
385
+ after = os.fstat(stream.fileno())
386
+ if (
387
+ before.st_size > maximum
388
+ or after.st_size != before.st_size
389
+ or after.st_mtime_ns != before.st_mtime_ns
390
+ or after.st_ctime_ns != before.st_ctime_ns
391
+ ):
392
+ raise ValueError("execution bundle source changed while being read")
393
+ return digest.hexdigest()
394
+
395
+
396
+ def _add(stream: tarfile.TarFile, name: str, content: bytes, mode: int) -> None:
397
+ member = tarfile.TarInfo(name)
398
+ member.size = len(content)
399
+ member.mode = mode
400
+ member.mtime = 0
401
+ member.uid = 0
402
+ member.gid = 0
403
+ member.uname = ""
404
+ member.gname = ""
405
+ stream.addfile(member, io.BytesIO(content))
406
+
407
+
408
+ def _zip_add(archive: zipfile.ZipFile, name: str, content: bytes) -> None:
409
+ member = zipfile.ZipInfo(name)
410
+ member.create_system = 3
411
+ member.date_time = (1980, 1, 1, 0, 0, 0)
412
+ member.external_attr = 0o100600 << 16
413
+ archive.writestr(member, content)
414
+
415
+
416
+ def _module_content(path: Path) -> bytes:
417
+ details = path.lstat()
418
+ if path.is_symlink() or not stat.S_ISREG(details.st_mode) or details.st_nlink != 1:
419
+ raise ValueError("run command receiver module is invalid")
420
+ content = path.read_bytes()
421
+ after = path.lstat()
422
+ if (
423
+ len(content) != details.st_size
424
+ or after.st_ino != details.st_ino
425
+ or after.st_mtime_ns != details.st_mtime_ns
426
+ ):
427
+ raise ValueError("run command receiver module changed")
428
+ return content
429
+
430
+
431
+ def _receipt(
432
+ *,
433
+ operation_id: str,
434
+ bundle_digest: str,
435
+ bundle_size: int,
436
+ file_count: int,
437
+ inventory_digest: str,
438
+ ) -> dict[str, object]:
439
+ result: dict[str, object] = {
440
+ "schema_version": "fdai.execution-bundle-receipt.v1",
441
+ "state": "prepared",
442
+ "operation_id": operation_id,
443
+ "bundle_digest": bundle_digest,
444
+ "bundle_size": bundle_size,
445
+ "file_count": file_count,
446
+ "inventory_digest": inventory_digest,
447
+ "apply_authorized": False,
448
+ "deployment_ready": False,
449
+ "mutation_performed": False,
450
+ }
451
+ result["receipt_digest"] = canonical_digest(result)
452
+ return result
453
+
454
+
455
+ def _file_digest(path: Path, *, maximum: int) -> str:
456
+ details = path.lstat()
457
+ if (
458
+ path.is_symlink()
459
+ or not stat.S_ISREG(details.st_mode)
460
+ or details.st_nlink != 1
461
+ or not 0 < details.st_size <= maximum
462
+ ):
463
+ raise ValueError("execution bundle file type or size is invalid")
464
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
465
+ digest = hashlib.sha256()
466
+ with os.fdopen(descriptor, "rb") as stream:
467
+ opened = os.fstat(stream.fileno())
468
+ while chunk := stream.read(1024 * 1024):
469
+ digest.update(chunk)
470
+ after = os.fstat(stream.fileno())
471
+ if (
472
+ opened.st_ino != details.st_ino
473
+ or opened.st_size != details.st_size
474
+ or after.st_size != opened.st_size
475
+ or after.st_mtime_ns != opened.st_mtime_ns
476
+ ):
477
+ raise ValueError("execution bundle file changed while being read")
478
+ return digest.hexdigest()
@@ -0,0 +1,63 @@
1
+ """Bind the workstation coordinator's fresh kit execution copies to its process."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ import sys
8
+ import tempfile
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+ from contextvars import ContextVar
12
+ from pathlib import Path
13
+
14
+ EXECUTION_COPY_PREFIXES = ("offline-source-", "bundle-recheck-")
15
+ _COPY_NAME = re.compile(r"(?:offline-source|bundle-recheck)-[A-Za-z0-9_]{8}")
16
+ _SCOPE: ContextVar[list[Path] | None] = ContextVar("fdai_execution_copy_scope", default=None)
17
+
18
+
19
+ def new_execution_copy(work_dir: Path, prefix: str) -> Path:
20
+ """Create one fresh private execution-copy parent and register it with an open scope."""
21
+
22
+ if prefix not in EXECUTION_COPY_PREFIXES:
23
+ raise ValueError("deployment kit execution copy prefix is unsupported")
24
+ parent = Path(tempfile.mkdtemp(prefix=prefix, dir=work_dir))
25
+ scope = _SCOPE.get()
26
+ if scope is not None:
27
+ scope.append(parent)
28
+ return parent
29
+
30
+
31
+ @contextmanager
32
+ def execution_copy_scope() -> Iterator[None]:
33
+ """Remove the execution copies created inside this scope when it exits.
34
+
35
+ Only the workstation coordinator opens a scope. Its copies are re-derivable from the
36
+ operator-supplied signed kit and no retained record references them, so every exit removes
37
+ them. The managed host opens no scope, because later steps of a run execute Terraform from
38
+ its copy.
39
+ """
40
+
41
+ created: list[Path] = []
42
+ token = _SCOPE.set(created)
43
+ try:
44
+ yield
45
+ finally:
46
+ _SCOPE.reset(token)
47
+ remaining = [parent for parent in created if not _remove_execution_copy(parent)]
48
+ if remaining:
49
+ print(
50
+ "fdaictl: warning: execution copies remain for manual removal: "
51
+ + ", ".join(parent.name for parent in remaining),
52
+ file=sys.stderr,
53
+ )
54
+
55
+
56
+ def _remove_execution_copy(parent: Path) -> bool:
57
+ if _COPY_NAME.fullmatch(parent.name) is None or parent.is_symlink():
58
+ return False
59
+ shutil.rmtree(parent, ignore_errors=True)
60
+ return not (parent.exists() or parent.is_symlink())
61
+
62
+
63
+ __all__ = ["EXECUTION_COPY_PREFIXES", "execution_copy_scope", "new_execution_copy"]