prefect-deployments-toolkit 0.0.1__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.
- prefect_deployments_toolkit/__main__.py +145 -0
- prefect_deployments_toolkit/deployment.py +250 -0
- prefect_deployments_toolkit/deployment_loader.py +139 -0
- prefect_deployments_toolkit/get_modified_deployments.py +110 -0
- prefect_deployments_toolkit/log_buffer.py +112 -0
- prefect_deployments_toolkit/prefect_api.py +291 -0
- prefect_deployments_toolkit/prefect_cli.py +56 -0
- prefect_deployments_toolkit/prefect_rest.py +286 -0
- prefect_deployments_toolkit/yaml_utils.py +156 -0
- prefect_deployments_toolkit-0.0.1.dist-info/METADATA +114 -0
- prefect_deployments_toolkit-0.0.1.dist-info/RECORD +14 -0
- prefect_deployments_toolkit-0.0.1.dist-info/WHEEL +4 -0
- prefect_deployments_toolkit-0.0.1.dist-info/entry_points.txt +2 -0
- prefect_deployments_toolkit-0.0.1.dist-info/licenses/LICENSE +132 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Entry point: parse args and apply all deployments concurrently.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python -m apply_deployments \
|
|
5
|
+
--deployment-names "hello_world3,hello_world_4" \
|
|
6
|
+
--enable-schedule false \
|
|
7
|
+
--tag dev \
|
|
8
|
+
--reference feature_branch1 \
|
|
9
|
+
--repo-name edp-flows \
|
|
10
|
+
--custom-image "" \
|
|
11
|
+
--deployments-dir prefect/deployments \
|
|
12
|
+
--dev-work-pool lapp-dev-work-pool-prefect3 \
|
|
13
|
+
--backend cli \
|
|
14
|
+
--enforce-unique-deployment-names false
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import logging
|
|
19
|
+
import sys
|
|
20
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .deployment import DeploymentContext, apply_single_deployment
|
|
24
|
+
from .log_buffer import buffered_deployment_log
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
MAX_WORKERS = 8
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_args() -> argparse.Namespace:
|
|
32
|
+
parser = argparse.ArgumentParser(description="Apply Prefect deployments.")
|
|
33
|
+
parser.add_argument("--deployment-names", required=True,
|
|
34
|
+
help="Comma-separated list of deployment names.")
|
|
35
|
+
parser.add_argument("--enable-schedule", required=True,
|
|
36
|
+
choices=["true", "false"],
|
|
37
|
+
help="Whether to enable schedules after deploy.")
|
|
38
|
+
parser.add_argument("--tag", required=True,
|
|
39
|
+
help="Image/environment tag (e.g. 'dev', 'v1.2.3').")
|
|
40
|
+
parser.add_argument("--reference", required=True,
|
|
41
|
+
help="Git branch or tag reference (e.g. 'main', 'feature_branch1').")
|
|
42
|
+
parser.add_argument("--repo-name", required=True,
|
|
43
|
+
help="Repository name used in job variable paths.")
|
|
44
|
+
parser.add_argument("--custom-image", default="",
|
|
45
|
+
help="Full custom image reference, or empty string to use pool default.")
|
|
46
|
+
parser.add_argument("--deployments-dir", default="deployments",
|
|
47
|
+
help="Path to the deployments directory.")
|
|
48
|
+
parser.add_argument("--dev-work-pool", default="",
|
|
49
|
+
help="Dev work pool name. When set with --tag=dev, activates dev overrides.")
|
|
50
|
+
parser.add_argument("--max-workers", type=int, default=MAX_WORKERS,
|
|
51
|
+
help=f"Max concurrent deployments (default: {MAX_WORKERS}).")
|
|
52
|
+
parser.add_argument("--backend", default="cli", choices=["cli", "rest"],
|
|
53
|
+
help="Deployment backend: 'cli' (prefect CLI, default) or "
|
|
54
|
+
"'rest' (direct Prefect Cloud REST API calls).")
|
|
55
|
+
parser.add_argument("--enforce-unique-deployment-names", default="false",
|
|
56
|
+
choices=["true", "false"],
|
|
57
|
+
help=(
|
|
58
|
+
"If false (default), only WARN in logs when a deployment name is used by"
|
|
59
|
+
"more than one flow — nothing is deleted. If true, delete every duplicate"
|
|
60
|
+
"deployment record except the one matching the flow currently resolved from"
|
|
61
|
+
"its entrypoint, enforcing globally unique deployment names."
|
|
62
|
+
))
|
|
63
|
+
return parser.parse_args()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _run_deployment(
|
|
67
|
+
deployment_name: str,
|
|
68
|
+
index: int,
|
|
69
|
+
total: int,
|
|
70
|
+
ctx: DeploymentContext,
|
|
71
|
+
) -> str | None:
|
|
72
|
+
"""Run a single deployment inside a buffered log context.
|
|
73
|
+
|
|
74
|
+
Returns the deployment_name if it failed, None on success.
|
|
75
|
+
"""
|
|
76
|
+
separator = "#" * 60
|
|
77
|
+
with buffered_deployment_log(deployment_name):
|
|
78
|
+
logger.info("%s [%d/%d] %s %s", separator, index, total, deployment_name, separator)
|
|
79
|
+
try:
|
|
80
|
+
apply_single_deployment(deployment_name, ctx)
|
|
81
|
+
logger.info(">>> DONE [%d/%d] %s", index, total, deployment_name)
|
|
82
|
+
return None
|
|
83
|
+
except Exception as exc: # noqa: BLE001
|
|
84
|
+
logger.error(">>> FAILED [%d/%d] %s: %s", index, total, deployment_name, exc)
|
|
85
|
+
return deployment_name
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main() -> None:
|
|
89
|
+
logging.basicConfig(
|
|
90
|
+
level=logging.INFO,
|
|
91
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
92
|
+
)
|
|
93
|
+
args = _parse_args()
|
|
94
|
+
names = [n.strip() for n in args.deployment_names.split(",") if n.strip()]
|
|
95
|
+
|
|
96
|
+
if not names:
|
|
97
|
+
logger.error("No deployment names provided.")
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
100
|
+
ctx = DeploymentContext(
|
|
101
|
+
deployment_names=names,
|
|
102
|
+
enable_schedule=args.enable_schedule == "true",
|
|
103
|
+
tag=args.tag,
|
|
104
|
+
reference=args.reference,
|
|
105
|
+
repo_name=args.repo_name,
|
|
106
|
+
custom_image=args.custom_image,
|
|
107
|
+
deployments_dir=Path(args.deployments_dir),
|
|
108
|
+
dev_prefect_work_pool_name=args.dev_work_pool,
|
|
109
|
+
backend=args.backend,
|
|
110
|
+
enforce_unique_deployment_names=args.enforce_unique_deployment_names == "true",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
total = len(names)
|
|
114
|
+
workers = min(args.max_workers, total)
|
|
115
|
+
logger.info(
|
|
116
|
+
"Applying %d deployment(s) with up to %d concurrent workers (backend=%s).",
|
|
117
|
+
total, workers, ctx.backend,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
failed: list[str] = []
|
|
121
|
+
|
|
122
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
123
|
+
futures = {
|
|
124
|
+
executor.submit(_run_deployment, name, i, total, ctx): name
|
|
125
|
+
for i, name in enumerate(names, start=1)
|
|
126
|
+
}
|
|
127
|
+
for future in as_completed(futures):
|
|
128
|
+
result = future.result() # re-raises only unexpected executor errors
|
|
129
|
+
if result is not None:
|
|
130
|
+
failed.append(result)
|
|
131
|
+
|
|
132
|
+
if failed:
|
|
133
|
+
logger.error(
|
|
134
|
+
"%d deployment(s) failed: %s",
|
|
135
|
+
len(failed),
|
|
136
|
+
", ".join(failed),
|
|
137
|
+
)
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
|
|
140
|
+
logger.info("All %d deployment(s) applied successfully.", total)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
main()
|
|
145
|
+
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Core logic for applying a single Prefect deployment."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import tempfile
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import prefect_api, prefect_cli, prefect_rest, yaml_utils
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
DEV_PREFIX = "dev--"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class DeploymentContext:
|
|
18
|
+
"""All runtime configuration for the apply run."""
|
|
19
|
+
|
|
20
|
+
deployment_names: list[str]
|
|
21
|
+
enable_schedule: bool
|
|
22
|
+
tag: str
|
|
23
|
+
reference: str
|
|
24
|
+
repo_name: str
|
|
25
|
+
custom_image: str
|
|
26
|
+
deployments_dir: Path
|
|
27
|
+
dev_prefect_work_pool_name: str = ""
|
|
28
|
+
backend: str = "cli" # "cli" or "rest"
|
|
29
|
+
enforce_unique_deployment_names: bool = False
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_dev(self) -> bool:
|
|
33
|
+
return bool(self.dev_prefect_work_pool_name) and self.tag == "dev"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_non_default_branch(self) -> bool:
|
|
37
|
+
return self.reference not in {"main", "master"}
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def client(self):
|
|
41
|
+
"""Return the backend module (prefect_cli or prefect_rest) for this run."""
|
|
42
|
+
return prefect_rest if self.backend == "rest" else prefect_cli
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resolve_flow_name(deployment_name: str, ctx: DeploymentContext) -> tuple[str, str | None]:
|
|
46
|
+
"""Return (flow_id, flow_name) for a deployment.
|
|
47
|
+
|
|
48
|
+
If more than one flow currently has a deployment with this name:
|
|
49
|
+
- default (enforce_unique_deployment_names=False): log a warning
|
|
50
|
+
listing every duplicate flow, and proceed using the FIRST match
|
|
51
|
+
returned by the API. Nothing is deleted.
|
|
52
|
+
- enforce mode (enforce_unique_deployment_names=True): proceed the
|
|
53
|
+
same way for now — the actual cleanup of duplicates happens AFTER
|
|
54
|
+
deploy, in apply_single_deployment, once we know the flow this
|
|
55
|
+
entrypoint currently resolves to.
|
|
56
|
+
"""
|
|
57
|
+
flow_ids = prefect_api.get_flow_ids_for_deployment(deployment_name)
|
|
58
|
+
|
|
59
|
+
if len(flow_ids) <= 1:
|
|
60
|
+
flow_id = flow_ids[0] if flow_ids else ""
|
|
61
|
+
flow_name = prefect_api.get_flow_name(flow_id) if flow_id else None
|
|
62
|
+
return flow_id, flow_name
|
|
63
|
+
|
|
64
|
+
flow_names = [prefect_api.get_flow_name(fid) for fid in flow_ids]
|
|
65
|
+
logger.warning(
|
|
66
|
+
"Deployment name '%s' is currently used by %d different flows: %s. "
|
|
67
|
+
"Deployment names should be globally unique.",
|
|
68
|
+
deployment_name, len(flow_ids), flow_names,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if not ctx.enforce_unique_deployment_names:
|
|
72
|
+
logger.warning(
|
|
73
|
+
"enforce-unique-deployment-names is OFF — proceeding with flow '%s' "
|
|
74
|
+
"(first match) without deleting the others. Pass "
|
|
75
|
+
"--enforce-unique-deployment-names true to clean up duplicates.",
|
|
76
|
+
flow_names[0],
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
logger.warning(
|
|
80
|
+
"enforce-unique-deployment-names is ON — duplicates not matching the "
|
|
81
|
+
"flow resolved from this deployment's entrypoint will be deleted after deploy."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return flow_ids[0], flow_names[0]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _cleanup_duplicate_deployments(
|
|
88
|
+
ctx: DeploymentContext,
|
|
89
|
+
deployment_name: str,
|
|
90
|
+
current_flow_id: str,
|
|
91
|
+
current_flow_name: str,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Delete every deployment named `deployment_name` under a flow OTHER than
|
|
94
|
+
current_flow_id. Only called when ctx.enforce_unique_deployment_names=True.
|
|
95
|
+
"""
|
|
96
|
+
flow_ids = prefect_api.get_flow_ids_for_deployment(deployment_name)
|
|
97
|
+
stale_flow_ids = [fid for fid in flow_ids if fid != current_flow_id]
|
|
98
|
+
|
|
99
|
+
for stale_flow_id in stale_flow_ids:
|
|
100
|
+
stale_flow_name = prefect_api.get_flow_name(stale_flow_id)
|
|
101
|
+
logger.warning(
|
|
102
|
+
"Deleting duplicate deployment '%s' under flow '%s' (flow_id=%s) — "
|
|
103
|
+
"keeping the one under current flow '%s' (flow_id=%s).",
|
|
104
|
+
deployment_name, stale_flow_name, stale_flow_id,
|
|
105
|
+
current_flow_name, current_flow_id,
|
|
106
|
+
)
|
|
107
|
+
ctx.client.delete_deployment(f"{stale_flow_name}/{deployment_name}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _build_tags(ctx: DeploymentContext, merged_file: Path, full_name: str) -> list[str]:
|
|
111
|
+
tags = [ctx.tag, ctx.reference]
|
|
112
|
+
tags += yaml_utils.get_deployment_tags(merged_file, full_name)
|
|
113
|
+
return tags
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _build_job_variables(
|
|
117
|
+
ctx: DeploymentContext,
|
|
118
|
+
merged_file: Path,
|
|
119
|
+
full_name: str,
|
|
120
|
+
) -> dict[str, str]:
|
|
121
|
+
job_vars: dict[str, str] = {"name": full_name}
|
|
122
|
+
|
|
123
|
+
if ctx.is_non_default_branch:
|
|
124
|
+
base = f"/opt/prefect/{ctx.repo_name}-{ctx.reference}"
|
|
125
|
+
job_vars["DBT_PROJECT_DIR"] = f"{base}/src/edp_flows/models"
|
|
126
|
+
job_vars["DBT_PROFILES_DIR"] = f"{base}/src/edp_flows/models"
|
|
127
|
+
job_vars["METRICS_EXPORTER_DIR"] = f"{base}/etc"
|
|
128
|
+
|
|
129
|
+
if ctx.custom_image:
|
|
130
|
+
job_vars["image"] = ctx.custom_image
|
|
131
|
+
|
|
132
|
+
# Merge job_variables from YAML; image from YAML only wins if not already set above
|
|
133
|
+
yaml_job_vars = yaml_utils.get_job_variables(merged_file, full_name)
|
|
134
|
+
for key, value in yaml_job_vars.items():
|
|
135
|
+
if key == "image" and "image" in job_vars:
|
|
136
|
+
continue
|
|
137
|
+
job_vars[key] = str(value)
|
|
138
|
+
|
|
139
|
+
return job_vars
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _handle_schedules(
|
|
143
|
+
ctx: DeploymentContext,
|
|
144
|
+
merged_file: Path,
|
|
145
|
+
full_name: str,
|
|
146
|
+
flow_name: str,
|
|
147
|
+
) -> None:
|
|
148
|
+
deployment_has_schedules = yaml_utils.has_schedules(merged_file, full_name)
|
|
149
|
+
|
|
150
|
+
if not deployment_has_schedules:
|
|
151
|
+
logger.info("No schedules in YAML — removing any existing schedules from Prefect Cloud...")
|
|
152
|
+
schedule_ids = prefect_api.get_schedule_ids(f"{flow_name}/{full_name}")
|
|
153
|
+
for sid in schedule_ids:
|
|
154
|
+
ctx.client.delete_schedule(f"{flow_name}/{full_name}", sid)
|
|
155
|
+
if not schedule_ids:
|
|
156
|
+
logger.info("No existing schedules to remove.")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
if ctx.enable_schedule:
|
|
160
|
+
logger.info("Resuming all schedules for '%s'...", full_name)
|
|
161
|
+
schedule_ids = prefect_api.get_schedule_ids(f"{flow_name}/{full_name}")
|
|
162
|
+
for sid in schedule_ids:
|
|
163
|
+
ctx.client.resume_schedule(f"{flow_name}/{full_name}", sid)
|
|
164
|
+
else:
|
|
165
|
+
logger.info("Schedules exist in YAML but enable_schedule=false — will remain paused.")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def remove_deployment(ctx: DeploymentContext, deployment_name: str, full_name: str, flow_name: str | None) -> None:
|
|
169
|
+
"""Delete a deployment that no longer exists in the YAML files."""
|
|
170
|
+
logger.info("Deployment '%s' has been removed from YAML.", deployment_name)
|
|
171
|
+
if flow_name:
|
|
172
|
+
ctx.client.delete_deployment(f"{flow_name}/{full_name}")
|
|
173
|
+
else:
|
|
174
|
+
logger.info(
|
|
175
|
+
"Deployment '%s' does not exist on Prefect Cloud (no matching flow). Skipping.",
|
|
176
|
+
full_name,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def apply_single_deployment(deployment_name: str, ctx: DeploymentContext) -> None:
|
|
181
|
+
"""Apply (create/update) or remove a single deployment."""
|
|
182
|
+
yaml_file = yaml_utils.find_deployment_file(deployment_name, ctx.deployments_dir)
|
|
183
|
+
prefix = DEV_PREFIX if ctx.is_dev else ""
|
|
184
|
+
full_name = f"{prefix}{deployment_name}"
|
|
185
|
+
|
|
186
|
+
flow_id, flow_name = _resolve_flow_name(full_name, ctx)
|
|
187
|
+
|
|
188
|
+
if yaml_file is None:
|
|
189
|
+
remove_deployment(ctx, deployment_name, full_name, flow_name)
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tmp:
|
|
193
|
+
merged_file = Path(tmp.name)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
yaml_utils.build_merged_prefect_file(
|
|
197
|
+
ctx.deployments_dir / "prefect_base.yaml",
|
|
198
|
+
yaml_file,
|
|
199
|
+
merged_file,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if ctx.is_dev:
|
|
203
|
+
yaml_utils.apply_dev_overrides(
|
|
204
|
+
merged_file, deployment_name, DEV_PREFIX, ctx.dev_prefect_work_pool_name
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
yaml_utils.validate_schedule(merged_file, full_name)
|
|
208
|
+
|
|
209
|
+
if ctx.is_non_default_branch:
|
|
210
|
+
yaml_utils.set_git_clone_branch(merged_file, ctx.reference)
|
|
211
|
+
|
|
212
|
+
if ctx.enable_schedule and yaml_utils.has_schedules(merged_file, full_name):
|
|
213
|
+
yaml_utils.set_schedules_active(merged_file, full_name, active=True)
|
|
214
|
+
|
|
215
|
+
tags = _build_tags(ctx, merged_file, full_name)
|
|
216
|
+
job_vars = _build_job_variables(ctx, merged_file, full_name)
|
|
217
|
+
logger.info("Tags: %s", tags)
|
|
218
|
+
logger.info("Job variables: %s", job_vars)
|
|
219
|
+
logger.info("Backend: %s", ctx.backend)
|
|
220
|
+
|
|
221
|
+
ctx.client.deploy(full_name, tags, job_vars, merged_file)
|
|
222
|
+
time.sleep(0.01)
|
|
223
|
+
|
|
224
|
+
# Detect flow rename: if flow_id changed post-deploy, clean up the old deployment
|
|
225
|
+
new_flow_ids = prefect_api.get_flow_ids_for_deployment(full_name)
|
|
226
|
+
new_flow_id = new_flow_ids[0] if new_flow_ids else ""
|
|
227
|
+
if flow_id and new_flow_id and new_flow_id != flow_id:
|
|
228
|
+
logger.info("Flow changed post-deploy — removing stale deployment under old flow.")
|
|
229
|
+
ctx.client.delete_deployment(f"{flow_name}/{full_name}")
|
|
230
|
+
flow_name = prefect_api.get_flow_name(new_flow_id)
|
|
231
|
+
|
|
232
|
+
if not flow_name:
|
|
233
|
+
logger.info("New deployment — retrieving flow name from Prefect Cloud post-deploy...")
|
|
234
|
+
post_ids = prefect_api.get_flow_ids_for_deployment(full_name)
|
|
235
|
+
new_flow_id = post_ids[0] if post_ids else ""
|
|
236
|
+
flow_name = prefect_api.get_flow_name(new_flow_id) if new_flow_id else None
|
|
237
|
+
|
|
238
|
+
# If duplicates existed under other flows and enforcement is on,
|
|
239
|
+
# clean up everything except the deployment matching the current flow.
|
|
240
|
+
if ctx.enforce_unique_deployment_names and flow_name and new_flow_id:
|
|
241
|
+
_cleanup_duplicate_deployments(ctx, full_name, new_flow_id, flow_name)
|
|
242
|
+
|
|
243
|
+
if flow_name:
|
|
244
|
+
_handle_schedules(ctx, merged_file, full_name, flow_name)
|
|
245
|
+
else:
|
|
246
|
+
logger.warning("Could not resolve flow name post-deploy — skipping schedule management.")
|
|
247
|
+
|
|
248
|
+
finally:
|
|
249
|
+
merged_file.unlink(missing_ok=True)
|
|
250
|
+
time.sleep(0.01)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Load and normalize Prefect deployment configs from local filesystem or git refs."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# Keys excluded from deployment comparison — they don't affect runtime behaviour
|
|
14
|
+
_IGNORED_KEYS = frozenset({"pull"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def normalize_deployment(deployment: dict) -> dict:
|
|
18
|
+
"""Return deployment config with non-comparable keys removed."""
|
|
19
|
+
return {k: v for k, v in deployment.items() if k not in _IGNORED_KEYS}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_deployments_from_source(
|
|
23
|
+
source: str,
|
|
24
|
+
source_name: str,
|
|
25
|
+
deployments_dir: str = "deployments",
|
|
26
|
+
) -> dict[str, dict]:
|
|
27
|
+
"""Load all deployments from a git ref or the local filesystem.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
source:
|
|
32
|
+
Git reference (e.g. "origin/main", "HEAD~1") or "local" for the
|
|
33
|
+
working-tree filesystem.
|
|
34
|
+
source_name:
|
|
35
|
+
Human-readable label used in log messages.
|
|
36
|
+
deployments_dir:
|
|
37
|
+
Path to the deployments directory.
|
|
38
|
+
|
|
39
|
+
Returns
|
|
40
|
+
-------
|
|
41
|
+
dict mapping deployment name → normalised config dict.
|
|
42
|
+
"""
|
|
43
|
+
logger.info("Getting deployments in %s...", source_name)
|
|
44
|
+
deployments: dict[str, dict] = {}
|
|
45
|
+
is_local = source == "local"
|
|
46
|
+
base_file_path = f"{deployments_dir}/prefect_base.yaml"
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
# --- load prefect_base.yaml ---
|
|
50
|
+
try:
|
|
51
|
+
if is_local:
|
|
52
|
+
base_path = Path(base_file_path)
|
|
53
|
+
base_content = base_path.read_text() if base_path.exists() else ""
|
|
54
|
+
else:
|
|
55
|
+
base_content = subprocess.check_output(
|
|
56
|
+
["git", "show", f"{source}:{base_file_path}"], # noqa: S607
|
|
57
|
+
).decode()
|
|
58
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
59
|
+
logger.warning("prefect_base.yaml not found in %s", source_name)
|
|
60
|
+
base_content = ""
|
|
61
|
+
|
|
62
|
+
# --- enumerate deployment YAML files ---
|
|
63
|
+
if is_local:
|
|
64
|
+
deployment_dir = Path(deployments_dir)
|
|
65
|
+
if not deployment_dir.exists():
|
|
66
|
+
logger.info("No %s folder found in %s", deployments_dir, source_name)
|
|
67
|
+
return deployments
|
|
68
|
+
yaml_files = [
|
|
69
|
+
str(f.relative_to("."))
|
|
70
|
+
for f in deployment_dir.rglob("*.yaml")
|
|
71
|
+
if f.name != "prefect_base.yaml"
|
|
72
|
+
]
|
|
73
|
+
else:
|
|
74
|
+
raw = subprocess.check_output(
|
|
75
|
+
["git", "ls-tree", "-r", "--name-only", source, f"{deployments_dir}/"], # noqa: S607
|
|
76
|
+
stderr=subprocess.DEVNULL,
|
|
77
|
+
).decode().strip().split("\n")
|
|
78
|
+
yaml_files = [
|
|
79
|
+
f for f in raw
|
|
80
|
+
if f
|
|
81
|
+
and f.endswith(".yaml")
|
|
82
|
+
and f.startswith(f"{deployments_dir}/")
|
|
83
|
+
and "prefect_base.yaml" not in f
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
logger.info("Found %d YAML files in %s folder", len(yaml_files), deployments_dir)
|
|
87
|
+
|
|
88
|
+
# --- parse each file ---
|
|
89
|
+
for yaml_file in yaml_files:
|
|
90
|
+
try:
|
|
91
|
+
if is_local:
|
|
92
|
+
deployment_content = Path(yaml_file).read_text()
|
|
93
|
+
else:
|
|
94
|
+
deployment_content = subprocess.check_output(
|
|
95
|
+
["git", "show", f"{source}:{yaml_file}"], # noqa: S607
|
|
96
|
+
).decode()
|
|
97
|
+
|
|
98
|
+
parsed = yaml.safe_load(base_content + "\n" + deployment_content)
|
|
99
|
+
|
|
100
|
+
if isinstance(parsed, dict) and "deployments" in parsed:
|
|
101
|
+
deployment_list = parsed["deployments"]
|
|
102
|
+
elif isinstance(parsed, list):
|
|
103
|
+
deployment_list = parsed
|
|
104
|
+
else:
|
|
105
|
+
deployment_list = None
|
|
106
|
+
|
|
107
|
+
if not isinstance(deployment_list, list):
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
for d in deployment_list:
|
|
111
|
+
if not isinstance(d, dict):
|
|
112
|
+
logger.error(
|
|
113
|
+
"Invalid deployment entry in %s: expected dict, got %s: %s",
|
|
114
|
+
yaml_file, type(d).__name__, d,
|
|
115
|
+
)
|
|
116
|
+
sys.exit(1)
|
|
117
|
+
name = d.get("name")
|
|
118
|
+
if not isinstance(name, str) or not name:
|
|
119
|
+
logger.error(
|
|
120
|
+
"Deployment in %s is missing a valid 'name' field: %s",
|
|
121
|
+
yaml_file, d,
|
|
122
|
+
)
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
deployments[name] = normalize_deployment(d)
|
|
125
|
+
|
|
126
|
+
logger.debug("Loaded %d deployments from %s", len(deployment_list), yaml_file)
|
|
127
|
+
|
|
128
|
+
except yaml.YAMLError as exc:
|
|
129
|
+
logger.error("Invalid YAML syntax in %s: %s", yaml_file, exc)
|
|
130
|
+
sys.exit(1)
|
|
131
|
+
except (subprocess.CalledProcessError, OSError) as exc:
|
|
132
|
+
logger.warning("Failed to load deployments from %s: %s", yaml_file, exc)
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
except subprocess.CalledProcessError:
|
|
136
|
+
logger.info("No %s folder found in %s", deployments_dir, source_name)
|
|
137
|
+
|
|
138
|
+
logger.debug("Deployments in %s:\n%s", source_name, json.dumps(deployments, indent=2))
|
|
139
|
+
return deployments
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Retrieve all deployments added, modified, or removed in the current branch."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .deployment_loader import get_deployments_from_source
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_base_branch_deployments(
|
|
15
|
+
base_branch: str,
|
|
16
|
+
deployments_dir: str = "deployments",
|
|
17
|
+
) -> dict[str, dict]:
|
|
18
|
+
"""Get all deployments defined in the base branch."""
|
|
19
|
+
try:
|
|
20
|
+
subprocess.run(
|
|
21
|
+
["git", "fetch", "origin", base_branch], # noqa: S607
|
|
22
|
+
check=True,
|
|
23
|
+
stderr=subprocess.DEVNULL,
|
|
24
|
+
)
|
|
25
|
+
except subprocess.CalledProcessError:
|
|
26
|
+
logger.warning("Failed to fetch base branch '%s'", base_branch)
|
|
27
|
+
return get_deployments_from_source(
|
|
28
|
+
f"origin/{base_branch}",
|
|
29
|
+
f"the base branch '{base_branch}'",
|
|
30
|
+
deployments_dir,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_pr_branch_deployments(deployments_dir: str = "deployments") -> dict[str, dict]:
|
|
35
|
+
"""Get all deployments defined in the current branch (local filesystem)."""
|
|
36
|
+
return get_deployments_from_source("local", "the current branch", deployments_dir)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_previous_commit_deployments(deployments_dir: str = "deployments") -> dict[str, dict]:
|
|
40
|
+
"""Get all deployments defined in the previous commit (HEAD~1)."""
|
|
41
|
+
result = subprocess.run(
|
|
42
|
+
["git", "rev-parse", "--verify", "HEAD~1"],
|
|
43
|
+
capture_output=True,
|
|
44
|
+
)
|
|
45
|
+
if result.returncode != 0:
|
|
46
|
+
logger.warning("HEAD~1 does not exist (possibly initial commit). Returning empty.")
|
|
47
|
+
return {}
|
|
48
|
+
return get_deployments_from_source("HEAD~1", "the previous commit", deployments_dir)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main() -> None:
|
|
52
|
+
logging.basicConfig(level=logging.INFO)
|
|
53
|
+
|
|
54
|
+
parser = argparse.ArgumentParser()
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--modified-by",
|
|
57
|
+
choices=["pull_request_target", "pull_request", "push"],
|
|
58
|
+
help="GitHub Actions event type driving this run.",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--base-ref",
|
|
62
|
+
required=True,
|
|
63
|
+
help="Base branch name (e.g. master, main, rc_3.3.1).",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--deployments-dir",
|
|
67
|
+
default="deployments",
|
|
68
|
+
help="Path to the deployments directory (default: deployments).",
|
|
69
|
+
)
|
|
70
|
+
args = parser.parse_args()
|
|
71
|
+
|
|
72
|
+
is_commit_compare = args.modified_by in ("pull_request_target", "push")
|
|
73
|
+
|
|
74
|
+
previous = (
|
|
75
|
+
get_previous_commit_deployments(args.deployments_dir)
|
|
76
|
+
if is_commit_compare
|
|
77
|
+
else get_base_branch_deployments(args.base_ref, args.deployments_dir)
|
|
78
|
+
)
|
|
79
|
+
current = (
|
|
80
|
+
get_base_branch_deployments(args.base_ref, args.deployments_dir)
|
|
81
|
+
if is_commit_compare
|
|
82
|
+
else get_pr_branch_deployments(args.deployments_dir)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
new_or_modified = [
|
|
86
|
+
name for name, cfg in current.items()
|
|
87
|
+
if name not in previous or cfg != previous[name]
|
|
88
|
+
]
|
|
89
|
+
removed = [name for name in previous if name not in current]
|
|
90
|
+
|
|
91
|
+
if not new_or_modified and not removed:
|
|
92
|
+
logger.info("No new, modified, or removed deployments found.")
|
|
93
|
+
sys.exit(0)
|
|
94
|
+
|
|
95
|
+
all_changed = ",".join(new_or_modified + removed)
|
|
96
|
+
logger.info("Found the following new, modified, or removed deployments: '%s'.", all_changed)
|
|
97
|
+
logger.info("New or modified: '%s'.", ",".join(new_or_modified))
|
|
98
|
+
logger.info("Removed: '%s'.", ",".join(removed))
|
|
99
|
+
print(all_changed)
|
|
100
|
+
|
|
101
|
+
github_env = os.getenv("GITHUB_ENV")
|
|
102
|
+
if os.getenv("GITHUB_ACTION") and github_env:
|
|
103
|
+
with open(github_env, "a") as f:
|
|
104
|
+
f.write(f"DEPLOYMENT_NAMES={all_changed}\n")
|
|
105
|
+
f.write(f"NEW_OR_MODIFIED_DEPLOYMENT_NAMES={','.join(new_or_modified)}\n")
|
|
106
|
+
f.write(f"REMOVED_DEPLOYMENT_NAMES={','.join(removed)}\n")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
main()
|