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.
Files changed (142) hide show
  1. {taskfile-0.3.77/src/taskfile.egg-info → taskfile-0.3.78}/PKG-INFO +1 -1
  2. {taskfile-0.3.77 → taskfile-0.3.78}/pyproject.toml +1 -1
  3. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/__init__.py +1 -1
  4. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/deploy_recipes.py +64 -11
  5. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/diagnostics/__init__.py +2 -2
  6. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/diagnostics/llm_repair.py +0 -46
  7. taskfile-0.3.78/src/taskfile/models/__init__.py +29 -0
  8. taskfile-0.3.77/src/taskfile/models.py → taskfile-0.3.78/src/taskfile/models/config.py +4 -237
  9. taskfile-0.3.78/src/taskfile/models/environment.py +107 -0
  10. taskfile-0.3.78/src/taskfile/models/pipeline.py +81 -0
  11. taskfile-0.3.78/src/taskfile/models/task.py +68 -0
  12. {taskfile-0.3.77 → taskfile-0.3.78/src/taskfile.egg-info}/PKG-INFO +1 -1
  13. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile.egg-info/SOURCES.txt +6 -1
  14. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_doctor_e2e.py +0 -212
  15. taskfile-0.3.78/tests/test_graceful_restart.py +259 -0
  16. {taskfile-0.3.77 → taskfile-0.3.78}/LICENSE +0 -0
  17. {taskfile-0.3.77 → taskfile-0.3.78}/README.md +0 -0
  18. {taskfile-0.3.77 → taskfile-0.3.78}/setup.cfg +0 -0
  19. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/__main__.py +0 -0
  20. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/addons/__init__.py +0 -0
  21. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/addons/monitoring.py +0 -0
  22. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/addons/postgres.py +0 -0
  23. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/addons/redis_addon.py +0 -0
  24. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/api/__init__.py +0 -0
  25. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/api/app.py +0 -0
  26. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/api/models.py +0 -0
  27. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cache.py +0 -0
  28. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cigen/__init__.py +0 -0
  29. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cigen/base.py +0 -0
  30. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cigen/drone.py +0 -0
  31. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cigen/gitea.py +0 -0
  32. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cigen/github.py +0 -0
  33. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cigen/gitlab.py +0 -0
  34. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cigen/jenkins.py +0 -0
  35. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cigen/makefile.py +0 -0
  36. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cirunner.py +0 -0
  37. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/__init__.py +0 -0
  38. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/api_cmd.py +0 -0
  39. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/auth.py +0 -0
  40. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/cache_cmds.py +0 -0
  41. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/ci.py +0 -0
  42. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/click_compat.py +0 -0
  43. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/completion.py +0 -0
  44. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/deploy.py +0 -0
  45. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/diagnostics.py +0 -0
  46. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/docker_cmds.py +0 -0
  47. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/e2e_cmd.py +0 -0
  48. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/explain_cmd.py +0 -0
  49. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/fleet.py +0 -0
  50. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/health.py +0 -0
  51. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/import_export.py +0 -0
  52. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/info_cmd.py +0 -0
  53. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/interactive/__init__.py +0 -0
  54. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/interactive/menu.py +0 -0
  55. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/interactive/wizards.py +0 -0
  56. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/main.py +0 -0
  57. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/quadlet.py +0 -0
  58. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/registry_cmds.py +0 -0
  59. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/release.py +0 -0
  60. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/setup.py +0 -0
  61. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/cli/version.py +0 -0
  62. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/compose.py +0 -0
  63. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/converters.py +0 -0
  64. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/deploy_utils.py +0 -0
  65. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/diagnostics/checks.py +0 -0
  66. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/diagnostics/checks_ports.py +0 -0
  67. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/diagnostics/checks_ssh.py +0 -0
  68. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/diagnostics/fixes.py +0 -0
  69. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/diagnostics/models.py +0 -0
  70. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/diagnostics/report.py +0 -0
  71. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/fleet.py +0 -0
  72. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/graph.py +0 -0
  73. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/health.py +0 -0
  74. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/importer.py +0 -0
  75. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/landing.py +0 -0
  76. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/notifications.py +0 -0
  77. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/parser.py +0 -0
  78. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/provisioner.py +0 -0
  79. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/quadlet.py +0 -0
  80. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/registry.py +0 -0
  81. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/__init__.py +0 -0
  82. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/classifier.py +0 -0
  83. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/commands.py +0 -0
  84. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/core.py +0 -0
  85. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/error_presenter.py +0 -0
  86. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/explainer.py +0 -0
  87. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/functions.py +0 -0
  88. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/resolver.py +0 -0
  89. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/runner/ssh.py +0 -0
  90. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/__init__.py +0 -0
  91. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/codereview.py +0 -0
  92. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/full.py +0 -0
  93. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/minimal.py +0 -0
  94. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/multiplatform.py +0 -0
  95. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/podman.py +0 -0
  96. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/publish.py +0 -0
  97. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/codereview.yml +0 -0
  98. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/full.yml +0 -0
  99. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/iot.yml +0 -0
  100. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/kubernetes.yml +0 -0
  101. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/minimal.yml +0 -0
  102. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/multiplatform.yml +0 -0
  103. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/podman.yml +0 -0
  104. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/publish.yml +0 -0
  105. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/saas.yml +0 -0
  106. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/terraform.yml +0 -0
  107. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/templates/web.yml +0 -0
  108. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/scaffold/web.py +0 -0
  109. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/ssh.py +0 -0
  110. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/watch.py +0 -0
  111. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/webui/__init__.py +0 -0
  112. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/webui/dashboard.py +0 -0
  113. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/webui/handlers.py +0 -0
  114. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile/webui/server.py +0 -0
  115. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile.egg-info/dependency_links.txt +0 -0
  116. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile.egg-info/entry_points.txt +0 -0
  117. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile.egg-info/requires.txt +0 -0
  118. {taskfile-0.3.77 → taskfile-0.3.78}/src/taskfile.egg-info/top_level.txt +0 -0
  119. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_api.py +0 -0
  120. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_auth.py +0 -0
  121. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_cigen.py +0 -0
  122. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_classifier.py +0 -0
  123. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_cli.py +0 -0
  124. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_compose.py +0 -0
  125. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_deploy_validation.py +0 -0
  126. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_diagnostics.py +0 -0
  127. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_docker_e2e.py +0 -0
  128. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_doctor_decomposition.py +0 -0
  129. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_dsl_commands.py +0 -0
  130. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_e2e_examples.py +0 -0
  131. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_fleet.py +0 -0
  132. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_health.py +0 -0
  133. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_landing.py +0 -0
  134. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_models.py +0 -0
  135. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_parser.py +0 -0
  136. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_provisioner.py +0 -0
  137. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_quadlet.py +0 -0
  138. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_release.py +0 -0
  139. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_resolver.py +0 -0
  140. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_runner.py +0 -0
  141. {taskfile-0.3.77 → taskfile-0.3.78}/tests/test_scaffold.py +0 -0
  142. {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.77
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.77"
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"
@@ -12,7 +12,7 @@ Features:
12
12
  - @remote SSH command execution
13
13
  """
14
14
 
15
- __version__ = "0.3.77"
15
+ __version__ = "0.3.78"
16
16
  __author__ = "Softreck"
17
17
 
18
18
  from taskfile.runner import TaskfileRunner
@@ -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.append(f"@remote systemctl --user restart ${{APP_NAME}}-{svc_name}")
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(deps: list[str], images: dict, registry: str, tag_var: str) -> dict:
165
- """Generate Quadlet-based deploy task."""
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.append(f"@remote systemctl --user restart ${{APP_NAME}}-{svc_name}")
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(deps: list[str], images: dict, registry: str, tag_var: str) -> dict:
190
- """Generate simple SSH pull+restart deploy task."""
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.append(f"@remote systemctl --user restart ${{APP_NAME}}-{svc_name}")
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
- """Data models for Taskfile configuration."""
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
- @dataclass
11
- class Environment:
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