djaploy 1.1.8__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.8 → djaploy-1.2.1}/PKG-INFO +102 -1
- {djaploy-1.1.8 → djaploy-1.2.1}/README.md +101 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/nginx/infra/djaploy_hooks.py +13 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/systemd/infra/djaploy_hooks.py +36 -11
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/builtin_hooks.py +2 -2
- djaploy-1.2.1/djaploy/commands/activate.py +26 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/commands/deploy.py +9 -0
- djaploy-1.2.1/djaploy/commands/status.py +62 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/config.py +2 -2
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/deploy.py +24 -0
- djaploy-1.2.1/djaploy/infra/bluegreen.py +145 -0
- djaploy-1.2.1/djaploy/infra/djaploy_hooks.py +941 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/infra/templates.py +121 -2
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/infra/utils.py +145 -42
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/management/commands/djaploy.py +11 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy.egg-info/PKG-INFO +102 -1
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy.egg-info/SOURCES.txt +6 -1
- {djaploy-1.1.8 → djaploy-1.2.1}/pyproject.toml +1 -1
- djaploy-1.2.1/tests/test_bluegreen.py +486 -0
- djaploy-1.2.1/tests/test_versioning.py +329 -0
- djaploy-1.1.8/djaploy/infra/djaploy_hooks.py +0 -511
- {djaploy-1.1.8 → djaploy-1.2.1}/LICENSE +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/app.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/borg/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/borg/infra/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/borg/infra/djaploy_hooks.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/janitor/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/janitor/apps.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/janitor/infra/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/janitor/infra/commands/createjanitoruser.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/janitor/infra/djaploy_hooks.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/nginx/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/nginx/apps.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/nginx/infra/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/rclone/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/rclone/apps.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/rclone/infra/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/rclone/infra/djaploy_hooks.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/sync_certs/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/sync_certs/apps.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/sync_certs/infra/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/sync_certs/infra/djaploy_hooks.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/systemd/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/systemd/apps.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/systemd/infra/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/tailscale/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/tailscale/apps.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/tailscale/infra/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/tailscale/infra/djaploy_hooks.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/versioning/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/versioning/apps.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/versioning/infra/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/apps/versioning/infra/djaploy_hooks.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/artifact.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/bin/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/bin/django_pyinfra.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/bin/gunicornherder.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/certificates.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/changelog.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/commands/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/commands/_utils.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/commands/configure.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/commands/restore.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/commands/rollback.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/commands/sync_certs.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/discovery.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/hooks.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/infra/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/management/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/management/commands/__init__.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/management/commands/restore_backup.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/management/commands/sync_certs.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/management/commands/update_certs.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/management/commands/verify.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/management/utils.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/notifications.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/utils.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/version.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy/versioning.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy.egg-info/dependency_links.txt +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy.egg-info/entry_points.txt +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy.egg-info/requires.txt +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/djaploy.egg-info/top_level.txt +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/setup.cfg +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/tests/test_borg.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/tests/test_config.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/tests/test_deploy_scripts.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/tests/test_discovery.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/tests/test_gunicornherder.py +0 -0
- {djaploy-1.1.8 → djaploy-1.2.1}/tests/test_hooks.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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -150,7 +150,7 @@ def _rollback_validate_strategy(context):
|
|
|
150
150
|
_, data = hosts[0]
|
|
151
151
|
strategy = (data.get("deployment_strategy") if isinstance(data, dict)
|
|
152
152
|
else getattr(data, "deployment_strategy", "zero_downtime"))
|
|
153
|
-
if strategy
|
|
153
|
+
if strategy not in ("zero_downtime", "bluegreen"):
|
|
154
154
|
raise ValueError(
|
|
155
|
-
"Rollback is only supported with deployment_strategy='zero_downtime'"
|
|
155
|
+
"Rollback is only supported with deployment_strategy='zero_downtime' or 'bluegreen'"
|
|
156
156
|
)
|
|
@@ -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,62 @@
|
|
|
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 server
|
|
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.bluegreen import print_status_cmd
|
|
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
|
+
@_deploy_decorator("show_bluegreen_status")
|
|
27
|
+
def show_bluegreen_status(host_data):
|
|
28
|
+
server.shell(
|
|
29
|
+
name="Show blue-green status",
|
|
30
|
+
commands=[
|
|
31
|
+
print_status_cmd(state_file, app_name),
|
|
32
|
+
f'echo "Sockets:"',
|
|
33
|
+
f'echo " Blue: {get_slot_socket_path(app_name, "blue")}"',
|
|
34
|
+
f'echo " Green: {get_slot_socket_path(app_name, "green")}"',
|
|
35
|
+
f'echo ""',
|
|
36
|
+
# Show systemd service status
|
|
37
|
+
f'echo "Services:"',
|
|
38
|
+
f'systemctl is-active {app_name}-blue.service 2>/dev/null && '
|
|
39
|
+
f'echo " {app_name}-blue: active" || '
|
|
40
|
+
f'echo " {app_name}-blue: inactive"',
|
|
41
|
+
f'systemctl is-active {app_name}-green.service 2>/dev/null && '
|
|
42
|
+
f'echo " {app_name}-green: active" || '
|
|
43
|
+
f'echo " {app_name}-green: inactive"',
|
|
44
|
+
f'echo ""',
|
|
45
|
+
],
|
|
46
|
+
_sudo=True,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
show_bluegreen_status(host.data)
|
|
50
|
+
else:
|
|
51
|
+
@_deploy_decorator("show_status")
|
|
52
|
+
def show_status(host_data):
|
|
53
|
+
server.shell(
|
|
54
|
+
name="Show deployment status",
|
|
55
|
+
commands=[
|
|
56
|
+
f'echo "Deployment strategy: {getattr(host.data, "deployment_strategy", "zero_downtime")}"',
|
|
57
|
+
f'echo "Not a blue-green deployment. Use deploy/rollback commands."',
|
|
58
|
+
],
|
|
59
|
+
_sudo=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
show_status(host.data)
|
|
@@ -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 '
|
|
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
|
# ------------------------------------------------------------------
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Blue-green deployment state management.
|
|
3
|
+
|
|
4
|
+
Functions that generate shell commands for managing state.json on
|
|
5
|
+
the remote server. All functions return command strings to be run
|
|
6
|
+
via pyinfra's server.shell().
|
|
7
|
+
|
|
8
|
+
State file format (state.json):
|
|
9
|
+
{
|
|
10
|
+
"active_slot": "blue" | "green" | null,
|
|
11
|
+
"slots": {
|
|
12
|
+
"blue": { "release": "...", "commit": "...", "deployed_at": "...",
|
|
13
|
+
"python_interpreter": "...", "venv_path": "..." } | null,
|
|
14
|
+
"green": { ... } | null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
|
|
21
|
+
SLOTS = ("blue", "green")
|
|
22
|
+
DEFAULT_STATE = {"active_slot": None, "slots": {"blue": None, "green": None}}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def other_slot(slot: str) -> str:
|
|
26
|
+
"""Return the other slot name."""
|
|
27
|
+
return "green" if slot == "blue" else "blue"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def init_state_cmd(state_file: str) -> str:
|
|
31
|
+
"""Shell command to create state.json if it doesn't exist."""
|
|
32
|
+
default = json.dumps(DEFAULT_STATE)
|
|
33
|
+
return (
|
|
34
|
+
f'test -f {state_file} || '
|
|
35
|
+
f"echo '{default}' > {state_file}"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read_active_slot_cmd(state_file: str) -> str:
|
|
40
|
+
"""Shell command that prints the active slot name (or empty string).
|
|
41
|
+
|
|
42
|
+
Returns empty string if state.json doesn't exist yet (first deploy).
|
|
43
|
+
"""
|
|
44
|
+
return (
|
|
45
|
+
f"python3 -c \""
|
|
46
|
+
f"import json, os; "
|
|
47
|
+
f"s=json.load(open('{state_file}')) if os.path.exists('{state_file}') else {json.dumps(DEFAULT_STATE)}; "
|
|
48
|
+
f"print(s.get('active_slot') or '')"
|
|
49
|
+
f"\""
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_state_cmd(state_file: str) -> str:
|
|
54
|
+
"""Shell command that prints the full state.json contents."""
|
|
55
|
+
return f"cat {state_file}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def update_slot_info_cmd(
|
|
59
|
+
state_file: str,
|
|
60
|
+
slot: str,
|
|
61
|
+
release: str,
|
|
62
|
+
commit: str,
|
|
63
|
+
python_interpreter: str,
|
|
64
|
+
venv_path: str,
|
|
65
|
+
) -> str:
|
|
66
|
+
"""Shell command to update a slot's deployment metadata in state.json."""
|
|
67
|
+
return (
|
|
68
|
+
f"python3 -c \""
|
|
69
|
+
f"import json, datetime; "
|
|
70
|
+
f"s=json.load(open('{state_file}')); "
|
|
71
|
+
f"s['slots']['{slot}']={{"
|
|
72
|
+
f"'release':'{release}',"
|
|
73
|
+
f"'commit':'{commit}',"
|
|
74
|
+
f"'deployed_at':datetime.datetime.now(datetime.timezone.utc).isoformat(),"
|
|
75
|
+
f"'python_interpreter':'{python_interpreter}',"
|
|
76
|
+
f"'venv_path':'{venv_path}'"
|
|
77
|
+
f"}}; "
|
|
78
|
+
f"f=open('{state_file}.tmp','w'); "
|
|
79
|
+
f"json.dump(s,f,indent=2); f.close(); "
|
|
80
|
+
f"import os; os.rename('{state_file}.tmp','{state_file}')"
|
|
81
|
+
f"\""
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def set_active_slot_cmd(state_file: str, slot: str) -> str:
|
|
86
|
+
"""Shell command to set the active_slot field in state.json."""
|
|
87
|
+
return (
|
|
88
|
+
f"python3 -c \""
|
|
89
|
+
f"import json; "
|
|
90
|
+
f"s=json.load(open('{state_file}')); "
|
|
91
|
+
f"s['active_slot']='{slot}'; "
|
|
92
|
+
f"f=open('{state_file}.tmp','w'); "
|
|
93
|
+
f"json.dump(s,f,indent=2); f.close(); "
|
|
94
|
+
f"import os; os.rename('{state_file}.tmp','{state_file}')"
|
|
95
|
+
f"\""
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def print_slot_info_cmd(state_file: str, slot: str) -> str:
|
|
100
|
+
"""Shell command that prints deployment info for a slot."""
|
|
101
|
+
return (
|
|
102
|
+
f"python3 -c \""
|
|
103
|
+
f"import json; "
|
|
104
|
+
f"s=json.load(open('{state_file}')); "
|
|
105
|
+
f"info=s['slots'].get('{slot}'); "
|
|
106
|
+
f"active=s.get('active_slot'); "
|
|
107
|
+
f"status='active' if active=='{slot}' else 'inactive'; "
|
|
108
|
+
f"print(); "
|
|
109
|
+
f"print(f'=== Slot: {slot} ({{status}}) ==='); "
|
|
110
|
+
f"if info: ["
|
|
111
|
+
f"print(f' Release: {{info[\\\"release\\\"]}}'),"
|
|
112
|
+
f"print(f' Commit: {{info[\\\"commit\\\"]}}'),"
|
|
113
|
+
f"print(f' Deployed: {{info[\\\"deployed_at\\\"]}}'),"
|
|
114
|
+
f"print(f' Python: {{info[\\\"python_interpreter\\\"]}}'),"
|
|
115
|
+
f"print(f' Venv: {{info[\\\"venv_path\\\"]}}'),"
|
|
116
|
+
f"]; "
|
|
117
|
+
f"print() if not info else None; "
|
|
118
|
+
f"print(' (empty)') if not info else None"
|
|
119
|
+
f"\""
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def print_status_cmd(state_file: str, app_name: str) -> str:
|
|
124
|
+
"""Shell command that prints full blue-green status."""
|
|
125
|
+
return (
|
|
126
|
+
f"python3 -c \""
|
|
127
|
+
f"import json; "
|
|
128
|
+
f"s=json.load(open('{state_file}')); "
|
|
129
|
+
f"active=s.get('active_slot') or 'none'; "
|
|
130
|
+
f"print(); "
|
|
131
|
+
f"print('Blue-Green Status for {app_name}'); "
|
|
132
|
+
f"print('=' * 40); "
|
|
133
|
+
f"print(f'Active slot: {{active}}'); "
|
|
134
|
+
f"print(); "
|
|
135
|
+
f"for slot in ('blue','green'): "
|
|
136
|
+
f" info=s['slots'].get(slot); "
|
|
137
|
+
f" tag='ACTIVE' if active==slot else 'inactive'; "
|
|
138
|
+
f" print(f'{{slot.upper()}} ({{tag}}):'); "
|
|
139
|
+
f" ["
|
|
140
|
+
f" (print(f' Release: {{info[k]}}') for k in ('release','commit','deployed_at','python_interpreter','venv_path'))"
|
|
141
|
+
f" if info else print(' (empty)')"
|
|
142
|
+
f" ]; "
|
|
143
|
+
f" print()"
|
|
144
|
+
f"\""
|
|
145
|
+
)
|