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,832 @@
|
|
|
1
|
+
"""Plan exact Console content updates for existing Azure installations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import stat
|
|
11
|
+
import subprocess
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import UTC, datetime, timedelta
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import cast
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
|
|
19
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
20
|
+
from fdai_deployment_cli.private_output import (
|
|
21
|
+
copy_private_file,
|
|
22
|
+
read_private_bytes,
|
|
23
|
+
write_private_output,
|
|
24
|
+
)
|
|
25
|
+
from fdai_deployment_cli.standalone_application import publish_verified_console
|
|
26
|
+
from fdai_deployment_cli.target import compute_target_binding
|
|
27
|
+
|
|
28
|
+
_GUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
|
|
29
|
+
_API_SCOPE = re.compile(rf"api://{_GUID.pattern}/[A-Za-z0-9._-]+")
|
|
30
|
+
_COMMIT = re.compile(r"[0-9a-f]{40}")
|
|
31
|
+
_HOSTNAME = re.compile(r"[a-z0-9-]+(?:[.][0-9]+)?[.]azurestaticapps[.]net")
|
|
32
|
+
_RESOURCE_ID = re.compile(
|
|
33
|
+
r"/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/"
|
|
34
|
+
r"Microsoft[.]Web/staticSites/([^/]+)",
|
|
35
|
+
re.IGNORECASE,
|
|
36
|
+
)
|
|
37
|
+
_MAX_TARGET_BYTES = 16_384
|
|
38
|
+
_MAX_MANIFEST_BYTES = 4096
|
|
39
|
+
_MAX_ARCHIVE_BYTES = 512 * 1024 * 1024
|
|
40
|
+
_ARTIFACT_FIELDS = frozenset({"schema_version", "source_commit", "archive_sha256"})
|
|
41
|
+
_TARGET_FIELDS = frozenset(
|
|
42
|
+
{
|
|
43
|
+
"schema_version",
|
|
44
|
+
"environment",
|
|
45
|
+
"subscription_id",
|
|
46
|
+
"tenant_id",
|
|
47
|
+
"console_static_web_app_id",
|
|
48
|
+
"console_hostname",
|
|
49
|
+
"operator_api_base_url",
|
|
50
|
+
"ingestion_api_base_url",
|
|
51
|
+
"entra_console_spa_client_id",
|
|
52
|
+
"entra_console_api_scope",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
_PLAN_FIELDS = frozenset(
|
|
56
|
+
{
|
|
57
|
+
"schema_version",
|
|
58
|
+
"environment",
|
|
59
|
+
"source_commit",
|
|
60
|
+
"rollback_source_commit",
|
|
61
|
+
"target_binding",
|
|
62
|
+
"target_digest",
|
|
63
|
+
"candidate_archive_sha256",
|
|
64
|
+
"rollback_archive_sha256",
|
|
65
|
+
"action",
|
|
66
|
+
"stop_condition",
|
|
67
|
+
"rollback_action",
|
|
68
|
+
"blast_radius",
|
|
69
|
+
"logical_target",
|
|
70
|
+
"idempotency_key",
|
|
71
|
+
"created_at",
|
|
72
|
+
"expires_at",
|
|
73
|
+
"mutation_performed",
|
|
74
|
+
"plan_digest",
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class ConsoleUpdateTarget:
|
|
81
|
+
"""Secret-free existing Console binding supplied outside source control."""
|
|
82
|
+
|
|
83
|
+
environment: str
|
|
84
|
+
subscription_id: str
|
|
85
|
+
tenant_id: str
|
|
86
|
+
console_static_web_app_id: str
|
|
87
|
+
console_hostname: str
|
|
88
|
+
operator_api_base_url: str
|
|
89
|
+
ingestion_api_base_url: str
|
|
90
|
+
entra_console_spa_client_id: str
|
|
91
|
+
entra_console_api_scope: str
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_mapping(cls, value: Mapping[str, object]) -> ConsoleUpdateTarget:
|
|
95
|
+
"""Decode a closed target manifest and reject unsafe endpoint bindings."""
|
|
96
|
+
|
|
97
|
+
if (
|
|
98
|
+
set(value) != _TARGET_FIELDS
|
|
99
|
+
or value.get("schema_version") != "fdai.console-update-target.v1"
|
|
100
|
+
):
|
|
101
|
+
raise ValueError("Console update target fields are invalid")
|
|
102
|
+
text = {
|
|
103
|
+
key: item
|
|
104
|
+
for key, item in value.items()
|
|
105
|
+
if key != "schema_version" and isinstance(item, str)
|
|
106
|
+
}
|
|
107
|
+
if len(text) != len(_TARGET_FIELDS) - 1:
|
|
108
|
+
raise ValueError("Console update target values MUST be strings")
|
|
109
|
+
target = cls(**text)
|
|
110
|
+
target.validate()
|
|
111
|
+
return target
|
|
112
|
+
|
|
113
|
+
def validate(self) -> None:
|
|
114
|
+
"""Validate the development-only target and its public, non-secret bindings."""
|
|
115
|
+
|
|
116
|
+
if self.environment != "dev":
|
|
117
|
+
raise ValueError("existing Console source updates are supported only in dev")
|
|
118
|
+
for label, value in (
|
|
119
|
+
("subscription", self.subscription_id),
|
|
120
|
+
("tenant", self.tenant_id),
|
|
121
|
+
("Console SPA client", self.entra_console_spa_client_id),
|
|
122
|
+
):
|
|
123
|
+
if _GUID.fullmatch(value) is None:
|
|
124
|
+
raise ValueError(f"Console update {label} id is invalid")
|
|
125
|
+
match = _RESOURCE_ID.fullmatch(self.console_static_web_app_id)
|
|
126
|
+
if match is None or match.group(1).casefold() != self.subscription_id:
|
|
127
|
+
raise ValueError("Console update Static Web App resource id is invalid")
|
|
128
|
+
if _HOSTNAME.fullmatch(self.console_hostname) is None:
|
|
129
|
+
raise ValueError("Console update hostname is invalid")
|
|
130
|
+
for label, value in (
|
|
131
|
+
("Operator API", self.operator_api_base_url),
|
|
132
|
+
("ingestion API", self.ingestion_api_base_url),
|
|
133
|
+
):
|
|
134
|
+
parsed = urlsplit(value)
|
|
135
|
+
if (
|
|
136
|
+
parsed.scheme != "https"
|
|
137
|
+
or not parsed.hostname
|
|
138
|
+
or parsed.username is not None
|
|
139
|
+
or parsed.password is not None
|
|
140
|
+
or parsed.query
|
|
141
|
+
or parsed.fragment
|
|
142
|
+
):
|
|
143
|
+
raise ValueError(f"Console update {label} URL is invalid")
|
|
144
|
+
if _API_SCOPE.fullmatch(self.entra_console_api_scope) is None:
|
|
145
|
+
raise ValueError("Console update API scope is invalid")
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def target_binding(self) -> str:
|
|
149
|
+
"""Return the canonical tenant and subscription binding."""
|
|
150
|
+
|
|
151
|
+
return compute_target_binding(
|
|
152
|
+
tenant_id=self.tenant_id,
|
|
153
|
+
subscription_id=self.subscription_id,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def to_mapping(self) -> dict[str, str]:
|
|
157
|
+
"""Return canonical target data for a private plan."""
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
"schema_version": "fdai.console-update-target.v1",
|
|
161
|
+
"environment": self.environment,
|
|
162
|
+
"subscription_id": self.subscription_id,
|
|
163
|
+
"tenant_id": self.tenant_id,
|
|
164
|
+
"console_static_web_app_id": self.console_static_web_app_id,
|
|
165
|
+
"console_hostname": self.console_hostname,
|
|
166
|
+
"operator_api_base_url": self.operator_api_base_url,
|
|
167
|
+
"ingestion_api_base_url": self.ingestion_api_base_url,
|
|
168
|
+
"entra_console_spa_client_id": self.entra_console_spa_client_id,
|
|
169
|
+
"entra_console_api_scope": self.entra_console_api_scope,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def prepare_console_update_plan(
|
|
174
|
+
*,
|
|
175
|
+
source_root: Path,
|
|
176
|
+
candidate_archive: Path,
|
|
177
|
+
candidate_manifest: Path,
|
|
178
|
+
rollback_archive: Path,
|
|
179
|
+
rollback_manifest: Path,
|
|
180
|
+
target_file: Path,
|
|
181
|
+
work_dir: Path,
|
|
182
|
+
ttl_seconds: int = 1200,
|
|
183
|
+
timeout_seconds: int = 60,
|
|
184
|
+
) -> dict[str, object]:
|
|
185
|
+
"""Create a private exact plan without changing Azure or published content."""
|
|
186
|
+
|
|
187
|
+
if not 60 <= ttl_seconds <= 1200:
|
|
188
|
+
raise ValueError("Console update plan TTL MUST be between 60 and 1200 seconds")
|
|
189
|
+
source_commit = _verified_source_commit(source_root, timeout_seconds=timeout_seconds)
|
|
190
|
+
target = _load_target(target_file)
|
|
191
|
+
_verify_active_target(target, timeout_seconds=timeout_seconds)
|
|
192
|
+
_verify_static_site(target, timeout_seconds=timeout_seconds)
|
|
193
|
+
candidate = _load_artifact_manifest(candidate_manifest, candidate_archive)
|
|
194
|
+
rollback = _load_artifact_manifest(rollback_manifest, rollback_archive)
|
|
195
|
+
if candidate["source_commit"] != source_commit:
|
|
196
|
+
raise ValueError("Console candidate artifact does not match protected origin/main")
|
|
197
|
+
if rollback["source_commit"] == source_commit:
|
|
198
|
+
raise ValueError("Console rollback artifact MUST use a distinct source revision")
|
|
199
|
+
candidate_digest = candidate["archive_sha256"]
|
|
200
|
+
rollback_digest = rollback["archive_sha256"]
|
|
201
|
+
if candidate_digest == rollback_digest:
|
|
202
|
+
raise ValueError("Console candidate and rollback archives MUST differ")
|
|
203
|
+
if work_dir.exists() or work_dir.is_symlink():
|
|
204
|
+
raise ValueError("Console update work directory already exists")
|
|
205
|
+
work_dir.mkdir(mode=0o700, parents=False)
|
|
206
|
+
copy_private_file(
|
|
207
|
+
candidate_archive, work_dir / "candidate.tar.gz", max_bytes=_MAX_ARCHIVE_BYTES
|
|
208
|
+
)
|
|
209
|
+
copy_private_file(rollback_archive, work_dir / "rollback.tar.gz", max_bytes=_MAX_ARCHIVE_BYTES)
|
|
210
|
+
target_copy = work_dir / "target.json"
|
|
211
|
+
write_private_output(
|
|
212
|
+
target_copy,
|
|
213
|
+
json.dumps(target.to_mapping(), sort_keys=True, separators=(",", ":")) + "\n",
|
|
214
|
+
)
|
|
215
|
+
plan: dict[str, object] = {
|
|
216
|
+
"schema_version": "fdai.console-update-plan.v1",
|
|
217
|
+
"environment": target.environment,
|
|
218
|
+
"source_commit": source_commit,
|
|
219
|
+
"rollback_source_commit": rollback["source_commit"],
|
|
220
|
+
"target_binding": target.target_binding,
|
|
221
|
+
"target_digest": canonical_digest(target.to_mapping()),
|
|
222
|
+
"candidate_archive_sha256": candidate_digest,
|
|
223
|
+
"rollback_archive_sha256": rollback_digest,
|
|
224
|
+
"action": "publish_console_static_content",
|
|
225
|
+
"stop_condition": "candidate_readback_mismatch",
|
|
226
|
+
"rollback_action": "republish_verified_rollback_archive",
|
|
227
|
+
"blast_radius": "one_existing_static_web_app",
|
|
228
|
+
"logical_target": canonical_digest(
|
|
229
|
+
{
|
|
230
|
+
"target_binding": target.target_binding,
|
|
231
|
+
"resource_id": target.console_static_web_app_id.casefold(),
|
|
232
|
+
}
|
|
233
|
+
),
|
|
234
|
+
"idempotency_key": canonical_digest(
|
|
235
|
+
{
|
|
236
|
+
"target_binding": target.target_binding,
|
|
237
|
+
"candidate_archive_sha256": candidate_digest,
|
|
238
|
+
}
|
|
239
|
+
),
|
|
240
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
241
|
+
"expires_at": (datetime.now(UTC) + timedelta(seconds=ttl_seconds)).isoformat(),
|
|
242
|
+
"mutation_performed": False,
|
|
243
|
+
}
|
|
244
|
+
plan["plan_digest"] = canonical_digest(plan)
|
|
245
|
+
write_private_output(
|
|
246
|
+
work_dir / "plan.json",
|
|
247
|
+
json.dumps(plan, sort_keys=True, separators=(",", ":")) + "\n",
|
|
248
|
+
)
|
|
249
|
+
return plan
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def apply_console_update_plan(
|
|
253
|
+
*,
|
|
254
|
+
source_root: Path,
|
|
255
|
+
work_dir: Path,
|
|
256
|
+
approved_plan_digest: str,
|
|
257
|
+
scripts: Path,
|
|
258
|
+
timeout_seconds: int = 900,
|
|
259
|
+
) -> dict[str, object]:
|
|
260
|
+
"""Apply one exact Console plan, or recover an existing claim by readback."""
|
|
261
|
+
|
|
262
|
+
claim_path = work_dir / "claim.json"
|
|
263
|
+
plan = _load_plan(work_dir / "plan.json", allow_expired_claim=claim_path.is_file())
|
|
264
|
+
if approved_plan_digest != plan["plan_digest"]:
|
|
265
|
+
raise ValueError("Console update approval does not match the exact plan digest")
|
|
266
|
+
source_commit = _verified_source_commit(source_root, timeout_seconds=min(timeout_seconds, 60))
|
|
267
|
+
if source_commit != plan["source_commit"]:
|
|
268
|
+
raise ValueError("Console update source changed after planning")
|
|
269
|
+
target = _load_target(work_dir / "target.json")
|
|
270
|
+
if (
|
|
271
|
+
target.target_binding != plan["target_binding"]
|
|
272
|
+
or canonical_digest(target.to_mapping()) != plan["target_digest"]
|
|
273
|
+
):
|
|
274
|
+
raise ValueError("Console update target changed after planning")
|
|
275
|
+
_verify_active_target(target, timeout_seconds=min(timeout_seconds, 60))
|
|
276
|
+
_verify_static_site(target, timeout_seconds=min(timeout_seconds, 60))
|
|
277
|
+
_require_digest(work_dir / "candidate.tar.gz", str(plan["candidate_archive_sha256"]))
|
|
278
|
+
_require_digest(work_dir / "rollback.tar.gz", str(plan["rollback_archive_sha256"]))
|
|
279
|
+
|
|
280
|
+
lock_descriptor = os.open(
|
|
281
|
+
work_dir / "target.lock",
|
|
282
|
+
os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW,
|
|
283
|
+
0o600,
|
|
284
|
+
)
|
|
285
|
+
try:
|
|
286
|
+
try:
|
|
287
|
+
fcntl.flock(lock_descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
288
|
+
except BlockingIOError as exc:
|
|
289
|
+
raise ValueError("Console update target is locked by another local operation") from exc
|
|
290
|
+
receipt_path = work_dir / "receipt.json"
|
|
291
|
+
if receipt_path.exists():
|
|
292
|
+
return _load_receipt(receipt_path, plan)
|
|
293
|
+
failure_path = work_dir / "failure.json"
|
|
294
|
+
if failure_path.exists():
|
|
295
|
+
_load_failure(failure_path, plan)
|
|
296
|
+
raise ValueError(
|
|
297
|
+
"Console update previously failed and the verified rollback was restored"
|
|
298
|
+
)
|
|
299
|
+
actor_digest = _active_actor_digest(target, timeout_seconds=min(timeout_seconds, 60))
|
|
300
|
+
if claim_path.exists():
|
|
301
|
+
_load_claim(claim_path, plan)
|
|
302
|
+
try:
|
|
303
|
+
candidate = _publish(
|
|
304
|
+
source_root=source_root,
|
|
305
|
+
work_dir=work_dir / "candidate-verify",
|
|
306
|
+
archive=work_dir / "candidate.tar.gz",
|
|
307
|
+
archive_digest=str(plan["candidate_archive_sha256"]),
|
|
308
|
+
target=target,
|
|
309
|
+
scripts=scripts,
|
|
310
|
+
timeout_seconds=timeout_seconds,
|
|
311
|
+
verify_only=True,
|
|
312
|
+
)
|
|
313
|
+
except (OSError, subprocess.SubprocessError, ValueError) as verification_error:
|
|
314
|
+
try:
|
|
315
|
+
rollback = _publish(
|
|
316
|
+
source_root=source_root,
|
|
317
|
+
work_dir=work_dir / "rollback-verify",
|
|
318
|
+
archive=work_dir / "rollback.tar.gz",
|
|
319
|
+
archive_digest=str(plan["rollback_archive_sha256"]),
|
|
320
|
+
target=target,
|
|
321
|
+
scripts=scripts,
|
|
322
|
+
timeout_seconds=timeout_seconds,
|
|
323
|
+
verify_only=True,
|
|
324
|
+
verify_service_contracts=False,
|
|
325
|
+
)
|
|
326
|
+
reason = "claimed_candidate_readback_failed_rollback_already_present"
|
|
327
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
328
|
+
try:
|
|
329
|
+
rollback = _publish(
|
|
330
|
+
source_root=source_root,
|
|
331
|
+
work_dir=work_dir / "rollback-recovery",
|
|
332
|
+
archive=work_dir / "rollback.tar.gz",
|
|
333
|
+
archive_digest=str(plan["rollback_archive_sha256"]),
|
|
334
|
+
target=target,
|
|
335
|
+
scripts=scripts,
|
|
336
|
+
timeout_seconds=timeout_seconds,
|
|
337
|
+
verify_only=False,
|
|
338
|
+
verify_service_contracts=False,
|
|
339
|
+
)
|
|
340
|
+
reason = "claimed_candidate_readback_failed_rollback_restored"
|
|
341
|
+
except (OSError, subprocess.SubprocessError, ValueError) as rollback_error:
|
|
342
|
+
raise ValueError(
|
|
343
|
+
"Console update readback and rollback both failed; retain the claim for review"
|
|
344
|
+
) from rollback_error
|
|
345
|
+
_write_failure_receipt(
|
|
346
|
+
failure_path,
|
|
347
|
+
plan=plan,
|
|
348
|
+
actor_digest=actor_digest,
|
|
349
|
+
rollback=rollback,
|
|
350
|
+
reason=reason,
|
|
351
|
+
)
|
|
352
|
+
raise ValueError(
|
|
353
|
+
"Console update readback failed and the verified rollback was restored"
|
|
354
|
+
) from verification_error
|
|
355
|
+
return _write_apply_receipt(
|
|
356
|
+
receipt_path,
|
|
357
|
+
plan=plan,
|
|
358
|
+
actor_digest=actor_digest,
|
|
359
|
+
publication=candidate,
|
|
360
|
+
recovered_by_readback=True,
|
|
361
|
+
)
|
|
362
|
+
approval = {
|
|
363
|
+
"schema_version": "fdai.console-update-approval.v1",
|
|
364
|
+
"plan_digest": plan["plan_digest"],
|
|
365
|
+
"target_binding": plan["target_binding"],
|
|
366
|
+
"actor_digest": actor_digest,
|
|
367
|
+
"approved_at": datetime.now(UTC).isoformat(),
|
|
368
|
+
}
|
|
369
|
+
approval["approval_digest"] = canonical_digest(approval)
|
|
370
|
+
write_private_output(
|
|
371
|
+
work_dir / "approval.json",
|
|
372
|
+
json.dumps(approval, sort_keys=True, separators=(",", ":")) + "\n",
|
|
373
|
+
)
|
|
374
|
+
claim = {
|
|
375
|
+
"schema_version": "fdai.console-update-claim.v1",
|
|
376
|
+
"plan_digest": plan["plan_digest"],
|
|
377
|
+
"approval_digest": approval["approval_digest"],
|
|
378
|
+
"idempotency_key": plan["idempotency_key"],
|
|
379
|
+
"claimed_at": datetime.now(UTC).isoformat(),
|
|
380
|
+
}
|
|
381
|
+
claim["claim_digest"] = canonical_digest(claim)
|
|
382
|
+
write_private_output(
|
|
383
|
+
claim_path,
|
|
384
|
+
json.dumps(claim, sort_keys=True, separators=(",", ":")) + "\n",
|
|
385
|
+
)
|
|
386
|
+
try:
|
|
387
|
+
candidate = _publish(
|
|
388
|
+
source_root=source_root,
|
|
389
|
+
work_dir=work_dir / "candidate-publish",
|
|
390
|
+
archive=work_dir / "candidate.tar.gz",
|
|
391
|
+
archive_digest=str(plan["candidate_archive_sha256"]),
|
|
392
|
+
target=target,
|
|
393
|
+
scripts=scripts,
|
|
394
|
+
timeout_seconds=timeout_seconds,
|
|
395
|
+
verify_only=False,
|
|
396
|
+
)
|
|
397
|
+
except (OSError, subprocess.SubprocessError, ValueError) as publish_error:
|
|
398
|
+
try:
|
|
399
|
+
rollback = _publish(
|
|
400
|
+
source_root=source_root,
|
|
401
|
+
work_dir=work_dir / "rollback-publish",
|
|
402
|
+
archive=work_dir / "rollback.tar.gz",
|
|
403
|
+
archive_digest=str(plan["rollback_archive_sha256"]),
|
|
404
|
+
target=target,
|
|
405
|
+
scripts=scripts,
|
|
406
|
+
timeout_seconds=timeout_seconds,
|
|
407
|
+
verify_only=False,
|
|
408
|
+
verify_service_contracts=False,
|
|
409
|
+
)
|
|
410
|
+
except (OSError, subprocess.SubprocessError, ValueError) as rollback_error:
|
|
411
|
+
raise ValueError(
|
|
412
|
+
"Console update and rollback both failed; retain the claim for review"
|
|
413
|
+
) from rollback_error
|
|
414
|
+
_write_failure_receipt(
|
|
415
|
+
failure_path,
|
|
416
|
+
plan=plan,
|
|
417
|
+
actor_digest=actor_digest,
|
|
418
|
+
rollback=rollback,
|
|
419
|
+
reason="candidate_publication_failed",
|
|
420
|
+
)
|
|
421
|
+
raise ValueError(
|
|
422
|
+
"Console update failed and the verified rollback was restored"
|
|
423
|
+
) from publish_error
|
|
424
|
+
return _write_apply_receipt(
|
|
425
|
+
receipt_path,
|
|
426
|
+
plan=plan,
|
|
427
|
+
actor_digest=actor_digest,
|
|
428
|
+
publication=candidate,
|
|
429
|
+
recovered_by_readback=False,
|
|
430
|
+
)
|
|
431
|
+
finally:
|
|
432
|
+
os.close(lock_descriptor)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _publish(
|
|
436
|
+
*,
|
|
437
|
+
source_root: Path,
|
|
438
|
+
work_dir: Path,
|
|
439
|
+
archive: Path,
|
|
440
|
+
archive_digest: str,
|
|
441
|
+
target: ConsoleUpdateTarget,
|
|
442
|
+
scripts: Path,
|
|
443
|
+
timeout_seconds: int,
|
|
444
|
+
verify_only: bool,
|
|
445
|
+
verify_service_contracts: bool = True,
|
|
446
|
+
) -> dict[str, object]:
|
|
447
|
+
work_dir.mkdir(mode=0o700, exist_ok=True)
|
|
448
|
+
if work_dir.is_symlink() or not work_dir.is_dir():
|
|
449
|
+
raise ValueError("Console publication work directory is invalid")
|
|
450
|
+
work_dir.chmod(0o700)
|
|
451
|
+
return publish_verified_console(
|
|
452
|
+
console_archive=archive,
|
|
453
|
+
console_archive_sha256=archive_digest,
|
|
454
|
+
bundle_root=source_root,
|
|
455
|
+
prepared_root=work_dir,
|
|
456
|
+
entra_bindings={
|
|
457
|
+
"ENTRA_CONSOLE_SPA_CLIENT_ID": target.entra_console_spa_client_id,
|
|
458
|
+
"ENTRA_CONSOLE_API_SCOPE": target.entra_console_api_scope,
|
|
459
|
+
},
|
|
460
|
+
browser_console={
|
|
461
|
+
"console_hostname": target.console_hostname,
|
|
462
|
+
"console_origin": f"https://{target.console_hostname}",
|
|
463
|
+
"console_static_web_app_id": target.console_static_web_app_id,
|
|
464
|
+
"operator_api_base_url": target.operator_api_base_url,
|
|
465
|
+
"ingestion_api_base_url": target.ingestion_api_base_url,
|
|
466
|
+
},
|
|
467
|
+
scripts=scripts,
|
|
468
|
+
subscription_id=target.subscription_id,
|
|
469
|
+
tenant_id=target.tenant_id,
|
|
470
|
+
timeout_seconds=timeout_seconds,
|
|
471
|
+
redirect_changed=False,
|
|
472
|
+
verify_only=verify_only,
|
|
473
|
+
verify_service_contracts=verify_service_contracts,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _write_apply_receipt(
|
|
478
|
+
path: Path,
|
|
479
|
+
*,
|
|
480
|
+
plan: dict[str, object],
|
|
481
|
+
actor_digest: str,
|
|
482
|
+
publication: dict[str, object],
|
|
483
|
+
recovered_by_readback: bool,
|
|
484
|
+
) -> dict[str, object]:
|
|
485
|
+
receipt: dict[str, object] = {
|
|
486
|
+
"schema_version": "fdai.console-update-receipt.v1",
|
|
487
|
+
"state": "applied",
|
|
488
|
+
"plan_digest": plan["plan_digest"],
|
|
489
|
+
"target_binding": plan["target_binding"],
|
|
490
|
+
"candidate_archive_sha256": plan["candidate_archive_sha256"],
|
|
491
|
+
"actor_digest": actor_digest,
|
|
492
|
+
"publication_receipt_digest": publication["receipt_digest"],
|
|
493
|
+
"artifact_readback_verified": True,
|
|
494
|
+
"api_health_verified": publication["api_health_verified"],
|
|
495
|
+
"authorization_preflight_verified": publication["authorization_preflight_verified"],
|
|
496
|
+
"entra_redirect_verified": publication["entra_redirect_verified"],
|
|
497
|
+
"recovered_by_readback": recovered_by_readback,
|
|
498
|
+
"completed_at": datetime.now(UTC).isoformat(),
|
|
499
|
+
"mutation_performed": not recovered_by_readback,
|
|
500
|
+
}
|
|
501
|
+
receipt["receipt_digest"] = canonical_digest(receipt)
|
|
502
|
+
write_private_output(path, json.dumps(receipt, sort_keys=True, separators=(",", ":")) + "\n")
|
|
503
|
+
return receipt
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _write_failure_receipt(
|
|
507
|
+
path: Path,
|
|
508
|
+
*,
|
|
509
|
+
plan: dict[str, object],
|
|
510
|
+
actor_digest: str,
|
|
511
|
+
rollback: dict[str, object],
|
|
512
|
+
reason: str,
|
|
513
|
+
) -> dict[str, object]:
|
|
514
|
+
receipt: dict[str, object] = {
|
|
515
|
+
"schema_version": "fdai.console-update-failure.v1",
|
|
516
|
+
"state": "rolled_back",
|
|
517
|
+
"reason": reason,
|
|
518
|
+
"plan_digest": plan["plan_digest"],
|
|
519
|
+
"target_binding": plan["target_binding"],
|
|
520
|
+
"rollback_archive_sha256": plan["rollback_archive_sha256"],
|
|
521
|
+
"actor_digest": actor_digest,
|
|
522
|
+
"rollback_publication_receipt_digest": rollback["receipt_digest"],
|
|
523
|
+
"rollback_readback_verified": True,
|
|
524
|
+
"api_health_verified": rollback["api_health_verified"],
|
|
525
|
+
"authorization_preflight_verified": rollback["authorization_preflight_verified"],
|
|
526
|
+
"entra_redirect_verified": rollback["entra_redirect_verified"],
|
|
527
|
+
"completed_at": datetime.now(UTC).isoformat(),
|
|
528
|
+
"mutation_performed": rollback.get("mutation_performed") is True,
|
|
529
|
+
}
|
|
530
|
+
receipt["receipt_digest"] = canonical_digest(receipt)
|
|
531
|
+
write_private_output(path, json.dumps(receipt, sort_keys=True, separators=(",", ":")) + "\n")
|
|
532
|
+
return receipt
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def load_console_update_plan(path: Path, *, allow_expired_claim: bool = False) -> dict[str, object]:
|
|
536
|
+
"""Load one private plan, allowing expiry only for claimed verification recovery."""
|
|
537
|
+
|
|
538
|
+
return _load_plan(path, allow_expired_claim=allow_expired_claim)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _load_plan(path: Path, *, allow_expired_claim: bool = False) -> dict[str, object]:
|
|
542
|
+
try:
|
|
543
|
+
value = json.loads(read_private_bytes(path, max_bytes=32_768))
|
|
544
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
545
|
+
raise ValueError("Console update plan is not valid JSON") from exc
|
|
546
|
+
if not isinstance(value, dict) or set(value) != _PLAN_FIELDS:
|
|
547
|
+
raise ValueError("Console update plan fields are invalid")
|
|
548
|
+
plan = cast(dict[str, object], value)
|
|
549
|
+
digest = plan.get("plan_digest")
|
|
550
|
+
if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None:
|
|
551
|
+
raise ValueError("Console update plan digest is invalid")
|
|
552
|
+
if (
|
|
553
|
+
canonical_digest({key: item for key, item in plan.items() if key != "plan_digest"})
|
|
554
|
+
!= digest
|
|
555
|
+
):
|
|
556
|
+
raise ValueError("Console update plan content digest is invalid")
|
|
557
|
+
expiry = plan.get("expires_at")
|
|
558
|
+
try:
|
|
559
|
+
expires_at = datetime.fromisoformat(str(expiry))
|
|
560
|
+
except ValueError as exc:
|
|
561
|
+
raise ValueError("Console update plan expiry is invalid") from exc
|
|
562
|
+
if expires_at.tzinfo is None or (expires_at <= datetime.now(UTC) and not allow_expired_claim):
|
|
563
|
+
raise ValueError("Console update plan is expired")
|
|
564
|
+
return plan
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _load_claim(path: Path, plan: dict[str, object]) -> dict[str, object]:
|
|
568
|
+
try:
|
|
569
|
+
value = json.loads(read_private_bytes(path, max_bytes=16_384))
|
|
570
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
571
|
+
raise ValueError("Console update claim is invalid") from exc
|
|
572
|
+
required = {
|
|
573
|
+
"schema_version",
|
|
574
|
+
"plan_digest",
|
|
575
|
+
"approval_digest",
|
|
576
|
+
"idempotency_key",
|
|
577
|
+
"claimed_at",
|
|
578
|
+
"claim_digest",
|
|
579
|
+
}
|
|
580
|
+
if (
|
|
581
|
+
not isinstance(value, dict)
|
|
582
|
+
or set(value) != required
|
|
583
|
+
or value.get("schema_version") != "fdai.console-update-claim.v1"
|
|
584
|
+
or value.get("plan_digest") != plan["plan_digest"]
|
|
585
|
+
or value.get("idempotency_key") != plan["idempotency_key"]
|
|
586
|
+
):
|
|
587
|
+
raise ValueError("Console update claim does not match the plan")
|
|
588
|
+
digest = value.get("claim_digest")
|
|
589
|
+
if digest != canonical_digest(
|
|
590
|
+
{key: item for key, item in value.items() if key != "claim_digest"}
|
|
591
|
+
):
|
|
592
|
+
raise ValueError("Console update claim digest is invalid")
|
|
593
|
+
return cast(dict[str, object], value)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _load_receipt(path: Path, plan: dict[str, object]) -> dict[str, object]:
|
|
597
|
+
try:
|
|
598
|
+
value = json.loads(read_private_bytes(path, max_bytes=32_768))
|
|
599
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
600
|
+
raise ValueError("Console update receipt is invalid") from exc
|
|
601
|
+
if (
|
|
602
|
+
not isinstance(value, dict)
|
|
603
|
+
or value.get("schema_version") != "fdai.console-update-receipt.v1"
|
|
604
|
+
or value.get("state") != "applied"
|
|
605
|
+
or value.get("plan_digest") != plan["plan_digest"]
|
|
606
|
+
or value.get("artifact_readback_verified") is not True
|
|
607
|
+
):
|
|
608
|
+
raise ValueError("Console update receipt does not match the plan")
|
|
609
|
+
digest = value.get("receipt_digest")
|
|
610
|
+
if digest != canonical_digest(
|
|
611
|
+
{key: item for key, item in value.items() if key != "receipt_digest"}
|
|
612
|
+
):
|
|
613
|
+
raise ValueError("Console update receipt digest is invalid")
|
|
614
|
+
return cast(dict[str, object], value)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _load_failure(path: Path, plan: dict[str, object]) -> dict[str, object]:
|
|
618
|
+
try:
|
|
619
|
+
value = json.loads(read_private_bytes(path, max_bytes=32_768))
|
|
620
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
621
|
+
raise ValueError("Console update failure receipt is invalid") from exc
|
|
622
|
+
if (
|
|
623
|
+
not isinstance(value, dict)
|
|
624
|
+
or value.get("schema_version") != "fdai.console-update-failure.v1"
|
|
625
|
+
or value.get("state") != "rolled_back"
|
|
626
|
+
or value.get("plan_digest") != plan["plan_digest"]
|
|
627
|
+
or value.get("rollback_readback_verified") is not True
|
|
628
|
+
):
|
|
629
|
+
raise ValueError("Console update failure receipt does not match the plan")
|
|
630
|
+
digest = value.get("receipt_digest")
|
|
631
|
+
if digest != canonical_digest(
|
|
632
|
+
{key: item for key, item in value.items() if key != "receipt_digest"}
|
|
633
|
+
):
|
|
634
|
+
raise ValueError("Console update failure receipt digest is invalid")
|
|
635
|
+
return cast(dict[str, object], value)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _active_actor_digest(target: ConsoleUpdateTarget, *, timeout_seconds: int) -> str:
|
|
639
|
+
completed = subprocess.run(
|
|
640
|
+
(
|
|
641
|
+
"az",
|
|
642
|
+
"account",
|
|
643
|
+
"show",
|
|
644
|
+
"--query",
|
|
645
|
+
"{subscription_id:id,tenant_id:tenantId,user_name:user.name,user_type:user.type}",
|
|
646
|
+
"--output",
|
|
647
|
+
"json",
|
|
648
|
+
"--only-show-errors",
|
|
649
|
+
),
|
|
650
|
+
check=False,
|
|
651
|
+
capture_output=True,
|
|
652
|
+
text=True,
|
|
653
|
+
timeout=timeout_seconds,
|
|
654
|
+
)
|
|
655
|
+
try:
|
|
656
|
+
value = json.loads(completed.stdout)
|
|
657
|
+
except json.JSONDecodeError as exc:
|
|
658
|
+
raise ValueError("Console update approval actor is unavailable") from exc
|
|
659
|
+
if (
|
|
660
|
+
completed.returncode != 0
|
|
661
|
+
or value.get("subscription_id") != target.subscription_id
|
|
662
|
+
or value.get("tenant_id") != target.tenant_id
|
|
663
|
+
or str(value.get("user_type", "")).casefold() != "user"
|
|
664
|
+
or not isinstance(value.get("user_name"), str)
|
|
665
|
+
or not value["user_name"]
|
|
666
|
+
):
|
|
667
|
+
raise ValueError("Console update requires the active Azure human user")
|
|
668
|
+
return hashlib.sha256(
|
|
669
|
+
f"{target.target_binding}:{value['user_name'].casefold()}".encode()
|
|
670
|
+
).hexdigest()
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _require_digest(path: Path, expected: str) -> None:
|
|
674
|
+
if _regular_digest(path) != expected:
|
|
675
|
+
raise ValueError("Console update archive changed after planning")
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _load_target(path: Path) -> ConsoleUpdateTarget:
|
|
679
|
+
try:
|
|
680
|
+
value = json.loads(read_private_bytes(path, max_bytes=_MAX_TARGET_BYTES))
|
|
681
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
682
|
+
raise ValueError("Console update target is not valid JSON") from exc
|
|
683
|
+
if not isinstance(value, dict):
|
|
684
|
+
raise TypeError("Console update target MUST contain an object")
|
|
685
|
+
return ConsoleUpdateTarget.from_mapping(value)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _load_artifact_manifest(path: Path, archive: Path) -> dict[str, str]:
|
|
689
|
+
try:
|
|
690
|
+
value = json.loads(read_private_bytes(path, max_bytes=_MAX_MANIFEST_BYTES))
|
|
691
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
692
|
+
raise ValueError("Console artifact manifest is not valid JSON") from exc
|
|
693
|
+
if (
|
|
694
|
+
not isinstance(value, dict)
|
|
695
|
+
or set(value) != _ARTIFACT_FIELDS
|
|
696
|
+
or value.get("schema_version") != "fdai.console-update-artifact.v1"
|
|
697
|
+
or not isinstance(value.get("source_commit"), str)
|
|
698
|
+
or _COMMIT.fullmatch(str(value["source_commit"])) is None
|
|
699
|
+
or not isinstance(value.get("archive_sha256"), str)
|
|
700
|
+
or re.fullmatch(r"[0-9a-f]{64}", str(value["archive_sha256"])) is None
|
|
701
|
+
):
|
|
702
|
+
raise ValueError("Console artifact manifest fields are invalid")
|
|
703
|
+
result = cast(dict[str, str], value)
|
|
704
|
+
if _regular_digest(archive) != result["archive_sha256"]:
|
|
705
|
+
raise ValueError("Console artifact does not match its manifest")
|
|
706
|
+
return result
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _verified_source_commit(source_root: Path, *, timeout_seconds: int) -> str:
|
|
710
|
+
if not source_root.is_absolute() or not source_root.is_dir() or source_root.is_symlink():
|
|
711
|
+
raise ValueError("Console update source MUST be an absolute regular directory")
|
|
712
|
+
head = _git(source_root, "rev-parse", "HEAD", timeout_seconds=timeout_seconds).stdout.strip()
|
|
713
|
+
if _COMMIT.fullmatch(head) is None:
|
|
714
|
+
raise ValueError("Console update source revision is invalid")
|
|
715
|
+
status = _git(
|
|
716
|
+
source_root,
|
|
717
|
+
"status",
|
|
718
|
+
"--porcelain=v1",
|
|
719
|
+
"--untracked-files=no",
|
|
720
|
+
timeout_seconds=timeout_seconds,
|
|
721
|
+
)
|
|
722
|
+
if status.stdout:
|
|
723
|
+
raise ValueError("Console update source has tracked changes")
|
|
724
|
+
protected_head = _git(
|
|
725
|
+
source_root,
|
|
726
|
+
"rev-parse",
|
|
727
|
+
"refs/remotes/origin/main",
|
|
728
|
+
timeout_seconds=timeout_seconds,
|
|
729
|
+
).stdout.strip()
|
|
730
|
+
if head != protected_head:
|
|
731
|
+
raise ValueError("Console update source MUST match protected origin/main")
|
|
732
|
+
return head
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _git(
|
|
736
|
+
source_root: Path, *arguments: str, timeout_seconds: int
|
|
737
|
+
) -> subprocess.CompletedProcess[str]:
|
|
738
|
+
completed = subprocess.run(
|
|
739
|
+
("git", "-C", str(source_root), *arguments),
|
|
740
|
+
check=False,
|
|
741
|
+
capture_output=True,
|
|
742
|
+
text=True,
|
|
743
|
+
timeout=timeout_seconds,
|
|
744
|
+
)
|
|
745
|
+
if completed.returncode != 0:
|
|
746
|
+
raise ValueError("Console update source could not be verified")
|
|
747
|
+
return completed
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _verify_active_target(target: ConsoleUpdateTarget, *, timeout_seconds: int) -> None:
|
|
751
|
+
completed = subprocess.run(
|
|
752
|
+
(
|
|
753
|
+
"az",
|
|
754
|
+
"account",
|
|
755
|
+
"show",
|
|
756
|
+
"--query",
|
|
757
|
+
"{subscription_id:id,tenant_id:tenantId}",
|
|
758
|
+
"--output",
|
|
759
|
+
"json",
|
|
760
|
+
"--only-show-errors",
|
|
761
|
+
),
|
|
762
|
+
check=False,
|
|
763
|
+
capture_output=True,
|
|
764
|
+
text=True,
|
|
765
|
+
timeout=timeout_seconds,
|
|
766
|
+
)
|
|
767
|
+
try:
|
|
768
|
+
observed = json.loads(completed.stdout)
|
|
769
|
+
except json.JSONDecodeError as exc:
|
|
770
|
+
raise ValueError("active Azure target is unavailable") from exc
|
|
771
|
+
if completed.returncode != 0 or observed != {
|
|
772
|
+
"subscription_id": target.subscription_id,
|
|
773
|
+
"tenant_id": target.tenant_id,
|
|
774
|
+
}:
|
|
775
|
+
raise ValueError("active Azure target does not match the Console update target")
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _verify_static_site(target: ConsoleUpdateTarget, *, timeout_seconds: int) -> None:
|
|
779
|
+
completed = subprocess.run(
|
|
780
|
+
(
|
|
781
|
+
"az",
|
|
782
|
+
"rest",
|
|
783
|
+
"--method",
|
|
784
|
+
"get",
|
|
785
|
+
"--url",
|
|
786
|
+
(
|
|
787
|
+
"https://management.azure.com"
|
|
788
|
+
f"{target.console_static_web_app_id}?api-version=2023-12-01"
|
|
789
|
+
),
|
|
790
|
+
"--query",
|
|
791
|
+
"properties.defaultHostname",
|
|
792
|
+
"--output",
|
|
793
|
+
"tsv",
|
|
794
|
+
),
|
|
795
|
+
check=False,
|
|
796
|
+
capture_output=True,
|
|
797
|
+
text=True,
|
|
798
|
+
timeout=timeout_seconds,
|
|
799
|
+
)
|
|
800
|
+
if completed.returncode != 0 or completed.stdout.strip().casefold() != target.console_hostname:
|
|
801
|
+
raise ValueError("Console update target does not match Azure readback")
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _regular_digest(path: Path) -> str:
|
|
805
|
+
if not path.is_absolute():
|
|
806
|
+
raise ValueError("Console archives MUST use absolute paths")
|
|
807
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
808
|
+
try:
|
|
809
|
+
before = os.fstat(descriptor)
|
|
810
|
+
if (
|
|
811
|
+
not stat.S_ISREG(before.st_mode)
|
|
812
|
+
or before.st_uid != os.geteuid()
|
|
813
|
+
or before.st_nlink != 1
|
|
814
|
+
or not 0 < before.st_size <= _MAX_ARCHIVE_BYTES
|
|
815
|
+
):
|
|
816
|
+
raise ValueError("Console archive is not an owned bounded regular file")
|
|
817
|
+
with os.fdopen(descriptor, "rb", closefd=False) as stream:
|
|
818
|
+
digest = hashlib.file_digest(stream, "sha256").hexdigest()
|
|
819
|
+
after = os.fstat(descriptor)
|
|
820
|
+
if (
|
|
821
|
+
before.st_size,
|
|
822
|
+
before.st_mtime_ns,
|
|
823
|
+
before.st_ctime_ns,
|
|
824
|
+
) != (
|
|
825
|
+
after.st_size,
|
|
826
|
+
after.st_mtime_ns,
|
|
827
|
+
after.st_ctime_ns,
|
|
828
|
+
):
|
|
829
|
+
raise ValueError("Console archive changed while it was read")
|
|
830
|
+
return digest
|
|
831
|
+
finally:
|
|
832
|
+
os.close(descriptor)
|