djaploy 1.2.6__tar.gz → 1.2.8__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.8}/PKG-INFO +1 -1
  2. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/systemd/infra/djaploy_hooks.py +12 -7
  3. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/infra/djaploy_hooks.py +82 -5
  4. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy.egg-info/PKG-INFO +1 -1
  5. {djaploy-1.2.6 → djaploy-1.2.8}/pyproject.toml +1 -1
  6. {djaploy-1.2.6 → djaploy-1.2.8}/tests/test_bluegreen.py +117 -0
  7. {djaploy-1.2.6 → djaploy-1.2.8}/LICENSE +0 -0
  8. {djaploy-1.2.6 → djaploy-1.2.8}/README.md +0 -0
  9. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/__init__.py +0 -0
  10. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/app.py +0 -0
  11. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/__init__.py +0 -0
  12. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/borg/__init__.py +0 -0
  13. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/borg/infra/__init__.py +0 -0
  14. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/borg/infra/djaploy_hooks.py +0 -0
  15. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/janitor/__init__.py +0 -0
  16. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/janitor/apps.py +0 -0
  17. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/janitor/infra/__init__.py +0 -0
  18. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/janitor/infra/commands/createjanitoruser.py +0 -0
  19. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/janitor/infra/djaploy_hooks.py +0 -0
  20. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/nginx/__init__.py +0 -0
  21. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/nginx/apps.py +0 -0
  22. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/nginx/infra/__init__.py +0 -0
  23. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/nginx/infra/djaploy_hooks.py +0 -0
  24. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/rclone/__init__.py +0 -0
  25. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/rclone/apps.py +0 -0
  26. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/rclone/infra/__init__.py +0 -0
  27. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/rclone/infra/djaploy_hooks.py +0 -0
  28. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/sync_certs/__init__.py +0 -0
  29. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/sync_certs/apps.py +0 -0
  30. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/sync_certs/infra/__init__.py +0 -0
  31. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/sync_certs/infra/djaploy_hooks.py +0 -0
  32. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/systemd/__init__.py +0 -0
  33. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/systemd/apps.py +0 -0
  34. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/systemd/infra/__init__.py +0 -0
  35. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/tailscale/__init__.py +0 -0
  36. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/tailscale/apps.py +0 -0
  37. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/tailscale/infra/__init__.py +0 -0
  38. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/tailscale/infra/djaploy_hooks.py +0 -0
  39. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/versioning/__init__.py +0 -0
  40. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/versioning/apps.py +0 -0
  41. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/versioning/infra/__init__.py +0 -0
  42. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/versioning/infra/djaploy_hooks.py +0 -0
  43. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/artifact.py +0 -0
  44. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/bin/__init__.py +0 -0
  45. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/bin/django_pyinfra.py +0 -0
  46. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/bin/gunicornherder.py +0 -0
  47. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/builtin_hooks.py +0 -0
  48. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/certificates.py +0 -0
  49. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/changelog.py +0 -0
  50. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/__init__.py +0 -0
  51. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/_utils.py +0 -0
  52. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/activate.py +0 -0
  53. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/configure.py +0 -0
  54. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/deploy.py +0 -0
  55. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/restore.py +0 -0
  56. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/rollback.py +0 -0
  57. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/status.py +0 -0
  58. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/sync_certs.py +0 -0
  59. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/config.py +0 -0
  60. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/deploy.py +0 -0
  61. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/discovery.py +0 -0
  62. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/hooks.py +0 -0
  63. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/infra/__init__.py +0 -0
  64. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/infra/bluegreen.py +0 -0
  65. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/infra/templates.py +0 -0
  66. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/infra/utils.py +0 -0
  67. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/management/__init__.py +0 -0
  68. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/management/commands/__init__.py +0 -0
  69. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/management/commands/djaploy.py +0 -0
  70. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/management/commands/restore_backup.py +0 -0
  71. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/management/commands/sync_certs.py +0 -0
  72. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/management/commands/update_certs.py +0 -0
  73. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/management/commands/verify.py +0 -0
  74. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/management/utils.py +0 -0
  75. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/notifications.py +0 -0
  76. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/utils.py +0 -0
  77. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/version.py +0 -0
  78. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/versioning.py +0 -0
  79. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy.egg-info/SOURCES.txt +0 -0
  80. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy.egg-info/dependency_links.txt +0 -0
  81. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy.egg-info/entry_points.txt +0 -0
  82. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy.egg-info/requires.txt +0 -0
  83. {djaploy-1.2.6 → djaploy-1.2.8}/djaploy.egg-info/top_level.txt +0 -0
  84. {djaploy-1.2.6 → djaploy-1.2.8}/setup.cfg +0 -0
  85. {djaploy-1.2.6 → djaploy-1.2.8}/tests/test_borg.py +0 -0
  86. {djaploy-1.2.6 → djaploy-1.2.8}/tests/test_config.py +0 -0
  87. {djaploy-1.2.6 → djaploy-1.2.8}/tests/test_deploy_scripts.py +0 -0
  88. {djaploy-1.2.6 → djaploy-1.2.8}/tests/test_discovery.py +0 -0
  89. {djaploy-1.2.6 → djaploy-1.2.8}/tests/test_gunicornherder.py +0 -0
  90. {djaploy-1.2.6 → djaploy-1.2.8}/tests/test_hooks.py +0 -0
  91. {djaploy-1.2.6 → djaploy-1.2.8}/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.8
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
+ # Uses if/else so enable/restart failures still surface as errors.
44
+ server.shell(
45
+ name=f"Start {slot_service} if unit exists",
46
+ commands=[
47
+ f"if [ -f /etc/systemd/system/{slot_service}.service ]; then "
48
+ f"systemctl enable {slot_service} && systemctl restart {slot_service}; "
49
+ f"else echo 'Unit {slot_service}.service not found, skipping'; fi",
50
+ ],
46
51
  _sudo=True,
47
52
  )
48
53
 
@@ -56,7 +61,7 @@ def start_services(host_data, artifact_path):
56
61
  f"systemctl stop {app_name}.service 2>/dev/null || true",
57
62
  f"systemctl disable {app_name}.service 2>/dev/null || true",
58
63
  f"rm -f /etc/systemd/system/{app_name}.service",
59
- f"systemctl daemon-reload 2>/dev/null || true",
64
+ "systemctl daemon-reload 2>/dev/null || true",
60
65
  ],
61
66
  _sudo=True,
62
67
  )
@@ -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"
@@ -755,6 +760,38 @@ def rollback_release(host_data, release=None):
755
760
  active_slot = (active_slot or "").strip()
756
761
  target_slot = other_slot(active_slot) if active_slot else "blue"
757
762
 
763
+ # Verify the target slot has a deployment before switching
764
+ server.shell(
765
+ name=f"Verify {target_slot} slot has a deployment for rollback",
766
+ commands=[
767
+ f"python3 -c \""
768
+ f"import json, sys; s=json.load(open('{state_file}')); "
769
+ f"sys.exit(0 if s['slots'].get('{target_slot}') else 1)"
770
+ f"\" || (echo 'ROLLBACK ABORTED: slot {target_slot} has no deployment.' && exit 1)",
771
+ ],
772
+ _sudo=True,
773
+ )
774
+
775
+ # Health check: verify the target slot is running before rolling back.
776
+ # Retries up to 3 times with 2s delay.
777
+ socket_path = get_slot_socket_path(app_name, target_slot)
778
+ server.shell(
779
+ name=f"Health check: verify {target_slot} slot is healthy for rollback",
780
+ commands=[
781
+ f"for attempt in 1 2 3; do "
782
+ f"HTTP_CODE=$(curl -o /dev/null -w '%{{http_code}}' --max-time 5 --unix-socket {socket_path} http://localhost/ 2>/dev/null); "
783
+ f"if [ \"$HTTP_CODE\" != \"000\" ]; then "
784
+ f"echo \"Health check passed: {target_slot} slot responding (HTTP $HTTP_CODE)\"; exit 0; fi; "
785
+ f"echo \"Health check attempt $attempt/3: no response, retrying in 2s...\"; "
786
+ f"sleep 2; "
787
+ f"done; "
788
+ f"echo 'ROLLBACK ABORTED: {target_slot} slot is not responding on {socket_path}' && "
789
+ f"echo 'Check: systemctl status {app_name}-{target_slot}.service' && "
790
+ f"exit 1",
791
+ ],
792
+ _sudo=True,
793
+ )
794
+
758
795
  # Store for activate:post hooks (timers, streaming, custom nginx)
759
796
  host.data._bluegreen_activated_slot = target_slot
760
797
 
@@ -835,6 +872,7 @@ def activate_bluegreen(host_data):
835
872
  from pyinfra.facts.server import Command
836
873
  from djaploy.infra.utils import (
837
874
  is_bluegreen, get_app_path, get_slot_socket_path,
875
+ get_slot_service_name,
838
876
  )
839
877
  from djaploy.infra.bluegreen import (
840
878
  read_active_slot_cmd, set_active_slot_cmd, other_slot,
@@ -887,6 +925,46 @@ def activate_bluegreen(host_data):
887
925
  # Store on host.data so activate:post hooks can read it
888
926
  host.data._bluegreen_activated_slot = target_slot
889
927
 
928
+ # Health check: verify all target slot services are running before
929
+ # switching nginx. Abort activation if any service is down.
930
+ services = getattr(host_data, "services", []) or []
931
+ failed_checks = []
932
+ for svc in services:
933
+ slot_svc = get_slot_service_name(svc, target_slot)
934
+ server.shell(
935
+ name=f"Health check: verify {slot_svc} is active",
936
+ commands=[
937
+ f"if systemctl is-active {slot_svc}.service > /dev/null 2>&1; then "
938
+ f"echo 'Health check passed: {slot_svc} is active'; "
939
+ f"else "
940
+ f"echo 'ACTIVATION ABORTED: {slot_svc} is not running.' && "
941
+ f"echo 'Check: systemctl status {slot_svc}.service' && "
942
+ f"exit 1; "
943
+ f"fi",
944
+ ],
945
+ _sudo=True,
946
+ )
947
+
948
+ # Extra gunicorn-specific check: verify the socket accepts HTTP requests.
949
+ # Retries up to 3 times with 2s delay to allow freshly started gunicorn to boot.
950
+ socket_path = get_slot_socket_path(app_name, target_slot)
951
+ server.shell(
952
+ name=f"Health check: verify {target_slot} gunicorn socket responds",
953
+ commands=[
954
+ f"for attempt in 1 2 3 4 5; do "
955
+ f"HTTP_CODE=$(curl -o /dev/null -w '%{{http_code}}' --max-time 5 --unix-socket {socket_path} http://localhost/ 2>/dev/null); "
956
+ f"if [ \"$HTTP_CODE\" != \"000\" ]; then "
957
+ f"echo \"Health check passed: gunicorn responding (HTTP $HTTP_CODE)\"; exit 0; fi; "
958
+ f"echo \"Health check attempt $attempt/5: no response, retrying in 3s...\"; "
959
+ f"sleep 3; "
960
+ f"done; "
961
+ f"echo 'ACTIVATION ABORTED: gunicorn not responding on {socket_path}' && "
962
+ f"echo 'Check: systemctl status {app_name}-{target_slot}.service' && "
963
+ f"exit 1",
964
+ ],
965
+ _sudo=True,
966
+ )
967
+
890
968
  # Update nginx upstream config to point to target slot
891
969
  _update_nginx_upstream(host_data, app_name, target_slot)
892
970
 
@@ -899,7 +977,6 @@ def activate_bluegreen(host_data):
899
977
 
900
978
  # Update state.json on remote
901
979
  prev_slot = active_slot or "none"
902
- socket_path = get_slot_socket_path(app_name, target_slot)
903
980
  server.shell(
904
981
  name=f"Set active slot to {target_slot}",
905
982
  commands=[set_active_slot_cmd(state_file, target_slot)],
@@ -938,7 +1015,7 @@ def activate_bluegreen(host_data):
938
1015
  print(f" Venv: {old_venv} -> {new_venv}")
939
1016
 
940
1017
  print(f"\n Previous slot ({p_slot}) is still running.")
941
- print(f" To rollback: manage.py djaploy rollback --env <env>\n")
1018
+ print(" To rollback: manage.py djaploy rollback --env <env>\n")
942
1019
 
943
1020
  python_op.call(
944
1021
  name="Print activation summary",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: djaploy
3
- Version: 1.2.6
3
+ Version: 1.2.8
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.8"
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("--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