fdai-deployment-cli 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fdai_deployment_cli/__about__.py +3 -0
- fdai_deployment_cli/__init__.py +5 -0
- fdai_deployment_cli/__main__.py +5 -0
- fdai_deployment_cli/acr_publish.py +585 -0
- fdai_deployment_cli/aks_catalog_review.py +137 -0
- fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
- fdai_deployment_cli/aks_job_environment.py +59 -0
- fdai_deployment_cli/aks_job_execution.py +794 -0
- fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
- fdai_deployment_cli/aks_preflight.py +422 -0
- fdai_deployment_cli/aks_readiness.py +220 -0
- fdai_deployment_cli/aks_service_update.py +180 -0
- fdai_deployment_cli/aks_workload_jobs.py +281 -0
- fdai_deployment_cli/application_state_adoption.py +347 -0
- fdai_deployment_cli/azure_actor.py +61 -0
- fdai_deployment_cli/azure_naming.py +252 -0
- fdai_deployment_cli/azure_retail_prices.py +173 -0
- fdai_deployment_cli/bootstrap_approval.py +99 -0
- fdai_deployment_cli/bootstrap_channel.py +321 -0
- fdai_deployment_cli/bootstrap_executor.py +311 -0
- fdai_deployment_cli/bootstrap_plan.py +237 -0
- fdai_deployment_cli/bootstrap_reconcile.py +402 -0
- fdai_deployment_cli/bootstrap_support.py +138 -0
- fdai_deployment_cli/bootstrap_worker.py +448 -0
- fdai_deployment_cli/bundle.py +352 -0
- fdai_deployment_cli/capacity.py +143 -0
- fdai_deployment_cli/catalog_review_profile.py +306 -0
- fdai_deployment_cli/cli.py +798 -0
- fdai_deployment_cli/cli_help.py +214 -0
- fdai_deployment_cli/cli_parser.py +791 -0
- fdai_deployment_cli/cli_plan.py +450 -0
- fdai_deployment_cli/compiler.py +99 -0
- fdai_deployment_cli/console_artifact.py +218 -0
- fdai_deployment_cli/console_config.py +214 -0
- fdai_deployment_cli/console_update.py +832 -0
- fdai_deployment_cli/contracts.py +307 -0
- fdai_deployment_cli/control_package.py +140 -0
- fdai_deployment_cli/deadline_transport.py +70 -0
- fdai_deployment_cli/deployment_cost.py +131 -0
- fdai_deployment_cli/deployment_deadline.py +28 -0
- fdai_deployment_cli/deployment_kit.py +790 -0
- fdai_deployment_cli/deployment_kit_cache.py +191 -0
- fdai_deployment_cli/deployment_progress.py +648 -0
- fdai_deployment_cli/development_authority_cli.py +56 -0
- fdai_deployment_cli/development_authority_profile.py +319 -0
- fdai_deployment_cli/doctor.py +136 -0
- fdai_deployment_cli/entitlement_preflight.py +68 -0
- fdai_deployment_cli/entitlement_transport.py +49 -0
- fdai_deployment_cli/entra_bootstrap.py +579 -0
- fdai_deployment_cli/entra_control_profile.py +214 -0
- fdai_deployment_cli/entra_graph.py +349 -0
- fdai_deployment_cli/entra_profiles.py +215 -0
- fdai_deployment_cli/entra_source.py +244 -0
- fdai_deployment_cli/execution_bundle.py +478 -0
- fdai_deployment_cli/execution_copies.py +63 -0
- fdai_deployment_cli/foundation_adoption.py +438 -0
- fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
- fdai_deployment_cli/foundation_adoption_host.py +157 -0
- fdai_deployment_cli/foundation_adoption_transport.py +51 -0
- fdai_deployment_cli/foundation_failure.py +72 -0
- fdai_deployment_cli/foundation_image.py +168 -0
- fdai_deployment_cli/foundation_input.py +235 -0
- fdai_deployment_cli/foundation_output.py +154 -0
- fdai_deployment_cli/foundation_plan.py +381 -0
- fdai_deployment_cli/foundation_process.py +64 -0
- fdai_deployment_cli/foundation_progress.py +231 -0
- fdai_deployment_cli/github_actions.py +171 -0
- fdai_deployment_cli/github_apply_artifact.py +259 -0
- fdai_deployment_cli/github_artifact_io.py +48 -0
- fdai_deployment_cli/github_dispatch.py +248 -0
- fdai_deployment_cli/github_plan_artifact.py +181 -0
- fdai_deployment_cli/github_provider_artifact.py +170 -0
- fdai_deployment_cli/github_transport.py +46 -0
- fdai_deployment_cli/github_workflow_values.py +312 -0
- fdai_deployment_cli/hub_image_deploy.py +354 -0
- fdai_deployment_cli/hub_image_stage.py +162 -0
- fdai_deployment_cli/hub_image_trust.py +281 -0
- fdai_deployment_cli/hub_ownership.py +161 -0
- fdai_deployment_cli/hub_workload_handoff.py +264 -0
- fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
- fdai_deployment_cli/identity_profile.py +227 -0
- fdai_deployment_cli/image_targets.py +197 -0
- fdai_deployment_cli/installation_scope.py +280 -0
- fdai_deployment_cli/license.py +311 -0
- fdai_deployment_cli/license_issue.py +239 -0
- fdai_deployment_cli/lifecycle_authorization.py +252 -0
- fdai_deployment_cli/lifecycle_commands.py +173 -0
- fdai_deployment_cli/lifecycle_configuration.py +722 -0
- fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
- fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
- fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
- fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
- fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
- fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
- fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
- fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
- fdai_deployment_cli/lifecycle_enrollment.py +45 -0
- fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
- fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
- fdai_deployment_cli/lifecycle_native.py +247 -0
- fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
- fdai_deployment_cli/lifecycle_native_transition.py +339 -0
- fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
- fdai_deployment_cli/lifecycle_plan.py +800 -0
- fdai_deployment_cli/lifecycle_promotion.py +185 -0
- fdai_deployment_cli/lifecycle_release.py +308 -0
- fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
- fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
- fdai_deployment_cli/notification_recipients_input.py +222 -0
- fdai_deployment_cli/observer_artifact.py +106 -0
- fdai_deployment_cli/oci_archive.py +353 -0
- fdai_deployment_cli/offline_kit.py +797 -0
- fdai_deployment_cli/offline_prepare.py +135 -0
- fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
- fdai_deployment_cli/plan_input.py +101 -0
- fdai_deployment_cli/private_output.py +192 -0
- fdai_deployment_cli/profile.py +70 -0
- fdai_deployment_cli/progress.py +183 -0
- fdai_deployment_cli/readiness.py +286 -0
- fdai_deployment_cli/run_command_receiver.py +418 -0
- fdai_deployment_cli/runtime_build.py +386 -0
- fdai_deployment_cli/runtime_profile.py +287 -0
- fdai_deployment_cli/runtime_release.py +745 -0
- fdai_deployment_cli/runtime_stage.py +82 -0
- fdai_deployment_cli/runtime_support_installation.py +215 -0
- fdai_deployment_cli/simulation.py +80 -0
- fdai_deployment_cli/source_application_inputs.py +397 -0
- fdai_deployment_cli/source_azure.py +586 -0
- fdai_deployment_cli/source_change_cli.py +107 -0
- fdai_deployment_cli/source_change_parser.py +75 -0
- fdai_deployment_cli/source_change_plan.py +181 -0
- fdai_deployment_cli/source_changed_update.py +196 -0
- fdai_deployment_cli/source_deploy.py +170 -0
- fdai_deployment_cli/source_foundation.py +174 -0
- fdai_deployment_cli/source_host_tools.py +163 -0
- fdai_deployment_cli/source_image_stage.py +400 -0
- fdai_deployment_cli/source_input.py +173 -0
- fdai_deployment_cli/source_projection.py +164 -0
- fdai_deployment_cli/source_receiver.py +76 -0
- fdai_deployment_cli/source_recovery.py +264 -0
- fdai_deployment_cli/source_runtime.py +81 -0
- fdai_deployment_cli/source_runtime_support.py +368 -0
- fdai_deployment_cli/source_service_update.py +726 -0
- fdai_deployment_cli/source_service_update_cli.py +47 -0
- fdai_deployment_cli/source_snapshot.py +178 -0
- fdai_deployment_cli/source_teardown.py +574 -0
- fdai_deployment_cli/source_terraform.py +85 -0
- fdai_deployment_cli/source_transport.py +399 -0
- fdai_deployment_cli/standalone_aks_inventory.py +558 -0
- fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
- fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
- fdai_deployment_cli/standalone_application.py +977 -0
- fdai_deployment_cli/standalone_application_completion.py +151 -0
- fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
- fdai_deployment_cli/standalone_application_recovery.py +116 -0
- fdai_deployment_cli/standalone_approval_input.py +24 -0
- fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
- fdai_deployment_cli/standalone_catalog_review.py +251 -0
- fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
- fdai_deployment_cli/standalone_console_publish.py +174 -0
- fdai_deployment_cli/standalone_database_principals.py +41 -0
- fdai_deployment_cli/standalone_deploy.py +638 -0
- fdai_deployment_cli/standalone_development_authority.py +166 -0
- fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
- fdai_deployment_cli/standalone_foundation_transition.py +767 -0
- fdai_deployment_cli/standalone_host.py +4608 -0
- fdai_deployment_cli/standalone_host_rebind.py +226 -0
- fdai_deployment_cli/standalone_host_residual.py +104 -0
- fdai_deployment_cli/standalone_host_state.py +145 -0
- fdai_deployment_cli/standalone_host_values.py +563 -0
- fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
- fdai_deployment_cli/standalone_license_installation.py +177 -0
- fdai_deployment_cli/standalone_management_egress.py +83 -0
- fdai_deployment_cli/standalone_migration_evidence.py +38 -0
- fdai_deployment_cli/standalone_operational_evidence.py +356 -0
- fdai_deployment_cli/standalone_planned_outputs.py +147 -0
- fdai_deployment_cli/standalone_private_access_probe.py +100 -0
- fdai_deployment_cli/standalone_product_profile.py +110 -0
- fdai_deployment_cli/standalone_remote_prepare.py +442 -0
- fdai_deployment_cli/standalone_residual_apply.py +540 -0
- fdai_deployment_cli/standalone_residual_successor.py +431 -0
- fdai_deployment_cli/standalone_review.py +266 -0
- fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
- fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
- fdai_deployment_cli/standalone_stage_targets.py +325 -0
- fdai_deployment_cli/standalone_status.py +67 -0
- fdai_deployment_cli/standalone_terraform_environment.py +237 -0
- fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
- fdai_deployment_cli/standalone_trial_activation.py +193 -0
- fdai_deployment_cli/state.py +434 -0
- fdai_deployment_cli/state_handoff.py +253 -0
- fdai_deployment_cli/status_projection.py +97 -0
- fdai_deployment_cli/support_install.py +257 -0
- fdai_deployment_cli/target.py +46 -0
- fdai_deployment_cli/trust/capabilities.json +62 -0
- fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
- fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
- fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
- fdai_deployment_cli/trust_roots.py +50 -0
- fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
- fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
- fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
- fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,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
|