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,48 @@
|
|
|
1
|
+
"""Bounded private artifact and GitHub JSON decoding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _private_artifact_json(path: Path, label: str) -> dict[str, object]:
|
|
13
|
+
return dict(_json_object(_private_artifact_bytes(path, label).decode("utf-8"), label))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _private_artifact_bytes(path: Path, label: str) -> bytes:
|
|
17
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
18
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
19
|
+
details = os.fstat(stream.fileno())
|
|
20
|
+
if (
|
|
21
|
+
not stat.S_ISREG(details.st_mode)
|
|
22
|
+
or details.st_uid != os.geteuid()
|
|
23
|
+
or details.st_size > 262_144
|
|
24
|
+
):
|
|
25
|
+
raise ValueError(f"github_{label.replace(' ', '_')}_file_invalid")
|
|
26
|
+
return stream.read(262_145)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _json_array(raw: str, label: str) -> list[Mapping[str, object]]:
|
|
30
|
+
try:
|
|
31
|
+
payload = json.loads(raw)
|
|
32
|
+
except json.JSONDecodeError as exc:
|
|
33
|
+
raise ValueError(f"{label} response is invalid") from exc
|
|
34
|
+
if not isinstance(payload, list) or not all(isinstance(item, dict) for item in payload):
|
|
35
|
+
raise ValueError(f"{label} response MUST be an array of objects")
|
|
36
|
+
if len(payload) > 50:
|
|
37
|
+
raise ValueError(f"{label} response exceeds the requested bound")
|
|
38
|
+
return payload
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _json_object(raw: str, label: str) -> Mapping[str, object]:
|
|
42
|
+
try:
|
|
43
|
+
payload = json.loads(raw)
|
|
44
|
+
except json.JSONDecodeError as exc:
|
|
45
|
+
raise ValueError(f"{label} response is invalid") from exc
|
|
46
|
+
if not isinstance(payload, dict):
|
|
47
|
+
raise ValueError(f"{label} response MUST be an object") # noqa: TRY004
|
|
48
|
+
return payload
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Protected GitHub workflow plan and apply dispatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fdai_deployment_cli.github_transport import run_github_cli
|
|
6
|
+
from fdai_deployment_cli.github_workflow_values import (
|
|
7
|
+
_COMMIT,
|
|
8
|
+
_DIGEST,
|
|
9
|
+
_ENVIRONMENTS,
|
|
10
|
+
_PLAN_ID,
|
|
11
|
+
_REQUEST_ID,
|
|
12
|
+
CommandRunner,
|
|
13
|
+
DeploymentSelection,
|
|
14
|
+
WorkflowDispatch,
|
|
15
|
+
_validate_repository,
|
|
16
|
+
deployment_context_digest,
|
|
17
|
+
enforce_plan_not_expired,
|
|
18
|
+
request_id,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def dispatch_plan(
|
|
23
|
+
*,
|
|
24
|
+
repository: str,
|
|
25
|
+
environment: str,
|
|
26
|
+
commit_sha: str,
|
|
27
|
+
target_binding: str,
|
|
28
|
+
region: str,
|
|
29
|
+
run_id: str,
|
|
30
|
+
selection: DeploymentSelection,
|
|
31
|
+
attempt: int = 1,
|
|
32
|
+
run: CommandRunner | None = None,
|
|
33
|
+
) -> WorkflowDispatch:
|
|
34
|
+
"""Dispatch one protected plan-only run."""
|
|
35
|
+
|
|
36
|
+
if environment == "prod":
|
|
37
|
+
raise ValueError("fdaictl production deployment inputs are not implemented")
|
|
38
|
+
context_digest = deployment_context_digest(
|
|
39
|
+
environment=environment,
|
|
40
|
+
commit_sha=commit_sha,
|
|
41
|
+
selection=selection,
|
|
42
|
+
)
|
|
43
|
+
bounded_request_id = request_id(
|
|
44
|
+
"plan",
|
|
45
|
+
run_id=run_id,
|
|
46
|
+
context_digest=context_digest,
|
|
47
|
+
target_binding=target_binding,
|
|
48
|
+
region=region,
|
|
49
|
+
attempt=attempt,
|
|
50
|
+
)
|
|
51
|
+
if selection.deploy_rca_reader_identity:
|
|
52
|
+
bounded_request_id = bounded_request_id.replace("plan-", "plan-rca-", 1)
|
|
53
|
+
elif selection.deploy_provider_schema:
|
|
54
|
+
request_prefix = (
|
|
55
|
+
"plan-provider-cost-"
|
|
56
|
+
if selection.runtime_image_profile == "cost-governance"
|
|
57
|
+
else "plan-provider-"
|
|
58
|
+
)
|
|
59
|
+
bounded_request_id = bounded_request_id.replace("plan-", request_prefix, 1)
|
|
60
|
+
elif selection.deploy_identity_migration:
|
|
61
|
+
bounded_request_id = bounded_request_id.replace("plan-", "plan-identity-", 1)
|
|
62
|
+
elif selection.deploy_operational_history:
|
|
63
|
+
bounded_request_id = bounded_request_id.replace("plan-", "plan-history-", 1)
|
|
64
|
+
elif selection.runtime_image_profile == "cost-governance":
|
|
65
|
+
bounded_request_id = bounded_request_id.replace("plan-", "plan-cost-", 1)
|
|
66
|
+
_dispatch(
|
|
67
|
+
repository=repository,
|
|
68
|
+
environment=environment,
|
|
69
|
+
commit_sha=commit_sha,
|
|
70
|
+
context_digest=context_digest,
|
|
71
|
+
request_id_value=bounded_request_id,
|
|
72
|
+
apply=False,
|
|
73
|
+
plan_id=None,
|
|
74
|
+
plan_digest=None,
|
|
75
|
+
resume_verification=False,
|
|
76
|
+
selection=selection,
|
|
77
|
+
run=run,
|
|
78
|
+
)
|
|
79
|
+
return WorkflowDispatch(
|
|
80
|
+
request_id=bounded_request_id,
|
|
81
|
+
run_name=f"deploy-{bounded_request_id}",
|
|
82
|
+
context_digest=context_digest,
|
|
83
|
+
mode="plan",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def dispatch_apply(
|
|
88
|
+
*,
|
|
89
|
+
repository: str,
|
|
90
|
+
environment: str,
|
|
91
|
+
commit_sha: str,
|
|
92
|
+
target_binding: str,
|
|
93
|
+
region: str,
|
|
94
|
+
approval_quorum: int,
|
|
95
|
+
run_id: str,
|
|
96
|
+
plan_id: str,
|
|
97
|
+
plan_digest: str,
|
|
98
|
+
plan_expires_at: str,
|
|
99
|
+
resume_verification: bool,
|
|
100
|
+
selection: DeploymentSelection,
|
|
101
|
+
attempt: int = 1,
|
|
102
|
+
run: CommandRunner | None = None,
|
|
103
|
+
) -> WorkflowDispatch:
|
|
104
|
+
"""Dispatch exact-plan apply or verification-only resume."""
|
|
105
|
+
|
|
106
|
+
if environment == "prod":
|
|
107
|
+
raise ValueError("fdaictl production deployment inputs are not implemented")
|
|
108
|
+
if _PLAN_ID.fullmatch(plan_id) is None:
|
|
109
|
+
raise ValueError("plan_id is invalid")
|
|
110
|
+
if _DIGEST.fullmatch(plan_digest) is None:
|
|
111
|
+
raise ValueError("plan_digest MUST be a lowercase SHA-256")
|
|
112
|
+
if not resume_verification:
|
|
113
|
+
enforce_plan_not_expired(plan_expires_at)
|
|
114
|
+
context_digest = deployment_context_digest(
|
|
115
|
+
environment=environment,
|
|
116
|
+
commit_sha=commit_sha,
|
|
117
|
+
selection=selection,
|
|
118
|
+
)
|
|
119
|
+
if _DIGEST.fullmatch(target_binding) is None:
|
|
120
|
+
raise ValueError("target_binding MUST be a lowercase SHA-256")
|
|
121
|
+
if approval_quorum < 1:
|
|
122
|
+
raise ValueError("approval_quorum MUST be positive")
|
|
123
|
+
runner = run or run_github_cli
|
|
124
|
+
bounded_request_id = request_id(
|
|
125
|
+
"resume" if resume_verification else "apply",
|
|
126
|
+
run_id=run_id,
|
|
127
|
+
context_digest=context_digest,
|
|
128
|
+
target_binding=target_binding,
|
|
129
|
+
region=region,
|
|
130
|
+
attempt=attempt,
|
|
131
|
+
)
|
|
132
|
+
if selection.deploy_rca_reader_identity:
|
|
133
|
+
bounded_request_id = bounded_request_id.replace("apply-", "apply-rca-", 1)
|
|
134
|
+
elif selection.deploy_provider_schema:
|
|
135
|
+
request_prefix = (
|
|
136
|
+
"apply-provider-cost-"
|
|
137
|
+
if selection.runtime_image_profile == "cost-governance"
|
|
138
|
+
else "apply-provider-"
|
|
139
|
+
)
|
|
140
|
+
bounded_request_id = bounded_request_id.replace("apply-", request_prefix, 1)
|
|
141
|
+
elif selection.deploy_identity_migration:
|
|
142
|
+
bounded_request_id = bounded_request_id.replace("apply-", "apply-identity-", 1)
|
|
143
|
+
elif selection.deploy_operational_history:
|
|
144
|
+
bounded_request_id = bounded_request_id.replace("apply-", "apply-history-", 1)
|
|
145
|
+
elif selection.runtime_image_profile == "cost-governance":
|
|
146
|
+
bounded_request_id = bounded_request_id.replace("apply-", "apply-cost-", 1)
|
|
147
|
+
_dispatch(
|
|
148
|
+
repository=repository,
|
|
149
|
+
environment=environment,
|
|
150
|
+
commit_sha=commit_sha,
|
|
151
|
+
context_digest=context_digest,
|
|
152
|
+
request_id_value=bounded_request_id,
|
|
153
|
+
apply=True,
|
|
154
|
+
plan_id=plan_id,
|
|
155
|
+
plan_digest=plan_digest,
|
|
156
|
+
resume_verification=resume_verification,
|
|
157
|
+
selection=selection,
|
|
158
|
+
run=runner,
|
|
159
|
+
)
|
|
160
|
+
return WorkflowDispatch(
|
|
161
|
+
request_id=bounded_request_id,
|
|
162
|
+
run_name=f"deploy-{bounded_request_id}",
|
|
163
|
+
context_digest=context_digest,
|
|
164
|
+
mode="resume-verification" if resume_verification else "apply",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _dispatch(
|
|
169
|
+
*,
|
|
170
|
+
repository: str,
|
|
171
|
+
environment: str,
|
|
172
|
+
commit_sha: str,
|
|
173
|
+
context_digest: str,
|
|
174
|
+
request_id_value: str,
|
|
175
|
+
apply: bool,
|
|
176
|
+
plan_id: str | None,
|
|
177
|
+
plan_digest: str | None,
|
|
178
|
+
resume_verification: bool,
|
|
179
|
+
selection: DeploymentSelection,
|
|
180
|
+
run: CommandRunner | None,
|
|
181
|
+
) -> None:
|
|
182
|
+
_validate_repository(repository)
|
|
183
|
+
if environment not in _ENVIRONMENTS:
|
|
184
|
+
raise ValueError("environment is unsupported")
|
|
185
|
+
if _COMMIT.fullmatch(commit_sha) is None:
|
|
186
|
+
raise ValueError("commit_sha MUST be a lowercase git SHA")
|
|
187
|
+
if _REQUEST_ID.fullmatch(request_id_value) is None:
|
|
188
|
+
raise ValueError("request_id is invalid")
|
|
189
|
+
fields: dict[str, str] = {
|
|
190
|
+
"environment": environment,
|
|
191
|
+
"apply": str(apply).lower(),
|
|
192
|
+
"promote_runtime_image": str(bool(selection.runtime_image_revision) and not apply).lower(),
|
|
193
|
+
"resume_verification": str(resume_verification).lower(),
|
|
194
|
+
"request_id": request_id_value,
|
|
195
|
+
"context_digest": context_digest,
|
|
196
|
+
"commit_sha": commit_sha,
|
|
197
|
+
**{
|
|
198
|
+
key: str(value).lower()
|
|
199
|
+
for key, value in selection.to_mapping().items()
|
|
200
|
+
if key
|
|
201
|
+
not in {
|
|
202
|
+
"deploy_identity_migration",
|
|
203
|
+
"deploy_operational_history",
|
|
204
|
+
"deploy_provider_schema",
|
|
205
|
+
"deploy_rca_reader_identity",
|
|
206
|
+
"runtime_call_evidence_transition",
|
|
207
|
+
"runtime_image_profile",
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
if apply:
|
|
212
|
+
if plan_id is None or plan_digest is None:
|
|
213
|
+
raise ValueError("exact apply requires plan id and digest")
|
|
214
|
+
fields["plan_id"] = plan_id
|
|
215
|
+
fields["plan_digest"] = plan_digest
|
|
216
|
+
workflow = "deploy-dev.yml"
|
|
217
|
+
if apply and (selection.deploy_operational_history or selection.deploy_rca_reader_identity):
|
|
218
|
+
workflow = "request-protected-operation.yml"
|
|
219
|
+
fields = {
|
|
220
|
+
"operation": (
|
|
221
|
+
"operational-history-apply"
|
|
222
|
+
if selection.deploy_operational_history
|
|
223
|
+
else "rca-reader-apply"
|
|
224
|
+
),
|
|
225
|
+
"environment": environment,
|
|
226
|
+
"commit_sha": commit_sha,
|
|
227
|
+
"request_id": request_id_value,
|
|
228
|
+
"context_digest": context_digest,
|
|
229
|
+
"plan_id": fields["plan_id"],
|
|
230
|
+
"plan_digest": fields["plan_digest"],
|
|
231
|
+
"resume_verification": str(resume_verification).lower(),
|
|
232
|
+
}
|
|
233
|
+
if selection.deploy_operational_history:
|
|
234
|
+
fields["runtime_image_revision"] = selection.runtime_image_revision
|
|
235
|
+
arguments = [
|
|
236
|
+
"workflow",
|
|
237
|
+
"run",
|
|
238
|
+
workflow,
|
|
239
|
+
"--repo",
|
|
240
|
+
repository,
|
|
241
|
+
"--ref",
|
|
242
|
+
"main",
|
|
243
|
+
]
|
|
244
|
+
for key, value in fields.items():
|
|
245
|
+
arguments.extend(("--field", f"{key}={value}"))
|
|
246
|
+
result = (run or run_github_cli)(tuple(arguments))
|
|
247
|
+
if result.returncode != 0:
|
|
248
|
+
raise ValueError("github_workflow_dispatch_failed")
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Protected plan artifact download and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
9
|
+
from fdai_deployment_cli.github_artifact_io import _private_artifact_json
|
|
10
|
+
from fdai_deployment_cli.github_workflow_values import (
|
|
11
|
+
_COMMIT,
|
|
12
|
+
_DIGEST,
|
|
13
|
+
_OCI_DIGEST,
|
|
14
|
+
_PLAN_ID,
|
|
15
|
+
CommandRunner,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_CORE_POST_APPLY_OBSERVATIONS = [
|
|
19
|
+
"database-migrations",
|
|
20
|
+
"runtime-health",
|
|
21
|
+
"initial-inventory-execution",
|
|
22
|
+
"canary-publisher",
|
|
23
|
+
"terraform-zero-change",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
_COST_GOVERNANCE_POST_APPLY_OBSERVATIONS = [
|
|
27
|
+
"terraform-zero-change",
|
|
28
|
+
"cost-governance-job-image-readback",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
_PROVIDER_SCHEMA_POST_APPLY_OBSERVATIONS = [
|
|
32
|
+
"terraform-zero-change",
|
|
33
|
+
"provider-schema-core-baseline",
|
|
34
|
+
"provider-schema-job-execution",
|
|
35
|
+
"provider-schema-durable-generation",
|
|
36
|
+
"provider-schema-agent-review",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _download_plan_metadata(
|
|
41
|
+
*,
|
|
42
|
+
repository: str,
|
|
43
|
+
workflow_run_id: int,
|
|
44
|
+
request_id_value: str,
|
|
45
|
+
expected_commit: str,
|
|
46
|
+
expected_context_digest: str,
|
|
47
|
+
run: CommandRunner,
|
|
48
|
+
) -> dict[str, object]:
|
|
49
|
+
with tempfile.TemporaryDirectory(prefix="fdai-plan-status-") as raw_directory:
|
|
50
|
+
directory = Path(raw_directory)
|
|
51
|
+
result = run(
|
|
52
|
+
(
|
|
53
|
+
"run",
|
|
54
|
+
"download",
|
|
55
|
+
str(workflow_run_id),
|
|
56
|
+
"--repo",
|
|
57
|
+
repository,
|
|
58
|
+
"--name",
|
|
59
|
+
f"deployment-plan-metadata-{request_id_value}",
|
|
60
|
+
"--dir",
|
|
61
|
+
str(directory),
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
if result.returncode != 0:
|
|
65
|
+
raise ValueError("github_plan_metadata_unavailable")
|
|
66
|
+
payload = _private_artifact_json(directory / "plan-metadata.json", "plan metadata")
|
|
67
|
+
required = {
|
|
68
|
+
"schema_version": "fdai.deployment-plan.v1",
|
|
69
|
+
"request_id": request_id_value,
|
|
70
|
+
"commit_sha": expected_commit,
|
|
71
|
+
"context_digest": expected_context_digest,
|
|
72
|
+
"status": "ready",
|
|
73
|
+
}
|
|
74
|
+
if any(payload.get(key) != value for key, value in required.items()):
|
|
75
|
+
raise ValueError("github_plan_metadata_context_mismatch")
|
|
76
|
+
plan_id = payload.get("plan_id")
|
|
77
|
+
plan_digest = payload.get("plan_digest")
|
|
78
|
+
context_digest = payload.get("context_digest")
|
|
79
|
+
expires_at = payload.get("expires_at")
|
|
80
|
+
if (
|
|
81
|
+
not isinstance(plan_id, str)
|
|
82
|
+
or _PLAN_ID.fullmatch(plan_id) is None
|
|
83
|
+
or not isinstance(plan_digest, str)
|
|
84
|
+
or _DIGEST.fullmatch(plan_digest) is None
|
|
85
|
+
or not isinstance(context_digest, str)
|
|
86
|
+
or _DIGEST.fullmatch(context_digest) is None
|
|
87
|
+
or not isinstance(expires_at, str)
|
|
88
|
+
):
|
|
89
|
+
raise ValueError("github_plan_metadata_invalid")
|
|
90
|
+
summary = _plan_summary(payload.get("plan_summary"))
|
|
91
|
+
observations = payload.get("post_apply_observations")
|
|
92
|
+
provider_schema = request_id_value.startswith("plan-provider-")
|
|
93
|
+
cost_governance = request_id_value.startswith("plan-cost-")
|
|
94
|
+
expected_observations = (
|
|
95
|
+
_PROVIDER_SCHEMA_POST_APPLY_OBSERVATIONS
|
|
96
|
+
if provider_schema
|
|
97
|
+
else (
|
|
98
|
+
_COST_GOVERNANCE_POST_APPLY_OBSERVATIONS
|
|
99
|
+
if cost_governance
|
|
100
|
+
else _CORE_POST_APPLY_OBSERVATIONS
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
if observations != expected_observations:
|
|
104
|
+
raise ValueError("github_plan_metadata_observations_invalid")
|
|
105
|
+
runtime_image = payload.get("runtime_image")
|
|
106
|
+
expected_profile = (
|
|
107
|
+
"cost-governance"
|
|
108
|
+
if cost_governance or request_id_value.startswith("plan-provider-cost-")
|
|
109
|
+
else "core-control-plane"
|
|
110
|
+
)
|
|
111
|
+
if (cost_governance or provider_schema) and (
|
|
112
|
+
not isinstance(runtime_image, dict)
|
|
113
|
+
or set(runtime_image) != {"source_revision", "digest", "profile"}
|
|
114
|
+
or not isinstance(runtime_image.get("source_revision"), str)
|
|
115
|
+
or _COMMIT.fullmatch(runtime_image["source_revision"]) is None
|
|
116
|
+
or not isinstance(runtime_image.get("digest"), str)
|
|
117
|
+
or _OCI_DIGEST.fullmatch(runtime_image["digest"]) is None
|
|
118
|
+
or runtime_image.get("profile") != expected_profile
|
|
119
|
+
):
|
|
120
|
+
raise ValueError("github_plan_metadata_runtime_image_invalid")
|
|
121
|
+
projected: dict[str, object] = {
|
|
122
|
+
"plan_id": plan_id,
|
|
123
|
+
"plan_digest": plan_digest,
|
|
124
|
+
"context_digest": context_digest,
|
|
125
|
+
"expires_at": expires_at,
|
|
126
|
+
"status": "ready",
|
|
127
|
+
"plan_summary": summary,
|
|
128
|
+
"post_apply_observations": observations,
|
|
129
|
+
}
|
|
130
|
+
if provider_schema and isinstance(runtime_image, dict):
|
|
131
|
+
projected["runtime_image"] = dict(runtime_image)
|
|
132
|
+
return projected
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _plan_summary(value: object) -> dict[str, object]:
|
|
136
|
+
"""Validate one address-free protected-plan action summary."""
|
|
137
|
+
|
|
138
|
+
if not isinstance(value, dict) or set(value) != {
|
|
139
|
+
"schema_version",
|
|
140
|
+
"action_counts",
|
|
141
|
+
"resource_type_counts",
|
|
142
|
+
"managed_resources",
|
|
143
|
+
"destructive",
|
|
144
|
+
"summary_digest",
|
|
145
|
+
}:
|
|
146
|
+
raise ValueError("github_plan_summary_invalid")
|
|
147
|
+
digest = value.get("summary_digest")
|
|
148
|
+
body = {key: item for key, item in value.items() if key != "summary_digest"}
|
|
149
|
+
if (
|
|
150
|
+
value.get("schema_version") != "fdai.deployment-plan-summary.v1"
|
|
151
|
+
or not isinstance(digest, str)
|
|
152
|
+
or _DIGEST.fullmatch(digest) is None
|
|
153
|
+
or canonical_digest(body) != digest
|
|
154
|
+
or type(value.get("managed_resources")) is not int
|
|
155
|
+
or int(value["managed_resources"]) < 0
|
|
156
|
+
or type(value.get("destructive")) is not bool
|
|
157
|
+
):
|
|
158
|
+
raise ValueError("github_plan_summary_invalid")
|
|
159
|
+
actions = value.get("action_counts")
|
|
160
|
+
expected_actions = {"create", "update", "delete", "replace", "no_op", "read"}
|
|
161
|
+
if (
|
|
162
|
+
not isinstance(actions, dict)
|
|
163
|
+
or set(actions) != expected_actions
|
|
164
|
+
or any(type(count) is not int or count < 0 for count in actions.values())
|
|
165
|
+
or sum(actions.values()) != value["managed_resources"]
|
|
166
|
+
):
|
|
167
|
+
raise ValueError("github_plan_summary_invalid")
|
|
168
|
+
resource_types = value.get("resource_type_counts")
|
|
169
|
+
if not isinstance(resource_types, dict) or len(resource_types) > 256:
|
|
170
|
+
raise ValueError("github_plan_summary_invalid")
|
|
171
|
+
for resource_type, counts in resource_types.items():
|
|
172
|
+
if (
|
|
173
|
+
not isinstance(resource_type, str)
|
|
174
|
+
or not resource_type
|
|
175
|
+
or len(resource_type) > 128
|
|
176
|
+
or not isinstance(counts, dict)
|
|
177
|
+
or not set(counts).issubset(expected_actions)
|
|
178
|
+
or any(type(count) is not int or count <= 0 for count in counts.values())
|
|
179
|
+
):
|
|
180
|
+
raise ValueError("github_plan_summary_invalid")
|
|
181
|
+
return {str(key): item for key, item in value.items()}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Provider-schema deployment evidence validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from fdai_deployment_cli.github_workflow_values import (
|
|
8
|
+
_DIGEST,
|
|
9
|
+
_OCI_DIGEST,
|
|
10
|
+
_PROVIDER_SOURCE_REVISION,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _validate_provider_schema_core_baseline(
|
|
15
|
+
value: Mapping[str, object],
|
|
16
|
+
*,
|
|
17
|
+
expected_source_revision: str,
|
|
18
|
+
expected_image_digest: str,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Reject a provider baseline that does not prove the exact healthy Core image."""
|
|
21
|
+
|
|
22
|
+
max_inactive_revisions = value.get("max_inactive_revisions")
|
|
23
|
+
if (
|
|
24
|
+
set(value)
|
|
25
|
+
!= {
|
|
26
|
+
"schema_version",
|
|
27
|
+
"source_revision",
|
|
28
|
+
"image_digest",
|
|
29
|
+
"core_app_ref_digest",
|
|
30
|
+
"active_revision_ref_digest",
|
|
31
|
+
"max_inactive_revisions",
|
|
32
|
+
"health_state",
|
|
33
|
+
"provisioning_state",
|
|
34
|
+
"observed_at",
|
|
35
|
+
"grants_authority",
|
|
36
|
+
}
|
|
37
|
+
or value.get("schema_version") != "fdai.provider-schema-core-baseline.v1"
|
|
38
|
+
or value.get("source_revision") != expected_source_revision
|
|
39
|
+
or value.get("image_digest") != expected_image_digest
|
|
40
|
+
or value.get("health_state") != "Healthy"
|
|
41
|
+
or value.get("provisioning_state") != "Provisioned"
|
|
42
|
+
or value.get("grants_authority") is not False
|
|
43
|
+
or not isinstance(max_inactive_revisions, int)
|
|
44
|
+
or isinstance(max_inactive_revisions, bool)
|
|
45
|
+
or max_inactive_revisions < 1
|
|
46
|
+
or not isinstance(value.get("observed_at"), str)
|
|
47
|
+
):
|
|
48
|
+
raise ValueError("github_provider_schema_core_baseline_invalid")
|
|
49
|
+
for field in ("core_app_ref_digest", "active_revision_ref_digest"):
|
|
50
|
+
digest = value.get(field)
|
|
51
|
+
if not isinstance(digest, str) or _DIGEST.fullmatch(digest) is None:
|
|
52
|
+
raise ValueError("github_provider_schema_core_baseline_invalid")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _validate_provider_schema_evidence(
|
|
56
|
+
value: Mapping[str, object],
|
|
57
|
+
*,
|
|
58
|
+
expected_application_source: str,
|
|
59
|
+
expected_runtime_revision: str,
|
|
60
|
+
expected_plan_id: str,
|
|
61
|
+
) -> dict[str, object]:
|
|
62
|
+
"""Validate durable provider generation and conditional agent-review evidence."""
|
|
63
|
+
|
|
64
|
+
expected_fields = {
|
|
65
|
+
"schema_version",
|
|
66
|
+
"application_source_commit",
|
|
67
|
+
"runtime_image_revision",
|
|
68
|
+
"plan_id",
|
|
69
|
+
"job_execution_ref_digest",
|
|
70
|
+
"job_execution_status",
|
|
71
|
+
"checked_at",
|
|
72
|
+
"provider_source_revision",
|
|
73
|
+
"baseline_digest",
|
|
74
|
+
"observed_digest",
|
|
75
|
+
"drift_digest",
|
|
76
|
+
"disposition",
|
|
77
|
+
"durable_generation_digest",
|
|
78
|
+
"durable_generation_revision",
|
|
79
|
+
"run_receipt_digest",
|
|
80
|
+
"review_package_digest",
|
|
81
|
+
"heimdall_review_dispatched",
|
|
82
|
+
"review_evidence_status",
|
|
83
|
+
"correlation_id",
|
|
84
|
+
"forseti_risk_verdict",
|
|
85
|
+
"forseti_reason",
|
|
86
|
+
"saga_audit_entry_hash",
|
|
87
|
+
"grants_authority",
|
|
88
|
+
}
|
|
89
|
+
generation_revision = value.get("durable_generation_revision")
|
|
90
|
+
if (
|
|
91
|
+
set(value) != expected_fields
|
|
92
|
+
or value.get("schema_version") != "fdai.provider-schema-deployment-evidence.v1"
|
|
93
|
+
or value.get("application_source_commit") != expected_application_source
|
|
94
|
+
or value.get("runtime_image_revision") != expected_runtime_revision
|
|
95
|
+
or value.get("plan_id") != expected_plan_id
|
|
96
|
+
or value.get("job_execution_status") != "Succeeded"
|
|
97
|
+
or value.get("grants_authority") is not False
|
|
98
|
+
or not isinstance(value.get("checked_at"), str)
|
|
99
|
+
or value.get("disposition") not in {"unchanged", "compatible", "breaking"}
|
|
100
|
+
or not isinstance(generation_revision, int)
|
|
101
|
+
or isinstance(generation_revision, bool)
|
|
102
|
+
or generation_revision < 1
|
|
103
|
+
):
|
|
104
|
+
raise ValueError("github_provider_schema_evidence_invalid")
|
|
105
|
+
job_execution_digest = value.get("job_execution_ref_digest")
|
|
106
|
+
if not isinstance(job_execution_digest, str) or _DIGEST.fullmatch(job_execution_digest) is None:
|
|
107
|
+
raise ValueError("github_provider_schema_evidence_invalid")
|
|
108
|
+
drift_digest = value.get("drift_digest")
|
|
109
|
+
if drift_digest is not None and (
|
|
110
|
+
not isinstance(drift_digest, str) or _DIGEST.fullmatch(drift_digest) is None
|
|
111
|
+
):
|
|
112
|
+
raise ValueError("github_provider_schema_evidence_invalid")
|
|
113
|
+
provider_revision = value.get("provider_source_revision")
|
|
114
|
+
if (
|
|
115
|
+
not isinstance(provider_revision, str)
|
|
116
|
+
or _PROVIDER_SOURCE_REVISION.fullmatch(provider_revision) is None
|
|
117
|
+
):
|
|
118
|
+
raise ValueError("github_provider_schema_evidence_invalid")
|
|
119
|
+
for field in (
|
|
120
|
+
"baseline_digest",
|
|
121
|
+
"observed_digest",
|
|
122
|
+
"durable_generation_digest",
|
|
123
|
+
"run_receipt_digest",
|
|
124
|
+
):
|
|
125
|
+
digest = value.get(field)
|
|
126
|
+
if not isinstance(digest, str) or _OCI_DIGEST.fullmatch(digest) is None:
|
|
127
|
+
raise ValueError("github_provider_schema_evidence_invalid")
|
|
128
|
+
review_status = value.get("review_evidence_status")
|
|
129
|
+
if review_status == "verified":
|
|
130
|
+
if (
|
|
131
|
+
value.get("disposition") != "breaking"
|
|
132
|
+
or value.get("heimdall_review_dispatched") is not True
|
|
133
|
+
or not isinstance(drift_digest, str)
|
|
134
|
+
or _DIGEST.fullmatch(drift_digest) is None
|
|
135
|
+
or value.get("correlation_id") != f"provider-schema:azure:{drift_digest}"
|
|
136
|
+
or value.get("forseti_risk_verdict") != "hil"
|
|
137
|
+
or value.get("forseti_reason") != "no_rule_match"
|
|
138
|
+
or not isinstance(value.get("review_package_digest"), str)
|
|
139
|
+
or _OCI_DIGEST.fullmatch(str(value["review_package_digest"])) is None
|
|
140
|
+
or not isinstance(value.get("saga_audit_entry_hash"), str)
|
|
141
|
+
):
|
|
142
|
+
raise ValueError("github_provider_schema_evidence_invalid")
|
|
143
|
+
elif review_status == "not_applicable":
|
|
144
|
+
if (
|
|
145
|
+
value.get("disposition") == "breaking"
|
|
146
|
+
or value.get("heimdall_review_dispatched") is not False
|
|
147
|
+
or any(
|
|
148
|
+
value.get(field) is not None
|
|
149
|
+
for field in (
|
|
150
|
+
"review_package_digest",
|
|
151
|
+
"correlation_id",
|
|
152
|
+
"forseti_risk_verdict",
|
|
153
|
+
"forseti_reason",
|
|
154
|
+
"saga_audit_entry_hash",
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
):
|
|
158
|
+
raise ValueError("github_provider_schema_evidence_invalid")
|
|
159
|
+
else:
|
|
160
|
+
raise ValueError("github_provider_schema_evidence_invalid")
|
|
161
|
+
return {
|
|
162
|
+
"provider_source_revision": provider_revision,
|
|
163
|
+
"durable_generation_digest": value["durable_generation_digest"],
|
|
164
|
+
"durable_generation_revision": value["durable_generation_revision"],
|
|
165
|
+
"disposition": value["disposition"],
|
|
166
|
+
"review_evidence_status": review_status,
|
|
167
|
+
"heimdall_review_dispatched": value["heimdall_review_dispatched"],
|
|
168
|
+
"forseti_risk_verdict": value["forseti_risk_verdict"],
|
|
169
|
+
"saga_audit_entry_hash": value["saga_audit_entry_hash"],
|
|
170
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Bounded GitHub CLI process transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
from fdai_deployment_cli.github_workflow_values import CommandResult, CommandRunner
|
|
9
|
+
|
|
10
|
+
_ARTIFACT_DOWNLOAD_TIMEOUT = 90
|
|
11
|
+
|
|
12
|
+
_DEFAULT_GH_TIMEOUT = 30
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_github_cli(
|
|
16
|
+
arguments: tuple[str, ...],
|
|
17
|
+
timeout: int = _DEFAULT_GH_TIMEOUT,
|
|
18
|
+
) -> CommandResult:
|
|
19
|
+
"""Execute one fixed GitHub CLI command with bounded output and duration."""
|
|
20
|
+
|
|
21
|
+
executable = shutil.which("gh")
|
|
22
|
+
if executable is None:
|
|
23
|
+
raise OSError("GitHub CLI is unavailable")
|
|
24
|
+
try:
|
|
25
|
+
completed = subprocess.run(
|
|
26
|
+
[executable, *arguments],
|
|
27
|
+
check=False,
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
timeout=timeout,
|
|
31
|
+
)
|
|
32
|
+
except subprocess.TimeoutExpired:
|
|
33
|
+
return CommandResult(returncode=124, stdout="", stderr="")
|
|
34
|
+
return CommandResult(
|
|
35
|
+
returncode=completed.returncode,
|
|
36
|
+
stdout=completed.stdout[:65_536],
|
|
37
|
+
stderr=completed.stderr[:65_536],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _artifact_runner(base: CommandRunner) -> CommandRunner:
|
|
42
|
+
"""Wrap the real runner with a longer timeout for artifact downloads."""
|
|
43
|
+
|
|
44
|
+
if base is not run_github_cli:
|
|
45
|
+
return base # Test runners handle their own timing.
|
|
46
|
+
return lambda args: run_github_cli(args, timeout=_ARTIFACT_DOWNLOAD_TIMEOUT)
|