djaploy 1.1.7__tar.gz → 1.2.1__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.7 → djaploy-1.2.1}/PKG-INFO +102 -1
  2. {djaploy-1.1.7 → djaploy-1.2.1}/README.md +101 -0
  3. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/borg/infra/djaploy_hooks.py +160 -29
  4. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/janitor/infra/djaploy_hooks.py +8 -6
  5. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/nginx/infra/djaploy_hooks.py +26 -12
  6. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/rclone/infra/djaploy_hooks.py +8 -5
  7. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/sync_certs/infra/djaploy_hooks.py +9 -16
  8. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/systemd/infra/djaploy_hooks.py +36 -11
  9. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/versioning/infra/djaploy_hooks.py +4 -3
  10. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/artifact.py +79 -55
  11. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/builtin_hooks.py +2 -2
  12. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/certificates.py +53 -29
  13. djaploy-1.2.1/djaploy/commands/activate.py +26 -0
  14. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/commands/deploy.py +9 -0
  15. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/commands/restore.py +7 -0
  16. djaploy-1.2.1/djaploy/commands/status.py +62 -0
  17. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/config.py +10 -0
  18. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/deploy.py +67 -20
  19. djaploy-1.2.1/djaploy/infra/bluegreen.py +145 -0
  20. djaploy-1.2.1/djaploy/infra/djaploy_hooks.py +941 -0
  21. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/infra/templates.py +121 -2
  22. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/infra/utils.py +145 -42
  23. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/management/commands/djaploy.py +11 -0
  24. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/management/commands/restore_backup.py +69 -15
  25. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/management/commands/update_certs.py +3 -9
  26. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/utils.py +41 -0
  27. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy.egg-info/PKG-INFO +102 -1
  28. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy.egg-info/SOURCES.txt +4 -0
  29. {djaploy-1.1.7 → djaploy-1.2.1}/pyproject.toml +1 -1
  30. djaploy-1.2.1/tests/test_bluegreen.py +486 -0
  31. {djaploy-1.1.7 → djaploy-1.2.1}/tests/test_borg.py +57 -3
  32. djaploy-1.1.7/djaploy/infra/djaploy_hooks.py +0 -510
  33. {djaploy-1.1.7 → djaploy-1.2.1}/LICENSE +0 -0
  34. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/__init__.py +0 -0
  35. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/app.py +0 -0
  36. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/__init__.py +0 -0
  37. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/borg/__init__.py +0 -0
  38. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/borg/infra/__init__.py +0 -0
  39. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/janitor/__init__.py +0 -0
  40. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/janitor/apps.py +0 -0
  41. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/janitor/infra/__init__.py +0 -0
  42. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/janitor/infra/commands/createjanitoruser.py +0 -0
  43. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/nginx/__init__.py +0 -0
  44. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/nginx/apps.py +0 -0
  45. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/nginx/infra/__init__.py +0 -0
  46. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/rclone/__init__.py +0 -0
  47. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/rclone/apps.py +0 -0
  48. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/rclone/infra/__init__.py +0 -0
  49. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/sync_certs/__init__.py +0 -0
  50. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/sync_certs/apps.py +0 -0
  51. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/sync_certs/infra/__init__.py +0 -0
  52. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/systemd/__init__.py +0 -0
  53. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/systemd/apps.py +0 -0
  54. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/systemd/infra/__init__.py +0 -0
  55. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/tailscale/__init__.py +0 -0
  56. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/tailscale/apps.py +0 -0
  57. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/tailscale/infra/__init__.py +0 -0
  58. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/tailscale/infra/djaploy_hooks.py +0 -0
  59. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/versioning/__init__.py +0 -0
  60. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/versioning/apps.py +0 -0
  61. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/versioning/infra/__init__.py +0 -0
  62. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/bin/__init__.py +0 -0
  63. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/bin/django_pyinfra.py +0 -0
  64. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/bin/gunicornherder.py +0 -0
  65. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/changelog.py +0 -0
  66. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/commands/__init__.py +0 -0
  67. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/commands/_utils.py +0 -0
  68. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/commands/configure.py +0 -0
  69. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/commands/rollback.py +0 -0
  70. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/commands/sync_certs.py +0 -0
  71. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/discovery.py +0 -0
  72. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/hooks.py +0 -0
  73. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/infra/__init__.py +0 -0
  74. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/management/__init__.py +0 -0
  75. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/management/commands/__init__.py +0 -0
  76. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/management/commands/sync_certs.py +0 -0
  77. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/management/commands/verify.py +0 -0
  78. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/management/utils.py +0 -0
  79. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/notifications.py +0 -0
  80. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/version.py +0 -0
  81. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/versioning.py +0 -0
  82. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy.egg-info/dependency_links.txt +0 -0
  83. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy.egg-info/entry_points.txt +0 -0
  84. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy.egg-info/requires.txt +0 -0
  85. {djaploy-1.1.7 → djaploy-1.2.1}/djaploy.egg-info/top_level.txt +0 -0
  86. {djaploy-1.1.7 → djaploy-1.2.1}/setup.cfg +0 -0
  87. {djaploy-1.1.7 → djaploy-1.2.1}/tests/test_config.py +0 -0
  88. {djaploy-1.1.7 → djaploy-1.2.1}/tests/test_deploy_scripts.py +0 -0
  89. {djaploy-1.1.7 → djaploy-1.2.1}/tests/test_discovery.py +0 -0
  90. {djaploy-1.1.7 → djaploy-1.2.1}/tests/test_gunicornherder.py +0 -0
  91. {djaploy-1.1.7 → djaploy-1.2.1}/tests/test_hooks.py +0 -0
  92. {djaploy-1.1.7 → djaploy-1.2.1}/tests/test_versioning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: djaploy
3
- Version: 1.1.7
3
+ Version: 1.2.1
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
@@ -14,7 +14,6 @@ is not the default, set ``ssh_key`` to the path on the target server
14
14
  """
15
15
 
16
16
  import shlex
17
- import tempfile
18
17
 
19
18
  from djaploy.hooks import deploy_hook
20
19
 
@@ -44,6 +43,11 @@ def _build_borg_rsh(borg_config) -> str:
44
43
  """Build BORG_RSH value from config.
45
44
 
46
45
  Returns empty string for local repos (no repo_host).
46
+
47
+ When ``ssh_known_hosts_file`` is set, uses strict host key checking
48
+ with that file. This is recommended for Hetzner Storage Boxes —
49
+ add the box's fingerprint to a known_hosts file on the target server
50
+ and point this setting at it.
47
51
  """
48
52
  repo_host = borg_config.get("repo_host", "")
49
53
  if not repo_host:
@@ -51,8 +55,13 @@ def _build_borg_rsh(borg_config) -> str:
51
55
 
52
56
  repo_port = borg_config.get("repo_port", 22)
53
57
  ssh_key = borg_config.get("ssh_key", "")
58
+ known_hosts = borg_config.get("ssh_known_hosts_file", "")
54
59
 
55
- rsh = f"ssh -o StrictHostKeyChecking=accept-new -p {repo_port}"
60
+ if known_hosts:
61
+ # The file is deployed to ~/.ssh/borg_known_hosts by configure_borg
62
+ rsh = f"ssh -o StrictHostKeyChecking=yes -o UserKnownHostsFile=~/.ssh/borg_known_hosts -p {repo_port}"
63
+ else:
64
+ rsh = f"ssh -o StrictHostKeyChecking=accept-new -p {repo_port}"
56
65
  if ssh_key:
57
66
  rsh += f" -i {ssh_key}"
58
67
  return rsh
@@ -132,7 +141,16 @@ def _generate_backup_script(borg_config, app_user: str, repo_name: str,
132
141
  # Fall back to HostConfig.db_dir, then generic default
133
142
  host_db_dir = getattr(host_data, 'db_dir', None) if not isinstance(host_data, dict) else host_data.get('db_dir')
134
143
  db_path = borg_config.get("db_path") or host_db_dir or f"/home/{app_user}/dbs"
135
- media_path = borg_config.get("media_path") or f"/home/{app_user}/apps/{app_name}/media"
144
+ deployment_strategy = (
145
+ getattr(host_data, 'deployment_strategy', 'zero_downtime')
146
+ if not isinstance(host_data, dict)
147
+ else host_data.get('deployment_strategy', 'zero_downtime')
148
+ )
149
+ if deployment_strategy == 'zero_downtime':
150
+ default_media = f"/home/{app_user}/apps/{app_name}/shared/media"
151
+ else:
152
+ default_media = f"/home/{app_user}/apps/{app_name}/media"
153
+ media_path = borg_config.get("media_path") or default_media
136
154
 
137
155
  if isinstance(databases, str):
138
156
  databases = [databases]
@@ -202,7 +220,8 @@ set -euo pipefail
202
220
  export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
203
221
  unset PYTHONPATH
204
222
 
205
- export BORG_PASSPHRASE={shlex.quote(passphrase)}
223
+ # Load passphrase from separate file (deployed with 0600 permissions)
224
+ source /home/{app_user}/.borg_env
206
225
  export BORG_REPO={shlex.quote(repo_url)}{rsh_export}{remote_path_export}
207
226
 
208
227
  LOG_FILE="/home/{app_user}/logs/borg_backup.log"
@@ -267,16 +286,33 @@ log_message "Borg backup completed successfully"
267
286
 
268
287
  def _deploy_backup_script(borg_config, app_user: str, repo_name: str,
269
288
  host_data):
270
- """Deploy borg backup script."""
289
+ """Deploy borg backup script and passphrase env file."""
271
290
  from pyinfra.operations import files
291
+ from djaploy.utils import temp_files
292
+
293
+ # Deploy passphrase in a separate file with restricted permissions
294
+ passphrase = borg_config.get("passphrase", "")
295
+ env_path = temp_files.create(suffix='.env')
296
+ with open(env_path, 'w') as f:
297
+ f.write(f"export BORG_PASSPHRASE={shlex.quote(passphrase)}\n")
298
+
299
+ files.put(
300
+ name="Deploy borg passphrase env file",
301
+ src=env_path,
302
+ dest=f"/home/{app_user}/.borg_env",
303
+ user=app_user,
304
+ group=app_user,
305
+ mode="600",
306
+ _sudo=True,
307
+ )
272
308
 
273
309
  script_content = _generate_backup_script(
274
310
  borg_config, app_user, repo_name, host_data
275
311
  )
276
312
 
277
- with tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False) as f:
313
+ temp_path = temp_files.create(suffix='.sh')
314
+ with open(temp_path, 'w') as f:
278
315
  f.write(script_content)
279
- temp_path = f.name
280
316
 
281
317
  files.put(
282
318
  name="Deploy borg backup script",
@@ -363,11 +399,14 @@ def configure_borg(host_data):
363
399
  _sudo=True,
364
400
  )
365
401
 
366
- # Deploy SSH keypair for app user if deploy_key is configured
402
+ # Deploy SSH config for borg backup
367
403
  borg_config = getattr(host_data, 'borg_backup', None)
368
404
  if borg_config:
405
+ repo_host = borg_config.get("repo_host", "")
369
406
  deploy_key = borg_config.get("deploy_key", None)
370
- if deploy_key:
407
+
408
+ # Ensure .ssh directory exists when we need SSH access
409
+ if deploy_key or repo_host:
371
410
  files.directory(
372
411
  name="Ensure .ssh directory for app user",
373
412
  path=f"/home/{app_user}/.ssh",
@@ -377,6 +416,7 @@ def configure_borg(host_data):
377
416
  _sudo=True,
378
417
  )
379
418
 
419
+ if deploy_key:
380
420
  files.put(
381
421
  name="Deploy SSH private key for borg backup",
382
422
  src=str(deploy_key),
@@ -399,6 +439,22 @@ def configure_borg(host_data):
399
439
  _sudo=True,
400
440
  )
401
441
 
442
+ # When ssh_known_hosts_file is set, deploy it to the target server
443
+ # so borg uses strict host key checking against known fingerprints
444
+ # (e.g. Hetzner's published Storage Box fingerprints).
445
+ # Without it, _build_borg_rsh falls back to accept-new.
446
+ known_hosts = borg_config.get("ssh_known_hosts_file", "")
447
+ if repo_host and known_hosts:
448
+ files.put(
449
+ name="Deploy borg SSH known_hosts file",
450
+ src=str(known_hosts),
451
+ dest=f"/home/{app_user}/.ssh/borg_known_hosts",
452
+ user=app_user,
453
+ group=app_user,
454
+ mode="644",
455
+ _sudo=True,
456
+ )
457
+
402
458
 
403
459
  @deploy_hook("deploy:configure")
404
460
  def deploy_borg(host_data, artifact_path):
@@ -434,35 +490,73 @@ def restore_borg(host_data, restore_opts):
434
490
  if backend and backend != "borg":
435
491
  return
436
492
 
437
- from pyinfra.operations import server, systemd
493
+ from pyinfra.operations import files, server, systemd
438
494
 
439
495
  app_user = getattr(host_data, "app_user", None) or "app"
440
- borg_config = getattr(host_data, "borg_backup", None)
496
+
497
+ # Use source borg config for cross-env restores (e.g. --env prod --target staging),
498
+ # fall back to the target host's own borg config for same-env restores.
499
+ source_borg_config = restore_opts.get("source_borg_config")
500
+ target_borg_config = getattr(host_data, "borg_backup", None)
501
+ borg_config = source_borg_config or target_borg_config
441
502
  if not borg_config:
442
503
  return
443
504
 
444
- passphrase = borg_config.get("passphrase", "")
445
- repo_name = getattr(host_data, 'name', 'unknown-host').replace(" ", "_").lower()
505
+ source_repo_name = restore_opts.get("source_repo_name", "")
506
+ if source_borg_config and not source_repo_name:
507
+ raise ValueError(
508
+ "source_repo_name is required when source_borg_config is provided"
509
+ )
510
+ repo_name = (
511
+ source_repo_name
512
+ or getattr(host_data, 'name', 'unknown-host').replace(" ", "_").lower()
513
+ )
446
514
 
515
+ # Restore paths come from the *target* host (where files will be written)
447
516
  app_name = getattr(host_data, 'app_name', '') if not isinstance(host_data, dict) else host_data.get('app_name', '')
448
517
  # Fall back to HostConfig.db_dir, then generic default
449
518
  host_db_dir = getattr(host_data, 'db_dir', None) if not isinstance(host_data, dict) else host_data.get('db_dir')
519
+ target_bc = target_borg_config or {}
450
520
  db_path = (
451
- borg_config.get("db_path") if isinstance(borg_config, dict)
452
- else getattr(borg_config, "db_path", None)
521
+ target_bc.get("db_path") if isinstance(target_bc, dict)
522
+ else getattr(target_bc, "db_path", None)
453
523
  ) or host_db_dir or f"/home/{app_user}/dbs"
454
524
 
525
+ deployment_strategy = (
526
+ getattr(host_data, 'deployment_strategy', 'zero_downtime')
527
+ if not isinstance(host_data, dict)
528
+ else host_data.get('deployment_strategy', 'zero_downtime')
529
+ )
530
+ if deployment_strategy == 'zero_downtime':
531
+ default_media = f"/home/{app_user}/apps/{app_name}/shared/media"
532
+ else:
533
+ default_media = f"/home/{app_user}/apps/{app_name}/media"
455
534
  media_path = (
456
- borg_config.get("media_path") if isinstance(borg_config, dict)
457
- else getattr(borg_config, "media_path", None)
458
- ) or f"/home/{app_user}/apps/{app_name}/media"
535
+ target_bc.get("media_path") if isinstance(target_bc, dict)
536
+ else getattr(target_bc, "media_path", None)
537
+ ) or default_media
459
538
 
460
539
  archive = restore_opts.get("archive", "latest")
461
540
  db_only = restore_opts.get("db_only", False)
462
541
  services = getattr(host_data, "services", None) or []
463
542
 
543
+ # Repo connection details come from the *source* borg config, but
544
+ # ssh_known_hosts_file must come from the *target* (the file lives on
545
+ # the machine running borg, not the machine the backup was taken from).
464
546
  repo_url = _build_repo_url(borg_config, repo_name)
465
- borg_rsh = _build_borg_rsh(borg_config)
547
+ if source_borg_config:
548
+ rsh_config = dict(borg_config)
549
+ # ssh_key and ssh_known_hosts_file refer to files on the machine
550
+ # running borg — use the *target* host's values, not the source's.
551
+ for _key in ("ssh_key", "ssh_known_hosts_file"):
552
+ rsh_config[_key] = (
553
+ target_bc.get(_key, "")
554
+ if isinstance(target_bc, dict)
555
+ else getattr(target_bc, _key, "")
556
+ )
557
+ else:
558
+ rsh_config = borg_config
559
+ borg_rsh = _build_borg_rsh(rsh_config)
466
560
  rsh_export = f'\nexport BORG_RSH={shlex.quote(borg_rsh)}' if borg_rsh else ''
467
561
  remote_path = borg_config.get("remote_path", None)
468
562
  remote_path_export = f'\nexport BORG_REMOTE_PATH={shlex.quote(remote_path)}' if remote_path else ''
@@ -484,29 +578,61 @@ def restore_borg(host_data, restore_opts):
484
578
  )
485
579
 
486
580
  # Build restore script
581
+ #
582
+ # For cross-env restores the media sits under the *source* host's path
583
+ # inside the archive, but must be copied to the *target* host's path.
584
+ source_media_path = restore_opts.get("source_media_path", "")
585
+ archive_media_path = source_media_path or media_path
586
+
487
587
  media_restore = ""
488
588
  if not db_only:
489
589
  media_restore = f'''
490
590
  # Restore media
491
- if [ -d "$TEMP_DIR{media_path}" ]; then
492
- log_message "Restoring media to {media_path}"
591
+ if [ -d "$TEMP_DIR{archive_media_path}" ]; then
592
+ log_message "Restoring media from $TEMP_DIR{archive_media_path} to {media_path}"
493
593
  mkdir -p "{media_path}"
494
- cp -a "$TEMP_DIR{media_path}/." "{media_path}/"
594
+ cp -a "$TEMP_DIR{archive_media_path}/." "{media_path}/"
495
595
  log_message "Media restore complete"
496
596
  else
497
- log_message "No media found in archive, skipping"
597
+ log_message "No media found in archive at {archive_media_path}, skipping"
498
598
  fi
499
599
  '''
500
600
 
601
+ # For cross-env restores, upload a temporary env file with the source
602
+ # passphrase (0600) instead of inlining it in the script.
603
+ if source_borg_config:
604
+ import os as _os
605
+ passphrase = _os.environ.get("DJAPLOY_SOURCE_BORG_PASSPHRASE", "")
606
+ restore_env_path = f"/home/{app_user}/.borg_restore_env"
607
+
608
+ from djaploy.utils import temp_files as _temp_files
609
+ _env_tmp = _temp_files.create(suffix='.env')
610
+ with open(_env_tmp, 'w') as _f:
611
+ _f.write(f"export BORG_PASSPHRASE={shlex.quote(passphrase)}\n")
612
+
613
+ files.put(
614
+ name="Deploy source borg passphrase for restore",
615
+ src=_env_tmp,
616
+ dest=restore_env_path,
617
+ user=app_user,
618
+ group=app_user,
619
+ mode="600",
620
+ _sudo=True,
621
+ )
622
+ passphrase_line = f'source {restore_env_path}'
623
+ else:
624
+ restore_env_path = None
625
+ passphrase_line = f'source /home/{app_user}/.borg_env'
626
+
501
627
  restore_script = f'''set -euo pipefail
502
628
  unset PYTHONPATH
503
- export BORG_PASSPHRASE={shlex.quote(passphrase)}{rsh_export}{remote_path_export}
629
+ {passphrase_line}{rsh_export}{remote_path_export}
504
630
 
505
631
  REPO={shlex.quote(repo_url)}
506
632
  TEMP_DIR="/home/{app_user}/tmp/borg_restore_$$"
507
633
 
508
634
  log_message() {{ echo "[$(date +"%Y-%m-%d %H:%M:%S")] $1"; }}
509
- cleanup() {{ rm -rf "$TEMP_DIR"; }}
635
+ cleanup() {{ rm -rf "$TEMP_DIR"; {f'rm -f {shlex.quote(restore_env_path)};' if restore_env_path else ''} }}
510
636
  trap cleanup EXIT
511
637
 
512
638
  ARCHIVE_NAME={archive_ref}
@@ -544,11 +670,12 @@ log_message "Borg restore complete"
544
670
  # Deploy restore script as temp file and execute it
545
671
  restore_script_path = f"/home/{app_user}/tmp/borg_restore.sh"
546
672
 
547
- with tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False) as f:
673
+ from djaploy.utils import temp_files
674
+
675
+ temp_path = temp_files.create(suffix='.sh')
676
+ with open(temp_path, 'w') as f:
548
677
  f.write(f"#!/bin/bash\n{restore_script}")
549
- temp_path = f.name
550
678
 
551
- from pyinfra.operations import files
552
679
  files.directory(
553
680
  name="Ensure tmp directory for borg restore",
554
681
  path=f"/home/{app_user}/tmp",
@@ -575,9 +702,13 @@ log_message "Borg restore complete"
575
702
  _use_sudo_login=True,
576
703
  )
577
704
 
705
+ cleanup_commands = [f"rm -f {restore_script_path}"]
706
+ if restore_env_path:
707
+ cleanup_commands.append(f"rm -f {restore_env_path}")
708
+
578
709
  server.shell(
579
710
  name="Clean up borg restore script",
580
- commands=[f"rm -f {restore_script_path}"],
711
+ commands=cleanup_commands,
581
712
  _sudo=True,
582
713
  _sudo_user=app_user,
583
714
  )
@@ -9,7 +9,6 @@ to bootstrap the deploy user that all subsequent commands use.
9
9
  import importlib.util
10
10
  import os
11
11
  import sys
12
- import tempfile
13
12
 
14
13
  from djaploy.hooks import deploy_hook, hook
15
14
 
@@ -23,21 +22,24 @@ def override_inventory_to_root(context):
23
22
  """
24
23
  inventory_file = context["inventory_file"]
25
24
 
26
- spec = importlib.util.spec_from_file_location("_janitor_inv", inventory_file)
25
+ module_name = f"_janitor_inv_{id(inventory_file)}"
26
+ spec = importlib.util.spec_from_file_location(module_name, inventory_file)
27
27
  module = importlib.util.module_from_spec(spec)
28
28
 
29
29
  try:
30
- sys.modules["_janitor_inv"] = module
30
+ sys.modules[module_name] = module
31
31
  spec.loader.exec_module(module)
32
32
  hosts = getattr(module, "hosts", [])
33
33
  finally:
34
- sys.modules.pop("_janitor_inv", None)
34
+ sys.modules.pop(module_name, None)
35
35
 
36
36
  # Write a temp inventory with ssh_user forced to root
37
37
  from djaploy.deploy import _make_value_serializable
38
38
 
39
- fd, tmp_path = tempfile.mkstemp(suffix=".py", prefix="janitor_inv_")
40
- with os.fdopen(fd, "w") as f:
39
+ from djaploy.utils import temp_files
40
+
41
+ tmp_path = temp_files.create(suffix=".py", prefix="janitor_inv_")
42
+ with open(tmp_path, "w") as f:
41
43
  f.write("# Auto-generated inventory (ssh_user overridden to root)\n\n")
42
44
  f.write("hosts = [\n")
43
45
  for host_entry in hosts:
@@ -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",
@@ -66,18 +67,31 @@ def deploy_nginx(host_data, artifact_path):
66
67
  _sudo=True,
67
68
  )
68
69
 
69
- # Symlink each site individually to avoid creating a literal '*' symlink
70
- # when sites-available is empty, and to avoid enabling unrelated sites
71
- # on multi-tenant servers.
72
- server.shell(
73
- name="Enable NGINX sites",
74
- commands=[
75
- "for f in /etc/nginx/sites-available/*; do "
76
- "[ -f \"$f\" ] && ln -fs \"$f\" /etc/nginx/sites-enabled/; "
77
- "done",
78
- ],
79
- _sudo=True,
80
- )
70
+ # Only enable the site for this app — avoid enabling unrelated sites
71
+ # on multi-tenant servers. Skip if the config file doesn't exist
72
+ # (e.g. when nginx_conf={"custom": True} and a custom hook handles it).
73
+ app_name = getattr(host_data, 'app_name', None)
74
+ if app_name:
75
+ server.shell(
76
+ name=f"Enable {app_name} NGINX site",
77
+ commands=[
78
+ f"test -f /etc/nginx/sites-available/{app_name} && "
79
+ f"ln -fs /etc/nginx/sites-available/{app_name} /etc/nginx/sites-enabled/{app_name} || true",
80
+ ],
81
+ _sudo=True,
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
+ )
81
95
 
82
96
 
83
97
  @deploy_hook("deploy:start")