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,162 @@
|
|
|
1
|
+
"""Mirror all signed FDAI images, independently observing every imported digest."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest, load_json_object
|
|
10
|
+
from fdai_deployment_cli.hub_image_trust import HubImageSelection, load_selection
|
|
11
|
+
from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
|
|
12
|
+
from fdai_deployment_cli.source_application_inputs import absolute_input_path
|
|
13
|
+
from fdai_deployment_cli.source_image_stage import (
|
|
14
|
+
RegistryBuildService,
|
|
15
|
+
SourceImageStageStopped,
|
|
16
|
+
SourceImageTarget,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def prepare_host_inputs(args: argparse.Namespace, work_dir: Path) -> HubImageSelection | None:
|
|
21
|
+
selected_hub_path = getattr(args, "hub_selection", None)
|
|
22
|
+
selection = prepare_host_selection(
|
|
23
|
+
path=absolute_input_path(selected_hub_path) if selected_hub_path is not None else None,
|
|
24
|
+
digest=getattr(args, "hub_selection_digest", None),
|
|
25
|
+
work_dir=work_dir,
|
|
26
|
+
)
|
|
27
|
+
if selection is not None and not hasattr(args, "source_snapshot"):
|
|
28
|
+
raise ValueError("Hub images require checkout support, not offline-kit mode")
|
|
29
|
+
return selection
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def require_foundation_source(selection: HubImageSelection | None, source_commit: str) -> None:
|
|
33
|
+
if selection is not None and selection.source_commit != source_commit:
|
|
34
|
+
raise ValueError("Hub Release and Foundation source revisions differ")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def require_retained_artifact_mode(
|
|
38
|
+
retained: dict[str, object], source_mode: bool, selection: HubImageSelection | None
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Reject any retained artifact mode or exact signed selection drift."""
|
|
41
|
+
expected_source = (
|
|
42
|
+
"hub-signed-images"
|
|
43
|
+
if selection is not None
|
|
44
|
+
else "operator-selected-source"
|
|
45
|
+
if source_mode
|
|
46
|
+
else "signed-kit"
|
|
47
|
+
)
|
|
48
|
+
if retained.get("artifact_source") != expected_source or retained.get(
|
|
49
|
+
"hub_selection_digest"
|
|
50
|
+
) != (selection.digest if selection is not None else None):
|
|
51
|
+
raise ValueError("standalone host artifact mode or Hub selection drifted")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def bind_host_selection(context: dict[str, object], selection: HubImageSelection | None) -> None:
|
|
55
|
+
if selection is not None:
|
|
56
|
+
context.update(selection.metadata())
|
|
57
|
+
context["hub_selection"] = str(selection.path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def prepare_host_selection(
|
|
61
|
+
*, path: Path | None, digest: str | None, work_dir: Path
|
|
62
|
+
) -> HubImageSelection | None:
|
|
63
|
+
"""Retain the independently verified workstation pin, never a host-fetched TOFU key."""
|
|
64
|
+
retained = work_dir / "hub-selection.json"
|
|
65
|
+
if path is None and digest is None:
|
|
66
|
+
if retained.exists():
|
|
67
|
+
raise ValueError("Hub artifact mode cannot change to source or offline kit")
|
|
68
|
+
return None
|
|
69
|
+
if path is None or digest is None:
|
|
70
|
+
raise ValueError("Hub selection and exact digest are both required")
|
|
71
|
+
selection = load_selection(path, digest)
|
|
72
|
+
content = read_private_bytes(path, max_bytes=12 * 1024 * 1024)
|
|
73
|
+
if retained.exists():
|
|
74
|
+
if read_private_bytes(retained, max_bytes=12 * 1024 * 1024) != content:
|
|
75
|
+
raise ValueError("Hub host immutable image selection changed")
|
|
76
|
+
else:
|
|
77
|
+
write_private_bytes(retained, content)
|
|
78
|
+
return load_selection(retained, selection.digest)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def run_hub_image_stage(
|
|
82
|
+
*,
|
|
83
|
+
selection: HubImageSelection,
|
|
84
|
+
target: SourceImageTarget,
|
|
85
|
+
registry: RegistryBuildService,
|
|
86
|
+
work_dir: Path,
|
|
87
|
+
) -> dict[str, object]:
|
|
88
|
+
if (
|
|
89
|
+
re.fullmatch(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", target.subscription_id) is None
|
|
90
|
+
or re.fullmatch(r"[a-z0-9]{5,50}", target.registry_name) is None
|
|
91
|
+
):
|
|
92
|
+
raise ValueError("Hub image target is malformed")
|
|
93
|
+
selection = load_selection(selection.path, selection.digest)
|
|
94
|
+
claim = {
|
|
95
|
+
"schema_version": "fdai.hub-image-stage-claim.v1",
|
|
96
|
+
"hub_selection_digest": selection.digest,
|
|
97
|
+
"subscription_id": target.subscription_id,
|
|
98
|
+
"registry_login_server": target.login_server,
|
|
99
|
+
"image_sources": selection.image_sources,
|
|
100
|
+
"artifact_source": "hub-signed-images",
|
|
101
|
+
}
|
|
102
|
+
work_dir.mkdir(mode=0o700, exist_ok=True)
|
|
103
|
+
claim_path, receipt_path = work_dir / "claim.json", work_dir / "receipt.json"
|
|
104
|
+
if claim_path.exists():
|
|
105
|
+
existing = load_json_object(
|
|
106
|
+
read_private_bytes(claim_path, max_bytes=65_536), label="Hub image claim", strict=True
|
|
107
|
+
)
|
|
108
|
+
if existing != claim:
|
|
109
|
+
raise ValueError("Hub image stage target, mode or selection changed")
|
|
110
|
+
else:
|
|
111
|
+
if any(work_dir.iterdir()):
|
|
112
|
+
raise ValueError("Hub image stage cannot adopt another artifact mode")
|
|
113
|
+
write_private_bytes(claim_path, canonical_bytes(claim))
|
|
114
|
+
digests = {name: ref.rsplit("@", 1)[1] for name, ref in selection.image_sources.items()}
|
|
115
|
+
tag = "hub-" + selection.digest[:32]
|
|
116
|
+
existing_receipt = None
|
|
117
|
+
if receipt_path.exists():
|
|
118
|
+
existing_receipt = load_json_object(
|
|
119
|
+
read_private_bytes(receipt_path, max_bytes=65_536),
|
|
120
|
+
label="Hub image receipt",
|
|
121
|
+
strict=True,
|
|
122
|
+
)
|
|
123
|
+
unsigned = {
|
|
124
|
+
key: value for key, value in existing_receipt.items() if key != "receipt_digest"
|
|
125
|
+
}
|
|
126
|
+
if (
|
|
127
|
+
existing_receipt.get("claim_digest") != canonical_digest(claim)
|
|
128
|
+
or existing_receipt.get("receipt_digest") != canonical_digest(unsigned)
|
|
129
|
+
or existing_receipt.get("image_digests") != digests
|
|
130
|
+
):
|
|
131
|
+
raise ValueError("Hub image receipt binding differs")
|
|
132
|
+
try:
|
|
133
|
+
if existing_receipt is None:
|
|
134
|
+
registry.require_available(target)
|
|
135
|
+
for name, source in sorted(selection.image_sources.items()):
|
|
136
|
+
registry.import_image(target, source=source, image=f"{name}:{tag}")
|
|
137
|
+
for name, digest in sorted(digests.items()):
|
|
138
|
+
for reference in (f"{name}@{digest}", f"{name}:{tag}"):
|
|
139
|
+
if registry.read_digest(target, reference=reference) != digest:
|
|
140
|
+
raise ValueError("Hub imported image digest readback differs")
|
|
141
|
+
except SourceImageStageStopped as error:
|
|
142
|
+
raise ValueError("Hub image import or observation failed; no builds or fallback") from error
|
|
143
|
+
if existing_receipt is not None:
|
|
144
|
+
return existing_receipt
|
|
145
|
+
receipt = {
|
|
146
|
+
"schema_version": "fdai.hub-image-stage-receipt.v1",
|
|
147
|
+
**selection.metadata(),
|
|
148
|
+
"claim_digest": canonical_digest(claim),
|
|
149
|
+
"source_commit": selection.source_commit,
|
|
150
|
+
"image_sources": selection.image_sources,
|
|
151
|
+
"image_digests": digests,
|
|
152
|
+
"image_refs": {
|
|
153
|
+
name: f"{target.login_server}/{name}@{digest}" for name, digest in digests.items()
|
|
154
|
+
},
|
|
155
|
+
"builder": "none",
|
|
156
|
+
"effect_verified": True,
|
|
157
|
+
"mutation_performed": True,
|
|
158
|
+
"deployment_ready": False,
|
|
159
|
+
}
|
|
160
|
+
receipt["receipt_digest"] = canonical_digest(receipt)
|
|
161
|
+
write_private_bytes(receipt_path, canonical_bytes(receipt))
|
|
162
|
+
return receipt
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Verify a public signed export without importing the dependent lifecycle Hub package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from base64 import b64decode, b64encode
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from functools import cmp_to_key
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from cryptography.exceptions import InvalidSignature
|
|
15
|
+
from cryptography.hazmat.primitives import serialization
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
17
|
+
|
|
18
|
+
from fdai_deployment_cli.contracts import canonical_bytes, load_json_object
|
|
19
|
+
from fdai_deployment_cli.lifecycle_release import (
|
|
20
|
+
APPLICATION_COMPONENT_RELEASE_SCHEMA,
|
|
21
|
+
parse_lifecycle_release_manifest,
|
|
22
|
+
)
|
|
23
|
+
from fdai_deployment_cli.private_output import read_private_bytes
|
|
24
|
+
from fdai_deployment_cli.runtime_release import (
|
|
25
|
+
RecallRecord,
|
|
26
|
+
compare_release_ids,
|
|
27
|
+
evaluate_recall_candidate,
|
|
28
|
+
is_release_id,
|
|
29
|
+
)
|
|
30
|
+
from fdai_deployment_cli.source_image_stage import DEPENDENCY_IMAGES, SERVICE_ORDER
|
|
31
|
+
|
|
32
|
+
MAX_EXPORT_BYTES = 8 * 1024 * 1024
|
|
33
|
+
MAX_SELECTION_BYTES = 12 * 1024 * 1024
|
|
34
|
+
EXPORT_DOMAIN = b"fdai.catalog-export.v1\n"
|
|
35
|
+
PROVENANCE = "hub-signed-images-with-checkout-support"
|
|
36
|
+
INVENTORY = frozenset(SERVICE_ORDER) | DEPENDENCY_IMAGES.keys()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def public_key(pem: bytes) -> Ed25519PublicKey:
|
|
40
|
+
if not 0 < len(pem) <= 4096:
|
|
41
|
+
raise ValueError("Hub trust requires a bounded independently provisioned public PEM")
|
|
42
|
+
key = serialization.load_pem_public_key(pem)
|
|
43
|
+
if not isinstance(key, Ed25519PublicKey):
|
|
44
|
+
raise ValueError("Hub trust requires an Ed25519 public key") # noqa: TRY004
|
|
45
|
+
return key
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def key_id(key: Ed25519PublicKey) -> str:
|
|
49
|
+
raw = key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
|
50
|
+
return "hub-" + hashlib.sha256(raw).hexdigest()[:16]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate_channel(channel: str) -> None:
|
|
54
|
+
if (
|
|
55
|
+
len(channel) > 64
|
|
56
|
+
or re.fullmatch(r"(?:DEV|RELEASE_CANDIDATE|RELEASE|custom-[a-z0-9][a-z0-9-]*)", channel)
|
|
57
|
+
is None
|
|
58
|
+
):
|
|
59
|
+
raise ValueError("Hub channel must be DEV, RELEASE_CANDIDATE, RELEASE, or custom-*")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _signature(key: Ed25519PublicKey, signature: object, payload: bytes) -> None:
|
|
63
|
+
if not isinstance(signature, str) or not 0 < len(signature) <= 128:
|
|
64
|
+
raise ValueError("Hub signature is missing or malformed")
|
|
65
|
+
try:
|
|
66
|
+
key.verify(b64decode(signature, validate=True), payload)
|
|
67
|
+
except (InvalidSignature, ValueError) as error:
|
|
68
|
+
raise ValueError("Hub signature verification failed; no source-build fallback") from error
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class HubImageSelection:
|
|
73
|
+
path: Path
|
|
74
|
+
digest: str
|
|
75
|
+
document: dict[str, Any]
|
|
76
|
+
release_id: str
|
|
77
|
+
source_commit: str
|
|
78
|
+
release_digest: str
|
|
79
|
+
image_sources: dict[str, str]
|
|
80
|
+
|
|
81
|
+
def metadata(self) -> dict[str, object]:
|
|
82
|
+
return {
|
|
83
|
+
"provenance": PROVENANCE,
|
|
84
|
+
"artifact_source": "hub-signed-images",
|
|
85
|
+
"hub_selection_digest": self.digest,
|
|
86
|
+
"hub_release_id": self.release_id,
|
|
87
|
+
"hub_channel": self.document["channel"],
|
|
88
|
+
"hub_catalog_digest": self.document["catalog_digest"],
|
|
89
|
+
"hub_catalog_revision": self.document["catalog_revision"],
|
|
90
|
+
"runtime_release_digest": self.release_digest,
|
|
91
|
+
"image_signature_verified": True,
|
|
92
|
+
"release_signature_verified": False,
|
|
93
|
+
"support_artifact_source": "operator-selected-source",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def verify_export(
|
|
98
|
+
raw: bytes,
|
|
99
|
+
*,
|
|
100
|
+
signature: str,
|
|
101
|
+
export_key_id: str,
|
|
102
|
+
trusted_pem: bytes,
|
|
103
|
+
channel: str,
|
|
104
|
+
release_id: str | None = None,
|
|
105
|
+
now: datetime | None = None,
|
|
106
|
+
minimum_revision: int = 0,
|
|
107
|
+
previous_digest: str | None = None,
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
"""Authenticate membership, all manifests and recall before selecting one exact Release."""
|
|
110
|
+
if not 0 < len(raw) <= MAX_EXPORT_BYTES:
|
|
111
|
+
raise ValueError("Hub export exceeds its size bound")
|
|
112
|
+
validate_channel(channel)
|
|
113
|
+
key = public_key(trusted_pem)
|
|
114
|
+
if export_key_id != key_id(key):
|
|
115
|
+
raise ValueError("Hub export key id differs from the independent trust key")
|
|
116
|
+
_signature(key, signature, EXPORT_DOMAIN + raw)
|
|
117
|
+
doc = load_json_object(raw, label="Hub signed export", max_bytes=MAX_EXPORT_BYTES, strict=True)
|
|
118
|
+
if set(doc) != {"revision", "expires_at", "releases", "channels", "recalls"}:
|
|
119
|
+
raise ValueError("Hub signed export fields are invalid")
|
|
120
|
+
revision = doc["revision"]
|
|
121
|
+
if type(revision) is not int or not 0 <= revision <= 2**63 - 1:
|
|
122
|
+
raise ValueError("Hub signed export revision is invalid")
|
|
123
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
124
|
+
if revision < minimum_revision or (
|
|
125
|
+
revision == minimum_revision and previous_digest not in {None, digest}
|
|
126
|
+
):
|
|
127
|
+
raise ValueError("Hub catalog rollback or same-revision substitution refused")
|
|
128
|
+
now = now or datetime.now(UTC)
|
|
129
|
+
try:
|
|
130
|
+
expires = datetime.fromisoformat(doc["expires_at"])
|
|
131
|
+
except (TypeError, ValueError) as error:
|
|
132
|
+
raise ValueError("Hub catalog expiry is invalid") from error
|
|
133
|
+
if now.tzinfo is None or expires.tzinfo is None or now >= expires:
|
|
134
|
+
raise ValueError("Hub catalog expired; no source-build fallback")
|
|
135
|
+
releases, channels, recalls = doc["releases"], doc["channels"], doc["recalls"]
|
|
136
|
+
if (
|
|
137
|
+
not isinstance(releases, dict)
|
|
138
|
+
or not 1 <= len(releases) <= 4096
|
|
139
|
+
or not isinstance(channels, dict)
|
|
140
|
+
or not 1 <= len(channels) <= 256
|
|
141
|
+
or not isinstance(recalls, list)
|
|
142
|
+
or len(recalls) > 8192
|
|
143
|
+
):
|
|
144
|
+
raise ValueError("Hub catalog inventory is invalid")
|
|
145
|
+
parsed = {}
|
|
146
|
+
for version, artifact in releases.items():
|
|
147
|
+
if not isinstance(version, str) or len(version) > 64 or not is_release_id(version):
|
|
148
|
+
raise ValueError("Hub release id is invalid")
|
|
149
|
+
if (
|
|
150
|
+
not isinstance(artifact, dict)
|
|
151
|
+
or set(artifact) != {"manifest", "signature"}
|
|
152
|
+
or not isinstance(artifact["manifest"], str)
|
|
153
|
+
):
|
|
154
|
+
raise ValueError("Hub release artifact is invalid")
|
|
155
|
+
manifest = artifact["manifest"].encode("utf-8")
|
|
156
|
+
_signature(key, artifact["signature"], manifest)
|
|
157
|
+
release = parse_lifecycle_release_manifest(manifest)
|
|
158
|
+
if release.to_mapping().get("release_version", version) != version or (
|
|
159
|
+
release.configuration_schema is not None
|
|
160
|
+
and release.configuration_schema.release_version != version
|
|
161
|
+
):
|
|
162
|
+
raise ValueError("Hub manifest version differs from the catalog release id")
|
|
163
|
+
parsed[version] = release
|
|
164
|
+
for name, members in channels.items():
|
|
165
|
+
validate_channel(name)
|
|
166
|
+
if (
|
|
167
|
+
not isinstance(members, list)
|
|
168
|
+
or len(members) > 4096
|
|
169
|
+
or any(not isinstance(member, str) or member not in parsed for member in members)
|
|
170
|
+
or len(set(members)) != len(members)
|
|
171
|
+
):
|
|
172
|
+
raise ValueError("Hub channel members are invalid")
|
|
173
|
+
active: dict[tuple[str, str], RecallRecord] = {}
|
|
174
|
+
records: list[RecallRecord] = []
|
|
175
|
+
try:
|
|
176
|
+
for value in recalls:
|
|
177
|
+
if not isinstance(value, dict):
|
|
178
|
+
raise ValueError("Hub recall is invalid") # noqa: TRY004
|
|
179
|
+
record = RecallRecord(**value)
|
|
180
|
+
identity = record.scope, record.target
|
|
181
|
+
prior = active.get(identity)
|
|
182
|
+
if prior is not None and record.sequence <= prior.sequence:
|
|
183
|
+
raise ValueError("Hub recall sequence did not advance")
|
|
184
|
+
active[identity] = record
|
|
185
|
+
records.append(record)
|
|
186
|
+
except (TypeError, ValueError) as error:
|
|
187
|
+
raise ValueError("Hub recall history is invalid") from error
|
|
188
|
+
members = channels.get(channel, [])
|
|
189
|
+
capabilities = tuple(record.target for record in records if record.scope == "capability")
|
|
190
|
+
eligible = [
|
|
191
|
+
version
|
|
192
|
+
for version in members
|
|
193
|
+
if evaluate_recall_candidate(
|
|
194
|
+
release_id=version, capability_ids=capabilities, recall_records=tuple(records)
|
|
195
|
+
).allowed
|
|
196
|
+
]
|
|
197
|
+
if release_id is not None:
|
|
198
|
+
if len(release_id) > 64 or not is_release_id(release_id) or release_id not in eligible:
|
|
199
|
+
raise ValueError("Hub requested Release is absent, outside channel, or recalled")
|
|
200
|
+
selected = release_id
|
|
201
|
+
else:
|
|
202
|
+
if not eligible:
|
|
203
|
+
raise ValueError("Hub channel has no non-recalled Release")
|
|
204
|
+
selected = max(eligible, key=cmp_to_key(compare_release_ids))
|
|
205
|
+
release = parsed[selected]
|
|
206
|
+
manifest_doc = release.to_mapping()
|
|
207
|
+
components = manifest_doc.get("components")
|
|
208
|
+
if (
|
|
209
|
+
release.schema_version != APPLICATION_COMPONENT_RELEASE_SCHEMA
|
|
210
|
+
or release.product != "fdai"
|
|
211
|
+
or release.platform_tag != "linux-x86_64"
|
|
212
|
+
or not isinstance(components, dict)
|
|
213
|
+
or set(components) != INVENTORY
|
|
214
|
+
or any(not isinstance(value.get("image_ref"), str) for value in components.values())
|
|
215
|
+
):
|
|
216
|
+
raise ValueError(
|
|
217
|
+
"Hub bootstrap requires an fdai linux-x86_64 application-release.v2 with all "
|
|
218
|
+
"five services and two dependencies, each with signed digest-pinned image_ref"
|
|
219
|
+
)
|
|
220
|
+
return {
|
|
221
|
+
"release_id": selected,
|
|
222
|
+
"source_commit": release.source_commit,
|
|
223
|
+
"release_digest": release.digest,
|
|
224
|
+
"image_sources": {name: value["image_ref"] for name, value in components.items()},
|
|
225
|
+
"catalog_digest": digest,
|
|
226
|
+
"catalog_revision": revision,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def load_selection(path: Path, expected_digest: str) -> HubImageSelection:
|
|
231
|
+
raw = read_private_bytes(path, max_bytes=MAX_SELECTION_BYTES)
|
|
232
|
+
if hashlib.sha256(raw).hexdigest() != expected_digest:
|
|
233
|
+
raise ValueError("Hub immutable selection digest differs")
|
|
234
|
+
doc = load_json_object(raw, label="Hub selection", max_bytes=MAX_SELECTION_BYTES, strict=True)
|
|
235
|
+
if doc.get("schema_version") != "fdai.hub-image-selection.v1":
|
|
236
|
+
raise ValueError("Hub selection schema is invalid")
|
|
237
|
+
result = verify_export(
|
|
238
|
+
b64decode(doc["catalog"], validate=True),
|
|
239
|
+
signature=doc["export_signature"],
|
|
240
|
+
export_key_id=doc["export_key_id"],
|
|
241
|
+
trusted_pem=doc["public_key"].encode("ascii"),
|
|
242
|
+
channel=doc["channel"],
|
|
243
|
+
release_id=doc["release_id"],
|
|
244
|
+
minimum_revision=doc["catalog_revision"],
|
|
245
|
+
previous_digest=doc["catalog_digest"],
|
|
246
|
+
)
|
|
247
|
+
if any(doc.get(name) != value for name, value in result.items()):
|
|
248
|
+
raise ValueError("Hub selection disagrees with its authenticated catalog")
|
|
249
|
+
return HubImageSelection(
|
|
250
|
+
path=path,
|
|
251
|
+
digest=expected_digest,
|
|
252
|
+
document=doc,
|
|
253
|
+
**{
|
|
254
|
+
name: result[name]
|
|
255
|
+
for name in ("release_id", "source_commit", "release_digest", "image_sources")
|
|
256
|
+
},
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def selection_bytes(
|
|
261
|
+
*,
|
|
262
|
+
raw: bytes,
|
|
263
|
+
signature: str,
|
|
264
|
+
export_key_id: str,
|
|
265
|
+
pem: bytes,
|
|
266
|
+
hub_url: str,
|
|
267
|
+
channel: str,
|
|
268
|
+
verified: dict[str, Any],
|
|
269
|
+
) -> bytes:
|
|
270
|
+
return canonical_bytes(
|
|
271
|
+
{
|
|
272
|
+
"schema_version": "fdai.hub-image-selection.v1",
|
|
273
|
+
"hub_url": hub_url,
|
|
274
|
+
"channel": channel,
|
|
275
|
+
"catalog": b64encode(raw).decode("ascii"),
|
|
276
|
+
"export_signature": signature,
|
|
277
|
+
"export_key_id": export_key_id,
|
|
278
|
+
"public_key": pem.decode("ascii"),
|
|
279
|
+
**verified,
|
|
280
|
+
}
|
|
281
|
+
)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Local Hub ownership marker: once the Lifecycle Agent owns the workloads, Terraform must not.
|
|
2
|
+
|
|
3
|
+
The marker is recorded after the Terraform release step of the ownership handoff. While it exists,
|
|
4
|
+
every entry point that would plan or apply the AKS workloads root refuses, so Terraform and the
|
|
5
|
+
agent never write the same object. A present but unreadable marker also refuses: a damaged marker
|
|
6
|
+
must not silently hand the objects back to Terraform. Rollback removes the marker only through the
|
|
7
|
+
explicit release function after the agent has stopped reconciling.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
18
|
+
from fdai_deployment_cli.standalone_host_state import private_json, replace_private_json
|
|
19
|
+
|
|
20
|
+
MARKER_NAME = "hub-ownership.json"
|
|
21
|
+
MARKER_SCHEMA = "fdai.hub-ownership-marker.v1"
|
|
22
|
+
ADOPTION_SCHEMA = "fdai.runtime-adoption-receipt.v1"
|
|
23
|
+
_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
|
|
24
|
+
_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def marker_path(work_dir: Path) -> Path:
|
|
28
|
+
return work_dir / MARKER_NAME
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def add_handoff_parser(subcommands: Any) -> None:
|
|
32
|
+
parser = subcommands.add_parser("record-hub-workload-handoff")
|
|
33
|
+
parser.add_argument("--adoption-receipt", type=Path, required=True)
|
|
34
|
+
parser.set_defaults(handler=_record_handoff_command)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _record_handoff_command(args: Any, work_dir: Path) -> dict[str, object]:
|
|
38
|
+
return record_hub_ownership_from_adoption(
|
|
39
|
+
work_dir,
|
|
40
|
+
adoption_receipt=args.adoption_receipt,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def record_hub_ownership(
|
|
45
|
+
work_dir: Path,
|
|
46
|
+
*,
|
|
47
|
+
installation_id: str,
|
|
48
|
+
handoff_receipt_digest: str,
|
|
49
|
+
recorded_at: datetime | None = None,
|
|
50
|
+
) -> dict[str, object]:
|
|
51
|
+
"""Record the marker once; repeating the same handoff returns the retained record."""
|
|
52
|
+
|
|
53
|
+
if _ID.fullmatch(installation_id) is None or _DIGEST.fullmatch(handoff_receipt_digest) is None:
|
|
54
|
+
raise ValueError("hub ownership marker inputs are invalid")
|
|
55
|
+
path = marker_path(work_dir)
|
|
56
|
+
if path.exists():
|
|
57
|
+
retained = _read(path)
|
|
58
|
+
if (
|
|
59
|
+
retained["installation_id"] != installation_id
|
|
60
|
+
or retained["handoff_receipt_digest"] != handoff_receipt_digest
|
|
61
|
+
):
|
|
62
|
+
raise ValueError("a different hub ownership marker is already recorded")
|
|
63
|
+
return retained
|
|
64
|
+
moment = recorded_at or datetime.now(tz=UTC)
|
|
65
|
+
if moment.tzinfo is None:
|
|
66
|
+
raise ValueError("hub ownership marker time MUST be timezone-aware")
|
|
67
|
+
body: dict[str, object] = {
|
|
68
|
+
"schema_version": MARKER_SCHEMA,
|
|
69
|
+
"installation_id": installation_id,
|
|
70
|
+
"handoff_receipt_digest": handoff_receipt_digest,
|
|
71
|
+
"recorded_at": moment.astimezone(UTC).isoformat(),
|
|
72
|
+
}
|
|
73
|
+
record = body | {"record_digest": "sha256:" + canonical_digest(body)}
|
|
74
|
+
replace_private_json(path, record)
|
|
75
|
+
return record
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def record_hub_ownership_from_adoption(
|
|
79
|
+
work_dir: Path,
|
|
80
|
+
*,
|
|
81
|
+
adoption_receipt: Path,
|
|
82
|
+
) -> dict[str, object]:
|
|
83
|
+
"""Record the marker only from one complete, digest-valid per-object adoption receipt."""
|
|
84
|
+
|
|
85
|
+
receipt = private_json(adoption_receipt.absolute(), "runtime adoption receipt")
|
|
86
|
+
digest = receipt.pop("receipt_digest", None)
|
|
87
|
+
if (
|
|
88
|
+
receipt.get("schema_version") != ADOPTION_SCHEMA
|
|
89
|
+
or receipt.get("complete") is not True
|
|
90
|
+
or not isinstance(receipt.get("objects"), list)
|
|
91
|
+
or not receipt["objects"]
|
|
92
|
+
or digest != "sha256:" + canonical_digest(receipt)
|
|
93
|
+
or _ID.fullmatch(str(receipt.get("installation_id", ""))) is None
|
|
94
|
+
or _ID.fullmatch(str(receipt.get("release_id", ""))) is None
|
|
95
|
+
):
|
|
96
|
+
raise ValueError("runtime adoption receipt is invalid")
|
|
97
|
+
required = {"kind", "name", "uid", "resource_version", "render_digest"}
|
|
98
|
+
if any(
|
|
99
|
+
not isinstance(item, dict)
|
|
100
|
+
or set(item) != required
|
|
101
|
+
or _ID.fullmatch(str(item["kind"])) is None
|
|
102
|
+
or _ID.fullmatch(str(item["name"])) is None
|
|
103
|
+
or _ID.fullmatch(str(item["uid"])) is None
|
|
104
|
+
or _ID.fullmatch(str(item["resource_version"])) is None
|
|
105
|
+
or _DIGEST.fullmatch(str(item["render_digest"])) is None
|
|
106
|
+
for item in receipt["objects"]
|
|
107
|
+
):
|
|
108
|
+
raise ValueError("runtime adoption receipt is invalid")
|
|
109
|
+
return record_hub_ownership(
|
|
110
|
+
work_dir,
|
|
111
|
+
installation_id=str(receipt["installation_id"]),
|
|
112
|
+
handoff_receipt_digest=str(digest),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def release_hub_ownership(work_dir: Path, *, rollback_receipt_digest: str) -> None:
|
|
117
|
+
"""Remove the marker for a verified rollback to Terraform; refuses without a receipt."""
|
|
118
|
+
|
|
119
|
+
if _DIGEST.fullmatch(rollback_receipt_digest) is None:
|
|
120
|
+
raise ValueError("hub ownership release requires a rollback receipt digest")
|
|
121
|
+
path = marker_path(work_dir)
|
|
122
|
+
if path.exists():
|
|
123
|
+
_read(path)
|
|
124
|
+
path.unlink()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def require_terraform_owned_workloads(work_dir: Path) -> None:
|
|
128
|
+
"""Refuse a Terraform workloads operation while the Hub owns the workloads."""
|
|
129
|
+
|
|
130
|
+
path = marker_path(work_dir)
|
|
131
|
+
if not path.exists() and not path.is_symlink():
|
|
132
|
+
return
|
|
133
|
+
_read(path)
|
|
134
|
+
raise ValueError(
|
|
135
|
+
"the workloads are Hub-managed; plan the Hub variant instead of the workloads root"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def workloads_terraform_data(context: dict[str, object], work_dir: Path) -> object:
|
|
140
|
+
"""Return the workloads Terraform data path only while Terraform owns the workloads."""
|
|
141
|
+
|
|
142
|
+
require_terraform_owned_workloads(work_dir)
|
|
143
|
+
return context.get("workloads_terraform_data")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _read(path: Path) -> dict[str, object]:
|
|
147
|
+
try:
|
|
148
|
+
record = private_json(path, "hub ownership marker")
|
|
149
|
+
digest = record.pop("record_digest", None)
|
|
150
|
+
if (
|
|
151
|
+
record.get("schema_version") != MARKER_SCHEMA
|
|
152
|
+
or set(record)
|
|
153
|
+
!= {"schema_version", "installation_id", "handoff_receipt_digest", "recorded_at"}
|
|
154
|
+
or digest != "sha256:" + canonical_digest(record)
|
|
155
|
+
or _ID.fullmatch(str(record["installation_id"])) is None
|
|
156
|
+
or _DIGEST.fullmatch(str(record["handoff_receipt_digest"])) is None
|
|
157
|
+
):
|
|
158
|
+
raise ValueError("hub ownership marker is invalid")
|
|
159
|
+
except (OSError, ValueError):
|
|
160
|
+
raise ValueError("hub ownership marker is unreadable; workloads stay Hub-managed") from None
|
|
161
|
+
return record | {"record_digest": digest}
|