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,726 @@
|
|
|
1
|
+
"""Build and deploy one source-selected service through retained AKS state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from datetime import UTC, datetime, timedelta
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from fdai_deployment_cli.aks_service_update import SERVICES
|
|
19
|
+
from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest, load_json_object
|
|
20
|
+
from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
|
|
21
|
+
from fdai_deployment_cli.development_authority_profile import (
|
|
22
|
+
DevelopmentAuthorityDeploymentProfile,
|
|
23
|
+
)
|
|
24
|
+
from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
|
|
25
|
+
from fdai_deployment_cli.source_input import inspect_source
|
|
26
|
+
from fdai_deployment_cli.source_projection import service_source_projection
|
|
27
|
+
from fdai_deployment_cli.source_snapshot import materialize_source, verify_source_snapshot
|
|
28
|
+
from fdai_deployment_cli.standalone_application import (
|
|
29
|
+
_approval_input,
|
|
30
|
+
_approve_plan,
|
|
31
|
+
_azure_actor_digest,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
|
35
|
+
_COMMIT = re.compile(r"[0-9a-f]{40}")
|
|
36
|
+
_IMAGE_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def deploy_source_service_update(
|
|
40
|
+
*,
|
|
41
|
+
source_root: Path,
|
|
42
|
+
application_work_dir: Path,
|
|
43
|
+
work_dir: Path,
|
|
44
|
+
service: str,
|
|
45
|
+
timeout_seconds: int,
|
|
46
|
+
adopt_historical_binding: Path | None = None,
|
|
47
|
+
adopt_historical_state: Path | None = None,
|
|
48
|
+
adopt_historical_variables: Path | None = None,
|
|
49
|
+
adopt_historical_live: Path | None = None,
|
|
50
|
+
adopt_historical_plan: Path | None = None,
|
|
51
|
+
approve_import: Callable[[Path, dict[str, Any], DeploymentDeadline], Path] | None = None,
|
|
52
|
+
approve_plan: Callable[[Path, dict[str, Any]], Path] | None = None,
|
|
53
|
+
development_authority_profile: DevelopmentAuthorityDeploymentProfile | None = None,
|
|
54
|
+
clear_development_authority_profile: bool = False,
|
|
55
|
+
expected_source_commit: str | None = None,
|
|
56
|
+
expected_current_source_commit: str | None = None,
|
|
57
|
+
) -> dict[str, object]:
|
|
58
|
+
"""Build, import, plan, approve, apply, and verify one dev AKS service update."""
|
|
59
|
+
|
|
60
|
+
operation_started = time.monotonic()
|
|
61
|
+
phase_started = operation_started
|
|
62
|
+
phase_timings: list[dict[str, object]] = []
|
|
63
|
+
if service not in SERVICES:
|
|
64
|
+
raise ValueError("source service update names an unsupported service")
|
|
65
|
+
if (
|
|
66
|
+
development_authority_profile is not None or clear_development_authority_profile
|
|
67
|
+
) and service != "core-control-plane":
|
|
68
|
+
raise ValueError("development authority changes require a Core service update")
|
|
69
|
+
if development_authority_profile is not None and clear_development_authority_profile:
|
|
70
|
+
raise ValueError("development authority select and clear are mutually exclusive")
|
|
71
|
+
if any(
|
|
72
|
+
value is not None and _COMMIT.fullmatch(value) is None
|
|
73
|
+
for value in (expected_source_commit, expected_current_source_commit)
|
|
74
|
+
):
|
|
75
|
+
raise ValueError("source service update expected revisions are invalid")
|
|
76
|
+
deadline = DeploymentDeadline(timeout_seconds)
|
|
77
|
+
source = (
|
|
78
|
+
inspect_source(source_root)
|
|
79
|
+
if expected_source_commit is None
|
|
80
|
+
else inspect_source(source_root, expected_commit=expected_source_commit)
|
|
81
|
+
)
|
|
82
|
+
application = application_work_dir.absolute()
|
|
83
|
+
_require_private_directory(application, create=False)
|
|
84
|
+
work = work_dir.absolute()
|
|
85
|
+
if work.resolve().is_relative_to(source.root) or application == work:
|
|
86
|
+
raise ValueError("source service update work directories are invalid")
|
|
87
|
+
_require_private_directory(work, create=True)
|
|
88
|
+
lock = _lock(work)
|
|
89
|
+
try:
|
|
90
|
+
development_authority_profile_path: Path | None = None
|
|
91
|
+
adoption_inputs = (
|
|
92
|
+
adopt_historical_binding,
|
|
93
|
+
adopt_historical_state,
|
|
94
|
+
adopt_historical_variables,
|
|
95
|
+
adopt_historical_live,
|
|
96
|
+
adopt_historical_plan,
|
|
97
|
+
)
|
|
98
|
+
if any(value is not None for value in adoption_inputs) and not all(
|
|
99
|
+
isinstance(value, Path) for value in adoption_inputs
|
|
100
|
+
):
|
|
101
|
+
raise ValueError("historical AKS adoption requires all five evidence inputs")
|
|
102
|
+
if all(isinstance(value, Path) for value in adoption_inputs):
|
|
103
|
+
binding, state, variables, live, plan = adoption_inputs
|
|
104
|
+
assert isinstance(binding, Path)
|
|
105
|
+
assert isinstance(state, Path)
|
|
106
|
+
assert isinstance(variables, Path)
|
|
107
|
+
assert isinstance(live, Path)
|
|
108
|
+
assert isinstance(plan, Path)
|
|
109
|
+
adoption_arguments = (
|
|
110
|
+
"adopt-historical-aks-application",
|
|
111
|
+
"--binding",
|
|
112
|
+
str(binding.absolute()),
|
|
113
|
+
"--state",
|
|
114
|
+
str(state.absolute()),
|
|
115
|
+
"--variables",
|
|
116
|
+
str(variables.absolute()),
|
|
117
|
+
"--live",
|
|
118
|
+
str(live.absolute()),
|
|
119
|
+
"--plan",
|
|
120
|
+
str(plan.absolute()),
|
|
121
|
+
)
|
|
122
|
+
adopted = _host_json(
|
|
123
|
+
application,
|
|
124
|
+
adoption_arguments,
|
|
125
|
+
timeout=deadline.remaining(2400),
|
|
126
|
+
)
|
|
127
|
+
if adopted.get("state") == "reconciliation-required":
|
|
128
|
+
review = adopted.get("reconciliation_review")
|
|
129
|
+
if (
|
|
130
|
+
not isinstance(review, dict)
|
|
131
|
+
or review.get("schema_version") != "fdai.standalone-application-plan.v1"
|
|
132
|
+
or review.get("stage") != "application"
|
|
133
|
+
):
|
|
134
|
+
raise ValueError("historical AKS reconciliation review is invalid")
|
|
135
|
+
recovered = _host_json(
|
|
136
|
+
application,
|
|
137
|
+
("recover-historical-aks-reconciliation",),
|
|
138
|
+
timeout=deadline.remaining(2100),
|
|
139
|
+
)
|
|
140
|
+
if recovered.get("state") == "applied":
|
|
141
|
+
if (
|
|
142
|
+
recovered.get("effect_verified") is not True
|
|
143
|
+
or recovered.get("terraform_zero_change_verified") is not True
|
|
144
|
+
):
|
|
145
|
+
raise ValueError("historical AKS reconciliation recovery is incomplete")
|
|
146
|
+
else:
|
|
147
|
+
reconciliation_approval = (
|
|
148
|
+
approve_plan(work, review)
|
|
149
|
+
if approve_plan is not None
|
|
150
|
+
else _approve_plan(work, review, deadline=deadline)
|
|
151
|
+
)
|
|
152
|
+
reconciled = _host_json(
|
|
153
|
+
application,
|
|
154
|
+
(
|
|
155
|
+
"apply-historical-aks-reconciliation",
|
|
156
|
+
"--approval",
|
|
157
|
+
str(reconciliation_approval),
|
|
158
|
+
),
|
|
159
|
+
timeout=deadline.remaining(7500),
|
|
160
|
+
)
|
|
161
|
+
if (
|
|
162
|
+
reconciled.get("state") != "applied"
|
|
163
|
+
or reconciled.get("effect_verified") is not True
|
|
164
|
+
or reconciled.get("terraform_zero_change_verified") is not True
|
|
165
|
+
):
|
|
166
|
+
raise ValueError("historical AKS reconciliation is incomplete")
|
|
167
|
+
adopted = _host_json(
|
|
168
|
+
application,
|
|
169
|
+
adoption_arguments,
|
|
170
|
+
timeout=deadline.remaining(2400),
|
|
171
|
+
)
|
|
172
|
+
if (
|
|
173
|
+
adopted.get("schema_version") != "fdai.historical-aks-application-adoption.v1"
|
|
174
|
+
or adopted.get("state") != "adopted"
|
|
175
|
+
or adopted.get("managed_identity_verified") is not True
|
|
176
|
+
or adopted.get("remote_state_verified") is not True
|
|
177
|
+
or adopted.get("live_baseline_verified") is not True
|
|
178
|
+
or adopted.get("terraform_zero_change_verified") is not True
|
|
179
|
+
or adopted.get("azure_resource_mutation_performed") is not False
|
|
180
|
+
):
|
|
181
|
+
raise ValueError("historical AKS application adoption is incomplete")
|
|
182
|
+
context = _host_json(
|
|
183
|
+
application,
|
|
184
|
+
("service-update-context", "--service", service),
|
|
185
|
+
timeout=deadline.remaining(120),
|
|
186
|
+
)
|
|
187
|
+
if (
|
|
188
|
+
context.get("schema_version") != "fdai.source-service-update-context.v1"
|
|
189
|
+
or context.get("state") != "verified"
|
|
190
|
+
or context.get("service") != service
|
|
191
|
+
or not isinstance(context.get("target_binding"), str)
|
|
192
|
+
or _DIGEST.fullmatch(str(context["target_binding"])) is None
|
|
193
|
+
):
|
|
194
|
+
raise ValueError("retained AKS service update context is invalid")
|
|
195
|
+
receipt_path = work / "source-service-update-receipt.json"
|
|
196
|
+
if (
|
|
197
|
+
not receipt_path.exists()
|
|
198
|
+
and not receipt_path.is_symlink()
|
|
199
|
+
and expected_current_source_commit is not None
|
|
200
|
+
and context.get("current_source_commit") != expected_current_source_commit
|
|
201
|
+
):
|
|
202
|
+
raise ValueError("retained AKS service source differs from the changed-update base")
|
|
203
|
+
intent = {
|
|
204
|
+
"schema_version": "fdai.source-service-update-intent.v1",
|
|
205
|
+
"source": source.to_mapping(),
|
|
206
|
+
"service": service,
|
|
207
|
+
"target_binding": context["target_binding"],
|
|
208
|
+
"application_work_binding": canonical_digest(
|
|
209
|
+
{"path": str(application), "target_binding": context["target_binding"]}
|
|
210
|
+
),
|
|
211
|
+
"development_authority_operation": (
|
|
212
|
+
"select"
|
|
213
|
+
if development_authority_profile is not None
|
|
214
|
+
else "clear"
|
|
215
|
+
if clear_development_authority_profile
|
|
216
|
+
else "preserve"
|
|
217
|
+
),
|
|
218
|
+
"development_authority_profile_digest": (
|
|
219
|
+
development_authority_profile.input_digest
|
|
220
|
+
if development_authority_profile is not None
|
|
221
|
+
else ""
|
|
222
|
+
),
|
|
223
|
+
}
|
|
224
|
+
intent_path = work / "source-service-update-intent.json"
|
|
225
|
+
snapshot = work / "source-snapshot"
|
|
226
|
+
if intent_path.exists() or intent_path.is_symlink():
|
|
227
|
+
if _read(intent_path) != intent:
|
|
228
|
+
raise ValueError("retained source service update intent differs")
|
|
229
|
+
snapshot_digest = str(_read(work / "source-snapshot-receipt.json")["snapshot_digest"])
|
|
230
|
+
verify_source_snapshot(snapshot, expected_digest=snapshot_digest)
|
|
231
|
+
else:
|
|
232
|
+
if any(path.name != "source-service-update.lock" for path in work.iterdir()):
|
|
233
|
+
raise ValueError("source service update cannot adopt existing work state")
|
|
234
|
+
write_private_bytes(intent_path, canonical_bytes(intent))
|
|
235
|
+
snapshot_digest = materialize_source(
|
|
236
|
+
source,
|
|
237
|
+
snapshot,
|
|
238
|
+
selected_paths=service_source_projection(source, service),
|
|
239
|
+
purpose=f"service-image:{service}",
|
|
240
|
+
)
|
|
241
|
+
write_private_bytes(
|
|
242
|
+
work / "source-snapshot-receipt.json",
|
|
243
|
+
canonical_bytes({"snapshot_digest": snapshot_digest}),
|
|
244
|
+
)
|
|
245
|
+
snapshot_schema, projection_files, projection_bytes = _projection_facts(snapshot)
|
|
246
|
+
phase_started = _record_timing(phase_timings, "source-projection", phase_started)
|
|
247
|
+
if development_authority_profile is not None:
|
|
248
|
+
development_authority_profile_path = work / "development-authority-profile.json"
|
|
249
|
+
profile_bytes = development_authority_profile.profile_json.encode("utf-8")
|
|
250
|
+
if development_authority_profile_path.exists():
|
|
251
|
+
if (
|
|
252
|
+
read_private_bytes(
|
|
253
|
+
development_authority_profile_path,
|
|
254
|
+
max_bytes=256 * 1024,
|
|
255
|
+
)
|
|
256
|
+
!= profile_bytes
|
|
257
|
+
):
|
|
258
|
+
raise ValueError("retained development authority profile differs")
|
|
259
|
+
else:
|
|
260
|
+
write_private_bytes(development_authority_profile_path, profile_bytes)
|
|
261
|
+
completed = _verified_completed_receipt(
|
|
262
|
+
work,
|
|
263
|
+
source_commit=source.commit,
|
|
264
|
+
service=service,
|
|
265
|
+
target_binding=str(context["target_binding"]),
|
|
266
|
+
context=context,
|
|
267
|
+
)
|
|
268
|
+
if completed is not None:
|
|
269
|
+
return completed
|
|
270
|
+
if (
|
|
271
|
+
expected_current_source_commit is not None
|
|
272
|
+
and context.get("current_source_commit") != expected_current_source_commit
|
|
273
|
+
):
|
|
274
|
+
raise ValueError("retained AKS service source differs from the changed-update base")
|
|
275
|
+
source.reverify()
|
|
276
|
+
image = _build_image(
|
|
277
|
+
snapshot=snapshot,
|
|
278
|
+
snapshot_digest=snapshot_digest,
|
|
279
|
+
work_dir=work / "source-images",
|
|
280
|
+
service=service,
|
|
281
|
+
timeout_seconds=deadline.remaining(3600),
|
|
282
|
+
)
|
|
283
|
+
if (
|
|
284
|
+
image.get("state") != "built"
|
|
285
|
+
or image.get("source_commit") != source.commit
|
|
286
|
+
or image.get("service") != service
|
|
287
|
+
):
|
|
288
|
+
raise ValueError("source service image build did not produce exact evidence")
|
|
289
|
+
phase_started = _record_timing(phase_timings, "image-build", phase_started)
|
|
290
|
+
import_review = {
|
|
291
|
+
"schema_version": "fdai.source-service-image-import-review.v1",
|
|
292
|
+
"service": service,
|
|
293
|
+
"source_commit": source.commit,
|
|
294
|
+
"archive_sha256": image["archive_sha256"],
|
|
295
|
+
"image_digest": image["image_digest"],
|
|
296
|
+
"target_binding": context["target_binding"],
|
|
297
|
+
"expires_at": _moment(datetime.now(UTC) + timedelta(hours=1)),
|
|
298
|
+
"mutation_performed": False,
|
|
299
|
+
}
|
|
300
|
+
import_review["review_digest"] = canonical_digest(import_review)
|
|
301
|
+
import_approval = work / "source-image-import-approval.json"
|
|
302
|
+
if import_approval.is_symlink():
|
|
303
|
+
raise ValueError("source service image import approval must not be a symlink")
|
|
304
|
+
if not import_approval.is_file():
|
|
305
|
+
import_approval = (approve_import or _approve_import)(work, import_review, deadline)
|
|
306
|
+
archive = work / "source-images" / str(image["archive_ref"])
|
|
307
|
+
imported = _host_json(
|
|
308
|
+
application,
|
|
309
|
+
(
|
|
310
|
+
"import-source-image",
|
|
311
|
+
"--service",
|
|
312
|
+
service,
|
|
313
|
+
"--archive",
|
|
314
|
+
str(archive),
|
|
315
|
+
"--archive-sha256",
|
|
316
|
+
str(image["archive_sha256"]),
|
|
317
|
+
"--image-digest",
|
|
318
|
+
str(image["image_digest"]),
|
|
319
|
+
"--source-commit",
|
|
320
|
+
source.commit,
|
|
321
|
+
"--approval",
|
|
322
|
+
str(import_approval),
|
|
323
|
+
),
|
|
324
|
+
timeout=deadline.remaining(2400),
|
|
325
|
+
)
|
|
326
|
+
_validate_import_receipt(
|
|
327
|
+
imported,
|
|
328
|
+
service=service,
|
|
329
|
+
source_commit=source.commit,
|
|
330
|
+
archive_sha256=str(image["archive_sha256"]),
|
|
331
|
+
image_digest=str(image["image_digest"]),
|
|
332
|
+
)
|
|
333
|
+
phase_started = _record_timing(phase_timings, "image-import", phase_started)
|
|
334
|
+
expected_update = {
|
|
335
|
+
"service": service,
|
|
336
|
+
"image": imported.get("image"),
|
|
337
|
+
"source_commit": source.commit,
|
|
338
|
+
}
|
|
339
|
+
if intent["development_authority_operation"] != "preserve":
|
|
340
|
+
expected_update.update(
|
|
341
|
+
development_authority_operation=intent["development_authority_operation"],
|
|
342
|
+
development_authority_profile_digest=intent["development_authority_profile_digest"],
|
|
343
|
+
)
|
|
344
|
+
active = context.get("active_service_update")
|
|
345
|
+
active_matches = isinstance(active, dict) and all(
|
|
346
|
+
active.get(key) == value for key, value in expected_update.items()
|
|
347
|
+
)
|
|
348
|
+
if active is None or (service == "core-control-plane" and not active_matches):
|
|
349
|
+
prepared = _host_json(
|
|
350
|
+
application,
|
|
351
|
+
(
|
|
352
|
+
"prepare-service-update",
|
|
353
|
+
"--service",
|
|
354
|
+
service,
|
|
355
|
+
"--image",
|
|
356
|
+
str(imported["image"]),
|
|
357
|
+
"--source-commit",
|
|
358
|
+
source.commit,
|
|
359
|
+
*(
|
|
360
|
+
(
|
|
361
|
+
"--development-authority-profile",
|
|
362
|
+
str(development_authority_profile_path),
|
|
363
|
+
)
|
|
364
|
+
if development_authority_profile is not None
|
|
365
|
+
else ()
|
|
366
|
+
),
|
|
367
|
+
*(
|
|
368
|
+
("--clear-development-profile",)
|
|
369
|
+
if clear_development_authority_profile
|
|
370
|
+
else ()
|
|
371
|
+
),
|
|
372
|
+
),
|
|
373
|
+
timeout=deadline.remaining(300),
|
|
374
|
+
)
|
|
375
|
+
if any(prepared.get(key) != value for key, value in expected_update.items()):
|
|
376
|
+
raise ValueError("prepared source service update differs from imported image")
|
|
377
|
+
elif not active_matches:
|
|
378
|
+
raise ValueError("another AKS service update is active")
|
|
379
|
+
recovered = _host_json(
|
|
380
|
+
application,
|
|
381
|
+
("recover-apply", "--stage", "application", "--service", service),
|
|
382
|
+
timeout=deadline.remaining(2100),
|
|
383
|
+
)
|
|
384
|
+
if recovered.get("state") == "applied":
|
|
385
|
+
applied = recovered
|
|
386
|
+
else:
|
|
387
|
+
review = _host_json(
|
|
388
|
+
application,
|
|
389
|
+
("plan", "--stage", "application", "--service", service),
|
|
390
|
+
timeout=deadline.remaining(3900),
|
|
391
|
+
)
|
|
392
|
+
plan_approval = (
|
|
393
|
+
approve_plan(work, review)
|
|
394
|
+
if approve_plan is not None
|
|
395
|
+
else _approve_plan(work, review, deadline=deadline)
|
|
396
|
+
)
|
|
397
|
+
applied = _host_json(
|
|
398
|
+
application,
|
|
399
|
+
(
|
|
400
|
+
"apply",
|
|
401
|
+
"--stage",
|
|
402
|
+
"application",
|
|
403
|
+
"--service",
|
|
404
|
+
service,
|
|
405
|
+
"--approval",
|
|
406
|
+
str(plan_approval),
|
|
407
|
+
),
|
|
408
|
+
timeout=deadline.remaining(7500),
|
|
409
|
+
)
|
|
410
|
+
phase_started = _record_timing(phase_timings, "plan-apply-verify", phase_started)
|
|
411
|
+
result: dict[str, object] = {
|
|
412
|
+
"schema_version": "fdai.source-service-update.v1",
|
|
413
|
+
"state": "applied",
|
|
414
|
+
"service": service,
|
|
415
|
+
"source_commit": source.commit,
|
|
416
|
+
"image_digest": image["image_digest"],
|
|
417
|
+
"target_binding": context["target_binding"],
|
|
418
|
+
"image_import_receipt_digest": imported["receipt_digest"],
|
|
419
|
+
"application_receipt_digest": applied["receipt_digest"],
|
|
420
|
+
"effect_verified": applied.get("control_plane_readback_verified") is True,
|
|
421
|
+
"peer_state_unchanged_verified": applied.get("peer_state_unchanged_verified") is True,
|
|
422
|
+
"terraform_zero_change_verified": applied.get("terraform_zero_change_verified") is True,
|
|
423
|
+
"mutation_performed": True,
|
|
424
|
+
"subscription_ready": False,
|
|
425
|
+
}
|
|
426
|
+
if not all(
|
|
427
|
+
result[key] is True
|
|
428
|
+
for key in (
|
|
429
|
+
"effect_verified",
|
|
430
|
+
"peer_state_unchanged_verified",
|
|
431
|
+
"terraform_zero_change_verified",
|
|
432
|
+
)
|
|
433
|
+
):
|
|
434
|
+
raise ValueError("source service update effect verification is incomplete")
|
|
435
|
+
finalized = _host_json(
|
|
436
|
+
application,
|
|
437
|
+
(
|
|
438
|
+
"finalize-service-update",
|
|
439
|
+
"--service",
|
|
440
|
+
service,
|
|
441
|
+
"--operation",
|
|
442
|
+
str(applied["operation"]),
|
|
443
|
+
"--receipt-digest",
|
|
444
|
+
str(applied["receipt_digest"]),
|
|
445
|
+
),
|
|
446
|
+
timeout=deadline.remaining(120),
|
|
447
|
+
)
|
|
448
|
+
if (
|
|
449
|
+
finalized.get("state") != "finalized"
|
|
450
|
+
or finalized.get("service") != service
|
|
451
|
+
or finalized.get("apply_receipt_digest") != applied["receipt_digest"]
|
|
452
|
+
or finalized.get("mutation_performed") is not False
|
|
453
|
+
):
|
|
454
|
+
raise ValueError("source service update finalization is incomplete")
|
|
455
|
+
result["finalization_receipt_digest"] = finalized["receipt_digest"]
|
|
456
|
+
result["receipt_digest"] = canonical_digest(result)
|
|
457
|
+
_record_timing(phase_timings, "finalization", phase_started)
|
|
458
|
+
receipt_path = work / "source-service-update-receipt.json"
|
|
459
|
+
if receipt_path.exists() or receipt_path.is_symlink():
|
|
460
|
+
if _read(receipt_path) != result:
|
|
461
|
+
raise ValueError("retained source service update receipt differs")
|
|
462
|
+
else:
|
|
463
|
+
write_private_bytes(receipt_path, canonical_bytes(result))
|
|
464
|
+
timing: dict[str, object] = {
|
|
465
|
+
"schema_version": "fdai.deployment-timing.v1",
|
|
466
|
+
"lane": "source-service-update",
|
|
467
|
+
"service": service,
|
|
468
|
+
"source_commit": source.commit,
|
|
469
|
+
"outcome": "applied",
|
|
470
|
+
"recorded_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
|
471
|
+
"total_elapsed_ms": round((time.monotonic() - operation_started) * 1000),
|
|
472
|
+
"phases": phase_timings,
|
|
473
|
+
"source_snapshot_schema": snapshot_schema,
|
|
474
|
+
"source_projection_file_count": projection_files,
|
|
475
|
+
"source_projection_bytes": projection_bytes,
|
|
476
|
+
"image_archive_bytes": archive.stat().st_size,
|
|
477
|
+
"effect_receipt_digest": result["receipt_digest"],
|
|
478
|
+
"timing_evidence_only": True,
|
|
479
|
+
"apply_authorized": False,
|
|
480
|
+
"deployment_ready": False,
|
|
481
|
+
}
|
|
482
|
+
timing["receipt_digest"] = canonical_digest(timing)
|
|
483
|
+
timing_directory = work / "timing"
|
|
484
|
+
timing_directory.mkdir(mode=0o700)
|
|
485
|
+
write_private_bytes(
|
|
486
|
+
timing_directory / f"{timing['receipt_digest']}.json",
|
|
487
|
+
canonical_bytes(timing),
|
|
488
|
+
)
|
|
489
|
+
return result
|
|
490
|
+
finally:
|
|
491
|
+
os.close(lock)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _record_timing(
|
|
495
|
+
timings: list[dict[str, object]],
|
|
496
|
+
name: str,
|
|
497
|
+
started: float,
|
|
498
|
+
) -> float:
|
|
499
|
+
now = time.monotonic()
|
|
500
|
+
timings.append({"name": name, "elapsed_ms": max(0, round((now - started) * 1000))})
|
|
501
|
+
return now
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _projection_facts(snapshot: Path) -> tuple[str, int, int]:
|
|
505
|
+
manifest = load_json_object(
|
|
506
|
+
read_private_bytes(snapshot / "source-input.json", max_bytes=16 * 1024 * 1024),
|
|
507
|
+
label="source service projection",
|
|
508
|
+
max_bytes=16 * 1024 * 1024,
|
|
509
|
+
)
|
|
510
|
+
projection = manifest.get("projection")
|
|
511
|
+
records = manifest.get("files")
|
|
512
|
+
schema = manifest.get("schema_version")
|
|
513
|
+
if not isinstance(records, list) or schema not in {
|
|
514
|
+
"fdai.source-snapshot.v1",
|
|
515
|
+
"fdai.source-snapshot.v2",
|
|
516
|
+
}:
|
|
517
|
+
raise ValueError("source service update snapshot inventory is invalid")
|
|
518
|
+
if schema == "fdai.source-snapshot.v2" and (
|
|
519
|
+
not isinstance(projection, dict) or projection.get("file_count") != len(records)
|
|
520
|
+
):
|
|
521
|
+
raise ValueError("source service projection inventory is invalid")
|
|
522
|
+
total = 0
|
|
523
|
+
for record in records:
|
|
524
|
+
if not isinstance(record, dict) or not isinstance(record.get("path"), str):
|
|
525
|
+
raise TypeError("source service projection inventory is invalid")
|
|
526
|
+
path = snapshot / "tree" / record["path"]
|
|
527
|
+
if not path.is_symlink():
|
|
528
|
+
total += path.stat().st_size
|
|
529
|
+
return schema, len(records), total
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _verified_completed_receipt(
|
|
533
|
+
work: Path,
|
|
534
|
+
*,
|
|
535
|
+
source_commit: str,
|
|
536
|
+
service: str,
|
|
537
|
+
target_binding: str,
|
|
538
|
+
context: dict[str, Any],
|
|
539
|
+
) -> dict[str, object] | None:
|
|
540
|
+
receipt_path = work / "source-service-update-receipt.json"
|
|
541
|
+
if not receipt_path.exists() and not receipt_path.is_symlink():
|
|
542
|
+
return None
|
|
543
|
+
receipt = _read(receipt_path)
|
|
544
|
+
image_digest = receipt.get("image_digest")
|
|
545
|
+
receipt_digest = receipt.get("receipt_digest")
|
|
546
|
+
unsigned = {key: value for key, value in receipt.items() if key != "receipt_digest"}
|
|
547
|
+
if (
|
|
548
|
+
receipt.get("schema_version") != "fdai.source-service-update.v1"
|
|
549
|
+
or receipt.get("state") != "applied"
|
|
550
|
+
or receipt.get("service") != service
|
|
551
|
+
or receipt.get("source_commit") != source_commit
|
|
552
|
+
or receipt.get("target_binding") != target_binding
|
|
553
|
+
or not isinstance(image_digest, str)
|
|
554
|
+
or _IMAGE_DIGEST.fullmatch(image_digest) is None
|
|
555
|
+
or not isinstance(receipt_digest, str)
|
|
556
|
+
or _DIGEST.fullmatch(receipt_digest) is None
|
|
557
|
+
or canonical_digest(unsigned) != receipt_digest
|
|
558
|
+
or any(
|
|
559
|
+
not isinstance(receipt.get(key), str) or _DIGEST.fullmatch(str(receipt[key])) is None
|
|
560
|
+
for key in (
|
|
561
|
+
"image_import_receipt_digest",
|
|
562
|
+
"application_receipt_digest",
|
|
563
|
+
"finalization_receipt_digest",
|
|
564
|
+
)
|
|
565
|
+
)
|
|
566
|
+
or any(
|
|
567
|
+
receipt.get(key) is not True
|
|
568
|
+
for key in (
|
|
569
|
+
"effect_verified",
|
|
570
|
+
"peer_state_unchanged_verified",
|
|
571
|
+
"terraform_zero_change_verified",
|
|
572
|
+
)
|
|
573
|
+
)
|
|
574
|
+
or context.get("current_source_commit") != source_commit
|
|
575
|
+
or not isinstance(context.get("current_image"), str)
|
|
576
|
+
or not str(context["current_image"]).endswith(f"@{image_digest}")
|
|
577
|
+
or context.get("active_service_update") is not None
|
|
578
|
+
):
|
|
579
|
+
raise ValueError("retained source service update receipt is not currently verified")
|
|
580
|
+
return receipt
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _validate_import_receipt(
|
|
584
|
+
receipt: dict[str, Any],
|
|
585
|
+
*,
|
|
586
|
+
service: str,
|
|
587
|
+
source_commit: str,
|
|
588
|
+
archive_sha256: str,
|
|
589
|
+
image_digest: str,
|
|
590
|
+
) -> None:
|
|
591
|
+
receipt_digest = receipt.get("receipt_digest")
|
|
592
|
+
claim_digest = receipt.get("claim_digest")
|
|
593
|
+
image = receipt.get("image")
|
|
594
|
+
unsigned = {key: value for key, value in receipt.items() if key != "receipt_digest"}
|
|
595
|
+
if (
|
|
596
|
+
receipt.get("schema_version") != "fdai.source-service-image-import-receipt.v1"
|
|
597
|
+
or receipt.get("state") != "imported"
|
|
598
|
+
or receipt.get("service") != service
|
|
599
|
+
or receipt.get("source_commit") != source_commit
|
|
600
|
+
or receipt.get("archive_sha256") != archive_sha256
|
|
601
|
+
or receipt.get("image_digest") != image_digest
|
|
602
|
+
or not isinstance(image, str)
|
|
603
|
+
or not image.endswith(f"/{service}@{image_digest}")
|
|
604
|
+
or receipt.get("effect_verified") is not True
|
|
605
|
+
or not isinstance(claim_digest, str)
|
|
606
|
+
or _DIGEST.fullmatch(claim_digest) is None
|
|
607
|
+
or not isinstance(receipt_digest, str)
|
|
608
|
+
or _DIGEST.fullmatch(receipt_digest) is None
|
|
609
|
+
or canonical_digest(unsigned) != receipt_digest
|
|
610
|
+
):
|
|
611
|
+
raise ValueError("source service image import receipt is invalid")
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _approve_import(root: Path, review: dict[str, Any], deadline: DeploymentDeadline) -> Path:
|
|
615
|
+
print(json.dumps(review, indent=2, sort_keys=True), file=sys.stderr)
|
|
616
|
+
expected = f"import-{review['service']}"
|
|
617
|
+
print(f"Type {expected} to approve the exact source image import: ", end="", file=sys.stderr)
|
|
618
|
+
supplied = _approval_input(timeout_seconds=deadline.remaining(600))
|
|
619
|
+
if supplied != expected:
|
|
620
|
+
raise ValueError("source service image import approval was denied")
|
|
621
|
+
actor = _azure_actor_digest(str(review["target_binding"]), timeout_seconds=60)
|
|
622
|
+
now = datetime.now(UTC).replace(microsecond=0)
|
|
623
|
+
approval = {
|
|
624
|
+
"schema_version": "fdai.source-service-image-import-approval.v1",
|
|
625
|
+
"service": review["service"],
|
|
626
|
+
"source_commit": review["source_commit"],
|
|
627
|
+
"archive_sha256": review["archive_sha256"],
|
|
628
|
+
"image_digest": review["image_digest"],
|
|
629
|
+
"target_binding": review["target_binding"],
|
|
630
|
+
"actor_digest": actor,
|
|
631
|
+
"approved_at": _moment(now),
|
|
632
|
+
"expires_at": _moment(min(now + timedelta(hours=1), _parse(review["expires_at"]))),
|
|
633
|
+
}
|
|
634
|
+
approval["approval_digest"] = canonical_digest(approval)
|
|
635
|
+
path = root / "source-image-import-approval.json"
|
|
636
|
+
path.unlink(missing_ok=True)
|
|
637
|
+
write_private_bytes(path, canonical_bytes(approval))
|
|
638
|
+
return path
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _build_image(**kwargs: Any) -> dict[str, object]:
|
|
642
|
+
snapshot = Path(kwargs["snapshot"])
|
|
643
|
+
scripts = snapshot / "tree/scripts/deployment/azure/source_image_build.py"
|
|
644
|
+
if not scripts.is_file():
|
|
645
|
+
raise ValueError("source service image builder is unavailable in the selected snapshot")
|
|
646
|
+
command = (
|
|
647
|
+
sys.executable,
|
|
648
|
+
str(scripts),
|
|
649
|
+
"--snapshot",
|
|
650
|
+
str(snapshot),
|
|
651
|
+
"--snapshot-digest",
|
|
652
|
+
str(kwargs["snapshot_digest"]),
|
|
653
|
+
"--work-dir",
|
|
654
|
+
str(kwargs["work_dir"]),
|
|
655
|
+
"--service",
|
|
656
|
+
str(kwargs["service"]),
|
|
657
|
+
"--timeout-seconds",
|
|
658
|
+
str(kwargs["timeout_seconds"]),
|
|
659
|
+
)
|
|
660
|
+
completed = subprocess.run(
|
|
661
|
+
command,
|
|
662
|
+
check=False,
|
|
663
|
+
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
|
664
|
+
stdout=subprocess.PIPE,
|
|
665
|
+
stderr=sys.stderr,
|
|
666
|
+
timeout=kwargs["timeout_seconds"],
|
|
667
|
+
)
|
|
668
|
+
if completed.returncode != 0:
|
|
669
|
+
raise ValueError("source service image build failed; preserve retained evidence")
|
|
670
|
+
return load_json_object(completed.stdout, label="source service image build")
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _host_json(work_dir: Path, arguments: tuple[str, ...], *, timeout: int) -> dict[str, Any]:
|
|
674
|
+
completed = subprocess.run(
|
|
675
|
+
(
|
|
676
|
+
sys.executable,
|
|
677
|
+
"-m",
|
|
678
|
+
"fdai_deployment_cli.standalone_host",
|
|
679
|
+
"--work-dir",
|
|
680
|
+
str(work_dir),
|
|
681
|
+
*arguments,
|
|
682
|
+
),
|
|
683
|
+
check=False,
|
|
684
|
+
capture_output=True,
|
|
685
|
+
timeout=timeout,
|
|
686
|
+
)
|
|
687
|
+
if completed.returncode != 0:
|
|
688
|
+
raise ValueError("managed-host source service update checkpoint failed")
|
|
689
|
+
return load_json_object(completed.stdout, label="managed-host source service update")
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _require_private_directory(path: Path, *, create: bool) -> None:
|
|
693
|
+
if create:
|
|
694
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
695
|
+
details = path.lstat()
|
|
696
|
+
if (
|
|
697
|
+
not stat.S_ISDIR(details.st_mode)
|
|
698
|
+
or details.st_uid != os.geteuid()
|
|
699
|
+
or stat.S_IMODE(details.st_mode) != 0o700
|
|
700
|
+
):
|
|
701
|
+
raise ValueError("source service update requires private owned work directories")
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _lock(work: Path) -> int:
|
|
705
|
+
path = work / "source-service-update.lock"
|
|
706
|
+
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
707
|
+
try:
|
|
708
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
709
|
+
except BlockingIOError:
|
|
710
|
+
os.close(descriptor)
|
|
711
|
+
raise ValueError("source service update is already active") from None
|
|
712
|
+
return descriptor
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _read(path: Path) -> dict[str, object]:
|
|
716
|
+
return load_json_object(read_private_bytes(path, max_bytes=65536), label="source update state")
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _parse(value: object) -> datetime:
|
|
720
|
+
if not isinstance(value, str):
|
|
721
|
+
raise TypeError("source image import review expiry is invalid")
|
|
722
|
+
return datetime.fromisoformat(value)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _moment(value: datetime) -> str:
|
|
726
|
+
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|