djaploy 1.2.6__tar.gz → 1.2.7__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 (91) hide show
  1. {djaploy-1.2.6 → djaploy-1.2.7}/PKG-INFO +1 -1
  2. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/systemd/infra/djaploy_hooks.py +11 -6
  3. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/infra/djaploy_hooks.py +47 -4
  4. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy.egg-info/PKG-INFO +1 -1
  5. {djaploy-1.2.6 → djaploy-1.2.7}/pyproject.toml +1 -1
  6. {djaploy-1.2.6 → djaploy-1.2.7}/tests/test_bluegreen.py +117 -0
  7. {djaploy-1.2.6 → djaploy-1.2.7}/LICENSE +0 -0
  8. {djaploy-1.2.6 → djaploy-1.2.7}/README.md +0 -0
  9. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/__init__.py +0 -0
  10. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/app.py +0 -0
  11. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/__init__.py +0 -0
  12. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/borg/__init__.py +0 -0
  13. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/borg/infra/__init__.py +0 -0
  14. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/borg/infra/djaploy_hooks.py +0 -0
  15. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/janitor/__init__.py +0 -0
  16. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/janitor/apps.py +0 -0
  17. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/janitor/infra/__init__.py +0 -0
  18. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/janitor/infra/commands/createjanitoruser.py +0 -0
  19. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/janitor/infra/djaploy_hooks.py +0 -0
  20. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/nginx/__init__.py +0 -0
  21. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/nginx/apps.py +0 -0
  22. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/nginx/infra/__init__.py +0 -0
  23. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/nginx/infra/djaploy_hooks.py +0 -0
  24. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/rclone/__init__.py +0 -0
  25. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/rclone/apps.py +0 -0
  26. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/rclone/infra/__init__.py +0 -0
  27. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/rclone/infra/djaploy_hooks.py +0 -0
  28. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/sync_certs/__init__.py +0 -0
  29. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/sync_certs/apps.py +0 -0
  30. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/sync_certs/infra/__init__.py +0 -0
  31. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/sync_certs/infra/djaploy_hooks.py +0 -0
  32. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/systemd/__init__.py +0 -0
  33. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/systemd/apps.py +0 -0
  34. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/systemd/infra/__init__.py +0 -0
  35. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/tailscale/__init__.py +0 -0
  36. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/tailscale/apps.py +0 -0
  37. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/tailscale/infra/__init__.py +0 -0
  38. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/tailscale/infra/djaploy_hooks.py +0 -0
  39. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/versioning/__init__.py +0 -0
  40. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/versioning/apps.py +0 -0
  41. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/versioning/infra/__init__.py +0 -0
  42. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/apps/versioning/infra/djaploy_hooks.py +0 -0
  43. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/artifact.py +0 -0
  44. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/bin/__init__.py +0 -0
  45. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/bin/django_pyinfra.py +0 -0
  46. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/bin/gunicornherder.py +0 -0
  47. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/builtin_hooks.py +0 -0
  48. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/certificates.py +0 -0
  49. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/changelog.py +0 -0
  50. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/commands/__init__.py +0 -0
  51. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/commands/_utils.py +0 -0
  52. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/commands/activate.py +0 -0
  53. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/commands/configure.py +0 -0
  54. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/commands/deploy.py +0 -0
  55. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/commands/restore.py +0 -0
  56. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/commands/rollback.py +0 -0
  57. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/commands/status.py +0 -0
  58. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/commands/sync_certs.py +0 -0
  59. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/config.py +0 -0
  60. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/deploy.py +0 -0
  61. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/discovery.py +0 -0
  62. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/hooks.py +0 -0
  63. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/infra/__init__.py +0 -0
  64. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/infra/bluegreen.py +0 -0
  65. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/infra/templates.py +0 -0
  66. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/infra/utils.py +0 -0
  67. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/management/__init__.py +0 -0
  68. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/management/commands/__init__.py +0 -0
  69. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/management/commands/djaploy.py +0 -0
  70. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/management/commands/restore_backup.py +0 -0
  71. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/management/commands/sync_certs.py +0 -0
  72. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/management/commands/update_certs.py +0 -0
  73. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/management/commands/verify.py +0 -0
  74. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/management/utils.py +0 -0
  75. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/notifications.py +0 -0
  76. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/utils.py +0 -0
  77. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/version.py +0 -0
  78. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy/versioning.py +0 -0
  79. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy.egg-info/SOURCES.txt +0 -0
  80. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy.egg-info/dependency_links.txt +0 -0
  81. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy.egg-info/entry_points.txt +0 -0
  82. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy.egg-info/requires.txt +0 -0
  83. {djaploy-1.2.6 → djaploy-1.2.7}/djaploy.egg-info/top_level.txt +0 -0
  84. {djaploy-1.2.6 → djaploy-1.2.7}/setup.cfg +0 -0
  85. {djaploy-1.2.6 → djaploy-1.2.7}/tests/test_borg.py +0 -0
  86. {djaploy-1.2.6 → djaploy-1.2.7}/tests/test_config.py +0 -0
  87. {djaploy-1.2.6 → djaploy-1.2.7}/tests/test_deploy_scripts.py +0 -0
  88. {djaploy-1.2.6 → djaploy-1.2.7}/tests/test_discovery.py +0 -0
  89. {djaploy-1.2.6 → djaploy-1.2.7}/tests/test_gunicornherder.py +0 -0
  90. {djaploy-1.2.6 → djaploy-1.2.7}/tests/test_hooks.py +0 -0
  91. {djaploy-1.2.6 → djaploy-1.2.7}/tests/test_versioning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: djaploy
3
- Version: 1.2.6
3
+ Version: 1.2.7
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
@@ -34,15 +34,20 @@ def start_services(host_data, artifact_path):
34
34
  if not target_slot:
35
35
  return
36
36
 
37
+ from pyinfra.operations import server
37
38
  for service in getattr(host_data, "services", []):
38
39
  slot_service = get_slot_service_name(service, target_slot)
39
40
 
40
- systemd.service(
41
- name=f"Start and restart {slot_service} (target slot)",
42
- service=slot_service,
43
- running=True,
44
- enabled=True,
45
- restarted=True,
41
+ # Only start if the service unit file exists — custom services
42
+ # (e.g. streaming) may be rendered by project hooks, not djaploy core.
43
+ server.shell(
44
+ name=f"Start {slot_service} if unit exists",
45
+ commands=[
46
+ f"test -f /etc/systemd/system/{slot_service}.service && "
47
+ f"systemctl enable {slot_service} && "
48
+ f"systemctl restart {slot_service} || "
49
+ f"echo 'Unit {slot_service}.service not found, skipping'",
50
+ ],
46
51
  _sudo=True,
47
52
  )
48
53
 
@@ -648,8 +648,13 @@ def activate_release(host_data, artifact_path):
648
648
  run_migrations(app_user, build_path, host_data)
649
649
  collect_static(app_user, build_path, host_data)
650
650
 
651
- # Update state.json on the remote server
651
+ # Update state.json on the remote server.
652
+ # Serialize release_name and commit with json.dumps to safely
653
+ # handle quotes and special characters in branch names / messages.
654
+ import json as _json
652
655
  commit = getattr(host_data, 'commit', 'unknown')
656
+ safe_release = _json.dumps(release_name)
657
+ safe_commit = _json.dumps(commit)
653
658
  socket_path = get_slot_socket_path(app_name, target_slot)
654
659
  server.shell(
655
660
  name="Update state.json with deployment info",
@@ -660,8 +665,8 @@ def activate_release(host_data, artifact_path):
660
665
  f"python_path = venv_path + '/bin/python' if venv_path != 'unknown' else 'unknown'\n"
661
666
  f"s = json.load(open('{state_file}'))\n"
662
667
  f"s['slots']['{target_slot}'] = {{\n"
663
- f" 'release': '{release_name}',\n"
664
- f" 'commit': '{commit}',\n"
668
+ f" 'release': {safe_release},\n"
669
+ f" 'commit': {safe_commit},\n"
665
670
  f" 'deployed_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),\n"
666
671
  f" 'python_interpreter': python_path,\n"
667
672
  f" 'venv_path': venv_path,\n"
@@ -835,6 +840,7 @@ def activate_bluegreen(host_data):
835
840
  from pyinfra.facts.server import Command
836
841
  from djaploy.infra.utils import (
837
842
  is_bluegreen, get_app_path, get_slot_socket_path,
843
+ get_slot_service_name,
838
844
  )
839
845
  from djaploy.infra.bluegreen import (
840
846
  read_active_slot_cmd, set_active_slot_cmd, other_slot,
@@ -887,6 +893,44 @@ def activate_bluegreen(host_data):
887
893
  # Store on host.data so activate:post hooks can read it
888
894
  host.data._bluegreen_activated_slot = target_slot
889
895
 
896
+ # Health check: verify all target slot services are running before
897
+ # switching nginx. Abort activation if any service is down.
898
+ services = getattr(host_data, "services", []) or []
899
+ failed_checks = []
900
+ for svc in services:
901
+ slot_svc = get_slot_service_name(svc, target_slot)
902
+ server.shell(
903
+ name=f"Health check: verify {slot_svc} is active",
904
+ commands=[
905
+ f"if systemctl is-active {slot_svc}.service > /dev/null 2>&1; then "
906
+ f"echo 'Health check passed: {slot_svc} is active'; "
907
+ f"else "
908
+ f"echo 'ACTIVATION ABORTED: {slot_svc} is not running.' && "
909
+ f"echo 'Check: systemctl status {slot_svc}.service' && "
910
+ f"exit 1; "
911
+ f"fi",
912
+ ],
913
+ _sudo=True,
914
+ )
915
+
916
+ # Extra gunicorn-specific check: verify the socket is responding
917
+ socket_path = get_slot_socket_path(app_name, target_slot)
918
+ server.shell(
919
+ name=f"Health check: verify {target_slot} gunicorn socket responds",
920
+ commands=[
921
+ f"if curl -sf --max-time 5 --unix-socket {socket_path} http://localhost/ > /dev/null 2>&1; then "
922
+ f"echo 'Health check passed: gunicorn socket is responding'; "
923
+ f"elif [ -S {socket_path} ]; then "
924
+ f"echo 'Health check: socket exists, HTTP may be non-200 (proceeding)'; "
925
+ f"else "
926
+ f"echo 'ACTIVATION ABORTED: gunicorn socket not responding.' && "
927
+ f"echo 'Socket: {socket_path}' && "
928
+ f"exit 1; "
929
+ f"fi",
930
+ ],
931
+ _sudo=True,
932
+ )
933
+
890
934
  # Update nginx upstream config to point to target slot
891
935
  _update_nginx_upstream(host_data, app_name, target_slot)
892
936
 
@@ -899,7 +943,6 @@ def activate_bluegreen(host_data):
899
943
 
900
944
  # Update state.json on remote
901
945
  prev_slot = active_slot or "none"
902
- socket_path = get_slot_socket_path(app_name, target_slot)
903
946
  server.shell(
904
947
  name=f"Set active slot to {target_slot}",
905
948
  commands=[set_active_slot_cmd(state_file, target_slot)],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: djaploy
3
- Version: 1.2.6
3
+ Version: 1.2.7
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.2.6"
3
+ version = "1.2.7"
4
4
  description = "Modular Django deployment system based on pyinfra"
5
5
  authors = [
6
6
  {name = "Johanna Mae Dimayuga", email = "johanna@techco.fi"}
@@ -483,5 +483,122 @@ class TestBluegreenHookDiscovery(unittest.TestCase):
483
483
  global_registry._hooks.update(saved_hooks)
484
484
 
485
485
 
486
+ class TestBluegreenHealthCheck(unittest.TestCase):
487
+ """Test health check logic in activate hook."""
488
+
489
+ def test_health_check_socket_path_uses_target_slot(self):
490
+ """Health check should curl the target slot's socket."""
491
+ from djaploy.infra.utils import get_slot_socket_path
492
+ blue_socket = get_slot_socket_path("myapp", "blue")
493
+ green_socket = get_slot_socket_path("myapp", "green")
494
+ self.assertEqual(blue_socket, "/run/myapp-blue/myapp.sock")
495
+ self.assertEqual(green_socket, "/run/myapp-green/myapp.sock")
496
+ # Sockets must be different so health check targets the right slot
497
+ self.assertNotEqual(blue_socket, green_socket)
498
+
499
+ def test_health_check_service_name_uses_target_slot(self):
500
+ """Health check fallback should check the target slot's service."""
501
+ from djaploy.infra.utils import get_slot_service_name
502
+ self.assertEqual(get_slot_service_name("myapp", "blue"), "myapp-blue")
503
+ self.assertEqual(get_slot_service_name("myapp", "green"), "myapp-green")
504
+
505
+ def test_health_check_command_structure(self):
506
+ """Verify the health check shell command contains the expected elements."""
507
+ # Simulate what activate_bluegreen generates
508
+ app_name = "testapp"
509
+ target_slot = "green"
510
+ from djaploy.infra.utils import get_slot_socket_path
511
+ socket_path = get_slot_socket_path(app_name, target_slot)
512
+
513
+ # Build the command the same way the hook does
514
+ cmd = (
515
+ f"if curl -sf --max-time 5 --unix-socket {socket_path} http://localhost/ > /dev/null 2>&1; then "
516
+ f"echo 'Health check passed: {target_slot} slot is responding'; "
517
+ f"else "
518
+ f"if [ -S {socket_path} ] && systemctl is-active {app_name}-{target_slot}.service > /dev/null 2>&1; then "
519
+ f"echo 'Health check: {target_slot} slot service is active (HTTP response may be non-200, proceeding)'; "
520
+ f"else "
521
+ f"echo 'ACTIVATION ABORTED: {target_slot} slot is not responding.' && "
522
+ f"echo 'Socket: {socket_path}' && "
523
+ f"echo 'Check: systemctl status {app_name}-{target_slot}.service' && "
524
+ f"exit 1; "
525
+ f"fi; "
526
+ f"fi"
527
+ )
528
+
529
+ # Must curl the correct socket
530
+ self.assertIn(f"--unix-socket /run/testapp-green/testapp.sock", cmd)
531
+ # Must check the correct service on fallback
532
+ self.assertIn("systemctl is-active testapp-green.service", cmd)
533
+ # Must abort with exit 1 on failure
534
+ self.assertIn("exit 1", cmd)
535
+ # Must include the abort message
536
+ self.assertIn("ACTIVATION ABORTED", cmd)
537
+
538
+ def test_health_check_different_slots_produce_different_commands(self):
539
+ """Blue and green health checks should target different sockets."""
540
+ from djaploy.infra.utils import get_slot_socket_path
541
+ blue_cmd = f"curl --unix-socket {get_slot_socket_path('app', 'blue')}"
542
+ green_cmd = f"curl --unix-socket {get_slot_socket_path('app', 'green')}"
543
+ self.assertIn("app-blue", blue_cmd)
544
+ self.assertIn("app-green", green_cmd)
545
+ self.assertNotEqual(blue_cmd, green_cmd)
546
+
547
+
548
+ class TestBluegreenServiceExistenceCheck(unittest.TestCase):
549
+ """Test that start_services checks unit file existence before starting."""
550
+
551
+ def test_service_check_command_structure(self):
552
+ """The start command should test for the unit file before starting."""
553
+ from djaploy.infra.utils import get_slot_service_name
554
+ slot_service = get_slot_service_name("myapp", "blue")
555
+
556
+ # Build the command the same way start_services does
557
+ cmd = (
558
+ f"test -f /etc/systemd/system/{slot_service}.service && "
559
+ f"systemctl enable {slot_service} && "
560
+ f"systemctl restart {slot_service} || "
561
+ f"echo 'Unit {slot_service}.service not found, skipping'"
562
+ )
563
+
564
+ self.assertIn("test -f /etc/systemd/system/myapp-blue.service", cmd)
565
+ self.assertIn("systemctl enable myapp-blue", cmd)
566
+ self.assertIn("systemctl restart myapp-blue", cmd)
567
+ self.assertIn("not found, skipping", cmd)
568
+
569
+ def test_streaming_service_gets_separate_slot_name(self):
570
+ """bostad-streaming should produce bostad-streaming-blue, not bostad-blue."""
571
+ from djaploy.infra.utils import get_slot_service_name
572
+ self.assertEqual(
573
+ get_slot_service_name("bostad-streaming", "blue"),
574
+ "bostad-streaming-blue",
575
+ )
576
+ self.assertEqual(
577
+ get_slot_service_name("bostad-streaming", "green"),
578
+ "bostad-streaming-green",
579
+ )
580
+
581
+
582
+ class TestHeredocInjectionSafety(unittest.TestCase):
583
+ """Test that state.json update safely handles special characters."""
584
+
585
+ def test_release_name_with_quotes_is_safe(self):
586
+ """json.dumps should escape quotes in release names."""
587
+ import json
588
+ release = "feat/o'hare"
589
+ safe = json.dumps(release)
590
+ self.assertEqual(safe, '"feat/o\'hare"')
591
+ # Should be parseable back
592
+ self.assertEqual(json.loads(safe), release)
593
+
594
+ def test_commit_with_special_chars_is_safe(self):
595
+ """json.dumps should handle commit messages with special chars."""
596
+ import json
597
+ commit = 'fix: handle "edge" case'
598
+ safe = json.dumps(commit)
599
+ self.assertIn('\\"edge\\"', safe)
600
+ self.assertEqual(json.loads(safe), commit)
601
+
602
+
486
603
  if __name__ == "__main__":
487
604
  unittest.main()
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