djaploy 1.2.4__tar.gz → 1.2.6__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.4 → djaploy-1.2.6}/PKG-INFO +3 -1
  2. {djaploy-1.2.4 → djaploy-1.2.6}/README.md +2 -0
  3. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/borg/infra/djaploy_hooks.py +24 -2
  4. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/systemd/infra/djaploy_hooks.py +16 -2
  5. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/builtin_hooks.py +3 -2
  6. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/status.py +4 -2
  7. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/infra/bluegreen.py +17 -33
  8. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/infra/djaploy_hooks.py +32 -45
  9. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/infra/utils.py +2 -2
  10. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/management/commands/djaploy.py +2 -0
  11. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy.egg-info/PKG-INFO +3 -1
  12. {djaploy-1.2.4 → djaploy-1.2.6}/pyproject.toml +1 -1
  13. {djaploy-1.2.4 → djaploy-1.2.6}/tests/test_bluegreen.py +13 -12
  14. {djaploy-1.2.4 → djaploy-1.2.6}/tests/test_borg.py +30 -0
  15. {djaploy-1.2.4 → djaploy-1.2.6}/LICENSE +0 -0
  16. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/__init__.py +0 -0
  17. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/app.py +0 -0
  18. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/__init__.py +0 -0
  19. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/borg/__init__.py +0 -0
  20. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/borg/infra/__init__.py +0 -0
  21. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/janitor/__init__.py +0 -0
  22. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/janitor/apps.py +0 -0
  23. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/janitor/infra/__init__.py +0 -0
  24. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/janitor/infra/commands/createjanitoruser.py +0 -0
  25. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/janitor/infra/djaploy_hooks.py +0 -0
  26. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/nginx/__init__.py +0 -0
  27. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/nginx/apps.py +0 -0
  28. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/nginx/infra/__init__.py +0 -0
  29. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/nginx/infra/djaploy_hooks.py +0 -0
  30. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/rclone/__init__.py +0 -0
  31. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/rclone/apps.py +0 -0
  32. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/rclone/infra/__init__.py +0 -0
  33. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/rclone/infra/djaploy_hooks.py +0 -0
  34. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/sync_certs/__init__.py +0 -0
  35. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/sync_certs/apps.py +0 -0
  36. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/sync_certs/infra/__init__.py +0 -0
  37. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/sync_certs/infra/djaploy_hooks.py +0 -0
  38. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/systemd/__init__.py +0 -0
  39. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/systemd/apps.py +0 -0
  40. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/systemd/infra/__init__.py +0 -0
  41. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/tailscale/__init__.py +0 -0
  42. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/tailscale/apps.py +0 -0
  43. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/tailscale/infra/__init__.py +0 -0
  44. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/tailscale/infra/djaploy_hooks.py +0 -0
  45. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/versioning/__init__.py +0 -0
  46. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/versioning/apps.py +0 -0
  47. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/versioning/infra/__init__.py +0 -0
  48. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/versioning/infra/djaploy_hooks.py +0 -0
  49. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/artifact.py +0 -0
  50. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/bin/__init__.py +0 -0
  51. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/bin/django_pyinfra.py +0 -0
  52. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/bin/gunicornherder.py +0 -0
  53. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/certificates.py +0 -0
  54. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/changelog.py +0 -0
  55. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/__init__.py +0 -0
  56. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/_utils.py +0 -0
  57. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/activate.py +0 -0
  58. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/configure.py +0 -0
  59. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/deploy.py +0 -0
  60. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/restore.py +0 -0
  61. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/rollback.py +0 -0
  62. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/sync_certs.py +0 -0
  63. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/config.py +0 -0
  64. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/deploy.py +0 -0
  65. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/discovery.py +0 -0
  66. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/hooks.py +0 -0
  67. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/infra/__init__.py +0 -0
  68. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/infra/templates.py +0 -0
  69. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/management/__init__.py +0 -0
  70. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/management/commands/__init__.py +0 -0
  71. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/management/commands/restore_backup.py +0 -0
  72. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/management/commands/sync_certs.py +0 -0
  73. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/management/commands/update_certs.py +0 -0
  74. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/management/commands/verify.py +0 -0
  75. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/management/utils.py +0 -0
  76. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/notifications.py +0 -0
  77. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/utils.py +0 -0
  78. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/version.py +0 -0
  79. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/versioning.py +0 -0
  80. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy.egg-info/SOURCES.txt +0 -0
  81. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy.egg-info/dependency_links.txt +0 -0
  82. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy.egg-info/entry_points.txt +0 -0
  83. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy.egg-info/requires.txt +0 -0
  84. {djaploy-1.2.4 → djaploy-1.2.6}/djaploy.egg-info/top_level.txt +0 -0
  85. {djaploy-1.2.4 → djaploy-1.2.6}/setup.cfg +0 -0
  86. {djaploy-1.2.4 → djaploy-1.2.6}/tests/test_config.py +0 -0
  87. {djaploy-1.2.4 → djaploy-1.2.6}/tests/test_deploy_scripts.py +0 -0
  88. {djaploy-1.2.4 → djaploy-1.2.6}/tests/test_discovery.py +0 -0
  89. {djaploy-1.2.4 → djaploy-1.2.6}/tests/test_gunicornherder.py +0 -0
  90. {djaploy-1.2.4 → djaploy-1.2.6}/tests/test_hooks.py +0 -0
  91. {djaploy-1.2.4 → djaploy-1.2.6}/tests/test_versioning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: djaploy
3
- Version: 1.2.4
3
+ Version: 1.2.6
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"`:
@@ -146,7 +146,7 @@ def _generate_backup_script(borg_config, app_user: str, repo_name: str,
146
146
  if not isinstance(host_data, dict)
147
147
  else host_data.get('deployment_strategy', 'zero_downtime')
148
148
  )
149
- if deployment_strategy == 'zero_downtime':
149
+ if deployment_strategy in ('zero_downtime', 'bluegreen'):
150
150
  default_media = f"/home/{app_user}/apps/{app_name}/shared/media"
151
151
  else:
152
152
  default_media = f"/home/{app_user}/apps/{app_name}/media"
@@ -527,7 +527,7 @@ def restore_borg(host_data, restore_opts):
527
527
  if not isinstance(host_data, dict)
528
528
  else host_data.get('deployment_strategy', 'zero_downtime')
529
529
  )
530
- if deployment_strategy == 'zero_downtime':
530
+ if deployment_strategy in ('zero_downtime', 'bluegreen'):
531
531
  default_media = f"/home/{app_user}/apps/{app_name}/shared/media"
532
532
  else:
533
533
  default_media = f"/home/{app_user}/apps/{app_name}/media"
@@ -540,6 +540,28 @@ def restore_borg(host_data, restore_opts):
540
540
  db_only = restore_opts.get("db_only", False)
541
541
  services = getattr(host_data, "services", None) or []
542
542
 
543
+ # For bluegreen, expand service names to per-slot services and include
544
+ # per-slot streaming services so they are also stopped during restore.
545
+ deployment_strategy = (
546
+ getattr(host_data, 'deployment_strategy', 'zero_downtime')
547
+ if not isinstance(host_data, dict)
548
+ else host_data.get('deployment_strategy', 'zero_downtime')
549
+ )
550
+ if deployment_strategy == 'bluegreen':
551
+ app_name = (
552
+ getattr(host_data, 'app_name', '')
553
+ if not isinstance(host_data, dict)
554
+ else host_data.get('app_name', '')
555
+ )
556
+ bluegreen_services = []
557
+ for svc in services:
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
+ bluegreen_services.append(f"{app_name}-streaming-blue")
562
+ bluegreen_services.append(f"{app_name}-streaming-green")
563
+ services = bluegreen_services
564
+
543
565
  # Repo connection details come from the *source* borg config, but
544
566
  # ssh_known_hosts_file must come from the *target* (the file lives on
545
567
  # the machine running borg, not the machine the backup was taken from).
@@ -35,8 +35,7 @@ def start_services(host_data, artifact_path):
35
35
  return
36
36
 
37
37
  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)
38
+ slot_service = get_slot_service_name(service, target_slot)
40
39
 
41
40
  systemd.service(
42
41
  name=f"Start and restart {slot_service} (target slot)",
@@ -46,6 +45,21 @@ def start_services(host_data, artifact_path):
46
45
  restarted=True,
47
46
  _sudo=True,
48
47
  )
48
+
49
+ # Disable legacy single service from zero_downtime/in_place if present.
50
+ # Runs after the new slot service is started so there's no downtime gap.
51
+ app_name = getattr(host_data, 'app_name', None)
52
+ from pyinfra.operations import server
53
+ server.shell(
54
+ name="Disable legacy service (migration to bluegreen)",
55
+ commands=[
56
+ f"systemctl stop {app_name}.service 2>/dev/null || true",
57
+ f"systemctl disable {app_name}.service 2>/dev/null || true",
58
+ f"rm -f /etc/systemd/system/{app_name}.service",
59
+ f"systemctl daemon-reload 2>/dev/null || true",
60
+ ],
61
+ _sudo=True,
62
+ )
49
63
  elif is_zero_downtime(host_data):
50
64
  for service in getattr(host_data, "services", []):
51
65
  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 {}
@@ -657,13 +682,13 @@ def activate_release(host_data, artifact_path):
657
682
  def _print_deploy_summary(state_f, t_slot, rel, sock):
658
683
  info = _read_slot_info_from_remote(host, t_slot, state_f)
659
684
 
660
- print(f"\n=== Blue-Green Deploy Summary ===")
685
+ print("\n=== Blue-Green Deploy Summary ===")
661
686
  print(f" Slot: {t_slot}")
662
687
  print(f" Release: {rel}")
663
688
  print(f" Socket: {sock}")
664
689
  print(f" Venv: {info.get('venv_path', 'unknown')}")
665
690
  print(f" Python: {info.get('python_interpreter', 'unknown')}")
666
- print(f"\n Status: staged (run 'djaploy activate' to switch traffic)\n")
691
+ print("\n Status: staged (run 'djaploy activate' to switch traffic)\n")
667
692
 
668
693
  python_op.call(
669
694
  name="Print deployment summary",
@@ -734,19 +759,7 @@ def rollback_release(host_data, release=None):
734
759
  host.data._bluegreen_activated_slot = target_slot
735
760
 
736
761
  # Update nginx upstream for non-custom setups
737
- ctx = build_template_context(host_data)
738
- ctx["active_slot"] = target_slot
739
- upstream_content = Environment().from_string(NGINX_UPSTREAM_BLUEGREEN).render(**ctx)
740
-
741
- nginx_cfg = getattr(host_data, 'nginx_conf', None) or {}
742
- if not nginx_cfg.get("custom"):
743
- from pyinfra.operations import files
744
- files.put(
745
- name=f"Update nginx upstream to {target_slot} slot",
746
- src=StringIO(upstream_content),
747
- dest=f"/etc/nginx/sites-available/{app_name}-upstream.conf",
748
- _sudo=True,
749
- )
762
+ _update_nginx_upstream(host_data, app_name, target_slot)
750
763
 
751
764
  # Reload nginx
752
765
  server.shell(
@@ -768,7 +781,7 @@ def rollback_release(host_data, release=None):
768
781
 
769
782
  def _print_rollback_summary(state_f, t_slot, p_slot):
770
783
  info = _read_slot_info_from_remote(host, t_slot, state_f)
771
- print(f"\n=== Blue-Green Rollback ===")
784
+ print("\n=== Blue-Green Rollback ===")
772
785
  print(f" Switched: {p_slot} -> {t_slot}")
773
786
  print(f" Release: {info.get('release', 'unknown')}")
774
787
  print(f" Python: {info.get('python_interpreter', 'unknown')}")
@@ -875,25 +888,7 @@ def activate_bluegreen(host_data):
875
888
  host.data._bluegreen_activated_slot = target_slot
876
889
 
877
890
  # Update nginx upstream config to point to target slot
878
- from io import StringIO
879
- from djaploy.infra.templates import NGINX_UPSTREAM_BLUEGREEN, build_template_context
880
- from jinja2 import Environment
881
-
882
- ctx = build_template_context(host_data)
883
- ctx["active_slot"] = target_slot
884
- upstream_content = Environment().from_string(NGINX_UPSTREAM_BLUEGREEN).render(**ctx)
885
-
886
- # Write the upstream config. Custom nginx setups (e.g. bostad) handle
887
- # their own upstream file via activate:post hooks.
888
- from pyinfra.operations import files
889
- nginx_cfg = getattr(host_data, 'nginx_conf', None) or {}
890
- if not nginx_cfg.get("custom"):
891
- files.put(
892
- name=f"Update nginx upstream to {target_slot} slot",
893
- src=StringIO(upstream_content),
894
- dest=f"/etc/nginx/sites-available/{app_name}-upstream.conf",
895
- _sudo=True,
896
- )
891
+ _update_nginx_upstream(host_data, app_name, target_slot)
897
892
 
898
893
  # Reload nginx
899
894
  server.shell(
@@ -919,7 +914,7 @@ def activate_bluegreen(host_data):
919
914
  new_info = _read_slot_info_from_remote(host, t_slot, state_f)
920
915
  old_info = _read_slot_info_from_remote(host, p_slot, state_f) if p_slot != "none" else {}
921
916
 
922
- print(f"\n=== Blue-Green Activation ===")
917
+ print("\n=== Blue-Green Activation ===")
923
918
  print(f" Switched: {p_slot} -> {t_slot}")
924
919
  print(f" Socket: {sock} (now serving production)")
925
920
  print(f" Deployed at: {new_info.get('deployed_at', 'unknown')}")
@@ -953,11 +948,3 @@ def activate_bluegreen(host_data):
953
948
  p_slot=prev_slot,
954
949
  sock=socket_path,
955
950
  )
956
-
957
-
958
- @deploy_hook("activate")
959
- def activate_bluegreen_status(host_data):
960
- """Print full blue-green status during status command."""
961
- # This hook is intentionally a no-op during activate —
962
- # the main activate_bluegreen hook handles everything.
963
- 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.4
3
+ Version: 1.2.6
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.4"
3
+ version = "1.2.6"
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):
@@ -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