taskfile 0.3.77__tar.gz → 0.3.78__tar.gz
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.
- {taskfile-0.3.77/src/taskfile.egg-info → taskfile-0.3.78}/PKG-INFO +1 -1
- {taskfile-0.3.77 → taskfile-0.3.78}/pyproject.toml +1 -1
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/__init__.py +1 -1
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/deploy_recipes.py +64 -11
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/diagnostics/__init__.py +2 -2
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/diagnostics/llm_repair.py +0 -46
- taskfile-0.3.78/src/taskfile/models/__init__.py +29 -0
- taskfile-0.3.77/src/taskfile/models.py → taskfile-0.3.78/src/taskfile/models/config.py +4 -237
- taskfile-0.3.78/src/taskfile/models/environment.py +107 -0
- taskfile-0.3.78/src/taskfile/models/pipeline.py +81 -0
- taskfile-0.3.78/src/taskfile/models/task.py +68 -0
- {taskfile-0.3.77 → taskfile-0.3.78/src/taskfile.egg-info}/PKG-INFO +1 -1
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile.egg-info/SOURCES.txt +6 -1
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_doctor_e2e.py +0 -212
- taskfile-0.3.78/tests/test_graceful_restart.py +259 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/LICENSE +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/README.md +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/setup.cfg +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/__main__.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/addons/__init__.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/addons/monitoring.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/addons/postgres.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/addons/redis_addon.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/api/__init__.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/api/app.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/api/models.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cache.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cigen/__init__.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cigen/base.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cigen/drone.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cigen/gitea.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cigen/github.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cigen/gitlab.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cigen/jenkins.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cigen/makefile.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cirunner.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/__init__.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/api_cmd.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/auth.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/cache_cmds.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/ci.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/click_compat.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/completion.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/deploy.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/diagnostics.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/docker_cmds.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/e2e_cmd.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/explain_cmd.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/fleet.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/health.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/import_export.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/info_cmd.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/interactive/__init__.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/interactive/menu.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/interactive/wizards.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/main.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/quadlet.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/registry_cmds.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/release.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/setup.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/version.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/compose.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/converters.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/deploy_utils.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/diagnostics/checks.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/diagnostics/checks_ports.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/diagnostics/checks_ssh.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/diagnostics/fixes.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/diagnostics/models.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/diagnostics/report.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/fleet.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/graph.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/health.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/importer.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/landing.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/notifications.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/parser.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/provisioner.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/quadlet.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/registry.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/__init__.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/classifier.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/commands.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/core.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/error_presenter.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/explainer.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/functions.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/resolver.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/ssh.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/__init__.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/codereview.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/full.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/minimal.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/multiplatform.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/podman.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/publish.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/codereview.yml +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/full.yml +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/iot.yml +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/kubernetes.yml +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/minimal.yml +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/multiplatform.yml +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/podman.yml +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/publish.yml +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/saas.yml +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/terraform.yml +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/web.yml +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/web.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/ssh.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/watch.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/webui/__init__.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/webui/dashboard.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/webui/handlers.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/webui/server.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile.egg-info/dependency_links.txt +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile.egg-info/entry_points.txt +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile.egg-info/requires.txt +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile.egg-info/top_level.txt +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_api.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_auth.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_cigen.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_classifier.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_cli.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_compose.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_deploy_validation.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_diagnostics.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_docker_e2e.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_doctor_decomposition.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_dsl_commands.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_e2e_examples.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_fleet.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_health.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_landing.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_models.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_parser.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_provisioner.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_quadlet.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_release.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_resolver.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_runner.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_scaffold.py +0 -0
- {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: taskfile
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.78
|
|
4
4
|
Summary: Universal Taskfile runner with multi-environment deploy support. CI/CD agnostic — run locally or from any pipeline.
|
|
5
5
|
Author-email: Tom Sapletta <tom@sapletta.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "taskfile"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.78"
|
|
8
8
|
description = "Universal Taskfile runner with multi-environment deploy support. CI/CD agnostic — run locally or from any pipeline."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -41,6 +41,7 @@ def expand_deploy_recipe(deploy_section: dict[str, Any], variables: dict[str, st
|
|
|
41
41
|
health_retries = deploy_section.get("health_retries", 5)
|
|
42
42
|
health_delay = deploy_section.get("health_delay", 5)
|
|
43
43
|
rollback_mode = deploy_section.get("rollback", "manual") # auto | manual
|
|
44
|
+
restart_delay = deploy_section.get("restart_delay", 3) # seconds between stop→start
|
|
44
45
|
tag_var = "${TAG}"
|
|
45
46
|
|
|
46
47
|
tasks: dict[str, dict] = {}
|
|
@@ -111,9 +112,9 @@ def expand_deploy_recipe(deploy_section: dict[str, Any], variables: dict[str, st
|
|
|
111
112
|
if strategy == "compose":
|
|
112
113
|
tasks["deploy"] = _compose_deploy(deploy_deps)
|
|
113
114
|
elif strategy == "quadlet":
|
|
114
|
-
tasks["deploy"] = _quadlet_deploy(deploy_deps, images, registry, tag_var)
|
|
115
|
+
tasks["deploy"] = _quadlet_deploy(deploy_deps, images, registry, tag_var, restart_delay)
|
|
115
116
|
elif strategy == "ssh-push":
|
|
116
|
-
tasks["deploy"] = _ssh_push_deploy(deploy_deps, images, registry, tag_var)
|
|
117
|
+
tasks["deploy"] = _ssh_push_deploy(deploy_deps, images, registry, tag_var, restart_delay)
|
|
117
118
|
else:
|
|
118
119
|
tasks["deploy"] = _compose_deploy(deploy_deps)
|
|
119
120
|
|
|
@@ -129,6 +130,16 @@ def expand_deploy_recipe(deploy_section: dict[str, Any], variables: dict[str, st
|
|
|
129
130
|
],
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
# ── Post-deploy health gate ──
|
|
134
|
+
tasks["post-deploy"] = {
|
|
135
|
+
"desc": "Post-deploy health gate — verify all services are healthy after deploy",
|
|
136
|
+
"tags": ["deploy", "health"],
|
|
137
|
+
"silent": True,
|
|
138
|
+
"retries": health_retries,
|
|
139
|
+
"retry_delay": health_delay,
|
|
140
|
+
"cmds": _post_deploy_health_cmds(images, health_check, registry, tag_var),
|
|
141
|
+
}
|
|
142
|
+
|
|
132
143
|
# ── Rollback ──
|
|
133
144
|
if images:
|
|
134
145
|
rollback_cmds = []
|
|
@@ -136,7 +147,9 @@ def expand_deploy_recipe(deploy_section: dict[str, Any], variables: dict[str, st
|
|
|
136
147
|
image_var = f"{registry}/{svc_name}:{tag_var}"
|
|
137
148
|
rollback_cmds.append(f"@remote podman pull {image_var}")
|
|
138
149
|
for svc_name in images:
|
|
139
|
-
rollback_cmds.
|
|
150
|
+
rollback_cmds.extend(
|
|
151
|
+
_graceful_restart_cmds(svc_name, restart_delay)
|
|
152
|
+
)
|
|
140
153
|
tasks["rollback"] = {
|
|
141
154
|
"desc": "Rollback to specified version (--var TAG=<prev>)",
|
|
142
155
|
"tags": ["deploy", "rollback"],
|
|
@@ -146,6 +159,38 @@ def expand_deploy_recipe(deploy_section: dict[str, Any], variables: dict[str, st
|
|
|
146
159
|
return tasks
|
|
147
160
|
|
|
148
161
|
|
|
162
|
+
def _graceful_restart_cmds(svc_name: str, restart_delay: int = 3) -> list[str]:
|
|
163
|
+
"""Generate graceful restart commands for a single service.
|
|
164
|
+
|
|
165
|
+
Pattern: stop → sleep(delay) → start
|
|
166
|
+
This avoids the hard restart that causes dropped connections.
|
|
167
|
+
The delay allows in-flight requests to complete.
|
|
168
|
+
"""
|
|
169
|
+
return [
|
|
170
|
+
f"@remote systemctl --user stop ${{APP_NAME}}-{svc_name}",
|
|
171
|
+
f"sleep {restart_delay}",
|
|
172
|
+
f"@remote systemctl --user start ${{APP_NAME}}-{svc_name}",
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _post_deploy_health_cmds(
|
|
177
|
+
images: dict, health_check: str, registry: str, tag_var: str,
|
|
178
|
+
) -> list[str]:
|
|
179
|
+
"""Generate post-deploy health verification commands."""
|
|
180
|
+
cmds: list[str] = []
|
|
181
|
+
# Check each service container is running
|
|
182
|
+
for svc_name in images:
|
|
183
|
+
cmds.append(
|
|
184
|
+
f"@remote systemctl --user is-active --quiet ${{APP_NAME}}-{svc_name} "
|
|
185
|
+
f"&& echo '{svc_name}: running' || (echo '{svc_name}: NOT RUNNING' && exit 1)"
|
|
186
|
+
)
|
|
187
|
+
# Check HTTP health endpoint
|
|
188
|
+
cmds.append(
|
|
189
|
+
f"curl -sf https://${{DOMAIN}}{health_check} && echo 'Health: OK' || exit 1"
|
|
190
|
+
)
|
|
191
|
+
return cmds
|
|
192
|
+
|
|
193
|
+
|
|
149
194
|
def _compose_deploy(deps: list[str]) -> dict:
|
|
150
195
|
"""Generate compose-based deploy task."""
|
|
151
196
|
return {
|
|
@@ -161,8 +206,11 @@ def _compose_deploy(deps: list[str]) -> dict:
|
|
|
161
206
|
}
|
|
162
207
|
|
|
163
208
|
|
|
164
|
-
def _quadlet_deploy(
|
|
165
|
-
|
|
209
|
+
def _quadlet_deploy(
|
|
210
|
+
deps: list[str], images: dict, registry: str, tag_var: str,
|
|
211
|
+
restart_delay: int = 3,
|
|
212
|
+
) -> dict:
|
|
213
|
+
"""Generate Quadlet-based deploy task with graceful restart."""
|
|
166
214
|
cmds = [
|
|
167
215
|
"taskfile quadlet generate",
|
|
168
216
|
"taskfile quadlet upload",
|
|
@@ -171,12 +219,13 @@ def _quadlet_deploy(deps: list[str], images: dict, registry: str, tag_var: str)
|
|
|
171
219
|
for svc_name in images:
|
|
172
220
|
image_var = f"{registry}/{svc_name}:{tag_var}"
|
|
173
221
|
cmds.append(f"@remote podman pull {image_var}")
|
|
222
|
+
# Graceful restart: stop → delay → start (one service at a time)
|
|
174
223
|
for svc_name in images:
|
|
175
|
-
cmds.
|
|
224
|
+
cmds.extend(_graceful_restart_cmds(svc_name, restart_delay))
|
|
176
225
|
cmds.append("@remote podman image prune -f")
|
|
177
226
|
|
|
178
227
|
return {
|
|
179
|
-
"desc": "Deploy via Podman Quadlet (generate → upload → pull → restart)",
|
|
228
|
+
"desc": "Deploy via Podman Quadlet (generate → upload → pull → graceful restart)",
|
|
180
229
|
"deps": deps,
|
|
181
230
|
"tags": ["ci", "deploy"],
|
|
182
231
|
"retries": 2,
|
|
@@ -186,18 +235,22 @@ def _quadlet_deploy(deps: list[str], images: dict, registry: str, tag_var: str)
|
|
|
186
235
|
}
|
|
187
236
|
|
|
188
237
|
|
|
189
|
-
def _ssh_push_deploy(
|
|
190
|
-
|
|
238
|
+
def _ssh_push_deploy(
|
|
239
|
+
deps: list[str], images: dict, registry: str, tag_var: str,
|
|
240
|
+
restart_delay: int = 3,
|
|
241
|
+
) -> dict:
|
|
242
|
+
"""Generate simple SSH pull+graceful restart deploy task."""
|
|
191
243
|
cmds = []
|
|
192
244
|
for svc_name in images:
|
|
193
245
|
image_var = f"{registry}/{svc_name}:{tag_var}"
|
|
194
246
|
cmds.append(f"@remote podman pull {image_var}")
|
|
247
|
+
# Graceful restart: stop → delay → start (one service at a time)
|
|
195
248
|
for svc_name in images:
|
|
196
|
-
cmds.
|
|
249
|
+
cmds.extend(_graceful_restart_cmds(svc_name, restart_delay))
|
|
197
250
|
cmds.append("@remote podman image prune -f")
|
|
198
251
|
|
|
199
252
|
return {
|
|
200
|
-
"desc": "Deploy via SSH (pull + restart)",
|
|
253
|
+
"desc": "Deploy via SSH (pull + graceful restart)",
|
|
201
254
|
"deps": deps,
|
|
202
255
|
"tags": ["ci", "deploy"],
|
|
203
256
|
"retries": 1,
|
|
@@ -223,8 +223,8 @@ class ProjectDiagnostics:
|
|
|
223
223
|
|
|
224
224
|
# ─── Layer 4: Algorithmic fix ───
|
|
225
225
|
|
|
226
|
-
def auto_fix(self) -> int:
|
|
227
|
-
fixed_count = apply_fixes(self._issues)
|
|
226
|
+
def auto_fix(self, *, interactive: bool = False) -> int:
|
|
227
|
+
fixed_count = apply_fixes(self._issues, interactive=interactive)
|
|
228
228
|
# Remove fixed issues from legacy list
|
|
229
229
|
fixed_msgs = {i.message for i in self._issues if i.context and i.context.get("_fixed")}
|
|
230
230
|
self.issues = [(m, s, f) for m, s, f in self.issues if m not in fixed_msgs]
|
|
@@ -100,34 +100,6 @@ def classify_runtime_error(
|
|
|
100
100
|
layer=3,
|
|
101
101
|
)
|
|
102
102
|
|
|
103
|
-
# Detect podman/docker pull localhost/ — image not transferred to remote
|
|
104
|
-
if ("connection refused" in stderr_lower and "localhost" in stderr_lower
|
|
105
|
-
and ("pull" in cmd.lower() or "pull" in stderr_lower)):
|
|
106
|
-
# Extract image name from stderr or cmd
|
|
107
|
-
image = _extract_image_name(cmd, stderr)
|
|
108
|
-
return Issue(
|
|
109
|
-
category=IssueCategory.CONFIG_ERROR,
|
|
110
|
-
message=f"Cannot pull '{image}' — no local registry on remote server",
|
|
111
|
-
fix_strategy=FixStrategy.MANUAL,
|
|
112
|
-
severity=SEVERITY_ERROR,
|
|
113
|
-
fix_description=(
|
|
114
|
-
f"Images with 'localhost/' prefix must be transferred before deploy.\n"
|
|
115
|
-
f" Fix: taskfile push {image}\n"
|
|
116
|
-
f" This transfers the image via SSH (docker save | ssh podman load).\n"
|
|
117
|
-
f" Then re-run the deploy."
|
|
118
|
-
),
|
|
119
|
-
teach=(
|
|
120
|
-
"Images prefixed with 'localhost/' are local-only — they exist on your machine "
|
|
121
|
-
"but not on the remote server. 'podman pull localhost/...' on the remote server "
|
|
122
|
-
"tries to contact a local registry there, which doesn't exist.\n\n"
|
|
123
|
-
"**Solution:** Use `taskfile push` to transfer images via SSH before deploying:\n"
|
|
124
|
-
"```\ntaskfile push myapp-web:latest myapp-landing:latest\n```\n"
|
|
125
|
-
"This uses `docker save | ssh podman load` — no registry needed."
|
|
126
|
-
),
|
|
127
|
-
context={"cmd": cmd, "stderr": stderr[:500], "image": image},
|
|
128
|
-
layer=3,
|
|
129
|
-
)
|
|
130
|
-
|
|
131
103
|
if "connection refused" in stderr_lower or "no route to host" in stderr_lower:
|
|
132
104
|
return Issue(
|
|
133
105
|
category=IssueCategory.EXTERNAL_ERROR,
|
|
@@ -193,24 +165,6 @@ def classify_runtime_error(
|
|
|
193
165
|
)
|
|
194
166
|
|
|
195
167
|
|
|
196
|
-
def _extract_image_name(cmd: str, stderr: str) -> str:
|
|
197
|
-
"""Extract Docker/Podman image name from pull command or stderr."""
|
|
198
|
-
import re
|
|
199
|
-
# Try from stderr: "Trying to pull localhost/foo:latest..."
|
|
200
|
-
m = re.search(r'Trying to pull\s+(\S+)', stderr)
|
|
201
|
-
if m:
|
|
202
|
-
return m.group(1).rstrip(".")
|
|
203
|
-
# Try from stderr: "docker://localhost/foo:latest"
|
|
204
|
-
m = re.search(r'docker://(\S+?):', stderr)
|
|
205
|
-
if m:
|
|
206
|
-
return m.group(1)
|
|
207
|
-
# Try from cmd: "podman pull IMAGE" or "docker pull IMAGE"
|
|
208
|
-
m = re.search(r'(?:podman|docker)\s+pull\s+(\S+)', cmd)
|
|
209
|
-
if m:
|
|
210
|
-
return m.group(1)
|
|
211
|
-
return "unknown-image"
|
|
212
|
-
|
|
213
|
-
|
|
214
168
|
def _extract_missing_binary(stderr: str) -> str:
|
|
215
169
|
"""Extract binary name from 'command not found' stderr."""
|
|
216
170
|
for line in stderr.splitlines():
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Data models for Taskfile configuration.
|
|
2
|
+
|
|
3
|
+
Split into submodules for maintainability:
|
|
4
|
+
environment.py — Environment, Platform, EnvironmentGroup
|
|
5
|
+
task.py — Task, Function, _normalize_commands
|
|
6
|
+
pipeline.py — PipelineStage, PipelineConfig
|
|
7
|
+
config.py — TaskfileConfig, ComposeConfig
|
|
8
|
+
|
|
9
|
+
All public symbols are re-exported here for backward compatibility.
|
|
10
|
+
Existing `from taskfile.models import X` imports continue to work.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from taskfile.models.environment import Environment, Platform, EnvironmentGroup
|
|
14
|
+
from taskfile.models.task import Task, Function, _normalize_commands
|
|
15
|
+
from taskfile.models.pipeline import PipelineStage, PipelineConfig
|
|
16
|
+
from taskfile.models.config import TaskfileConfig, ComposeConfig
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Environment",
|
|
20
|
+
"Platform",
|
|
21
|
+
"EnvironmentGroup",
|
|
22
|
+
"Task",
|
|
23
|
+
"Function",
|
|
24
|
+
"_normalize_commands",
|
|
25
|
+
"PipelineStage",
|
|
26
|
+
"PipelineConfig",
|
|
27
|
+
"TaskfileConfig",
|
|
28
|
+
"ComposeConfig",
|
|
29
|
+
]
|
|
@@ -1,111 +1,13 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""TaskfileConfig and ComposeConfig — main configuration container."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import os
|
|
6
5
|
from dataclasses import dataclass, field
|
|
7
6
|
from typing import Any
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"""Deployment environment configuration."""
|
|
13
|
-
|
|
14
|
-
name: str
|
|
15
|
-
variables: dict[str, str] = field(default_factory=dict)
|
|
16
|
-
ssh_host: str | None = None
|
|
17
|
-
ssh_user: str = "deploy"
|
|
18
|
-
ssh_port: int = 22
|
|
19
|
-
ssh_key: str | None = None
|
|
20
|
-
container_runtime: str = "docker" # docker | podman
|
|
21
|
-
compose_command: str = "docker compose" # docker compose | podman-compose
|
|
22
|
-
service_manager: str = "compose" # compose | quadlet | systemd
|
|
23
|
-
env_file: str | None = None # .env.local, .env.prod
|
|
24
|
-
compose_file: str = "docker-compose.yml" # source compose file
|
|
25
|
-
quadlet_dir: str = "deploy/quadlet" # local dir for generated .container files
|
|
26
|
-
quadlet_remote_dir: str = "~/.config/containers/systemd" # remote dir for Quadlet
|
|
27
|
-
|
|
28
|
-
@property
|
|
29
|
-
def ssh_target(self) -> str | None:
|
|
30
|
-
if not self.ssh_host:
|
|
31
|
-
return None
|
|
32
|
-
return f"{self.ssh_user}@{self.ssh_host}"
|
|
33
|
-
|
|
34
|
-
@property
|
|
35
|
-
def ssh_opts(self) -> str:
|
|
36
|
-
parts = [f"-p {self.ssh_port}"]
|
|
37
|
-
if self.ssh_key:
|
|
38
|
-
key_path = os.path.expanduser(self.ssh_key)
|
|
39
|
-
parts.append(f"-i {key_path}")
|
|
40
|
-
parts.append("-o StrictHostKeyChecking=accept-new")
|
|
41
|
-
return " ".join(parts)
|
|
42
|
-
|
|
43
|
-
@property
|
|
44
|
-
def scp_opts(self) -> str:
|
|
45
|
-
"""SCP options — uses -P (uppercase) for port, unlike ssh's -p."""
|
|
46
|
-
parts = [f"-P {self.ssh_port}"]
|
|
47
|
-
if self.ssh_key:
|
|
48
|
-
key_path = os.path.expanduser(self.ssh_key)
|
|
49
|
-
parts.append(f"-i {key_path}")
|
|
50
|
-
parts.append("-o StrictHostKeyChecking=accept-new")
|
|
51
|
-
return " ".join(parts)
|
|
52
|
-
|
|
53
|
-
@property
|
|
54
|
-
def is_remote(self) -> bool:
|
|
55
|
-
return self.ssh_host is not None
|
|
56
|
-
|
|
57
|
-
def resolve_variables(self, global_vars: dict[str, str], dotenv_vars: dict[str, str] | None = None) -> dict[str, str]:
|
|
58
|
-
"""Merge global variables with environment-specific ones.
|
|
59
|
-
Environment variables override global ones.
|
|
60
|
-
CLI --var overrides are applied separately in the runner.
|
|
61
|
-
|
|
62
|
-
Priority (highest wins): env-specific vars > global vars > dotenv.
|
|
63
|
-
Dotenv only fills in values that still contain ${VAR} patterns.
|
|
64
|
-
"""
|
|
65
|
-
merged = {**global_vars, **self.variables}
|
|
66
|
-
resolved = {}
|
|
67
|
-
env_source = dotenv_vars if dotenv_vars is not None else os.environ
|
|
68
|
-
for key, value in merged.items():
|
|
69
|
-
# Only use dotenv to fill values containing ${...} or when value is empty
|
|
70
|
-
if isinstance(value, str) and ("${" in value or "$" in value or value == ""):
|
|
71
|
-
resolved[key] = env_source.get(key, value)
|
|
72
|
-
else:
|
|
73
|
-
resolved[key] = value
|
|
74
|
-
return resolved
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
@dataclass
|
|
78
|
-
class Platform:
|
|
79
|
-
"""Target platform configuration (e.g. desktop, web, mobile)."""
|
|
80
|
-
|
|
81
|
-
name: str
|
|
82
|
-
variables: dict[str, str] = field(default_factory=dict)
|
|
83
|
-
build_cmd: str | None = None
|
|
84
|
-
deploy_cmd: str | None = None
|
|
85
|
-
description: str = ""
|
|
86
|
-
|
|
87
|
-
def resolve_variables(self, global_vars: dict[str, str], dotenv_vars: dict[str, str] | None = None) -> dict[str, str]:
|
|
88
|
-
"""Merge global variables with platform-specific ones.
|
|
89
|
-
Platform variables override global ones.
|
|
90
|
-
"""
|
|
91
|
-
merged = {**global_vars, **self.variables}
|
|
92
|
-
resolved = {}
|
|
93
|
-
# Use dotenv_vars if provided, otherwise fall back to os.environ
|
|
94
|
-
env_source = dotenv_vars if dotenv_vars is not None else os.environ
|
|
95
|
-
for key, value in merged.items():
|
|
96
|
-
resolved[key] = env_source.get(key, value)
|
|
97
|
-
return resolved
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
@dataclass
|
|
101
|
-
class EnvironmentGroup:
|
|
102
|
-
"""Group of environments sharing an update strategy (e.g. RPi fleet)."""
|
|
103
|
-
|
|
104
|
-
name: str
|
|
105
|
-
members: list[str] = field(default_factory=list)
|
|
106
|
-
strategy: str = "parallel" # rolling | parallel | canary
|
|
107
|
-
max_parallel: int = 5
|
|
108
|
-
canary_count: int = 1
|
|
8
|
+
from taskfile.models.environment import Environment, Platform, EnvironmentGroup
|
|
9
|
+
from taskfile.models.task import Task, Function, _normalize_commands
|
|
10
|
+
from taskfile.models.pipeline import PipelineStage, PipelineConfig
|
|
109
11
|
|
|
110
12
|
|
|
111
13
|
@dataclass
|
|
@@ -118,141 +20,6 @@ class ComposeConfig:
|
|
|
118
20
|
auto_update: bool = True
|
|
119
21
|
|
|
120
22
|
|
|
121
|
-
@dataclass
|
|
122
|
-
class Function:
|
|
123
|
-
"""Embedded function callable from tasks via @fn prefix."""
|
|
124
|
-
|
|
125
|
-
name: str
|
|
126
|
-
lang: str = "shell" # shell | python | node | binary
|
|
127
|
-
code: str | None = None # inline code
|
|
128
|
-
file: str | None = None # external file path
|
|
129
|
-
function: str | None = None # specific function to call (Python)
|
|
130
|
-
description: str = ""
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
@dataclass
|
|
134
|
-
class Task:
|
|
135
|
-
"""Single task definition."""
|
|
136
|
-
|
|
137
|
-
name: str
|
|
138
|
-
description: str = ""
|
|
139
|
-
commands: list[str] = field(default_factory=list)
|
|
140
|
-
script: str | None = None # external script file path (alternative to inline cmds)
|
|
141
|
-
deps: list[str] = field(default_factory=list)
|
|
142
|
-
env_filter: list[str] | None = None
|
|
143
|
-
platform_filter: list[str] | None = None
|
|
144
|
-
working_dir: str | None = None
|
|
145
|
-
silent: bool = False
|
|
146
|
-
ignore_errors: bool = False
|
|
147
|
-
condition: str | None = None
|
|
148
|
-
stage: str | None = None # pipeline stage this task belongs to
|
|
149
|
-
parallel: bool = False # run deps in parallel (concurrent execution)
|
|
150
|
-
retries: int = 0 # retry count on failure (Ansible-inspired)
|
|
151
|
-
retry_delay: int = 1 # seconds between retries
|
|
152
|
-
timeout: int = 0 # command timeout in seconds (0 = no timeout)
|
|
153
|
-
tags: list[str] = field(default_factory=list) # tags for selective execution
|
|
154
|
-
register: str | None = None # capture stdout into this variable name
|
|
155
|
-
|
|
156
|
-
def should_run_on(self, env_name: str) -> bool:
|
|
157
|
-
if self.env_filter is None:
|
|
158
|
-
return True
|
|
159
|
-
return env_name in self.env_filter
|
|
160
|
-
|
|
161
|
-
def should_run_on_platform(self, platform_name: str | None) -> bool:
|
|
162
|
-
if self.platform_filter is None:
|
|
163
|
-
return True
|
|
164
|
-
if platform_name is None:
|
|
165
|
-
return True
|
|
166
|
-
return platform_name in self.platform_filter
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
@dataclass
|
|
170
|
-
class PipelineStage:
|
|
171
|
-
"""A stage in the CI/CD pipeline."""
|
|
172
|
-
|
|
173
|
-
name: str
|
|
174
|
-
tasks: list[str] = field(default_factory=list)
|
|
175
|
-
env: str | None = None # override environment for this stage
|
|
176
|
-
when: str = "auto" # auto | manual | tag | branch:main
|
|
177
|
-
runner: str | None = None # override runner image
|
|
178
|
-
docker_in_docker: bool = False
|
|
179
|
-
artifacts: list[str] = field(default_factory=list)
|
|
180
|
-
cache: list[str] = field(default_factory=list)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
@dataclass
|
|
184
|
-
class PipelineConfig:
|
|
185
|
-
"""CI/CD pipeline configuration."""
|
|
186
|
-
|
|
187
|
-
stages: list[PipelineStage] = field(default_factory=list)
|
|
188
|
-
python_version: str = "3.12"
|
|
189
|
-
runner_image: str = "ubuntu-latest"
|
|
190
|
-
docker_in_docker: bool = False
|
|
191
|
-
cache: list[str] = field(default_factory=list)
|
|
192
|
-
artifacts: list[str] = field(default_factory=list)
|
|
193
|
-
secrets: list[str] = field(default_factory=list)
|
|
194
|
-
branches: list[str] = field(default_factory=lambda: ["main"])
|
|
195
|
-
install_cmd: str = "pip install taskfile"
|
|
196
|
-
|
|
197
|
-
@classmethod
|
|
198
|
-
def from_dict(cls, data: dict[str, Any]) -> PipelineConfig:
|
|
199
|
-
config = cls(
|
|
200
|
-
python_version=str(data.get("python_version", "3.12")),
|
|
201
|
-
runner_image=data.get("runner_image", "ubuntu-latest"),
|
|
202
|
-
docker_in_docker=data.get("docker_in_docker", False),
|
|
203
|
-
cache=data.get("cache", []),
|
|
204
|
-
artifacts=data.get("artifacts", []),
|
|
205
|
-
secrets=data.get("secrets", []),
|
|
206
|
-
branches=data.get("branches", ["main"]),
|
|
207
|
-
install_cmd=data.get("install_cmd", "pip install taskfile"),
|
|
208
|
-
)
|
|
209
|
-
for stage_data in data.get("stages", []):
|
|
210
|
-
if isinstance(stage_data, dict):
|
|
211
|
-
config.stages.append(PipelineStage(
|
|
212
|
-
name=stage_data.get("name", ""),
|
|
213
|
-
tasks=stage_data.get("tasks", []),
|
|
214
|
-
env=stage_data.get("env"),
|
|
215
|
-
when=stage_data.get("when", "auto"),
|
|
216
|
-
runner=stage_data.get("runner"),
|
|
217
|
-
docker_in_docker=stage_data.get("docker_in_docker", False),
|
|
218
|
-
artifacts=stage_data.get("artifacts", []),
|
|
219
|
-
cache=stage_data.get("cache", []),
|
|
220
|
-
))
|
|
221
|
-
elif isinstance(stage_data, str):
|
|
222
|
-
# Shorthand: stage name = task name
|
|
223
|
-
config.stages.append(PipelineStage(
|
|
224
|
-
name=stage_data, tasks=[stage_data],
|
|
225
|
-
))
|
|
226
|
-
return config
|
|
227
|
-
|
|
228
|
-
def infer_from_tasks(self, tasks: dict[str, "Task"]) -> None:
|
|
229
|
-
"""Auto-generate pipeline stages from task 'stage' fields if no stages defined."""
|
|
230
|
-
if self.stages:
|
|
231
|
-
return
|
|
232
|
-
stage_map: dict[str, list[str]] = {}
|
|
233
|
-
for task_name, task in tasks.items():
|
|
234
|
-
if task.stage:
|
|
235
|
-
stage_map.setdefault(task.stage, []).append(task_name)
|
|
236
|
-
# Preserve insertion order
|
|
237
|
-
for stage_name, task_names in stage_map.items():
|
|
238
|
-
self.stages.append(PipelineStage(name=stage_name, tasks=task_names))
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def _normalize_commands(cmds: list) -> list[str]:
|
|
242
|
-
"""Normalize commands list — YAML can misparse 'echo key: value' as dicts."""
|
|
243
|
-
result = []
|
|
244
|
-
for item in cmds:
|
|
245
|
-
if isinstance(item, str):
|
|
246
|
-
result.append(item)
|
|
247
|
-
elif isinstance(item, dict):
|
|
248
|
-
# YAML misparse: {'echo " App': 'http://...'} → reconstruct
|
|
249
|
-
for k, v in item.items():
|
|
250
|
-
result.append(f"{k}: {v}" if v else str(k))
|
|
251
|
-
else:
|
|
252
|
-
result.append(str(item))
|
|
253
|
-
return result
|
|
254
|
-
|
|
255
|
-
|
|
256
23
|
@dataclass
|
|
257
24
|
class TaskfileConfig:
|
|
258
25
|
"""Parsed Taskfile configuration."""
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Environment, Platform, and EnvironmentGroup data models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Environment:
|
|
11
|
+
"""Deployment environment configuration."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
variables: dict[str, str] = field(default_factory=dict)
|
|
15
|
+
ssh_host: str | None = None
|
|
16
|
+
ssh_user: str = "deploy"
|
|
17
|
+
ssh_port: int = 22
|
|
18
|
+
ssh_key: str | None = None
|
|
19
|
+
container_runtime: str = "docker" # docker | podman
|
|
20
|
+
compose_command: str = "docker compose" # docker compose | podman-compose
|
|
21
|
+
service_manager: str = "compose" # compose | quadlet | systemd
|
|
22
|
+
env_file: str | None = None # .env.local, .env.prod
|
|
23
|
+
compose_file: str = "docker-compose.yml" # source compose file
|
|
24
|
+
quadlet_dir: str = "deploy/quadlet" # local dir for generated .container files
|
|
25
|
+
quadlet_remote_dir: str = "~/.config/containers/systemd" # remote dir for Quadlet
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def ssh_target(self) -> str | None:
|
|
29
|
+
if not self.ssh_host:
|
|
30
|
+
return None
|
|
31
|
+
return f"{self.ssh_user}@{self.ssh_host}"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def ssh_opts(self) -> str:
|
|
35
|
+
parts = [f"-p {self.ssh_port}"]
|
|
36
|
+
if self.ssh_key:
|
|
37
|
+
key_path = os.path.expanduser(self.ssh_key)
|
|
38
|
+
parts.append(f"-i {key_path}")
|
|
39
|
+
parts.append("-o StrictHostKeyChecking=accept-new")
|
|
40
|
+
return " ".join(parts)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def scp_opts(self) -> str:
|
|
44
|
+
"""SCP options — uses -P (uppercase) for port, unlike ssh's -p."""
|
|
45
|
+
parts = [f"-P {self.ssh_port}"]
|
|
46
|
+
if self.ssh_key:
|
|
47
|
+
key_path = os.path.expanduser(self.ssh_key)
|
|
48
|
+
parts.append(f"-i {key_path}")
|
|
49
|
+
parts.append("-o StrictHostKeyChecking=accept-new")
|
|
50
|
+
return " ".join(parts)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_remote(self) -> bool:
|
|
54
|
+
return self.ssh_host is not None
|
|
55
|
+
|
|
56
|
+
def resolve_variables(self, global_vars: dict[str, str], dotenv_vars: dict[str, str] | None = None) -> dict[str, str]:
|
|
57
|
+
"""Merge global variables with environment-specific ones.
|
|
58
|
+
Environment variables override global ones.
|
|
59
|
+
CLI --var overrides are applied separately in the runner.
|
|
60
|
+
|
|
61
|
+
Priority (highest wins): env-specific vars > global vars > dotenv.
|
|
62
|
+
Dotenv only fills in values that still contain ${VAR} patterns.
|
|
63
|
+
"""
|
|
64
|
+
merged = {**global_vars, **self.variables}
|
|
65
|
+
resolved = {}
|
|
66
|
+
env_source = dotenv_vars if dotenv_vars is not None else os.environ
|
|
67
|
+
for key, value in merged.items():
|
|
68
|
+
# Only use dotenv to fill values containing ${...} or when value is empty
|
|
69
|
+
if isinstance(value, str) and ("${" in value or "$" in value or value == ""):
|
|
70
|
+
resolved[key] = env_source.get(key, value)
|
|
71
|
+
else:
|
|
72
|
+
resolved[key] = value
|
|
73
|
+
return resolved
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class Platform:
|
|
78
|
+
"""Target platform configuration (e.g. desktop, web, mobile)."""
|
|
79
|
+
|
|
80
|
+
name: str
|
|
81
|
+
variables: dict[str, str] = field(default_factory=dict)
|
|
82
|
+
build_cmd: str | None = None
|
|
83
|
+
deploy_cmd: str | None = None
|
|
84
|
+
description: str = ""
|
|
85
|
+
|
|
86
|
+
def resolve_variables(self, global_vars: dict[str, str], dotenv_vars: dict[str, str] | None = None) -> dict[str, str]:
|
|
87
|
+
"""Merge global variables with platform-specific ones.
|
|
88
|
+
Platform variables override global ones.
|
|
89
|
+
"""
|
|
90
|
+
merged = {**global_vars, **self.variables}
|
|
91
|
+
resolved = {}
|
|
92
|
+
# Use dotenv_vars if provided, otherwise fall back to os.environ
|
|
93
|
+
env_source = dotenv_vars if dotenv_vars is not None else os.environ
|
|
94
|
+
for key, value in merged.items():
|
|
95
|
+
resolved[key] = env_source.get(key, value)
|
|
96
|
+
return resolved
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class EnvironmentGroup:
|
|
101
|
+
"""Group of environments sharing an update strategy (e.g. RPi fleet)."""
|
|
102
|
+
|
|
103
|
+
name: str
|
|
104
|
+
members: list[str] = field(default_factory=list)
|
|
105
|
+
strategy: str = "parallel" # rolling | parallel | canary
|
|
106
|
+
max_parallel: int = 5
|
|
107
|
+
canary_count: int = 1
|