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.
- {djaploy-1.1.7 → djaploy-1.2.1}/PKG-INFO +102 -1
- {djaploy-1.1.7 → djaploy-1.2.1}/README.md +101 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/borg/infra/djaploy_hooks.py +160 -29
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/janitor/infra/djaploy_hooks.py +8 -6
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/nginx/infra/djaploy_hooks.py +26 -12
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/rclone/infra/djaploy_hooks.py +8 -5
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/sync_certs/infra/djaploy_hooks.py +9 -16
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/systemd/infra/djaploy_hooks.py +36 -11
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/versioning/infra/djaploy_hooks.py +4 -3
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/artifact.py +79 -55
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/builtin_hooks.py +2 -2
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/certificates.py +53 -29
- djaploy-1.2.1/djaploy/commands/activate.py +26 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/commands/deploy.py +9 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/commands/restore.py +7 -0
- djaploy-1.2.1/djaploy/commands/status.py +62 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/config.py +10 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/deploy.py +67 -20
- djaploy-1.2.1/djaploy/infra/bluegreen.py +145 -0
- djaploy-1.2.1/djaploy/infra/djaploy_hooks.py +941 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/infra/templates.py +121 -2
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/infra/utils.py +145 -42
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/management/commands/djaploy.py +11 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/management/commands/restore_backup.py +69 -15
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/management/commands/update_certs.py +3 -9
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/utils.py +41 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy.egg-info/PKG-INFO +102 -1
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy.egg-info/SOURCES.txt +4 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/pyproject.toml +1 -1
- djaploy-1.2.1/tests/test_bluegreen.py +486 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/tests/test_borg.py +57 -3
- djaploy-1.1.7/djaploy/infra/djaploy_hooks.py +0 -510
- {djaploy-1.1.7 → djaploy-1.2.1}/LICENSE +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/app.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/borg/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/borg/infra/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/janitor/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/janitor/apps.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/janitor/infra/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/janitor/infra/commands/createjanitoruser.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/nginx/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/nginx/apps.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/nginx/infra/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/rclone/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/rclone/apps.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/rclone/infra/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/sync_certs/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/sync_certs/apps.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/sync_certs/infra/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/systemd/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/systemd/apps.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/systemd/infra/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/tailscale/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/tailscale/apps.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/tailscale/infra/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/tailscale/infra/djaploy_hooks.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/versioning/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/versioning/apps.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/apps/versioning/infra/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/bin/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/bin/django_pyinfra.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/bin/gunicornherder.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/changelog.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/commands/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/commands/_utils.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/commands/configure.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/commands/rollback.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/commands/sync_certs.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/discovery.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/hooks.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/infra/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/management/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/management/commands/__init__.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/management/commands/sync_certs.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/management/commands/verify.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/management/utils.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/notifications.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/version.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy/versioning.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy.egg-info/dependency_links.txt +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy.egg-info/entry_points.txt +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy.egg-info/requires.txt +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/djaploy.egg-info/top_level.txt +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/setup.cfg +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/tests/test_config.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/tests/test_deploy_scripts.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/tests/test_discovery.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/tests/test_gunicornherder.py +0 -0
- {djaploy-1.1.7 → djaploy-1.2.1}/tests/test_hooks.py +0 -0
- {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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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
|
-
|
|
452
|
-
else getattr(
|
|
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
|
-
|
|
457
|
-
else getattr(
|
|
458
|
-
) or
|
|
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
|
-
|
|
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{
|
|
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{
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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[
|
|
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(
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
"
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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")
|