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,75 @@
|
|
|
1
|
+
"""Register changed-source CLI commands without growing the top-level parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register(
|
|
11
|
+
provision_commands: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
12
|
+
*,
|
|
13
|
+
command: Callable[..., argparse.ArgumentParser],
|
|
14
|
+
handlers: Mapping[str, Callable[[argparse.Namespace], int]],
|
|
15
|
+
) -> None:
|
|
16
|
+
plan = command(
|
|
17
|
+
provision_commands,
|
|
18
|
+
"source-change-plan",
|
|
19
|
+
"Classify one exact Git diff into the narrowest safe deployment lane",
|
|
20
|
+
)
|
|
21
|
+
plan.add_argument("--source", type=Path, required=True, help="Clean Git checkout to inspect")
|
|
22
|
+
plan.add_argument("--base", required=True, help="Base Git revision for the exact diff")
|
|
23
|
+
plan.add_argument(
|
|
24
|
+
"--head",
|
|
25
|
+
default="HEAD",
|
|
26
|
+
help="Head Git revision for the exact diff (default: %(default)s)",
|
|
27
|
+
)
|
|
28
|
+
plan.add_argument(
|
|
29
|
+
"--output",
|
|
30
|
+
choices=("text", "json"),
|
|
31
|
+
default="text",
|
|
32
|
+
help="Output format (default: %(default)s)",
|
|
33
|
+
)
|
|
34
|
+
plan.set_defaults(handler=handlers["provision_source_change_plan"])
|
|
35
|
+
|
|
36
|
+
update = command(
|
|
37
|
+
provision_commands,
|
|
38
|
+
"source-changed-update",
|
|
39
|
+
"Deploy every affected baseline service from one exact Git diff",
|
|
40
|
+
)
|
|
41
|
+
update.add_argument(
|
|
42
|
+
"--source",
|
|
43
|
+
type=Path,
|
|
44
|
+
required=True,
|
|
45
|
+
help="Clean checkout whose HEAD supplies the update",
|
|
46
|
+
)
|
|
47
|
+
update.add_argument("--base", required=True, help="Base Git revision for affected services")
|
|
48
|
+
update.add_argument(
|
|
49
|
+
"--application-work-dir",
|
|
50
|
+
type=Path,
|
|
51
|
+
required=True,
|
|
52
|
+
help="Retained existing-development application work directory",
|
|
53
|
+
)
|
|
54
|
+
update.add_argument(
|
|
55
|
+
"--work-dir",
|
|
56
|
+
type=Path,
|
|
57
|
+
required=True,
|
|
58
|
+
help="Fresh or retained private changed-update work directory",
|
|
59
|
+
)
|
|
60
|
+
update.add_argument(
|
|
61
|
+
"--timeout-seconds",
|
|
62
|
+
type=int,
|
|
63
|
+
default=14400,
|
|
64
|
+
help="Total update deadline in seconds (default: %(default)s)",
|
|
65
|
+
)
|
|
66
|
+
update.add_argument(
|
|
67
|
+
"--output",
|
|
68
|
+
choices=("text", "json"),
|
|
69
|
+
default="text",
|
|
70
|
+
help="Output format (default: %(default)s)",
|
|
71
|
+
)
|
|
72
|
+
update.set_defaults(handler=handlers["provision_source_changed_update"])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = ["register"]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Classify a clean Git diff into the narrowest safe source deployment lane."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fdai_deployment_cli.contracts import canonical_digest
|
|
11
|
+
from fdai_deployment_cli.image_targets import IMAGE_TARGETS, select_image_targets
|
|
12
|
+
from fdai_deployment_cli.runtime_release import RUNTIME_SERVICES
|
|
13
|
+
|
|
14
|
+
_COMMIT = re.compile(r"[0-9a-f]{40}")
|
|
15
|
+
_MAX_CHANGED_PATHS = 20_000
|
|
16
|
+
_HIGHER_LANE_PREFIXES = (
|
|
17
|
+
"alembic/",
|
|
18
|
+
"infra/",
|
|
19
|
+
"lifecycle/",
|
|
20
|
+
"packages/deployment-cli/",
|
|
21
|
+
"scripts/deployment/azure/",
|
|
22
|
+
"service-migrations/",
|
|
23
|
+
)
|
|
24
|
+
_HIGHER_LANE_EXACT = frozenset({"alembic.ini", "azure.yaml"})
|
|
25
|
+
_NO_DEPLOY_PREFIXES = (
|
|
26
|
+
".github/",
|
|
27
|
+
".vscode/",
|
|
28
|
+
"docs/",
|
|
29
|
+
"examples/",
|
|
30
|
+
"mocks/",
|
|
31
|
+
"site/",
|
|
32
|
+
"tests/",
|
|
33
|
+
"tools/",
|
|
34
|
+
)
|
|
35
|
+
_RUNTIME_EXCEPTION_PATHS = frozenset({"docs/internals/sregym-absorption-ledger.json"})
|
|
36
|
+
_RUNTIME_EXCEPTION_PREFIXES = ("services/core-control-plane/tests/scenarios/",)
|
|
37
|
+
_SERVICE_BUILD_CONTRACTS = frozenset(
|
|
38
|
+
{
|
|
39
|
+
*(target.dockerfile for target in IMAGE_TARGETS),
|
|
40
|
+
*(f"services/{target.service}/pyproject.toml" for target in IMAGE_TARGETS),
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def source_change_plan(
|
|
46
|
+
changed_paths: Iterable[str],
|
|
47
|
+
*,
|
|
48
|
+
base_commit: str,
|
|
49
|
+
head_commit: str,
|
|
50
|
+
deleted_paths: Iterable[str] = (),
|
|
51
|
+
) -> dict[str, object]:
|
|
52
|
+
"""Return a no-effect deployment route for one exact commit range."""
|
|
53
|
+
if _COMMIT.fullmatch(base_commit) is None or _COMMIT.fullmatch(head_commit) is None:
|
|
54
|
+
raise ValueError("source change plan requires exact commit ids")
|
|
55
|
+
paths = tuple(sorted({path.removeprefix("./") for path in changed_paths if path}))
|
|
56
|
+
if len(paths) > _MAX_CHANGED_PATHS or any(
|
|
57
|
+
path.startswith("/") or ".." in Path(path).parts or "\x00" in path for path in paths
|
|
58
|
+
):
|
|
59
|
+
raise ValueError("source change plan paths are invalid or exceed their bound")
|
|
60
|
+
deleted = tuple(sorted({path.removeprefix("./") for path in deleted_paths if path}))
|
|
61
|
+
if any(path not in paths for path in deleted):
|
|
62
|
+
raise ValueError("source change plan deletions must belong to the exact diff")
|
|
63
|
+
runtime_paths = tuple(path for path in paths if not _is_no_deploy_path(path))
|
|
64
|
+
|
|
65
|
+
higher_lane = tuple(
|
|
66
|
+
path
|
|
67
|
+
for path in runtime_paths
|
|
68
|
+
if path in _HIGHER_LANE_EXACT or path.startswith(_HIGHER_LANE_PREFIXES)
|
|
69
|
+
)
|
|
70
|
+
removed_contracts = tuple(path for path in deleted if path in _SERVICE_BUILD_CONTRACTS)
|
|
71
|
+
console_paths = tuple(path for path in runtime_paths if path.startswith("console/"))
|
|
72
|
+
image_targets = select_image_targets(runtime_paths)
|
|
73
|
+
unsupported = tuple(
|
|
74
|
+
target.target
|
|
75
|
+
for target in image_targets
|
|
76
|
+
if target.service not in RUNTIME_SERVICES or target.target != target.service
|
|
77
|
+
)
|
|
78
|
+
services = tuple(
|
|
79
|
+
target.service
|
|
80
|
+
for target in image_targets
|
|
81
|
+
if target.service in RUNTIME_SERVICES and target.target == target.service
|
|
82
|
+
)
|
|
83
|
+
services = tuple(dict.fromkeys(services))
|
|
84
|
+
|
|
85
|
+
if removed_contracts:
|
|
86
|
+
lane, reason = "higher-lane-required", "service_build_contract_removed"
|
|
87
|
+
higher_lane = tuple(sorted({*higher_lane, *removed_contracts}))
|
|
88
|
+
elif higher_lane:
|
|
89
|
+
lane, reason = "higher-lane-required", "deployment_contract_changed"
|
|
90
|
+
elif unsupported:
|
|
91
|
+
lane, reason = "full-release-required", "unsupported_hot_path_target"
|
|
92
|
+
elif console_paths and services:
|
|
93
|
+
lane, reason = "split-update-required", "console_and_service_changed"
|
|
94
|
+
elif console_paths:
|
|
95
|
+
lane, reason = "console-update", "console_changed"
|
|
96
|
+
elif len(services) == 1:
|
|
97
|
+
lane, reason = "single-service-update", "one_service_changed"
|
|
98
|
+
elif len(services) > 1:
|
|
99
|
+
lane, reason = "multi-service-update", "multiple_services_changed"
|
|
100
|
+
elif paths and not runtime_paths:
|
|
101
|
+
lane, reason = "no-deploy", "non_runtime_files_changed"
|
|
102
|
+
elif paths:
|
|
103
|
+
lane, reason = "higher-lane-required", "unclassified_runtime_impact"
|
|
104
|
+
else:
|
|
105
|
+
lane, reason = "no-deploy", "no_files_changed"
|
|
106
|
+
|
|
107
|
+
result: dict[str, object] = {
|
|
108
|
+
"schema_version": "fdai.source-change-plan.v1",
|
|
109
|
+
"base_commit": base_commit,
|
|
110
|
+
"head_commit": head_commit,
|
|
111
|
+
"lane": lane,
|
|
112
|
+
"reason_code": reason,
|
|
113
|
+
"changed_path_count": len(paths),
|
|
114
|
+
"changed_paths_digest": canonical_digest({"paths": list(paths)}),
|
|
115
|
+
"services": list(services),
|
|
116
|
+
"image_targets": [target.target for target in image_targets],
|
|
117
|
+
"unsupported_hot_path_targets": list(unsupported),
|
|
118
|
+
"console_changed": bool(console_paths),
|
|
119
|
+
"higher_lane_paths": list(higher_lane),
|
|
120
|
+
"mutation_performed": False,
|
|
121
|
+
"deployment_ready": False,
|
|
122
|
+
}
|
|
123
|
+
result["plan_digest"] = canonical_digest(result)
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def plan_git_changes(source_root: Path, base: str, head: str = "HEAD") -> dict[str, object]:
|
|
128
|
+
"""Resolve an exact local diff and classify it without contacting Azure."""
|
|
129
|
+
root = source_root.resolve(strict=True)
|
|
130
|
+
if root.is_symlink():
|
|
131
|
+
raise ValueError("source change plan requires a non-symlink checkout")
|
|
132
|
+
base_commit = _git(root, "rev-parse", "--verify", f"{base}^{{commit}}").strip()
|
|
133
|
+
head_commit = _git(root, "rev-parse", "--verify", f"{head}^{{commit}}").strip()
|
|
134
|
+
if _COMMIT.fullmatch(base_commit) is None or _COMMIT.fullmatch(head_commit) is None:
|
|
135
|
+
raise ValueError("source change plan could not resolve exact commits")
|
|
136
|
+
output = subprocess.run(
|
|
137
|
+
[
|
|
138
|
+
"git",
|
|
139
|
+
"-C",
|
|
140
|
+
str(root),
|
|
141
|
+
"diff",
|
|
142
|
+
"--no-renames",
|
|
143
|
+
"--name-status",
|
|
144
|
+
"-z",
|
|
145
|
+
"--diff-filter=ACMRD",
|
|
146
|
+
base_commit,
|
|
147
|
+
head_commit,
|
|
148
|
+
],
|
|
149
|
+
check=True,
|
|
150
|
+
capture_output=True,
|
|
151
|
+
timeout=30,
|
|
152
|
+
).stdout
|
|
153
|
+
fields = [value for value in output.split(b"\0") if value]
|
|
154
|
+
if len(fields) % 2:
|
|
155
|
+
raise ValueError("source change plan received invalid Git diff status output")
|
|
156
|
+
changes = [
|
|
157
|
+
(fields[index].decode("ascii"), fields[index + 1].decode("utf-8"))
|
|
158
|
+
for index in range(0, len(fields), 2)
|
|
159
|
+
]
|
|
160
|
+
return source_change_plan(
|
|
161
|
+
(path for _status, path in changes),
|
|
162
|
+
base_commit=base_commit,
|
|
163
|
+
head_commit=head_commit,
|
|
164
|
+
deleted_paths=(path for status, path in changes if status == "D"),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _git(root: Path, *arguments: str) -> str:
|
|
169
|
+
return subprocess.run(
|
|
170
|
+
["git", "-C", str(root), *arguments],
|
|
171
|
+
check=True,
|
|
172
|
+
capture_output=True,
|
|
173
|
+
text=True,
|
|
174
|
+
timeout=30,
|
|
175
|
+
).stdout
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _is_no_deploy_path(path: str) -> bool:
|
|
179
|
+
if path in _RUNTIME_EXCEPTION_PATHS or path.startswith(_RUNTIME_EXCEPTION_PREFIXES):
|
|
180
|
+
return False
|
|
181
|
+
return path.startswith(_NO_DEPLOY_PREFIXES) or "/tests/" in path or "/docs/" in path
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Apply the service-only portion of one exact changed-source plan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest, load_json_object
|
|
11
|
+
from fdai_deployment_cli.deployment_deadline import DeploymentDeadline
|
|
12
|
+
from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
|
|
13
|
+
from fdai_deployment_cli.source_change_plan import plan_git_changes
|
|
14
|
+
from fdai_deployment_cli.source_service_update import (
|
|
15
|
+
_require_private_directory,
|
|
16
|
+
deploy_source_service_update,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_SUPPORTED_LANES = frozenset({"single-service-update", "multi-service-update"})
|
|
20
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
|
21
|
+
_IMAGE_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def deploy_changed_source(
|
|
25
|
+
*,
|
|
26
|
+
source_root: Path,
|
|
27
|
+
base: str,
|
|
28
|
+
application_work_dir: Path,
|
|
29
|
+
work_dir: Path,
|
|
30
|
+
timeout_seconds: int,
|
|
31
|
+
) -> dict[str, object]:
|
|
32
|
+
"""Deploy affected baseline services serially under their existing exact approvals."""
|
|
33
|
+
|
|
34
|
+
started = time.monotonic()
|
|
35
|
+
plan = plan_git_changes(source_root, base)
|
|
36
|
+
lane = str(plan["lane"])
|
|
37
|
+
services = plan.get("services")
|
|
38
|
+
if not isinstance(services, list) or any(not isinstance(item, str) for item in services):
|
|
39
|
+
raise TypeError("source change plan contains an invalid service list")
|
|
40
|
+
if lane == "no-deploy":
|
|
41
|
+
result: dict[str, object] = {
|
|
42
|
+
"schema_version": "fdai.source-changed-update.v1",
|
|
43
|
+
"state": "no-deploy",
|
|
44
|
+
"change_plan_digest": plan["plan_digest"],
|
|
45
|
+
"services": [],
|
|
46
|
+
"service_receipts": [],
|
|
47
|
+
"elapsed_ms": round((time.monotonic() - started) * 1000),
|
|
48
|
+
"mutation_performed": False,
|
|
49
|
+
"deployment_ready": False,
|
|
50
|
+
}
|
|
51
|
+
result["receipt_digest"] = canonical_digest(result)
|
|
52
|
+
return result
|
|
53
|
+
if lane not in _SUPPORTED_LANES:
|
|
54
|
+
result = {
|
|
55
|
+
"schema_version": "fdai.source-changed-update.v1",
|
|
56
|
+
"state": "review",
|
|
57
|
+
"lane": lane,
|
|
58
|
+
"reason_code": plan["reason_code"],
|
|
59
|
+
"change_plan_digest": plan["plan_digest"],
|
|
60
|
+
"services": services,
|
|
61
|
+
"next_action": "use_the_reported_deployment_lane",
|
|
62
|
+
"mutation_performed": False,
|
|
63
|
+
"deployment_ready": False,
|
|
64
|
+
}
|
|
65
|
+
result["receipt_digest"] = canonical_digest(result)
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
deadline = DeploymentDeadline(timeout_seconds)
|
|
69
|
+
source = source_root.resolve(strict=True)
|
|
70
|
+
application = application_work_dir.absolute()
|
|
71
|
+
root = work_dir.absolute()
|
|
72
|
+
_require_private_directory(root, create=True)
|
|
73
|
+
intent: dict[str, object] = {
|
|
74
|
+
"schema_version": "fdai.source-changed-update-intent.v1",
|
|
75
|
+
"change_plan_digest": plan["plan_digest"],
|
|
76
|
+
"base_commit": plan["base_commit"],
|
|
77
|
+
"head_commit": plan["head_commit"],
|
|
78
|
+
"services": services,
|
|
79
|
+
"source_binding": canonical_digest({"path": str(source)}),
|
|
80
|
+
"application_work_binding": canonical_digest({"path": str(application)}),
|
|
81
|
+
}
|
|
82
|
+
intent["intent_digest"] = canonical_digest(intent)
|
|
83
|
+
_persist_exact(
|
|
84
|
+
root / "source-changed-update-intent.json",
|
|
85
|
+
intent,
|
|
86
|
+
label="source changed update intent",
|
|
87
|
+
)
|
|
88
|
+
receipts: list[dict[str, Any]] = []
|
|
89
|
+
for index, service in enumerate(services, start=1):
|
|
90
|
+
assert isinstance(service, str)
|
|
91
|
+
receipt = deploy_source_service_update(
|
|
92
|
+
source_root=source,
|
|
93
|
+
application_work_dir=application,
|
|
94
|
+
work_dir=root / service,
|
|
95
|
+
service=service,
|
|
96
|
+
timeout_seconds=deadline.remaining(),
|
|
97
|
+
expected_source_commit=str(plan["head_commit"]),
|
|
98
|
+
expected_current_source_commit=str(plan["base_commit"]),
|
|
99
|
+
)
|
|
100
|
+
summary = _service_receipt_summary(
|
|
101
|
+
receipt,
|
|
102
|
+
expected_service=service,
|
|
103
|
+
expected_source_commit=str(plan["head_commit"]),
|
|
104
|
+
)
|
|
105
|
+
receipts.append(summary)
|
|
106
|
+
progress: dict[str, object] = {
|
|
107
|
+
"schema_version": "fdai.source-changed-update-progress.v1",
|
|
108
|
+
"intent_digest": intent["intent_digest"],
|
|
109
|
+
"completed_service_count": index,
|
|
110
|
+
"service_receipts": list(receipts),
|
|
111
|
+
}
|
|
112
|
+
progress["progress_digest"] = canonical_digest(progress)
|
|
113
|
+
_persist_exact(
|
|
114
|
+
root / f"source-changed-update-progress-{index:02d}.json",
|
|
115
|
+
progress,
|
|
116
|
+
label="source changed update progress",
|
|
117
|
+
)
|
|
118
|
+
effect: dict[str, object] = {
|
|
119
|
+
"schema_version": "fdai.source-changed-update-effect.v1",
|
|
120
|
+
"state": "applied",
|
|
121
|
+
"intent_digest": intent["intent_digest"],
|
|
122
|
+
"change_plan_digest": plan["plan_digest"],
|
|
123
|
+
"head_commit": plan["head_commit"],
|
|
124
|
+
"services": services,
|
|
125
|
+
"service_receipts": receipts,
|
|
126
|
+
"effect_verified": True,
|
|
127
|
+
"mutation_performed": True,
|
|
128
|
+
"deployment_ready": False,
|
|
129
|
+
}
|
|
130
|
+
effect["receipt_digest"] = canonical_digest(effect)
|
|
131
|
+
_persist_exact(
|
|
132
|
+
root / "source-changed-update-effect.json",
|
|
133
|
+
effect,
|
|
134
|
+
label="source changed update effect",
|
|
135
|
+
)
|
|
136
|
+
result = {
|
|
137
|
+
"schema_version": "fdai.source-changed-update.v1",
|
|
138
|
+
"state": "applied",
|
|
139
|
+
"change_plan_digest": plan["plan_digest"],
|
|
140
|
+
"services": services,
|
|
141
|
+
"service_receipts": receipts,
|
|
142
|
+
"elapsed_ms": round((time.monotonic() - started) * 1000),
|
|
143
|
+
"effect_verified": True,
|
|
144
|
+
"effect_receipt_digest": effect["receipt_digest"],
|
|
145
|
+
"mutation_performed": True,
|
|
146
|
+
"deployment_ready": False,
|
|
147
|
+
}
|
|
148
|
+
result["receipt_digest"] = canonical_digest(result)
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _service_receipt_summary(
|
|
153
|
+
receipt: dict[str, object],
|
|
154
|
+
*,
|
|
155
|
+
expected_service: str,
|
|
156
|
+
expected_source_commit: str,
|
|
157
|
+
) -> dict[str, object]:
|
|
158
|
+
receipt_digest = receipt.get("receipt_digest")
|
|
159
|
+
image_digest = receipt.get("image_digest")
|
|
160
|
+
if (
|
|
161
|
+
receipt.get("schema_version") != "fdai.source-service-update.v1"
|
|
162
|
+
or receipt.get("state") != "applied"
|
|
163
|
+
or receipt.get("service") != expected_service
|
|
164
|
+
or receipt.get("source_commit") != expected_source_commit
|
|
165
|
+
or not isinstance(receipt_digest, str)
|
|
166
|
+
or _DIGEST.fullmatch(receipt_digest) is None
|
|
167
|
+
or not isinstance(image_digest, str)
|
|
168
|
+
or _IMAGE_DIGEST.fullmatch(image_digest) is None
|
|
169
|
+
or any(
|
|
170
|
+
receipt.get(key) is not True
|
|
171
|
+
for key in (
|
|
172
|
+
"effect_verified",
|
|
173
|
+
"peer_state_unchanged_verified",
|
|
174
|
+
"terraform_zero_change_verified",
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
):
|
|
178
|
+
raise ValueError("source changed update received incomplete service effect evidence")
|
|
179
|
+
return {
|
|
180
|
+
"service": expected_service,
|
|
181
|
+
"source_commit": expected_source_commit,
|
|
182
|
+
"receipt_digest": receipt_digest,
|
|
183
|
+
"image_digest": image_digest,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _persist_exact(path: Path, value: dict[str, object], *, label: str) -> None:
|
|
188
|
+
if path.exists() or path.is_symlink():
|
|
189
|
+
retained = load_json_object(
|
|
190
|
+
read_private_bytes(path, max_bytes=1024 * 1024),
|
|
191
|
+
label=label,
|
|
192
|
+
)
|
|
193
|
+
if retained != value:
|
|
194
|
+
raise ValueError(f"retained {label} differs")
|
|
195
|
+
return
|
|
196
|
+
write_private_bytes(path, canonical_bytes(value))
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Prepare connected source deployments without constructing or trusting a release kit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import stat
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fdai_deployment_cli.contracts import canonical_bytes, canonical_digest, load_json_object
|
|
12
|
+
from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
|
|
13
|
+
from fdai_deployment_cli.runtime_profile import RuntimeDeploymentProfile
|
|
14
|
+
from fdai_deployment_cli.source_input import inspect_source
|
|
15
|
+
from fdai_deployment_cli.source_snapshot import materialize_source, verify_source_snapshot
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def prepare_source_deployment(
|
|
19
|
+
*,
|
|
20
|
+
source_root: Path,
|
|
21
|
+
work_dir: Path,
|
|
22
|
+
runtime_profile: RuntimeDeploymentProfile,
|
|
23
|
+
region: str,
|
|
24
|
+
monthly_cost_ceiling: int,
|
|
25
|
+
hub_selection_digest: str | None = None,
|
|
26
|
+
) -> dict[str, object]:
|
|
27
|
+
"""Seal and reverify one connected dev source run without cloud access or execution.
|
|
28
|
+
|
|
29
|
+
A mode-specific work directory cannot adopt kit state, another source or runtime
|
|
30
|
+
profile. A partial snapshot is retained and blocked rather than silently replaced.
|
|
31
|
+
The receipt proves local preparation only and never grants apply or Trial authority.
|
|
32
|
+
"""
|
|
33
|
+
if runtime_profile.runtime_platform.value != "aks" or (
|
|
34
|
+
runtime_profile.database_placement.value not in {"postgres-flex", "postgres-aks"}
|
|
35
|
+
):
|
|
36
|
+
raise ValueError("source deployment currently requires AKS")
|
|
37
|
+
if re.fullmatch(r"[a-z][a-z0-9]+", region) is None:
|
|
38
|
+
raise ValueError("source deployment region is invalid")
|
|
39
|
+
if type(monthly_cost_ceiling) is not int or monthly_cost_ceiling <= 0:
|
|
40
|
+
raise ValueError("source deployment cost review ceiling must be positive")
|
|
41
|
+
source = inspect_source(source_root)
|
|
42
|
+
work_dir = work_dir.absolute()
|
|
43
|
+
if (work_dir / "hub-images").exists() and hub_selection_digest is None:
|
|
44
|
+
raise ValueError("source builds cannot reuse a Hub-image work directory")
|
|
45
|
+
if hub_selection_digest is not None:
|
|
46
|
+
from fdai_deployment_cli.hub_image_trust import load_selection
|
|
47
|
+
|
|
48
|
+
selection = load_selection(work_dir / "hub-images/selection.json", hub_selection_digest)
|
|
49
|
+
if selection.source_commit != source.commit:
|
|
50
|
+
raise ValueError("Hub images and source support revisions differ")
|
|
51
|
+
if work_dir.resolve().is_relative_to(source.root):
|
|
52
|
+
raise ValueError("source deployment work directory must be outside the selected checkout")
|
|
53
|
+
work_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
54
|
+
details = work_dir.lstat()
|
|
55
|
+
if (
|
|
56
|
+
not stat.S_ISDIR(details.st_mode)
|
|
57
|
+
or details.st_uid != os.geteuid()
|
|
58
|
+
or stat.S_IMODE(details.st_mode) != 0o700
|
|
59
|
+
):
|
|
60
|
+
raise ValueError("source work directory must be a private owned directory")
|
|
61
|
+
from fdai_deployment_cli.private_output import _open_private_parent
|
|
62
|
+
|
|
63
|
+
parent = _open_private_parent(work_dir / "source.lock")
|
|
64
|
+
try:
|
|
65
|
+
lock = os.open(
|
|
66
|
+
"source.lock",
|
|
67
|
+
os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK,
|
|
68
|
+
0o600,
|
|
69
|
+
dir_fd=parent,
|
|
70
|
+
)
|
|
71
|
+
finally:
|
|
72
|
+
os.close(parent)
|
|
73
|
+
try:
|
|
74
|
+
lock_stat = os.fstat(lock)
|
|
75
|
+
if (
|
|
76
|
+
not stat.S_ISREG(lock_stat.st_mode)
|
|
77
|
+
or lock_stat.st_uid != os.geteuid()
|
|
78
|
+
or lock_stat.st_nlink != 1
|
|
79
|
+
or stat.S_IMODE(lock_stat.st_mode) != 0o600
|
|
80
|
+
):
|
|
81
|
+
raise ValueError("source deployment lock identity is invalid")
|
|
82
|
+
try:
|
|
83
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
84
|
+
except BlockingIOError:
|
|
85
|
+
raise ValueError("source deployment preparation is already active") from None
|
|
86
|
+
intent = {
|
|
87
|
+
"schema_version": "fdai.source-deployment-intent.v1",
|
|
88
|
+
"source": source.to_mapping(),
|
|
89
|
+
"source_input_digest": source.digest,
|
|
90
|
+
"runtime_profile": runtime_profile.to_mapping(),
|
|
91
|
+
"environment": "dev",
|
|
92
|
+
"region": region,
|
|
93
|
+
"monthly_cost_ceiling": monthly_cost_ceiling,
|
|
94
|
+
}
|
|
95
|
+
if hub_selection_digest is not None:
|
|
96
|
+
intent["hub_selection_digest"] = hub_selection_digest
|
|
97
|
+
intent["artifact_source"] = "hub-signed-images"
|
|
98
|
+
intent_path = work_dir / "source-intent.json"
|
|
99
|
+
if intent_path.exists():
|
|
100
|
+
prior = load_json_object(
|
|
101
|
+
read_private_bytes(intent_path, max_bytes=65_536), label="source intent"
|
|
102
|
+
)
|
|
103
|
+
if prior != intent:
|
|
104
|
+
changed = sorted(
|
|
105
|
+
key for key in set(prior) | set(intent) if prior.get(key) != intent.get(key)
|
|
106
|
+
)
|
|
107
|
+
raise ValueError(
|
|
108
|
+
"retained source deployment intent differs; preserve the existing run. "
|
|
109
|
+
f"Repeat the original values for: {', '.join(changed)}"
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
expected_entries = {"source.lock"}
|
|
113
|
+
if hub_selection_digest is not None:
|
|
114
|
+
expected_entries.add("hub-images")
|
|
115
|
+
if {path.name for path in work_dir.iterdir()} != expected_entries:
|
|
116
|
+
raise ValueError("source deployment cannot adopt existing or partial work state")
|
|
117
|
+
write_private_bytes(intent_path, canonical_bytes(intent))
|
|
118
|
+
receipt_path = work_dir / "source-preparation.json"
|
|
119
|
+
snapshot = work_dir / "source-snapshot"
|
|
120
|
+
if receipt_path.exists():
|
|
121
|
+
receipt = load_json_object(
|
|
122
|
+
read_private_bytes(receipt_path, max_bytes=65_536), label="source preparation"
|
|
123
|
+
)
|
|
124
|
+
digest = receipt.pop("receipt_digest", None)
|
|
125
|
+
if canonical_digest(receipt) != digest or receipt.get(
|
|
126
|
+
"intent_digest"
|
|
127
|
+
) != canonical_digest(intent):
|
|
128
|
+
raise ValueError("source preparation receipt binding is invalid")
|
|
129
|
+
snapshot_digest = receipt.get("source_snapshot_digest")
|
|
130
|
+
if not isinstance(snapshot_digest, str):
|
|
131
|
+
raise ValueError("source snapshot digest is unavailable")
|
|
132
|
+
if (
|
|
133
|
+
verify_source_snapshot(snapshot, expected_digest=snapshot_digest)
|
|
134
|
+
!= source.to_mapping()
|
|
135
|
+
):
|
|
136
|
+
raise ValueError("source snapshot differs from the selected checkout")
|
|
137
|
+
else:
|
|
138
|
+
if snapshot.exists() or snapshot.is_symlink():
|
|
139
|
+
raise ValueError(
|
|
140
|
+
"incomplete source snapshot requires review; no automatic replacement"
|
|
141
|
+
)
|
|
142
|
+
snapshot_digest = materialize_source(source, snapshot)
|
|
143
|
+
result: dict[str, object] = {
|
|
144
|
+
"schema_version": "fdai.source-deployment-preparation.v1",
|
|
145
|
+
"state": "prepared",
|
|
146
|
+
"provenance": "operator-selected-source",
|
|
147
|
+
"source_commit": source.commit,
|
|
148
|
+
"source_input_digest": source.digest,
|
|
149
|
+
"source_snapshot_digest": snapshot_digest,
|
|
150
|
+
"intent_digest": canonical_digest(intent),
|
|
151
|
+
"runtime_profile_digest": runtime_profile.digest,
|
|
152
|
+
"release_signature_verified": False,
|
|
153
|
+
"apply_authorized": False,
|
|
154
|
+
"mutation_performed": False,
|
|
155
|
+
"deployment_ready": False,
|
|
156
|
+
"subscription_ready": False,
|
|
157
|
+
}
|
|
158
|
+
if hub_selection_digest is not None:
|
|
159
|
+
result["hub_selection_digest"] = hub_selection_digest
|
|
160
|
+
result["image_artifact_source"] = "hub-signed-images"
|
|
161
|
+
result["image_signature_verified"] = True
|
|
162
|
+
result["receipt_digest"] = canonical_digest(result)
|
|
163
|
+
source.reverify()
|
|
164
|
+
if not receipt_path.exists():
|
|
165
|
+
write_private_bytes(receipt_path, canonical_bytes(result))
|
|
166
|
+
elif read_private_bytes(receipt_path, max_bytes=65_536) != canonical_bytes(result):
|
|
167
|
+
raise ValueError("retained source preparation receipt differs")
|
|
168
|
+
return result
|
|
169
|
+
finally:
|
|
170
|
+
os.close(lock)
|