djaploy 1.6.1__tar.gz → 1.6.2__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 (107) hide show
  1. {djaploy-1.6.1 → djaploy-1.6.2}/PKG-INFO +1 -1
  2. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/deploy.py +9 -1
  3. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/gitops.py +40 -5
  4. djaploy-1.6.2/djaploy/version.py +1 -0
  5. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy.egg-info/PKG-INFO +1 -1
  6. {djaploy-1.6.1 → djaploy-1.6.2}/pyproject.toml +1 -1
  7. {djaploy-1.6.1 → djaploy-1.6.2}/tests/test_gitops.py +89 -3
  8. djaploy-1.6.1/djaploy/version.py +0 -1
  9. {djaploy-1.6.1 → djaploy-1.6.2}/LICENSE +0 -0
  10. {djaploy-1.6.1 → djaploy-1.6.2}/README.md +0 -0
  11. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/__init__.py +0 -0
  12. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/app.py +0 -0
  13. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/__init__.py +0 -0
  14. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/borg/__init__.py +0 -0
  15. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/borg/infra/__init__.py +0 -0
  16. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/borg/infra/djaploy_hooks.py +0 -0
  17. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/janitor/__init__.py +0 -0
  18. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/janitor/apps.py +0 -0
  19. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/janitor/infra/__init__.py +0 -0
  20. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/janitor/infra/commands/createjanitoruser.py +0 -0
  21. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/janitor/infra/djaploy_hooks.py +0 -0
  22. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/nginx/__init__.py +0 -0
  23. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/nginx/apps.py +0 -0
  24. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/nginx/infra/__init__.py +0 -0
  25. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/nginx/infra/djaploy_hooks.py +0 -0
  26. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/rclone/__init__.py +0 -0
  27. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/rclone/apps.py +0 -0
  28. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/rclone/infra/__init__.py +0 -0
  29. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/rclone/infra/djaploy_hooks.py +0 -0
  30. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/sync_certs/__init__.py +0 -0
  31. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/sync_certs/apps.py +0 -0
  32. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/sync_certs/infra/__init__.py +0 -0
  33. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/sync_certs/infra/djaploy_hooks.py +0 -0
  34. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/systemd/__init__.py +0 -0
  35. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/systemd/apps.py +0 -0
  36. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/systemd/infra/__init__.py +0 -0
  37. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/systemd/infra/djaploy_hooks.py +0 -0
  38. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/tailscale/__init__.py +0 -0
  39. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/tailscale/apps.py +0 -0
  40. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/tailscale/infra/__init__.py +0 -0
  41. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/tailscale/infra/djaploy_hooks.py +0 -0
  42. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/versioning/__init__.py +0 -0
  43. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/versioning/apps.py +0 -0
  44. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/versioning/infra/__init__.py +0 -0
  45. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/apps/versioning/infra/djaploy_hooks.py +0 -0
  46. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/artifact.py +0 -0
  47. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/bin/__init__.py +0 -0
  48. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/bin/django_pyinfra.py +0 -0
  49. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/bin/gunicornherder.py +0 -0
  50. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/builtin_hooks.py +0 -0
  51. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/certificates.py +0 -0
  52. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/changelog.py +0 -0
  53. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/commands/__init__.py +0 -0
  54. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/commands/_utils.py +0 -0
  55. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/commands/activate.py +0 -0
  56. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/commands/configure.py +0 -0
  57. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/commands/deploy.py +0 -0
  58. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/commands/restore.py +0 -0
  59. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/commands/rollback.py +0 -0
  60. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/commands/status.py +0 -0
  61. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/commands/sync_certs.py +0 -0
  62. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/config.py +0 -0
  63. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/discovery.py +0 -0
  64. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/hooks.py +0 -0
  65. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/__init__.py +0 -0
  66. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/bluegreen.py +0 -0
  67. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/charts/django-app/Chart.yaml +0 -0
  68. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/_helpers.tpl +0 -0
  69. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/configmap.yaml +0 -0
  70. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/cronjobs.yaml +0 -0
  71. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/deployment.yaml +0 -0
  72. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/ingress.yaml +0 -0
  73. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/onepassword.yaml +0 -0
  74. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/pvc.yaml +0 -0
  75. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/service.yaml +0 -0
  76. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/workers.yaml +0 -0
  77. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/charts/django-app/values.yaml +0 -0
  78. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/djaploy_hooks.py +0 -0
  79. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/k3s.py +0 -0
  80. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/templates.py +0 -0
  81. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/infra/utils.py +0 -0
  82. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/management/__init__.py +0 -0
  83. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/management/commands/__init__.py +0 -0
  84. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/management/commands/djaploy.py +0 -0
  85. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/management/commands/restore_backup.py +0 -0
  86. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/management/commands/sync_certs.py +0 -0
  87. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/management/commands/update_certs.py +0 -0
  88. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/management/commands/verify.py +0 -0
  89. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/management/utils.py +0 -0
  90. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/notifications.py +0 -0
  91. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/utils.py +0 -0
  92. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy/versioning.py +0 -0
  93. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy.egg-info/SOURCES.txt +0 -0
  94. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy.egg-info/dependency_links.txt +0 -0
  95. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy.egg-info/entry_points.txt +0 -0
  96. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy.egg-info/requires.txt +0 -0
  97. {djaploy-1.6.1 → djaploy-1.6.2}/djaploy.egg-info/top_level.txt +0 -0
  98. {djaploy-1.6.1 → djaploy-1.6.2}/setup.cfg +0 -0
  99. {djaploy-1.6.1 → djaploy-1.6.2}/tests/test_bluegreen.py +0 -0
  100. {djaploy-1.6.1 → djaploy-1.6.2}/tests/test_borg.py +0 -0
  101. {djaploy-1.6.1 → djaploy-1.6.2}/tests/test_config.py +0 -0
  102. {djaploy-1.6.1 → djaploy-1.6.2}/tests/test_deploy_scripts.py +0 -0
  103. {djaploy-1.6.1 → djaploy-1.6.2}/tests/test_discovery.py +0 -0
  104. {djaploy-1.6.1 → djaploy-1.6.2}/tests/test_gunicornherder.py +0 -0
  105. {djaploy-1.6.1 → djaploy-1.6.2}/tests/test_hooks.py +0 -0
  106. {djaploy-1.6.1 → djaploy-1.6.2}/tests/test_k3s.py +0 -0
  107. {djaploy-1.6.1 → djaploy-1.6.2}/tests/test_versioning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: djaploy
3
- Version: 1.6.1
3
+ Version: 1.6.2
4
4
  Summary: Modular Django deployment system based on pyinfra
5
5
  Author-email: Johanna Mae Dimayuga <johanna@techco.fi>
6
6
  License: MIT License
@@ -61,7 +61,15 @@ def run_command(context: Dict[str, Any]) -> None:
61
61
  call_hook(f"{command_name}:precommand", context)
62
62
  call_hook("precommand", context)
63
63
 
64
- # 2. Run pyinfra
64
+ # 2. Run pyinfra (unless a precommand hook already did all the work,
65
+ # e.g. the gitops strategy, which has no remote hooks)
66
+ if context.get("skip_remote"):
67
+ print(f"No remote execution needed ({context['skip_remote']}).", flush=True)
68
+ context["success"] = True
69
+ call_hook(f"{command_name}:postcommand", context)
70
+ call_hook("postcommand", context)
71
+ return
72
+
65
73
  print("Starting remote execution...", flush=True)
66
74
  processed_inventory = _preprocess_inventory(str(context["inventory_file"]))
67
75
 
@@ -15,8 +15,9 @@ pins its digest in the GitOps repository and syncs the Argo CD application:
15
15
  5. sync that commit in Argo CD and wait until Synced and Healthy
16
16
 
17
17
  The inventory host is the build node: its SSH connection (ssh_hostname,
18
- ssh_user, ssh_key, ssh_known_hosts_file) is used for the build, and no remote
19
- pyinfra hooks run. Kubernetes access uses the local kubeconfig.
18
+ ssh_user, ssh_key, ssh_known_hosts_file) is used for the build over one
19
+ multiplexed connection, retried if the connection drops. pyinfra is not run
20
+ for gitops-only inventories. Kubernetes access uses the local kubeconfig.
20
21
 
21
22
  HostConfig fields used: app_name, manage_py_path, secret_key, data, and
22
23
  ``gitops_conf``::
@@ -50,7 +51,9 @@ import json
50
51
  import os
51
52
  import re
52
53
  import shlex
54
+ import shutil
53
55
  import subprocess
56
+ import tempfile
54
57
  import time
55
58
  import uuid
56
59
  from pathlib import Path
@@ -203,11 +206,23 @@ def check_infra_repo(repo: Path, manifest: str) -> str:
203
206
 
204
207
 
205
208
  class BuildNode:
206
- """SSH to the build node with the inventory's connection settings."""
209
+ """SSH to the build node with the inventory's connection settings.
210
+
211
+ All commands share one multiplexed connection, so a build makes one SSH
212
+ handshake. A failed connection (ssh exit status 255) is retried: every
213
+ build-job command is safe to repeat, and an input stream is rewound first.
214
+ """
215
+
216
+ RETRIES = 3
207
217
 
208
218
  def __init__(self, host_data):
209
219
  self.target = f"{_get(host_data, 'ssh_user')}@{_get(host_data, 'ssh_hostname')}"
210
- self.opts = ["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes"]
220
+ # Short private directory: Unix socket paths are limited (~104 bytes on macOS).
221
+ self.control_dir = tempfile.mkdtemp(prefix="djaploy-ssh-", dir="/tmp")
222
+ self.opts = ["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
223
+ "-o", "ConnectTimeout=20", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=4",
224
+ "-o", "ControlMaster=auto", "-o", f"ControlPath={self.control_dir}/%C",
225
+ "-o", "ControlPersist=120"]
211
226
  if _get(host_data, "ssh_port"):
212
227
  self.opts += ["-p", str(_get(host_data, "ssh_port"))]
213
228
  if _get(host_data, "ssh_key"):
@@ -216,7 +231,23 @@ class BuildNode:
216
231
  self.opts += ["-o", f"UserKnownHostsFile={_get(host_data, 'ssh_known_hosts_file')}"]
217
232
 
218
233
  def run(self, command: str, **kwargs):
219
- return _run(["ssh", *self.opts, self.target, command], **kwargs)
234
+ stdin = kwargs.get("stdin")
235
+ for attempt in range(1, self.RETRIES + 1):
236
+ if attempt > 1 and hasattr(stdin, "seek"):
237
+ stdin.seek(0)
238
+ try:
239
+ return _run(["ssh", *self.opts, self.target, command], **kwargs)
240
+ except subprocess.CalledProcessError as exc:
241
+ if exc.returncode != 255 or attempt == self.RETRIES:
242
+ raise
243
+ print(f"[gitops] SSH connection to {self.target} failed; retrying ({attempt}/{self.RETRIES - 1})",
244
+ flush=True)
245
+ time.sleep(2 * attempt)
246
+
247
+ def close(self):
248
+ _run(["ssh", *self.opts, "-O", "exit", self.target], check=False,
249
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
250
+ shutil.rmtree(self.control_dir, ignore_errors=True)
220
251
 
221
252
 
222
253
  BUILDER_DEFAULTS = {
@@ -317,6 +348,7 @@ def build_and_push(host_data, artifact_path: Path, image: str, tag: str) -> str:
317
348
  result = json.loads(node.run(cmd["result"], capture_output=True, text=True).stdout)
318
349
  finally:
319
350
  node.run(cmd["cleanup"], check=False)
351
+ node.close()
320
352
  digest = result.get("containerimage.digest", "")
321
353
  if not DIGEST_RE.fullmatch(digest):
322
354
  raise RuntimeError(f"Build finished without a pushed digest: {result}")
@@ -399,6 +431,9 @@ def _gitops_deploy(context):
399
431
  gitops_hosts = [data for _, data in hosts if _get(data, "deployment_strategy") == GITOPS]
400
432
  if not gitops_hosts:
401
433
  return
434
+ if len(gitops_hosts) == len(hosts):
435
+ # No remote pyinfra hooks run for gitops hosts: don't connect at all.
436
+ context["skip_remote"] = "gitops deploys from this machine"
402
437
  if len(gitops_hosts) > 1:
403
438
  raise ValueError("gitops inventories deploy one app per environment; use one host")
404
439
  host_data = gitops_hosts[0]
@@ -0,0 +1 @@
1
+ __version__ = "1.6.2"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: djaploy
3
- Version: 1.6.1
3
+ Version: 1.6.2
4
4
  Summary: Modular Django deployment system based on pyinfra
5
5
  Author-email: Johanna Mae Dimayuga <johanna@techco.fi>
6
6
  License: MIT License
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "djaploy"
3
- version = "1.6.1"
3
+ version = "1.6.2"
4
4
  description = "Modular Django deployment system based on pyinfra"
5
5
  authors = [
6
6
  {name = "Johanna Mae Dimayuga", email = "johanna@techco.fi"}
@@ -181,6 +181,7 @@ class SyncOperationTests(unittest.TestCase):
181
181
  class BuildNodeTests(unittest.TestCase):
182
182
  def test_ssh_options_from_inventory(self):
183
183
  node = gitops.BuildNode(host(ssh_port=2222, ssh_known_hosts_file="/tmp/kh"))
184
+ self.addCleanup(lambda: __import__("shutil").rmtree(node.control_dir, ignore_errors=True))
184
185
  self.assertEqual(node.target, "janitor@203.0.113.10")
185
186
  self.assertIn("StrictHostKeyChecking=yes", node.opts)
186
187
  self.assertIn("UserKnownHostsFile=/tmp/kh", node.opts)
@@ -201,6 +202,9 @@ class FakeNode:
201
202
  stdout = '{"containerimage.digest": "%s"}' % NEW if command.endswith("result.json") else ""
202
203
  return subprocess.CompletedProcess(command, 0, stdout=stdout)
203
204
 
205
+ def close(self):
206
+ self.commands.append("<close>")
207
+
204
208
 
205
209
  class BuilderTests(unittest.TestCase):
206
210
  def setUp(self):
@@ -228,7 +232,8 @@ class BuilderTests(unittest.TestCase):
228
232
  def test_shared_build_sequence(self):
229
233
  node = FakeNode()
230
234
  self.assertEqual(self.build(node), NEW)
231
- self.assertTrue(all(c.startswith("sudo -u builder ") for c in node.commands), node.commands)
235
+ self.assertEqual(node.commands[-1], "<close>")
236
+ self.assertTrue(all(c.startswith("sudo -u builder ") for c in node.commands[:-1]), node.commands)
232
237
  self.assertFalse(any("systemd-run" in c for c in node.commands))
233
238
  steps = ["mkdir -p", "tar --no-same-owner", "umask 077", "buildctl --addr", "/result.json", "rm -rf --"]
234
239
  positions = [next(i for i, c in enumerate(node.commands) if step in c and (step != "/result.json" or c.endswith(step))) for step in steps]
@@ -242,14 +247,64 @@ class BuilderTests(unittest.TestCase):
242
247
  node = FakeNode(fail_on=" build ")
243
248
  with self.assertRaises(subprocess.CalledProcessError):
244
249
  self.build(node)
245
- self.assertTrue(node.commands[-1].startswith("sudo -u builder rm -rf -- /var/lib/techco-buildkit/jobs/"))
250
+ self.assertTrue(node.commands[-2].startswith("sudo -u builder rm -rf -- /var/lib/techco-buildkit/jobs/"))
251
+ self.assertEqual(node.commands[-1], "<close>")
246
252
 
247
253
  def test_temporary_mode_starts_its_own_daemon(self):
248
254
  node = FakeNode()
249
255
  self.assertEqual(self.build(node, mode="temporary"), NEW)
250
256
  self.assertTrue(any("systemd-run" in c for c in node.commands))
251
257
  self.assertTrue(any("/data/djaploy-build/docms-prod-abc1234-" in c for c in node.commands))
252
- self.assertIn("systemctl stop djaploy-buildkit-", node.commands[-1])
258
+ self.assertIn("systemctl stop djaploy-buildkit-", node.commands[-2])
259
+
260
+
261
+ class BuildNodeConnectionTests(unittest.TestCase):
262
+ def setUp(self):
263
+ self.node = gitops.BuildNode(host())
264
+ self.addCleanup(lambda: __import__("shutil").rmtree(self.node.control_dir, ignore_errors=True))
265
+ patcher = patch.object(gitops.time, "sleep")
266
+ patcher.start()
267
+ self.addCleanup(patcher.stop)
268
+
269
+ def test_one_multiplexed_connection_with_keepalives(self):
270
+ self.assertTrue(self.node.control_dir.startswith("/tmp/djaploy-ssh-"))
271
+ self.assertIn(f"ControlPath={self.node.control_dir}/%C", self.node.opts)
272
+ for opt in ("ControlMaster=auto", "ServerAliveInterval=15", "ConnectTimeout=20"):
273
+ self.assertIn(opt, self.node.opts)
274
+
275
+ def test_connection_failure_is_retried_and_input_rewound(self):
276
+ failures = [subprocess.CalledProcessError(255, "ssh")]
277
+ seen = []
278
+
279
+ def fake_run(cmd, **kwargs):
280
+ seen.append(kwargs["stdin"].read())
281
+ if failures:
282
+ raise failures.pop()
283
+ return subprocess.CompletedProcess(cmd, 0)
284
+
285
+ with tempfile.TemporaryFile() as archive, patch.object(gitops, "_run", side_effect=fake_run):
286
+ archive.write(b"source")
287
+ archive.seek(0)
288
+ self.node.run("tar -xzf -", stdin=archive)
289
+ self.assertEqual(seen, [b"source", b"source"])
290
+
291
+ def test_command_failure_is_not_retried(self):
292
+ with patch.object(gitops, "_run", side_effect=subprocess.CalledProcessError(1, "ssh")) as run:
293
+ with self.assertRaises(subprocess.CalledProcessError):
294
+ self.node.run("false")
295
+ self.assertEqual(run.call_count, 1)
296
+
297
+ def test_gives_up_after_retries(self):
298
+ with patch.object(gitops, "_run", side_effect=subprocess.CalledProcessError(255, "ssh")) as run:
299
+ with self.assertRaises(subprocess.CalledProcessError):
300
+ self.node.run("true")
301
+ self.assertEqual(run.call_count, gitops.BuildNode.RETRIES)
302
+
303
+ def test_close_stops_master_and_removes_socket_dir(self):
304
+ with patch.object(gitops, "_run") as run:
305
+ self.node.close()
306
+ self.assertIn("-O", run.call_args[0][0])
307
+ self.assertFalse(Path(self.node.control_dir).exists())
253
308
 
254
309
 
255
310
  def git(cwd, *args):
@@ -310,6 +365,7 @@ class HookTests(unittest.TestCase):
310
365
  context = self.context([("extor", host())])
311
366
  gitops._gitops_deploy(context)
312
367
  self.assertEqual(calls, ["check", "build", "secret", "pin", "sync"])
368
+ self.assertTrue(context["skip_remote"])
313
369
  self.assertEqual(context["gitops"], {"image": f"{IMAGE}@{NEW}", "revision": "f" * 40})
314
370
 
315
371
  def test_build_failure_publishes_nothing(self):
@@ -326,6 +382,14 @@ class HookTests(unittest.TestCase):
326
382
  gitops._gitops_deploy(self.context([("web", host(deployment_strategy="zero_downtime"))]))
327
383
  check.assert_not_called()
328
384
 
385
+ def test_mixed_inventory_keeps_remote_stage(self):
386
+ with patch.object(gitops, "check_infra_repo", return_value="main"), \
387
+ patch.object(gitops, "build_and_push", return_value=NEW), patch.object(gitops, "apply_env_secret"), \
388
+ patch.object(gitops, "publish_pin", return_value="f" * 40), patch.object(gitops, "sync_and_wait"):
389
+ context = self.context([("extor", host()), ("web", host(deployment_strategy="zero_downtime"))])
390
+ gitops._gitops_deploy(context)
391
+ self.assertNotIn("skip_remote", context)
392
+
329
393
  def test_one_host_only(self):
330
394
  with self.assertRaisesRegex(ValueError, "one host"):
331
395
  gitops._gitops_deploy(self.context([("a", host()), ("b", host())]))
@@ -333,3 +397,25 @@ class HookTests(unittest.TestCase):
333
397
 
334
398
  if __name__ == "__main__":
335
399
  unittest.main()
400
+
401
+
402
+ class RunCommandSkipTests(unittest.TestCase):
403
+ def test_skip_remote_runs_no_pyinfra_but_postcommand(self):
404
+ from djaploy import deploy
405
+ called = []
406
+
407
+ def fake_call_hook(name, context):
408
+ called.append(name)
409
+ if name == "deploy:precommand":
410
+ context["skip_remote"] = "gitops deploys from this machine"
411
+ return []
412
+
413
+ context = {"command": "deploy", "env": "dev", "command_file": "x", "inventory_file": "inv.py",
414
+ "pyinfra_data": {}}
415
+ with patch("djaploy.hooks.discover_hooks"), patch("djaploy.hooks.call_hook", side_effect=fake_call_hook), \
416
+ patch.object(deploy, "_run_pyinfra") as pyinfra, patch.object(deploy, "_preprocess_inventory") as pre:
417
+ deploy.run_command(context)
418
+ pyinfra.assert_not_called()
419
+ pre.assert_not_called()
420
+ self.assertTrue(context["success"])
421
+ self.assertEqual(called, ["deploy:precommand", "precommand", "deploy:postcommand", "postcommand"])
@@ -1 +0,0 @@
1
- __version__ = "1.6.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes