djaploy 1.2.5__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.5 → djaploy-1.2.7}/PKG-INFO +3 -1
  2. {djaploy-1.2.5 → djaploy-1.2.7}/README.md +2 -0
  3. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/borg/infra/djaploy_hooks.py +3 -3
  4. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/systemd/infra/djaploy_hooks.py +27 -8
  5. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/builtin_hooks.py +3 -2
  6. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/status.py +4 -2
  7. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/infra/bluegreen.py +17 -33
  8. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/infra/djaploy_hooks.py +78 -49
  9. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/infra/utils.py +2 -2
  10. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/management/commands/djaploy.py +2 -0
  11. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy.egg-info/PKG-INFO +3 -1
  12. {djaploy-1.2.5 → djaploy-1.2.7}/pyproject.toml +1 -1
  13. {djaploy-1.2.5 → djaploy-1.2.7}/tests/test_bluegreen.py +130 -12
  14. {djaploy-1.2.5 → djaploy-1.2.7}/tests/test_borg.py +30 -0
  15. {djaploy-1.2.5 → djaploy-1.2.7}/LICENSE +0 -0
  16. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/__init__.py +0 -0
  17. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/app.py +0 -0
  18. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/__init__.py +0 -0
  19. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/borg/__init__.py +0 -0
  20. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/borg/infra/__init__.py +0 -0
  21. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/janitor/__init__.py +0 -0
  22. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/janitor/apps.py +0 -0
  23. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/janitor/infra/__init__.py +0 -0
  24. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/janitor/infra/commands/createjanitoruser.py +0 -0
  25. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/janitor/infra/djaploy_hooks.py +0 -0
  26. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/nginx/__init__.py +0 -0
  27. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/nginx/apps.py +0 -0
  28. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/nginx/infra/__init__.py +0 -0
  29. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/nginx/infra/djaploy_hooks.py +0 -0
  30. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/rclone/__init__.py +0 -0
  31. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/rclone/apps.py +0 -0
  32. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/rclone/infra/__init__.py +0 -0
  33. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/rclone/infra/djaploy_hooks.py +0 -0
  34. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/sync_certs/__init__.py +0 -0
  35. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/sync_certs/apps.py +0 -0
  36. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/sync_certs/infra/__init__.py +0 -0
  37. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/sync_certs/infra/djaploy_hooks.py +0 -0
  38. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/systemd/__init__.py +0 -0
  39. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/systemd/apps.py +0 -0
  40. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/systemd/infra/__init__.py +0 -0
  41. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/tailscale/__init__.py +0 -0
  42. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/tailscale/apps.py +0 -0
  43. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/tailscale/infra/__init__.py +0 -0
  44. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/tailscale/infra/djaploy_hooks.py +0 -0
  45. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/versioning/__init__.py +0 -0
  46. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/versioning/apps.py +0 -0
  47. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/versioning/infra/__init__.py +0 -0
  48. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/versioning/infra/djaploy_hooks.py +0 -0
  49. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/artifact.py +0 -0
  50. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/bin/__init__.py +0 -0
  51. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/bin/django_pyinfra.py +0 -0
  52. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/bin/gunicornherder.py +0 -0
  53. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/certificates.py +0 -0
  54. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/changelog.py +0 -0
  55. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/__init__.py +0 -0
  56. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/_utils.py +0 -0
  57. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/activate.py +0 -0
  58. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/configure.py +0 -0
  59. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/deploy.py +0 -0
  60. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/restore.py +0 -0
  61. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/rollback.py +0 -0
  62. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/sync_certs.py +0 -0
  63. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/config.py +0 -0
  64. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/deploy.py +0 -0
  65. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/discovery.py +0 -0
  66. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/hooks.py +0 -0
  67. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/infra/__init__.py +0 -0
  68. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/infra/templates.py +0 -0
  69. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/management/__init__.py +0 -0
  70. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/management/commands/__init__.py +0 -0
  71. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/management/commands/restore_backup.py +0 -0
  72. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/management/commands/sync_certs.py +0 -0
  73. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/management/commands/update_certs.py +0 -0
  74. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/management/commands/verify.py +0 -0
  75. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/management/utils.py +0 -0
  76. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/notifications.py +0 -0
  77. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/utils.py +0 -0
  78. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/version.py +0 -0
  79. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/versioning.py +0 -0
  80. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy.egg-info/SOURCES.txt +0 -0
  81. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy.egg-info/dependency_links.txt +0 -0
  82. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy.egg-info/entry_points.txt +0 -0
  83. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy.egg-info/requires.txt +0 -0
  84. {djaploy-1.2.5 → djaploy-1.2.7}/djaploy.egg-info/top_level.txt +0 -0
  85. {djaploy-1.2.5 → djaploy-1.2.7}/setup.cfg +0 -0
  86. {djaploy-1.2.5 → djaploy-1.2.7}/tests/test_config.py +0 -0
  87. {djaploy-1.2.5 → djaploy-1.2.7}/tests/test_deploy_scripts.py +0 -0
  88. {djaploy-1.2.5 → djaploy-1.2.7}/tests/test_discovery.py +0 -0
  89. {djaploy-1.2.5 → djaploy-1.2.7}/tests/test_gunicornherder.py +0 -0
  90. {djaploy-1.2.5 → djaploy-1.2.7}/tests/test_hooks.py +0 -0
  91. {djaploy-1.2.5 → 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.5
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
@@ -289,6 +289,8 @@ python manage.py djaploy rollback --env production
289
289
  3. **Activate** -- rewrites nginx upstream to point to the new slot, reloads nginx
290
290
  4. **Rollback** (if needed) -- switches nginx back to the previous slot, which is still running
291
291
 
292
+ > **Note:** Migrations run during Step 1, before traffic switches. Both the old and new slots share the same database, so migrations must be **backward-compatible** (use the expand/contract pattern). Deploy new code that handles both old and new schema, activate, then clean up in a subsequent deploy.
293
+
292
294
  ### Server directory layout comparison
293
295
 
294
296
  For `app_user="myapp-api"`, `app_name="myapp"`:
@@ -227,6 +227,8 @@ python manage.py djaploy rollback --env production
227
227
  3. **Activate** -- rewrites nginx upstream to point to the new slot, reloads nginx
228
228
  4. **Rollback** (if needed) -- switches nginx back to the previous slot, which is still running
229
229
 
230
+ > **Note:** Migrations run during Step 1, before traffic switches. Both the old and new slots share the same database, so migrations must be **backward-compatible** (use the expand/contract pattern). Deploy new code that handles both old and new schema, activate, then clean up in a subsequent deploy.
231
+
230
232
  ### Server directory layout comparison
231
233
 
232
234
  For `app_user="myapp-api"`, `app_name="myapp"`:
@@ -555,9 +555,9 @@ def restore_borg(host_data, restore_opts):
555
555
  )
556
556
  bluegreen_services = []
557
557
  for svc in services:
558
- bluegreen_services.append(f"{app_name}-blue")
559
- bluegreen_services.append(f"{app_name}-green")
560
- # Also stop streaming coordinator instances
558
+ bluegreen_services.append(f"{svc}-blue")
559
+ bluegreen_services.append(f"{svc}-green")
560
+ # Also stop streaming coordinator instances (app-level, not per-service)
561
561
  bluegreen_services.append(f"{app_name}-streaming-blue")
562
562
  bluegreen_services.append(f"{app_name}-streaming-green")
563
563
  services = bluegreen_services
@@ -34,18 +34,37 @@ 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
- app_name = getattr(host_data, 'app_name', service)
39
- slot_service = get_slot_service_name(app_name, target_slot)
39
+ slot_service = get_slot_service_name(service, target_slot)
40
40
 
41
- systemd.service(
42
- name=f"Start and restart {slot_service} (target slot)",
43
- service=slot_service,
44
- running=True,
45
- enabled=True,
46
- 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
+ ],
47
51
  _sudo=True,
48
52
  )
53
+
54
+ # Disable legacy single service from zero_downtime/in_place if present.
55
+ # Runs after the new slot service is started so there's no downtime gap.
56
+ app_name = getattr(host_data, 'app_name', None)
57
+ from pyinfra.operations import server
58
+ server.shell(
59
+ name="Disable legacy service (migration to bluegreen)",
60
+ commands=[
61
+ f"systemctl stop {app_name}.service 2>/dev/null || true",
62
+ f"systemctl disable {app_name}.service 2>/dev/null || true",
63
+ f"rm -f /etc/systemd/system/{app_name}.service",
64
+ f"systemctl daemon-reload 2>/dev/null || true",
65
+ ],
66
+ _sudo=True,
67
+ )
49
68
  elif is_zero_downtime(host_data):
50
69
  for service in getattr(host_data, "services", []):
51
70
  systemd.service(
@@ -114,8 +114,9 @@ def _deploy_calculate_release_info(context):
114
114
  text=True,
115
115
  ).strip()
116
116
  context["pyinfra_data"]["commit"] = commit
117
- except Exception:
118
- pass
117
+ except Exception as e:
118
+ import logging
119
+ logging.getLogger(__name__).debug("Failed to get commit hash: %s", e)
119
120
 
120
121
 
121
122
  # ── deploy:postcommand ───────────────────────────────────────────────
@@ -64,8 +64,10 @@ if is_bluegreen(host.data):
64
64
  status_str = "unknown"
65
65
  if res[0] and len(res) > 1:
66
66
  try:
67
- status_str = (list(res[1])[0].line if hasattr(list(res[1])[0], 'line') else str(list(res[1])[0])).strip()
68
- except (IndexError, AttributeError):
67
+ first_item = next(iter(res[1]), None)
68
+ if first_item is not None:
69
+ status_str = (first_item.line if hasattr(first_item, 'line') else str(first_item)).strip()
70
+ except (StopIteration, AttributeError):
69
71
  pass
70
72
  print(f"Service {svc}: {status_str}")
71
73
  print()
@@ -63,21 +63,28 @@ def update_slot_info_cmd(
63
63
  python_interpreter: str,
64
64
  venv_path: str,
65
65
  ) -> str:
66
- """Shell command to update a slot's deployment metadata in state.json."""
66
+ """Shell command to update a slot's deployment metadata in state.json.
67
+
68
+ Note: Production code uses heredocs instead of this function.
69
+ Kept for testing and as a utility.
70
+ """
71
+ # Serialize values safely to avoid injection
72
+ safe_vals = json.dumps({
73
+ "release": release,
74
+ "commit": commit,
75
+ "python_interpreter": python_interpreter,
76
+ "venv_path": venv_path,
77
+ })
67
78
  return (
68
79
  f"python3 -c \""
69
- f"import json, datetime; "
80
+ f"import json, datetime, os; "
70
81
  f"s=json.load(open('{state_file}')); "
71
- f"s['slots']['{slot}']={{"
72
- f"'release':'{release}',"
73
- f"'commit':'{commit}',"
74
- f"'deployed_at':datetime.datetime.now(datetime.timezone.utc).isoformat(),"
75
- f"'python_interpreter':'{python_interpreter}',"
76
- f"'venv_path':'{venv_path}'"
77
- f"}}; "
82
+ f"vals={safe_vals}; "
83
+ f"vals['deployed_at']=datetime.datetime.now(datetime.timezone.utc).isoformat(); "
84
+ f"s['slots']['{slot}']=vals; "
78
85
  f"f=open('{state_file}.tmp','w'); "
79
86
  f"json.dump(s,f,indent=2); f.close(); "
80
- f"import os; os.rename('{state_file}.tmp','{state_file}')"
87
+ f"os.rename('{state_file}.tmp','{state_file}')"
81
88
  f"\""
82
89
  )
83
90
 
@@ -120,26 +127,3 @@ def print_slot_info_cmd(state_file: str, slot: str) -> str:
120
127
  )
121
128
 
122
129
 
123
- def print_status_cmd(state_file: str, app_name: str) -> str:
124
- """Shell command that prints full blue-green status."""
125
- return (
126
- f"python3 -c \""
127
- f"import json; "
128
- f"s=json.load(open('{state_file}')); "
129
- f"active=s.get('active_slot') or 'none'; "
130
- f"print(); "
131
- f"print('Blue-Green Status for {app_name}'); "
132
- f"print('=' * 40); "
133
- f"print(f'Active slot: {{active}}'); "
134
- f"print(); "
135
- f"for slot in ('blue','green'): "
136
- f" info=s['slots'].get(slot); "
137
- f" tag='ACTIVE' if active==slot else 'inactive'; "
138
- f" print(f'{{slot.upper()}} ({{tag}}):'); "
139
- f" ["
140
- f" (print(f' Release: {{info[k]}}') for k in ('release','commit','deployed_at','python_interpreter','venv_path'))"
141
- f" if info else print(' (empty)')"
142
- f" ]; "
143
- f" print()"
144
- f"\""
145
- )
@@ -8,6 +8,31 @@ Handles server configuration, application deployment, post-deploy tasks
8
8
  from djaploy.hooks import deploy_hook
9
9
 
10
10
 
11
+ def _update_nginx_upstream(host_data, app_name, target_slot):
12
+ """Render and deploy the bluegreen nginx upstream config.
13
+
14
+ Skipped when nginx_conf.custom is set — custom setups handle
15
+ their own upstream via activate:post hooks.
16
+ """
17
+ from io import StringIO
18
+ from pyinfra.operations import files
19
+ from djaploy.infra.templates import NGINX_UPSTREAM_BLUEGREEN, build_template_context
20
+ from jinja2 import Environment
21
+
22
+ ctx = build_template_context(host_data)
23
+ ctx["active_slot"] = target_slot
24
+ upstream_content = Environment().from_string(NGINX_UPSTREAM_BLUEGREEN).render(**ctx)
25
+
26
+ nginx_cfg = getattr(host_data, 'nginx_conf', None) or {}
27
+ if not nginx_cfg.get("custom"):
28
+ files.put(
29
+ name=f"Update nginx upstream to {target_slot} slot",
30
+ src=StringIO(upstream_content),
31
+ dest=f"/etc/nginx/sites-available/{app_name}-upstream.conf",
32
+ _sudo=True,
33
+ )
34
+
35
+
11
36
  def _read_slot_info_from_remote(host, slot, state_file):
12
37
  """Read a slot's deployment info from state.json on the remote server.
13
38
 
@@ -28,7 +53,7 @@ def _read_slot_info_from_remote(host, slot, state_file):
28
53
  if status and output:
29
54
  try:
30
55
  # pyinfra returns OutputLine objects with a .line attribute
31
- lines = [l.line if hasattr(l, 'line') else str(l) for l in output]
56
+ lines = [line.line if hasattr(line, 'line') else str(line) for line in output]
32
57
  raw = "\n".join(lines)
33
58
  state_data = json.loads(raw)
34
59
  return state_data.get("slots", {}).get(slot) or {}
@@ -160,7 +185,6 @@ def configure_server(host_data):
160
185
  _sudo_user=app_user,
161
186
  )
162
187
 
163
-
164
188
  shared_resources = getattr(host_data, 'shared_resources', None) or []
165
189
  if shared_resources:
166
190
  mkdir_commands = [
@@ -624,8 +648,13 @@ def activate_release(host_data, artifact_path):
624
648
  run_migrations(app_user, build_path, host_data)
625
649
  collect_static(app_user, build_path, host_data)
626
650
 
627
- # 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
628
655
  commit = getattr(host_data, 'commit', 'unknown')
656
+ safe_release = _json.dumps(release_name)
657
+ safe_commit = _json.dumps(commit)
629
658
  socket_path = get_slot_socket_path(app_name, target_slot)
630
659
  server.shell(
631
660
  name="Update state.json with deployment info",
@@ -636,8 +665,8 @@ def activate_release(host_data, artifact_path):
636
665
  f"python_path = venv_path + '/bin/python' if venv_path != 'unknown' else 'unknown'\n"
637
666
  f"s = json.load(open('{state_file}'))\n"
638
667
  f"s['slots']['{target_slot}'] = {{\n"
639
- f" 'release': '{release_name}',\n"
640
- f" 'commit': '{commit}',\n"
668
+ f" 'release': {safe_release},\n"
669
+ f" 'commit': {safe_commit},\n"
641
670
  f" 'deployed_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),\n"
642
671
  f" 'python_interpreter': python_path,\n"
643
672
  f" 'venv_path': venv_path,\n"
@@ -658,13 +687,13 @@ def activate_release(host_data, artifact_path):
658
687
  def _print_deploy_summary(state_f, t_slot, rel, sock):
659
688
  info = _read_slot_info_from_remote(host, t_slot, state_f)
660
689
 
661
- print(f"\n=== Blue-Green Deploy Summary ===")
690
+ print("\n=== Blue-Green Deploy Summary ===")
662
691
  print(f" Slot: {t_slot}")
663
692
  print(f" Release: {rel}")
664
693
  print(f" Socket: {sock}")
665
694
  print(f" Venv: {info.get('venv_path', 'unknown')}")
666
695
  print(f" Python: {info.get('python_interpreter', 'unknown')}")
667
- print(f"\n Status: staged (run 'djaploy activate' to switch traffic)\n")
696
+ print("\n Status: staged (run 'djaploy activate' to switch traffic)\n")
668
697
 
669
698
  python_op.call(
670
699
  name="Print deployment summary",
@@ -735,19 +764,7 @@ def rollback_release(host_data, release=None):
735
764
  host.data._bluegreen_activated_slot = target_slot
736
765
 
737
766
  # Update nginx upstream for non-custom setups
738
- ctx = build_template_context(host_data)
739
- ctx["active_slot"] = target_slot
740
- upstream_content = Environment().from_string(NGINX_UPSTREAM_BLUEGREEN).render(**ctx)
741
-
742
- nginx_cfg = getattr(host_data, 'nginx_conf', None) or {}
743
- if not nginx_cfg.get("custom"):
744
- from pyinfra.operations import files
745
- files.put(
746
- name=f"Update nginx upstream to {target_slot} slot",
747
- src=StringIO(upstream_content),
748
- dest=f"/etc/nginx/sites-available/{app_name}-upstream.conf",
749
- _sudo=True,
750
- )
767
+ _update_nginx_upstream(host_data, app_name, target_slot)
751
768
 
752
769
  # Reload nginx
753
770
  server.shell(
@@ -769,7 +786,7 @@ def rollback_release(host_data, release=None):
769
786
 
770
787
  def _print_rollback_summary(state_f, t_slot, p_slot):
771
788
  info = _read_slot_info_from_remote(host, t_slot, state_f)
772
- print(f"\n=== Blue-Green Rollback ===")
789
+ print("\n=== Blue-Green Rollback ===")
773
790
  print(f" Switched: {p_slot} -> {t_slot}")
774
791
  print(f" Release: {info.get('release', 'unknown')}")
775
792
  print(f" Python: {info.get('python_interpreter', 'unknown')}")
@@ -823,6 +840,7 @@ def activate_bluegreen(host_data):
823
840
  from pyinfra.facts.server import Command
824
841
  from djaploy.infra.utils import (
825
842
  is_bluegreen, get_app_path, get_slot_socket_path,
843
+ get_slot_service_name,
826
844
  )
827
845
  from djaploy.infra.bluegreen import (
828
846
  read_active_slot_cmd, set_active_slot_cmd, other_slot,
@@ -875,27 +893,47 @@ def activate_bluegreen(host_data):
875
893
  # Store on host.data so activate:post hooks can read it
876
894
  host.data._bluegreen_activated_slot = target_slot
877
895
 
878
- # Update nginx upstream config to point to target slot
879
- from io import StringIO
880
- from djaploy.infra.templates import NGINX_UPSTREAM_BLUEGREEN, build_template_context
881
- from jinja2 import Environment
882
-
883
- ctx = build_template_context(host_data)
884
- ctx["active_slot"] = target_slot
885
- upstream_content = Environment().from_string(NGINX_UPSTREAM_BLUEGREEN).render(**ctx)
886
-
887
- # Write the upstream config. Custom nginx setups (e.g. bostad) handle
888
- # their own upstream file via activate:post hooks.
889
- from pyinfra.operations import files
890
- nginx_cfg = getattr(host_data, 'nginx_conf', None) or {}
891
- if not nginx_cfg.get("custom"):
892
- files.put(
893
- name=f"Update nginx upstream to {target_slot} slot",
894
- src=StringIO(upstream_content),
895
- dest=f"/etc/nginx/sites-available/{app_name}-upstream.conf",
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
+ ],
896
913
  _sudo=True,
897
914
  )
898
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
+
934
+ # Update nginx upstream config to point to target slot
935
+ _update_nginx_upstream(host_data, app_name, target_slot)
936
+
899
937
  # Reload nginx
900
938
  server.shell(
901
939
  name="Test and reload nginx",
@@ -905,7 +943,6 @@ def activate_bluegreen(host_data):
905
943
 
906
944
  # Update state.json on remote
907
945
  prev_slot = active_slot or "none"
908
- socket_path = get_slot_socket_path(app_name, target_slot)
909
946
  server.shell(
910
947
  name=f"Set active slot to {target_slot}",
911
948
  commands=[set_active_slot_cmd(state_file, target_slot)],
@@ -920,7 +957,7 @@ def activate_bluegreen(host_data):
920
957
  new_info = _read_slot_info_from_remote(host, t_slot, state_f)
921
958
  old_info = _read_slot_info_from_remote(host, p_slot, state_f) if p_slot != "none" else {}
922
959
 
923
- print(f"\n=== Blue-Green Activation ===")
960
+ print("\n=== Blue-Green Activation ===")
924
961
  print(f" Switched: {p_slot} -> {t_slot}")
925
962
  print(f" Socket: {sock} (now serving production)")
926
963
  print(f" Deployed at: {new_info.get('deployed_at', 'unknown')}")
@@ -954,11 +991,3 @@ def activate_bluegreen(host_data):
954
991
  p_slot=prev_slot,
955
992
  sock=socket_path,
956
993
  )
957
-
958
-
959
- @deploy_hook("activate")
960
- def activate_bluegreen_status(host_data):
961
- """Print full blue-green status during status command."""
962
- # This hook is intentionally a no-op during activate —
963
- # the main activate_bluegreen hook handles everything.
964
- pass
@@ -526,8 +526,8 @@ def get_manage_py_path(host_data) -> str:
526
526
  def get_python_cmd(app_user: str, app_path: str, host_data) -> str:
527
527
  """Get the python command prefix for running management commands.
528
528
 
529
- For zero-downtime deploys, uses the release's .venv/bin/python directly.
530
- For in-place deploys, uses 'poetry run python'.
529
+ For zero-downtime and blue-green deploys, uses the release/slot's
530
+ .venv/bin/python directly. For in-place deploys, uses 'poetry run python'.
531
531
  """
532
532
  if is_zero_downtime(host_data) or is_bluegreen(host_data):
533
533
  return f"{app_path}/.venv/bin/python"
@@ -217,6 +217,8 @@ class Command(BaseCommand):
217
217
 
218
218
  # Pass --activate flag for bluegreen deploy+activate in one step
219
219
  if options.get("activate"):
220
+ if command_name != "deploy":
221
+ raise CommandError("--activate is only valid with the 'deploy' command")
220
222
  context["activate"] = True
221
223
  context["pyinfra_data"]["activate"] = "true"
222
224
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: djaploy
3
- Version: 1.2.5
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
@@ -289,6 +289,8 @@ python manage.py djaploy rollback --env production
289
289
  3. **Activate** -- rewrites nginx upstream to point to the new slot, reloads nginx
290
290
  4. **Rollback** (if needed) -- switches nginx back to the previous slot, which is still running
291
291
 
292
+ > **Note:** Migrations run during Step 1, before traffic switches. Both the old and new slots share the same database, so migrations must be **backward-compatible** (use the expand/contract pattern). Deploy new code that handles both old and new schema, activate, then clean up in a subsequent deploy.
293
+
292
294
  ### Server directory layout comparison
293
295
 
294
296
  For `app_user="myapp-api"`, `app_name="myapp"`:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "djaploy"
3
- version = "1.2.5"
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"}
@@ -212,21 +212,22 @@ except ImportError:
212
212
  class TestBluegreenTemplateRender(unittest.TestCase):
213
213
  """Render blue-green templates with Jinja2 and verify output."""
214
214
 
215
- SYSTEMD_CTX = dict(
216
- project_name="myapp",
217
- app_user="app",
218
- slot="blue",
219
- slot_path="/home/app/apps/myapp/slots/blue",
220
- manage_subdir="",
221
- workers=4,
222
- timeout=30,
223
- umask="002",
224
- wsgi_module="myapp.wsgi:application",
225
- )
215
+ def _base_ctx(self):
216
+ return dict(
217
+ project_name="myapp",
218
+ app_user="app",
219
+ slot="blue",
220
+ slot_path="/home/app/apps/myapp/slots/blue",
221
+ manage_subdir="",
222
+ workers=4,
223
+ timeout=30,
224
+ umask="002",
225
+ wsgi_module="myapp.wsgi:application",
226
+ )
226
227
 
227
228
  def _render(self, template_str, **overrides):
228
229
  from jinja2 import Environment
229
- ctx = {**self.SYSTEMD_CTX, **overrides}
230
+ ctx = {**self._base_ctx(), **overrides}
230
231
  return Environment().from_string(template_str).render(**ctx)
231
232
 
232
233
  def test_systemd_renders_slot_in_socket_path(self):
@@ -482,5 +483,122 @@ class TestBluegreenHookDiscovery(unittest.TestCase):
482
483
  global_registry._hooks.update(saved_hooks)
483
484
 
484
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
+
485
603
  if __name__ == "__main__":
486
604
  unittest.main()
@@ -245,6 +245,36 @@ class TestBorgBackupScriptGeneration(unittest.TestCase):
245
245
  self.assertIn('MEDIA_DIR="/home/app/apps/myapp/media"', script)
246
246
  self.assertNotIn("/shared/media", script)
247
247
 
248
+ def test_script_default_media_path_bluegreen(self):
249
+ """Bluegreen deploys use shared/media by default (same as zero_downtime)."""
250
+ config = {"passphrase": "s", "repo_host": ""}
251
+ host_data = MagicMock()
252
+ host_data.app_name = "myapp"
253
+ host_data.db_dir = None
254
+ host_data.deployment_strategy = "bluegreen"
255
+ script = self._generate(config, app_user="app", host_data=host_data)
256
+ self.assertIn('MEDIA_DIR="/home/app/apps/myapp/shared/media"', script)
257
+
258
+ def test_script_default_db_path_bluegreen(self):
259
+ """Bluegreen db_path defaults to host db_dir or /home/{user}/dbs."""
260
+ config = {"passphrase": "s", "repo_host": ""}
261
+ host_data = MagicMock()
262
+ host_data.app_name = "myapp"
263
+ host_data.db_dir = "/home/app/dbs/myapp"
264
+ host_data.deployment_strategy = "bluegreen"
265
+ script = self._generate(config, app_user="app", host_data=host_data)
266
+ self.assertIn('DB_DIR="/home/app/dbs/myapp"', script)
267
+
268
+ def test_script_default_media_path_bluegreen_in_place(self):
269
+ """Bluegreen should NOT use the in_place media path."""
270
+ config = {"passphrase": "s", "repo_host": ""}
271
+ host_data = MagicMock()
272
+ host_data.app_name = "myapp"
273
+ host_data.db_dir = None
274
+ host_data.deployment_strategy = "bluegreen"
275
+ script = self._generate(config, app_user="app", host_data=host_data)
276
+ self.assertNotIn('MEDIA_DIR="/home/app/apps/myapp/media"', script)
277
+
248
278
  def test_script_explicit_media_path_overrides_strategy(self):
249
279
  """Explicit media_path overrides the strategy-derived default."""
250
280
  config = {"passphrase": "s", "repo_host": "", "media_path": "/custom/media"}
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