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.
- {djaploy-1.2.6 → djaploy-1.2.8}/PKG-INFO +1 -1
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/systemd/infra/djaploy_hooks.py +12 -7
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/infra/djaploy_hooks.py +82 -5
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy.egg-info/PKG-INFO +1 -1
- {djaploy-1.2.6 → djaploy-1.2.8}/pyproject.toml +1 -1
- {djaploy-1.2.6 → djaploy-1.2.8}/tests/test_bluegreen.py +117 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/LICENSE +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/README.md +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/app.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/borg/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/borg/infra/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/borg/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/janitor/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/janitor/apps.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/janitor/infra/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/janitor/infra/commands/createjanitoruser.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/janitor/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/nginx/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/nginx/apps.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/nginx/infra/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/nginx/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/rclone/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/rclone/apps.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/rclone/infra/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/rclone/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/sync_certs/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/sync_certs/apps.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/sync_certs/infra/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/sync_certs/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/systemd/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/systemd/apps.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/systemd/infra/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/tailscale/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/tailscale/apps.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/tailscale/infra/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/tailscale/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/versioning/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/versioning/apps.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/versioning/infra/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/apps/versioning/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/artifact.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/bin/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/bin/django_pyinfra.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/bin/gunicornherder.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/builtin_hooks.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/certificates.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/changelog.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/_utils.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/activate.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/configure.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/deploy.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/restore.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/rollback.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/status.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/commands/sync_certs.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/config.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/deploy.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/discovery.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/hooks.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/infra/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/infra/bluegreen.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/infra/templates.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/infra/utils.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/management/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/management/commands/__init__.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/management/commands/djaploy.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/management/commands/restore_backup.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/management/commands/sync_certs.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/management/commands/update_certs.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/management/commands/verify.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/management/utils.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/notifications.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/utils.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/version.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy/versioning.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy.egg-info/SOURCES.txt +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy.egg-info/dependency_links.txt +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy.egg-info/entry_points.txt +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy.egg-info/requires.txt +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/djaploy.egg-info/top_level.txt +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/setup.cfg +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/tests/test_borg.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/tests/test_config.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/tests/test_deploy_scripts.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/tests/test_discovery.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/tests/test_gunicornherder.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/tests/test_hooks.py +0 -0
- {djaploy-1.2.6 → djaploy-1.2.8}/tests/test_versioning.py +0 -0
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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':
|
|
664
|
-
f" 'commit':
|
|
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(
|
|
1018
|
+
print(" To rollback: manage.py djaploy rollback --env <env>\n")
|
|
942
1019
|
|
|
943
1020
|
python_op.call(
|
|
944
1021
|
name="Print activation summary",
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|