djaploy 1.1.8__tar.gz → 1.2.2__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 (92) hide show
  1. {djaploy-1.1.8 → djaploy-1.2.2}/PKG-INFO +102 -1
  2. {djaploy-1.1.8 → djaploy-1.2.2}/README.md +101 -0
  3. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/nginx/infra/djaploy_hooks.py +13 -0
  4. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/systemd/infra/djaploy_hooks.py +36 -11
  5. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/builtin_hooks.py +15 -2
  6. djaploy-1.2.2/djaploy/commands/activate.py +26 -0
  7. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/commands/deploy.py +9 -0
  8. djaploy-1.2.2/djaploy/commands/status.py +88 -0
  9. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/config.py +2 -2
  10. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/deploy.py +24 -0
  11. djaploy-1.2.2/djaploy/infra/bluegreen.py +145 -0
  12. djaploy-1.2.2/djaploy/infra/djaploy_hooks.py +941 -0
  13. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/infra/templates.py +121 -2
  14. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/infra/utils.py +145 -42
  15. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/management/commands/djaploy.py +11 -0
  16. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy.egg-info/PKG-INFO +102 -1
  17. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy.egg-info/SOURCES.txt +6 -1
  18. {djaploy-1.1.8 → djaploy-1.2.2}/pyproject.toml +1 -1
  19. djaploy-1.2.2/tests/test_bluegreen.py +486 -0
  20. djaploy-1.2.2/tests/test_versioning.py +329 -0
  21. djaploy-1.1.8/djaploy/infra/djaploy_hooks.py +0 -511
  22. {djaploy-1.1.8 → djaploy-1.2.2}/LICENSE +0 -0
  23. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/__init__.py +0 -0
  24. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/app.py +0 -0
  25. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/__init__.py +0 -0
  26. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/borg/__init__.py +0 -0
  27. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/borg/infra/__init__.py +0 -0
  28. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/borg/infra/djaploy_hooks.py +0 -0
  29. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/janitor/__init__.py +0 -0
  30. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/janitor/apps.py +0 -0
  31. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/janitor/infra/__init__.py +0 -0
  32. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/janitor/infra/commands/createjanitoruser.py +0 -0
  33. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/janitor/infra/djaploy_hooks.py +0 -0
  34. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/nginx/__init__.py +0 -0
  35. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/nginx/apps.py +0 -0
  36. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/nginx/infra/__init__.py +0 -0
  37. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/rclone/__init__.py +0 -0
  38. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/rclone/apps.py +0 -0
  39. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/rclone/infra/__init__.py +0 -0
  40. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/rclone/infra/djaploy_hooks.py +0 -0
  41. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/sync_certs/__init__.py +0 -0
  42. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/sync_certs/apps.py +0 -0
  43. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/sync_certs/infra/__init__.py +0 -0
  44. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/sync_certs/infra/djaploy_hooks.py +0 -0
  45. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/systemd/__init__.py +0 -0
  46. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/systemd/apps.py +0 -0
  47. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/systemd/infra/__init__.py +0 -0
  48. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/tailscale/__init__.py +0 -0
  49. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/tailscale/apps.py +0 -0
  50. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/tailscale/infra/__init__.py +0 -0
  51. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/tailscale/infra/djaploy_hooks.py +0 -0
  52. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/versioning/__init__.py +0 -0
  53. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/versioning/apps.py +0 -0
  54. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/versioning/infra/__init__.py +0 -0
  55. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/apps/versioning/infra/djaploy_hooks.py +0 -0
  56. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/artifact.py +0 -0
  57. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/bin/__init__.py +0 -0
  58. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/bin/django_pyinfra.py +0 -0
  59. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/bin/gunicornherder.py +0 -0
  60. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/certificates.py +0 -0
  61. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/changelog.py +0 -0
  62. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/commands/__init__.py +0 -0
  63. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/commands/_utils.py +0 -0
  64. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/commands/configure.py +0 -0
  65. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/commands/restore.py +0 -0
  66. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/commands/rollback.py +0 -0
  67. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/commands/sync_certs.py +0 -0
  68. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/discovery.py +0 -0
  69. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/hooks.py +0 -0
  70. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/infra/__init__.py +0 -0
  71. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/management/__init__.py +0 -0
  72. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/management/commands/__init__.py +0 -0
  73. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/management/commands/restore_backup.py +0 -0
  74. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/management/commands/sync_certs.py +0 -0
  75. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/management/commands/update_certs.py +0 -0
  76. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/management/commands/verify.py +0 -0
  77. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/management/utils.py +0 -0
  78. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/notifications.py +0 -0
  79. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/utils.py +0 -0
  80. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/version.py +0 -0
  81. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy/versioning.py +0 -0
  82. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy.egg-info/dependency_links.txt +0 -0
  83. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy.egg-info/entry_points.txt +0 -0
  84. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy.egg-info/requires.txt +0 -0
  85. {djaploy-1.1.8 → djaploy-1.2.2}/djaploy.egg-info/top_level.txt +0 -0
  86. {djaploy-1.1.8 → djaploy-1.2.2}/setup.cfg +0 -0
  87. {djaploy-1.1.8 → djaploy-1.2.2}/tests/test_borg.py +0 -0
  88. {djaploy-1.1.8 → djaploy-1.2.2}/tests/test_config.py +0 -0
  89. {djaploy-1.1.8 → djaploy-1.2.2}/tests/test_deploy_scripts.py +0 -0
  90. {djaploy-1.1.8 → djaploy-1.2.2}/tests/test_discovery.py +0 -0
  91. {djaploy-1.1.8 → djaploy-1.2.2}/tests/test_gunicornherder.py +0 -0
  92. {djaploy-1.1.8 → djaploy-1.2.2}/tests/test_hooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: djaploy
3
- Version: 1.1.8
3
+ Version: 1.2.2
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
@@ -236,6 +236,107 @@ Deployment flow:
236
236
  7. Collects static files
237
237
  8. Restarts services
238
238
 
239
+ ## Deployment Strategies
240
+
241
+ djaploy supports three deployment strategies, configured via `deployment_strategy` on `HostConfig`.
242
+
243
+ ### In-place (`"in_place"`)
244
+
245
+ The simplest strategy. Code is extracted directly into the app directory and services are restarted. Has brief downtime during restart.
246
+
247
+ ### Zero-downtime (`"zero_downtime"`)
248
+
249
+ Uses a `releases/` directory with a `current` symlink. Each deploy creates a new immutable release, swaps the symlink atomically, and sends USR2 via gunicornherder to reload gunicorn. No downtime, but no pre-activation testing.
250
+
251
+ ### Blue-green (`"bluegreen"`)
252
+
253
+ Two independent slots (blue and green), each running its own gunicorn process on a separate Unix socket. Traffic switching happens via nginx reload. Supports staging a release for testing before switching.
254
+
255
+ ```python
256
+ HostConfig(
257
+ "my-server",
258
+ ssh_hostname="192.168.1.100",
259
+ app_name="myapp",
260
+ app_user="myapp-api",
261
+ deployment_strategy="bluegreen",
262
+ # ...
263
+ )
264
+ ```
265
+
266
+ #### Blue-green commands
267
+
268
+ ```bash
269
+ # Deploy to inactive slot (does NOT switch traffic)
270
+ python manage.py djaploy deploy --env production --latest
271
+
272
+ # Activate: switch nginx to the staged slot (zero downtime)
273
+ python manage.py djaploy activate --env production
274
+
275
+ # Deploy + activate in one step
276
+ python manage.py djaploy deploy --env production --latest --activate
277
+
278
+ # Show both slots with release info, paths, service status
279
+ python manage.py djaploy status --env production
280
+
281
+ # Rollback: switch back to previous slot (instant)
282
+ python manage.py djaploy rollback --env production
283
+ ```
284
+
285
+ #### Blue-green deployment flow
286
+
287
+ 1. **Deploy** -- extracts artifact to inactive slot, installs dependencies, runs migrations, starts the slot's gunicorn service
288
+ 2. **Test** -- the staged slot is running and reachable via its socket (e.g. `curl --unix-socket /run/myapp-green/myapp.sock http://localhost/health/`)
289
+ 3. **Activate** -- rewrites nginx upstream to point to the new slot, reloads nginx
290
+ 4. **Rollback** (if needed) -- switches nginx back to the previous slot, which is still running
291
+
292
+ ### Server directory layout comparison
293
+
294
+ For `app_user="myapp-api"`, `app_name="myapp"`:
295
+
296
+ | Path | `in_place` | `zero_downtime` | `bluegreen` |
297
+ |------|-----------|-----------------|-------------|
298
+ | App code | `.../apps/myapp/` | `.../apps/myapp/current/` | `.../apps/myapp/slots/{blue\|green}/` |
299
+ | Virtualenv | Managed by Poetry | `.../shared/venv-{HASH}-py{ver}/` | `.../shared/venv-{HASH}-py{ver}/` |
300
+ | Static files | `.../apps/myapp/staticfiles/` | `.../apps/myapp/shared/staticfiles/` | `.../apps/myapp/shared/staticfiles/` |
301
+ | Media files | `.../apps/myapp/media/` | `.../apps/myapp/shared/media/` | `.../apps/myapp/shared/media/` |
302
+ | Database | via `db_dir` | via `db_dir` | via `db_dir` |
303
+
304
+ All paths are relative to `/home/{app_user}/`.
305
+
306
+ #### Shared directory (`zero_downtime` and `bluegreen`)
307
+
308
+ Both strategies use a `shared/` directory for resources that persist across deployments:
309
+
310
+ | Content | Purpose |
311
+ |---------|---------|
312
+ | `venv-{HASH}-py{version}/` | Virtualenvs keyed by `poetry.lock` hash. Reused when dependencies haven't changed. |
313
+ | `staticfiles/` | Output of `collectstatic`. Served by nginx. |
314
+ | `media/` | User-uploaded files. Served by nginx. |
315
+ | Custom paths via `shared_resources` | Project-specific shared directories (e.g. `bostad/public`). |
316
+
317
+ #### Systemd services comparison
318
+
319
+ | Strategy | Service name | Socket path | Process |
320
+ |----------|-------------|-------------|---------|
321
+ | `in_place` | `{app}.service` | `/run/{app}/{app}.sock` | `poetry run gunicorn` |
322
+ | `zero_downtime` | `{app}.service` | `/run/{app}/{app}.sock` | gunicornherder wrapping gunicorn |
323
+ | `bluegreen` | `{app}-blue.service`, `{app}-green.service` | `/run/{app}-blue/{app}.sock`, `/run/{app}-green/{app}.sock` | Plain gunicorn (`Type=notify`) |
324
+
325
+ Blue-green uses `Type=notify` -- gunicorn has native systemd-notify support, so systemd knows when the process is ready without needing gunicornherder.
326
+
327
+ #### Nginx configuration (bluegreen)
328
+
329
+ Blue-green deploys the nginx upstream as a separate include file so it can be rewritten during activation without touching the site config:
330
+
331
+ - Site config: `/etc/nginx/sites-available/{app_name}` (no inline upstream block)
332
+ - Upstream config: `/etc/nginx/sites-available/{app_name}-upstream.conf`
333
+
334
+ Activation rewrites the upstream file to point to the new slot's socket and reloads nginx.
335
+
336
+ #### State tracking (bluegreen)
337
+
338
+ Blue-green maintains a `state.json` file at `/home/{app_user}/apps/{app_name}/state.json` that tracks the active slot and deployment metadata (release name, commit, venv path, python interpreter) for each slot. This is printed during deploy, activate, and status commands.
339
+
239
340
  ### Certificate management
240
341
 
241
342
  ```bash
@@ -174,6 +174,107 @@ Deployment flow:
174
174
  7. Collects static files
175
175
  8. Restarts services
176
176
 
177
+ ## Deployment Strategies
178
+
179
+ djaploy supports three deployment strategies, configured via `deployment_strategy` on `HostConfig`.
180
+
181
+ ### In-place (`"in_place"`)
182
+
183
+ The simplest strategy. Code is extracted directly into the app directory and services are restarted. Has brief downtime during restart.
184
+
185
+ ### Zero-downtime (`"zero_downtime"`)
186
+
187
+ Uses a `releases/` directory with a `current` symlink. Each deploy creates a new immutable release, swaps the symlink atomically, and sends USR2 via gunicornherder to reload gunicorn. No downtime, but no pre-activation testing.
188
+
189
+ ### Blue-green (`"bluegreen"`)
190
+
191
+ Two independent slots (blue and green), each running its own gunicorn process on a separate Unix socket. Traffic switching happens via nginx reload. Supports staging a release for testing before switching.
192
+
193
+ ```python
194
+ HostConfig(
195
+ "my-server",
196
+ ssh_hostname="192.168.1.100",
197
+ app_name="myapp",
198
+ app_user="myapp-api",
199
+ deployment_strategy="bluegreen",
200
+ # ...
201
+ )
202
+ ```
203
+
204
+ #### Blue-green commands
205
+
206
+ ```bash
207
+ # Deploy to inactive slot (does NOT switch traffic)
208
+ python manage.py djaploy deploy --env production --latest
209
+
210
+ # Activate: switch nginx to the staged slot (zero downtime)
211
+ python manage.py djaploy activate --env production
212
+
213
+ # Deploy + activate in one step
214
+ python manage.py djaploy deploy --env production --latest --activate
215
+
216
+ # Show both slots with release info, paths, service status
217
+ python manage.py djaploy status --env production
218
+
219
+ # Rollback: switch back to previous slot (instant)
220
+ python manage.py djaploy rollback --env production
221
+ ```
222
+
223
+ #### Blue-green deployment flow
224
+
225
+ 1. **Deploy** -- extracts artifact to inactive slot, installs dependencies, runs migrations, starts the slot's gunicorn service
226
+ 2. **Test** -- the staged slot is running and reachable via its socket (e.g. `curl --unix-socket /run/myapp-green/myapp.sock http://localhost/health/`)
227
+ 3. **Activate** -- rewrites nginx upstream to point to the new slot, reloads nginx
228
+ 4. **Rollback** (if needed) -- switches nginx back to the previous slot, which is still running
229
+
230
+ ### Server directory layout comparison
231
+
232
+ For `app_user="myapp-api"`, `app_name="myapp"`:
233
+
234
+ | Path | `in_place` | `zero_downtime` | `bluegreen` |
235
+ |------|-----------|-----------------|-------------|
236
+ | App code | `.../apps/myapp/` | `.../apps/myapp/current/` | `.../apps/myapp/slots/{blue\|green}/` |
237
+ | Virtualenv | Managed by Poetry | `.../shared/venv-{HASH}-py{ver}/` | `.../shared/venv-{HASH}-py{ver}/` |
238
+ | Static files | `.../apps/myapp/staticfiles/` | `.../apps/myapp/shared/staticfiles/` | `.../apps/myapp/shared/staticfiles/` |
239
+ | Media files | `.../apps/myapp/media/` | `.../apps/myapp/shared/media/` | `.../apps/myapp/shared/media/` |
240
+ | Database | via `db_dir` | via `db_dir` | via `db_dir` |
241
+
242
+ All paths are relative to `/home/{app_user}/`.
243
+
244
+ #### Shared directory (`zero_downtime` and `bluegreen`)
245
+
246
+ Both strategies use a `shared/` directory for resources that persist across deployments:
247
+
248
+ | Content | Purpose |
249
+ |---------|---------|
250
+ | `venv-{HASH}-py{version}/` | Virtualenvs keyed by `poetry.lock` hash. Reused when dependencies haven't changed. |
251
+ | `staticfiles/` | Output of `collectstatic`. Served by nginx. |
252
+ | `media/` | User-uploaded files. Served by nginx. |
253
+ | Custom paths via `shared_resources` | Project-specific shared directories (e.g. `bostad/public`). |
254
+
255
+ #### Systemd services comparison
256
+
257
+ | Strategy | Service name | Socket path | Process |
258
+ |----------|-------------|-------------|---------|
259
+ | `in_place` | `{app}.service` | `/run/{app}/{app}.sock` | `poetry run gunicorn` |
260
+ | `zero_downtime` | `{app}.service` | `/run/{app}/{app}.sock` | gunicornherder wrapping gunicorn |
261
+ | `bluegreen` | `{app}-blue.service`, `{app}-green.service` | `/run/{app}-blue/{app}.sock`, `/run/{app}-green/{app}.sock` | Plain gunicorn (`Type=notify`) |
262
+
263
+ Blue-green uses `Type=notify` -- gunicorn has native systemd-notify support, so systemd knows when the process is ready without needing gunicornherder.
264
+
265
+ #### Nginx configuration (bluegreen)
266
+
267
+ Blue-green deploys the nginx upstream as a separate include file so it can be rewritten during activation without touching the site config:
268
+
269
+ - Site config: `/etc/nginx/sites-available/{app_name}` (no inline upstream block)
270
+ - Upstream config: `/etc/nginx/sites-available/{app_name}-upstream.conf`
271
+
272
+ Activation rewrites the upstream file to point to the new slot's socket and reloads nginx.
273
+
274
+ #### State tracking (bluegreen)
275
+
276
+ Blue-green maintains a `state.json` file at `/home/{app_user}/apps/{app_name}/state.json` that tracks the active slot and deployment metadata (release name, commit, venv path, python interpreter) for each slot. This is printed during deploy, activate, and status commands.
277
+
177
278
  ### Certificate management
178
279
 
179
280
  ```bash
@@ -34,6 +34,7 @@ def configure_nginx(host_data):
34
34
  def deploy_nginx(host_data, artifact_path):
35
35
  """Deploy NGINX configuration files and SSL certificates."""
36
36
  from pyinfra.operations import server, files
37
+ from djaploy.infra.utils import is_bluegreen
37
38
 
38
39
  server.shell(
39
40
  name="Clear default NGINX sites",
@@ -80,6 +81,18 @@ def deploy_nginx(host_data, artifact_path):
80
81
  _sudo=True,
81
82
  )
82
83
 
84
+ # For bluegreen, also enable the upstream config
85
+ if is_bluegreen(host_data):
86
+ server.shell(
87
+ name=f"Enable {app_name} NGINX upstream config",
88
+ commands=[
89
+ f"test -f /etc/nginx/sites-available/{app_name}-upstream.conf && "
90
+ f"ln -fs /etc/nginx/sites-available/{app_name}-upstream.conf "
91
+ f"/etc/nginx/sites-enabled/{app_name}-upstream.conf || true",
92
+ ],
93
+ _sudo=True,
94
+ )
95
+
83
96
 
84
97
  @deploy_hook("deploy:start")
85
98
  def reload_nginx(host_data, artifact_path):
@@ -21,13 +21,33 @@ def reload_systemd_daemon(host_data, artifact_path):
21
21
  @deploy_hook("deploy:start")
22
22
  def start_services(host_data, artifact_path):
23
23
  """Start or restart application services after deploy."""
24
+ from pyinfra import host
24
25
  from pyinfra.operations import systemd
25
26
 
26
- from djaploy.infra.utils import is_zero_downtime
27
- zero_downtime = is_zero_downtime(host_data)
27
+ from djaploy.infra.utils import is_zero_downtime, is_bluegreen, get_slot_service_name
28
+
29
+ if is_bluegreen(host_data):
30
+ # Start/restart only the target slot's service.
31
+ # The other slot may be empty (first deploy) so we must NOT
32
+ # try to start it — only enable the target slot's service.
33
+ target_slot = getattr(host.data, '_bluegreen_target_slot', None)
34
+ if not target_slot:
35
+ return
36
+
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)
28
40
 
29
- for service in getattr(host_data, "services", []):
30
- if zero_downtime:
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,
47
+ _sudo=True,
48
+ )
49
+ elif is_zero_downtime(host_data):
50
+ for service in getattr(host_data, "services", []):
31
51
  systemd.service(
32
52
  name=f"Start and enable {service}",
33
53
  service=service,
@@ -41,7 +61,8 @@ def start_services(host_data, artifact_path):
41
61
  reloaded=True,
42
62
  _sudo=True,
43
63
  )
44
- else:
64
+ else:
65
+ for service in getattr(host_data, "services", []):
45
66
  systemd.service(
46
67
  name=f"Restart and enable {service}",
47
68
  service=service,
@@ -65,19 +86,23 @@ def start_services(host_data, artifact_path):
65
86
  def reload_services_on_rollback(host_data, release):
66
87
  """Reload or restart services after a rollback."""
67
88
  from pyinfra.operations import systemd
68
- from djaploy.infra.utils import is_zero_downtime
69
-
70
- zero_downtime = is_zero_downtime(host_data)
89
+ from djaploy.infra.utils import is_zero_downtime, is_bluegreen
71
90
 
72
- for service in getattr(host_data, "services", []):
73
- if zero_downtime:
91
+ if is_bluegreen(host_data):
92
+ # For bluegreen rollback, nginx switching is handled by the core
93
+ # rollback hook. We just need to ensure the target slot's service
94
+ # is running (it should already be from the previous deploy).
95
+ pass
96
+ elif is_zero_downtime(host_data):
97
+ for service in getattr(host_data, "services", []):
74
98
  systemd.service(
75
99
  name=f"Reload {service} after rollback",
76
100
  service=service,
77
101
  reloaded=True,
78
102
  _sudo=True,
79
103
  )
80
- else:
104
+ else:
105
+ for service in getattr(host_data, "services", []):
81
106
  systemd.service(
82
107
  name=f"Restart {service} after rollback",
83
108
  service=service,
@@ -103,6 +103,19 @@ def _deploy_calculate_release_info(context):
103
103
  if release_info:
104
104
  context["pyinfra_data"]["version"] = release_info["new_version"]
105
105
  context["pyinfra_data"]["commit"] = release_info["commit"]
106
+ elif "commit" not in context["pyinfra_data"]:
107
+ # Always pass commit hash even without versioning configured
108
+ try:
109
+ import subprocess
110
+ from django.conf import settings
111
+ commit = subprocess.check_output(
112
+ ["git", "rev-parse", "--short", "HEAD"],
113
+ cwd=str(settings.GIT_DIR),
114
+ text=True,
115
+ ).strip()
116
+ context["pyinfra_data"]["commit"] = commit
117
+ except Exception:
118
+ pass
106
119
 
107
120
 
108
121
  # ── deploy:postcommand ───────────────────────────────────────────────
@@ -150,7 +163,7 @@ def _rollback_validate_strategy(context):
150
163
  _, data = hosts[0]
151
164
  strategy = (data.get("deployment_strategy") if isinstance(data, dict)
152
165
  else getattr(data, "deployment_strategy", "zero_downtime"))
153
- if strategy != "zero_downtime":
166
+ if strategy not in ("zero_downtime", "bluegreen"):
154
167
  raise ValueError(
155
- "Rollback is only supported with deployment_strategy='zero_downtime'"
168
+ "Rollback is only supported with deployment_strategy='zero_downtime' or 'bluegreen'"
156
169
  )
@@ -0,0 +1,26 @@
1
+ """
2
+ Pyinfra command: activate a blue-green deployment slot.
3
+
4
+ Discovers and runs all @deploy_hook functions for the activate phase:
5
+
6
+ activate:pre — verify target slot is healthy
7
+ activate — switch nginx upstream, update state
8
+ activate:post — cleanup tasks
9
+
10
+ Usage (via djaploy management command):
11
+ manage.py djaploy activate --env production
12
+ """
13
+
14
+ from pyinfra import host
15
+ from pyinfra.api import deploy as _deploy_decorator
16
+
17
+ from djaploy.hooks import discover_hooks, get_registry
18
+
19
+ discover_hooks()
20
+ registry = get_registry()
21
+
22
+ for phase in ("activate:pre", "activate", "activate:post"):
23
+ for hook in registry.get_remote_hooks(phase):
24
+ _deploy_decorator(hook.function.__name__)(hook.function)(
25
+ host.data
26
+ )
@@ -29,3 +29,12 @@ for phase in ("deploy:upload", "deploy:configure", "deploy:pre", "deploy:start")
29
29
  _deploy_decorator(hook.function.__name__)(hook.function)(
30
30
  host.data, artifact_path
31
31
  )
32
+
33
+ # If --activate flag is set, run activation phases after deploy
34
+ _activate_val = getattr(host.data, "activate", None)
35
+ if _activate_val in (True, "true", "True"):
36
+ for phase in ("activate:pre", "activate", "activate:post"):
37
+ for hook in registry.get_remote_hooks(phase):
38
+ _deploy_decorator(hook.function.__name__)(hook.function)(
39
+ host.data
40
+ )
@@ -0,0 +1,88 @@
1
+ """
2
+ Pyinfra command: show blue-green deployment status.
3
+
4
+ Reads and displays the blue-green state from each host.
5
+
6
+ Usage (via djaploy management command):
7
+ manage.py djaploy status --env production
8
+ """
9
+
10
+ from pyinfra import host
11
+ from pyinfra.api import deploy as _deploy_decorator
12
+ from pyinfra.operations import python as python_op
13
+
14
+ from djaploy.hooks import discover_hooks, get_registry
15
+ from djaploy.infra.utils import is_bluegreen, get_app_path, get_slot_socket_path
16
+ from djaploy.infra.djaploy_hooks import _read_slot_info_from_remote
17
+
18
+ discover_hooks()
19
+ registry = get_registry()
20
+
21
+ if is_bluegreen(host.data):
22
+ app_name = getattr(host.data, 'app_name', 'unknown')
23
+ app_path = get_app_path(host.data)
24
+ state_file = f"{app_path}/state.json"
25
+
26
+ def _show_status(app_n, state_f):
27
+ blue_info = _read_slot_info_from_remote(host, "blue", state_f)
28
+ green_info = _read_slot_info_from_remote(host, "green", state_f)
29
+
30
+ # Read active slot
31
+ import json
32
+ result = host.run_shell_command(command=f"cat {state_f}")
33
+ active = "none"
34
+ if result[0] and len(result) > 1:
35
+ try:
36
+ lines = [l.line if hasattr(l, 'line') else str(l) for l in result[1]]
37
+ state_data = json.loads("\n".join(lines))
38
+ active = state_data.get("active_slot") or "none"
39
+ except (json.JSONDecodeError, KeyError):
40
+ pass
41
+
42
+ print(f"\nBlue-Green Status for {app_n}")
43
+ print("=" * 40)
44
+ print(f"Active slot: {active}\n")
45
+
46
+ for slot, info in [("blue", blue_info), ("green", green_info)]:
47
+ tag = "ACTIVE" if active == slot else "inactive"
48
+ print(f"{slot.upper()} ({tag}):")
49
+ if info:
50
+ print(f" Release: {info.get('release', 'unknown')}")
51
+ print(f" Commit: {info.get('commit', 'unknown')}")
52
+ print(f" Deployed at: {info.get('deployed_at', 'unknown')}")
53
+ print(f" Python: {info.get('python_interpreter', 'unknown')}")
54
+ print(f" Venv: {info.get('venv_path', 'unknown')}")
55
+ print(f" Socket: {get_slot_socket_path(app_n, slot)}")
56
+ else:
57
+ print(" (empty)")
58
+ print()
59
+
60
+ # Show service status
61
+ for slot in ("blue", "green"):
62
+ svc = f"{app_n}-{slot}.service"
63
+ res = host.run_shell_command(command=f"systemctl is-active {svc} 2>/dev/null || echo inactive")
64
+ status_str = "unknown"
65
+ if res[0] and len(res) > 1:
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):
69
+ pass
70
+ print(f"Service {svc}: {status_str}")
71
+ print()
72
+
73
+ python_op.call(
74
+ name="Show blue-green status",
75
+ function=_show_status,
76
+ app_n=app_name,
77
+ state_f=state_file,
78
+ )
79
+ else:
80
+ def _show_non_bluegreen():
81
+ strategy = getattr(host.data, "deployment_strategy", "zero_downtime")
82
+ print(f"\nDeployment strategy: {strategy}")
83
+ print("Not a blue-green deployment. Use deploy/rollback commands.\n")
84
+
85
+ python_op.call(
86
+ name="Show deployment status",
87
+ function=_show_non_bluegreen,
88
+ )
@@ -213,10 +213,10 @@ class HostConfig(tuple, metaclass=HostConfigMetaclass):
213
213
 
214
214
  # Validate deployment_strategy
215
215
  strategy = config.get("deployment_strategy", "zero_downtime")
216
- if strategy not in ("in_place", "zero_downtime"):
216
+ if strategy not in ("in_place", "zero_downtime", "bluegreen"):
217
217
  raise ValueError(
218
218
  f"Invalid deployment_strategy: {strategy!r}. "
219
- f"Must be 'in_place' or 'zero_downtime'"
219
+ f"Must be 'in_place', 'zero_downtime', or 'bluegreen'"
220
220
  )
221
221
 
222
222
  # Expand SSH key path if provided
@@ -222,6 +222,30 @@ def rollback_project(inventory_file: str,
222
222
  })
223
223
 
224
224
 
225
+ def activate_project(inventory_file: str, **kwargs):
226
+ """Activate the staged blue-green slot."""
227
+ env_name = Path(inventory_file).stem
228
+ run_command({
229
+ "command": "activate",
230
+ "env": env_name,
231
+ "command_file": str(_get_command_file("activate")),
232
+ "inventory_file": inventory_file,
233
+ "pyinfra_data": _build_pyinfra_data(env_name),
234
+ })
235
+
236
+
237
+ def bluegreen_status(inventory_file: str, **kwargs):
238
+ """Show blue-green deployment status."""
239
+ env_name = Path(inventory_file).stem
240
+ run_command({
241
+ "command": "status",
242
+ "env": env_name,
243
+ "command_file": str(_get_command_file("status")),
244
+ "inventory_file": inventory_file,
245
+ "pyinfra_data": _build_pyinfra_data(env_name),
246
+ })
247
+
248
+
225
249
  # ------------------------------------------------------------------
226
250
  # Internal helpers (called by hooks in builtin_hooks.py)
227
251
  # ------------------------------------------------------------------