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,192 @@
1
+ """Descriptor-safe writes for private deployment CLI artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import secrets
7
+ import stat
8
+ from pathlib import Path
9
+
10
+ _COPY_BUFFER_BYTES = 1024 * 1024
11
+
12
+
13
+ def write_private_output(path: Path, content: str) -> None:
14
+ """Create one mode-0600 artifact through a held mode-0700 parent."""
15
+
16
+ write_private_bytes(path, content.encode("utf-8"))
17
+
18
+
19
+ def write_private_bytes(path: Path, content: bytes) -> None:
20
+ """Exclusively persist binary content through a held private parent."""
21
+
22
+ directory = _open_private_parent(path)
23
+ try:
24
+ descriptor = os.open(
25
+ path.name,
26
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
27
+ 0o600,
28
+ dir_fd=directory,
29
+ )
30
+ try:
31
+ with os.fdopen(descriptor, "wb") as stream:
32
+ stream.write(content)
33
+ stream.flush()
34
+ os.fsync(stream.fileno())
35
+ except BaseException:
36
+ os.unlink(path.name, dir_fd=directory)
37
+ raise
38
+ finally:
39
+ os.close(directory)
40
+
41
+
42
+ def read_private_bytes(path: Path, *, max_bytes: int) -> bytes:
43
+ """Read bounded current-UID mode-0600 content without following any symlink."""
44
+
45
+ if max_bytes <= 0:
46
+ raise ValueError("private input size bound MUST be positive")
47
+ directory = _open_private_parent(path)
48
+ try:
49
+ descriptor = os.open(
50
+ path.name, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW, dir_fd=directory
51
+ )
52
+ with os.fdopen(descriptor, "rb") as stream:
53
+ details = os.fstat(stream.fileno())
54
+ if (
55
+ not stat.S_ISREG(details.st_mode)
56
+ or stat.S_IMODE(details.st_mode) != 0o600
57
+ or details.st_uid != os.geteuid()
58
+ or details.st_nlink != 1
59
+ ):
60
+ raise PermissionError(
61
+ "private input MUST be a current-UID mode-0600 single-link file"
62
+ )
63
+ if not 0 < details.st_size <= max_bytes:
64
+ raise ValueError("private input is empty or exceeds its size limit")
65
+ content = stream.read(max_bytes + 1)
66
+ after = os.fstat(stream.fileno())
67
+ if (
68
+ len(content) != details.st_size
69
+ or after.st_size != details.st_size
70
+ or after.st_mtime_ns != details.st_mtime_ns
71
+ or after.st_ctime_ns != details.st_ctime_ns
72
+ ):
73
+ raise ValueError("private input changed while being read")
74
+ return content
75
+ finally:
76
+ os.close(directory)
77
+
78
+
79
+ def copy_private_file(source: Path, destination: Path, *, max_bytes: int) -> int:
80
+ """Copy one bounded regular file to a new mode-0600 private destination."""
81
+
82
+ if max_bytes <= 0:
83
+ raise ValueError("private copy size bound MUST be positive")
84
+ source_parent = _open_private_parent(source)
85
+ destination_parent = _open_private_parent(destination)
86
+ try:
87
+ source_descriptor = os.open(
88
+ source.name,
89
+ os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW,
90
+ dir_fd=source_parent,
91
+ )
92
+ try:
93
+ source_details = os.fstat(source_descriptor)
94
+ if (
95
+ not stat.S_ISREG(source_details.st_mode)
96
+ or source_details.st_uid != os.geteuid()
97
+ or source_details.st_nlink != 1
98
+ or not 0 < source_details.st_size <= max_bytes
99
+ ):
100
+ raise PermissionError("private copy source is not an owned bounded regular file")
101
+ temporary_name = f".{destination.name}.copy-{os.getpid()}-{secrets.token_hex(8)}"
102
+ destination_descriptor = os.open(
103
+ temporary_name,
104
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
105
+ 0o600,
106
+ dir_fd=destination_parent,
107
+ )
108
+ copied = 0
109
+ published = False
110
+ try:
111
+ with os.fdopen(source_descriptor, "rb", closefd=False) as input_stream:
112
+ with os.fdopen(destination_descriptor, "wb") as output_stream:
113
+ while chunk := input_stream.read(_COPY_BUFFER_BYTES):
114
+ copied += len(chunk)
115
+ if copied > max_bytes:
116
+ raise ValueError("private copy source exceeds its size limit")
117
+ output_stream.write(chunk)
118
+ output_stream.flush()
119
+ os.fsync(output_stream.fileno())
120
+ after = os.fstat(source_descriptor)
121
+ if (
122
+ copied != source_details.st_size
123
+ or after.st_size != source_details.st_size
124
+ or after.st_mtime_ns != source_details.st_mtime_ns
125
+ or after.st_ctime_ns != source_details.st_ctime_ns
126
+ ):
127
+ raise ValueError("private copy source changed while being copied")
128
+ os.link(
129
+ temporary_name,
130
+ destination.name,
131
+ src_dir_fd=destination_parent,
132
+ dst_dir_fd=destination_parent,
133
+ follow_symlinks=False,
134
+ )
135
+ published = True
136
+ os.unlink(temporary_name, dir_fd=destination_parent)
137
+ os.fsync(destination_parent)
138
+ except BaseException:
139
+ if published:
140
+ try:
141
+ os.unlink(destination.name, dir_fd=destination_parent)
142
+ except FileNotFoundError:
143
+ pass
144
+ try:
145
+ os.unlink(temporary_name, dir_fd=destination_parent)
146
+ except FileNotFoundError:
147
+ pass
148
+ raise
149
+ return copied
150
+ finally:
151
+ os.close(source_descriptor)
152
+ finally:
153
+ os.close(source_parent)
154
+ os.close(destination_parent)
155
+
156
+
157
+ def _open_private_parent(path: Path) -> int:
158
+ if not path.is_absolute() or path.name in {"", ".", ".."}:
159
+ raise ValueError("private output path MUST be an absolute file path")
160
+ descriptor = os.open(path.anchor, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
161
+ trusted_owners = {0, os.geteuid()}
162
+ try:
163
+ for part in path.parent.parts[1:]:
164
+ if part in {"", "."}:
165
+ continue
166
+ if part == "..":
167
+ raise ValueError("private output path MUST NOT traverse parent directories")
168
+ child = os.open(
169
+ part,
170
+ os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
171
+ dir_fd=descriptor,
172
+ )
173
+ details = os.fstat(child)
174
+ if details.st_uid not in trusted_owners:
175
+ os.close(child)
176
+ raise PermissionError("private output path has an unsafe owner")
177
+ mode = stat.S_IMODE(details.st_mode)
178
+ if mode & 0o022 and not details.st_mode & stat.S_ISVTX:
179
+ os.close(child)
180
+ raise PermissionError("private output path has a replaceable ancestor")
181
+ os.close(descriptor)
182
+ descriptor = child
183
+ details = os.fstat(descriptor)
184
+ mode = stat.S_IMODE(details.st_mode)
185
+ if details.st_uid != os.geteuid() or mode not in {0o700, 0o2700}:
186
+ raise PermissionError(
187
+ "private output directory MUST be current-UID owner-only mode 0700"
188
+ )
189
+ return descriptor
190
+ except BaseException:
191
+ os.close(descriptor)
192
+ raise
@@ -0,0 +1,70 @@
1
+ """Secure local persistence for secret-free provisioning profiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ from pathlib import Path
8
+
9
+ from fdai_deployment_cli.contracts import ProvisionProfile, canonical_bytes, load_json_object
10
+
11
+
12
+ def write_profile(path: Path, profile: ProvisionProfile, *, force: bool = False) -> None:
13
+ """Create a private profile atomically without following links."""
14
+
15
+ parent = path.parent
16
+ parent.mkdir(parents=True, exist_ok=True, mode=0o700)
17
+ _require_private_directory(parent)
18
+ if path.exists() or path.is_symlink():
19
+ if not force:
20
+ raise FileExistsError("provision profile already exists")
21
+ _require_regular_private_file(path)
22
+ temporary = parent / f".{path.name}.tmp-{os.getpid()}"
23
+ descriptor = os.open(
24
+ temporary,
25
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
26
+ 0o600,
27
+ )
28
+ try:
29
+ with os.fdopen(descriptor, "wb") as stream:
30
+ stream.write(canonical_bytes(profile.to_mapping()) + b"\n")
31
+ stream.flush()
32
+ os.fsync(stream.fileno())
33
+ _publish_profile(temporary, path, force=force)
34
+ path.chmod(0o600)
35
+ finally:
36
+ temporary.unlink(missing_ok=True)
37
+
38
+
39
+ def _publish_profile(temporary: Path, destination: Path, *, force: bool) -> None:
40
+ if force:
41
+ os.replace(temporary, destination)
42
+ return
43
+ os.link(temporary, destination, follow_symlinks=False)
44
+ temporary.unlink()
45
+
46
+
47
+ def load_profile(path: Path) -> ProvisionProfile:
48
+ """Read and validate a private regular-file profile."""
49
+
50
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
51
+ with os.fdopen(descriptor, "rb") as stream:
52
+ details = os.fstat(stream.fileno())
53
+ if not stat.S_ISREG(details.st_mode) or stat.S_IMODE(details.st_mode) != 0o600:
54
+ raise PermissionError("provision profile MUST be a mode-0600 regular file")
55
+ payload = stream.read(1_048_577)
56
+ return ProvisionProfile.from_mapping(load_json_object(payload, label="provision profile"))
57
+
58
+
59
+ def _require_private_directory(path: Path) -> None:
60
+ details = path.lstat()
61
+ if not stat.S_ISDIR(details.st_mode) or stat.S_IMODE(details.st_mode) != 0o700:
62
+ raise PermissionError("provision profile directory MUST have mode 0700")
63
+
64
+
65
+ def _require_regular_private_file(path: Path) -> None:
66
+ details = path.lstat()
67
+ if not stat.S_ISREG(details.st_mode):
68
+ raise PermissionError("provision profile MUST be a regular file")
69
+ if stat.S_IMODE(details.st_mode) != 0o600:
70
+ raise PermissionError("provision profile MUST have mode 0600")
@@ -0,0 +1,183 @@
1
+ """Monotonic run progress and full-subscription inventory closure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from enum import StrEnum
8
+
9
+
10
+ class ProgressState(StrEnum):
11
+ """Current progress disposition."""
12
+
13
+ RUNNING = "running"
14
+ WAITING = "waiting"
15
+ FAILED = "failed"
16
+ COMPLETE = "complete"
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class ProgressSnapshot:
21
+ """Bounded counters shared by CLI and operator projections."""
22
+
23
+ sequence: int
24
+ state: ProgressState
25
+ stages_completed: int
26
+ stages_total: int
27
+ checkpoints_completed: int
28
+ checkpoints_total: int
29
+ started_at: str
30
+ last_progress_at: str
31
+ resources_observed: int | None = None
32
+ resources_expected: int | None = None
33
+ pages_completed: int | None = None
34
+ pages_expected: int | None = None
35
+
36
+ def __post_init__(self) -> None:
37
+ if self.sequence < 1:
38
+ raise ValueError("progress sequence MUST be positive")
39
+ _bounded_pair(self.stages_completed, self.stages_total, "stages")
40
+ _bounded_pair(self.checkpoints_completed, self.checkpoints_total, "checkpoints")
41
+ _optional_pair(self.resources_observed, self.resources_expected, "resources")
42
+ _optional_pair(self.pages_completed, self.pages_expected, "pages")
43
+ started = _moment(self.started_at, "started_at")
44
+ progressed = _moment(self.last_progress_at, "last_progress_at")
45
+ if progressed < started:
46
+ raise ValueError("last_progress_at MUST NOT precede started_at")
47
+ if self.state is ProgressState.COMPLETE and (
48
+ self.stages_completed != self.stages_total
49
+ or self.checkpoints_completed != self.checkpoints_total
50
+ ):
51
+ raise ValueError("complete progress MUST close every stage and checkpoint")
52
+
53
+ @property
54
+ def fraction(self) -> float:
55
+ """Return exact work completion, never an elapsed-time estimate."""
56
+
57
+ stage_fraction = self.stages_completed / self.stages_total
58
+ checkpoint_fraction = self.checkpoints_completed / self.checkpoints_total
59
+ value = (stage_fraction + checkpoint_fraction) / 2
60
+ return 1.0 if self.state is ProgressState.COMPLETE else min(value, 0.999)
61
+
62
+
63
+ def validate_progression(
64
+ previous: ProgressSnapshot,
65
+ current: ProgressSnapshot,
66
+ ) -> None:
67
+ """Reject replay, regression, changing totals, and post-terminal progress."""
68
+
69
+ if previous.state in {ProgressState.COMPLETE, ProgressState.FAILED}:
70
+ raise ValueError("terminal progress cannot advance")
71
+ if current.sequence != previous.sequence + 1:
72
+ raise ValueError("progress sequence MUST advance by one")
73
+ if (
74
+ current.stages_total != previous.stages_total
75
+ or current.checkpoints_total != previous.checkpoints_total
76
+ ):
77
+ raise ValueError("sealed progress totals MUST NOT change")
78
+ if (
79
+ current.stages_completed < previous.stages_completed
80
+ or current.checkpoints_completed < previous.checkpoints_completed
81
+ ):
82
+ raise ValueError("progress counters MUST NOT regress")
83
+ _validate_optional_progression(
84
+ previous.resources_observed,
85
+ previous.resources_expected,
86
+ current.resources_observed,
87
+ current.resources_expected,
88
+ "resources",
89
+ )
90
+ _validate_optional_progression(
91
+ previous.pages_completed,
92
+ previous.pages_expected,
93
+ current.pages_completed,
94
+ current.pages_expected,
95
+ "pages",
96
+ )
97
+ if _moment(current.last_progress_at, "last_progress_at") < _moment(
98
+ previous.last_progress_at,
99
+ "last_progress_at",
100
+ ):
101
+ raise ValueError("progress time MUST NOT regress")
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class InventoryClosure:
106
+ """Independent postconditions required to close genesis inventory."""
107
+
108
+ subscription_root: bool
109
+ resource_type_filter: bool
110
+ final_fence: bool
111
+ provider_coverage_complete: bool
112
+ truncated: bool
113
+ active_generation_matches: bool
114
+ overlay_open: bool
115
+ child_sources_complete: bool
116
+ observer_distinct: bool
117
+
118
+ def blockers(self) -> tuple[str, ...]:
119
+ """Return stable reasons that keep the initial scan incomplete."""
120
+
121
+ reasons: list[str] = []
122
+ if not self.subscription_root:
123
+ reasons.append("subscription_root_missing")
124
+ if self.resource_type_filter:
125
+ reasons.append("genesis_resource_type_filter_present")
126
+ if not self.final_fence:
127
+ reasons.append("final_fence_missing")
128
+ if not self.provider_coverage_complete:
129
+ reasons.append("provider_coverage_incomplete")
130
+ if self.truncated:
131
+ reasons.append("inventory_truncated")
132
+ if not self.active_generation_matches:
133
+ reasons.append("active_generation_mismatch")
134
+ if self.overlay_open:
135
+ reasons.append("newer_change_overlay_open")
136
+ if not self.child_sources_complete:
137
+ reasons.append("child_source_incomplete")
138
+ if not self.observer_distinct:
139
+ reasons.append("observer_not_independent")
140
+ return tuple(reasons)
141
+
142
+ @property
143
+ def complete(self) -> bool:
144
+ """Return whether independent inventory closure succeeded."""
145
+
146
+ return not self.blockers()
147
+
148
+
149
+ def _bounded_pair(completed: int, total: int, label: str) -> None:
150
+ if total < 1 or not 0 <= completed <= total:
151
+ raise ValueError(f"{label} progress MUST be within a positive total")
152
+
153
+
154
+ def _optional_pair(completed: int | None, total: int | None, label: str) -> None:
155
+ if (completed is None) != (total is None):
156
+ raise ValueError(f"{label} progress values MUST be supplied together")
157
+ if completed is not None and total is not None and (completed < 0 or total < 0):
158
+ raise ValueError(f"{label} progress values MUST be non-negative")
159
+
160
+
161
+ def _validate_optional_progression(
162
+ previous_completed: int | None,
163
+ previous_total: int | None,
164
+ current_completed: int | None,
165
+ current_total: int | None,
166
+ label: str,
167
+ ) -> None:
168
+ if previous_completed is None:
169
+ return
170
+ if current_completed is None or current_total is None or previous_total is None:
171
+ raise ValueError(f"{label} progress MUST NOT disappear")
172
+ if current_completed < previous_completed or current_total < previous_total:
173
+ raise ValueError(f"{label} progress MUST NOT regress")
174
+
175
+
176
+ def _moment(value: str, field: str) -> datetime:
177
+ try:
178
+ moment = datetime.fromisoformat(value)
179
+ except ValueError as exc:
180
+ raise ValueError(f"{field} MUST be ISO 8601") from exc
181
+ if moment.tzinfo is None:
182
+ raise ValueError(f"{field} MUST be timezone-aware")
183
+ return moment