djaploy 1.2.4__tar.gz → 1.2.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {djaploy-1.2.4 → djaploy-1.2.6}/PKG-INFO +3 -1
- {djaploy-1.2.4 → djaploy-1.2.6}/README.md +2 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/borg/infra/djaploy_hooks.py +24 -2
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/systemd/infra/djaploy_hooks.py +16 -2
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/builtin_hooks.py +3 -2
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/status.py +4 -2
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/infra/bluegreen.py +17 -33
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/infra/djaploy_hooks.py +32 -45
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/infra/utils.py +2 -2
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/management/commands/djaploy.py +2 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy.egg-info/PKG-INFO +3 -1
- {djaploy-1.2.4 → djaploy-1.2.6}/pyproject.toml +1 -1
- {djaploy-1.2.4 → djaploy-1.2.6}/tests/test_bluegreen.py +13 -12
- {djaploy-1.2.4 → djaploy-1.2.6}/tests/test_borg.py +30 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/LICENSE +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/app.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/borg/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/borg/infra/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/janitor/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/janitor/apps.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/janitor/infra/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/janitor/infra/commands/createjanitoruser.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/janitor/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/nginx/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/nginx/apps.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/nginx/infra/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/nginx/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/rclone/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/rclone/apps.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/rclone/infra/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/rclone/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/sync_certs/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/sync_certs/apps.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/sync_certs/infra/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/sync_certs/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/systemd/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/systemd/apps.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/systemd/infra/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/tailscale/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/tailscale/apps.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/tailscale/infra/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/tailscale/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/versioning/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/versioning/apps.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/versioning/infra/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/apps/versioning/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/artifact.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/bin/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/bin/django_pyinfra.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/bin/gunicornherder.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/certificates.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/changelog.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/_utils.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/activate.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/configure.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/deploy.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/restore.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/rollback.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/commands/sync_certs.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/config.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/deploy.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/discovery.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/hooks.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/infra/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/infra/templates.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/management/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/management/commands/__init__.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/management/commands/restore_backup.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/management/commands/sync_certs.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/management/commands/update_certs.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/management/commands/verify.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/management/utils.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/notifications.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/utils.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/version.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy/versioning.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy.egg-info/SOURCES.txt +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy.egg-info/dependency_links.txt +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy.egg-info/entry_points.txt +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy.egg-info/requires.txt +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/djaploy.egg-info/top_level.txt +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/setup.cfg +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/tests/test_config.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/tests/test_deploy_scripts.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/tests/test_discovery.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/tests/test_gunicornherder.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/tests/test_hooks.py +0 -0
- {djaploy-1.2.4 → djaploy-1.2.6}/tests/test_versioning.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: djaploy
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.6
|
|
4
4
|
Summary: Modular Django deployment system based on pyinfra
|
|
5
5
|
Author-email: Johanna Mae Dimayuga <johanna@techco.fi>
|
|
6
6
|
License: MIT License
|
|
@@ -289,6 +289,8 @@ python manage.py djaploy rollback --env production
|
|
|
289
289
|
3. **Activate** -- rewrites nginx upstream to point to the new slot, reloads nginx
|
|
290
290
|
4. **Rollback** (if needed) -- switches nginx back to the previous slot, which is still running
|
|
291
291
|
|
|
292
|
+
> **Note:** Migrations run during Step 1, before traffic switches. Both the old and new slots share the same database, so migrations must be **backward-compatible** (use the expand/contract pattern). Deploy new code that handles both old and new schema, activate, then clean up in a subsequent deploy.
|
|
293
|
+
|
|
292
294
|
### Server directory layout comparison
|
|
293
295
|
|
|
294
296
|
For `app_user="myapp-api"`, `app_name="myapp"`:
|
|
@@ -227,6 +227,8 @@ python manage.py djaploy rollback --env production
|
|
|
227
227
|
3. **Activate** -- rewrites nginx upstream to point to the new slot, reloads nginx
|
|
228
228
|
4. **Rollback** (if needed) -- switches nginx back to the previous slot, which is still running
|
|
229
229
|
|
|
230
|
+
> **Note:** Migrations run during Step 1, before traffic switches. Both the old and new slots share the same database, so migrations must be **backward-compatible** (use the expand/contract pattern). Deploy new code that handles both old and new schema, activate, then clean up in a subsequent deploy.
|
|
231
|
+
|
|
230
232
|
### Server directory layout comparison
|
|
231
233
|
|
|
232
234
|
For `app_user="myapp-api"`, `app_name="myapp"`:
|
|
@@ -146,7 +146,7 @@ def _generate_backup_script(borg_config, app_user: str, repo_name: str,
|
|
|
146
146
|
if not isinstance(host_data, dict)
|
|
147
147
|
else host_data.get('deployment_strategy', 'zero_downtime')
|
|
148
148
|
)
|
|
149
|
-
if deployment_strategy
|
|
149
|
+
if deployment_strategy in ('zero_downtime', 'bluegreen'):
|
|
150
150
|
default_media = f"/home/{app_user}/apps/{app_name}/shared/media"
|
|
151
151
|
else:
|
|
152
152
|
default_media = f"/home/{app_user}/apps/{app_name}/media"
|
|
@@ -527,7 +527,7 @@ def restore_borg(host_data, restore_opts):
|
|
|
527
527
|
if not isinstance(host_data, dict)
|
|
528
528
|
else host_data.get('deployment_strategy', 'zero_downtime')
|
|
529
529
|
)
|
|
530
|
-
if deployment_strategy
|
|
530
|
+
if deployment_strategy in ('zero_downtime', 'bluegreen'):
|
|
531
531
|
default_media = f"/home/{app_user}/apps/{app_name}/shared/media"
|
|
532
532
|
else:
|
|
533
533
|
default_media = f"/home/{app_user}/apps/{app_name}/media"
|
|
@@ -540,6 +540,28 @@ def restore_borg(host_data, restore_opts):
|
|
|
540
540
|
db_only = restore_opts.get("db_only", False)
|
|
541
541
|
services = getattr(host_data, "services", None) or []
|
|
542
542
|
|
|
543
|
+
# For bluegreen, expand service names to per-slot services and include
|
|
544
|
+
# per-slot streaming services so they are also stopped during restore.
|
|
545
|
+
deployment_strategy = (
|
|
546
|
+
getattr(host_data, 'deployment_strategy', 'zero_downtime')
|
|
547
|
+
if not isinstance(host_data, dict)
|
|
548
|
+
else host_data.get('deployment_strategy', 'zero_downtime')
|
|
549
|
+
)
|
|
550
|
+
if deployment_strategy == 'bluegreen':
|
|
551
|
+
app_name = (
|
|
552
|
+
getattr(host_data, 'app_name', '')
|
|
553
|
+
if not isinstance(host_data, dict)
|
|
554
|
+
else host_data.get('app_name', '')
|
|
555
|
+
)
|
|
556
|
+
bluegreen_services = []
|
|
557
|
+
for svc in services:
|
|
558
|
+
bluegreen_services.append(f"{svc}-blue")
|
|
559
|
+
bluegreen_services.append(f"{svc}-green")
|
|
560
|
+
# Also stop streaming coordinator instances (app-level, not per-service)
|
|
561
|
+
bluegreen_services.append(f"{app_name}-streaming-blue")
|
|
562
|
+
bluegreen_services.append(f"{app_name}-streaming-green")
|
|
563
|
+
services = bluegreen_services
|
|
564
|
+
|
|
543
565
|
# Repo connection details come from the *source* borg config, but
|
|
544
566
|
# ssh_known_hosts_file must come from the *target* (the file lives on
|
|
545
567
|
# the machine running borg, not the machine the backup was taken from).
|
|
@@ -35,8 +35,7 @@ def start_services(host_data, artifact_path):
|
|
|
35
35
|
return
|
|
36
36
|
|
|
37
37
|
for service in getattr(host_data, "services", []):
|
|
38
|
-
|
|
39
|
-
slot_service = get_slot_service_name(app_name, target_slot)
|
|
38
|
+
slot_service = get_slot_service_name(service, target_slot)
|
|
40
39
|
|
|
41
40
|
systemd.service(
|
|
42
41
|
name=f"Start and restart {slot_service} (target slot)",
|
|
@@ -46,6 +45,21 @@ def start_services(host_data, artifact_path):
|
|
|
46
45
|
restarted=True,
|
|
47
46
|
_sudo=True,
|
|
48
47
|
)
|
|
48
|
+
|
|
49
|
+
# Disable legacy single service from zero_downtime/in_place if present.
|
|
50
|
+
# Runs after the new slot service is started so there's no downtime gap.
|
|
51
|
+
app_name = getattr(host_data, 'app_name', None)
|
|
52
|
+
from pyinfra.operations import server
|
|
53
|
+
server.shell(
|
|
54
|
+
name="Disable legacy service (migration to bluegreen)",
|
|
55
|
+
commands=[
|
|
56
|
+
f"systemctl stop {app_name}.service 2>/dev/null || true",
|
|
57
|
+
f"systemctl disable {app_name}.service 2>/dev/null || true",
|
|
58
|
+
f"rm -f /etc/systemd/system/{app_name}.service",
|
|
59
|
+
f"systemctl daemon-reload 2>/dev/null || true",
|
|
60
|
+
],
|
|
61
|
+
_sudo=True,
|
|
62
|
+
)
|
|
49
63
|
elif is_zero_downtime(host_data):
|
|
50
64
|
for service in getattr(host_data, "services", []):
|
|
51
65
|
systemd.service(
|
|
@@ -114,8 +114,9 @@ def _deploy_calculate_release_info(context):
|
|
|
114
114
|
text=True,
|
|
115
115
|
).strip()
|
|
116
116
|
context["pyinfra_data"]["commit"] = commit
|
|
117
|
-
except Exception:
|
|
118
|
-
|
|
117
|
+
except Exception as e:
|
|
118
|
+
import logging
|
|
119
|
+
logging.getLogger(__name__).debug("Failed to get commit hash: %s", e)
|
|
119
120
|
|
|
120
121
|
|
|
121
122
|
# ── deploy:postcommand ───────────────────────────────────────────────
|
|
@@ -64,8 +64,10 @@ if is_bluegreen(host.data):
|
|
|
64
64
|
status_str = "unknown"
|
|
65
65
|
if res[0] and len(res) > 1:
|
|
66
66
|
try:
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
first_item = next(iter(res[1]), None)
|
|
68
|
+
if first_item is not None:
|
|
69
|
+
status_str = (first_item.line if hasattr(first_item, 'line') else str(first_item)).strip()
|
|
70
|
+
except (StopIteration, AttributeError):
|
|
69
71
|
pass
|
|
70
72
|
print(f"Service {svc}: {status_str}")
|
|
71
73
|
print()
|
|
@@ -63,21 +63,28 @@ def update_slot_info_cmd(
|
|
|
63
63
|
python_interpreter: str,
|
|
64
64
|
venv_path: str,
|
|
65
65
|
) -> str:
|
|
66
|
-
"""Shell command to update a slot's deployment metadata in state.json.
|
|
66
|
+
"""Shell command to update a slot's deployment metadata in state.json.
|
|
67
|
+
|
|
68
|
+
Note: Production code uses heredocs instead of this function.
|
|
69
|
+
Kept for testing and as a utility.
|
|
70
|
+
"""
|
|
71
|
+
# Serialize values safely to avoid injection
|
|
72
|
+
safe_vals = json.dumps({
|
|
73
|
+
"release": release,
|
|
74
|
+
"commit": commit,
|
|
75
|
+
"python_interpreter": python_interpreter,
|
|
76
|
+
"venv_path": venv_path,
|
|
77
|
+
})
|
|
67
78
|
return (
|
|
68
79
|
f"python3 -c \""
|
|
69
|
-
f"import json, datetime; "
|
|
80
|
+
f"import json, datetime, os; "
|
|
70
81
|
f"s=json.load(open('{state_file}')); "
|
|
71
|
-
f"
|
|
72
|
-
f"'
|
|
73
|
-
f"'
|
|
74
|
-
f"'deployed_at':datetime.datetime.now(datetime.timezone.utc).isoformat(),"
|
|
75
|
-
f"'python_interpreter':'{python_interpreter}',"
|
|
76
|
-
f"'venv_path':'{venv_path}'"
|
|
77
|
-
f"}}; "
|
|
82
|
+
f"vals={safe_vals}; "
|
|
83
|
+
f"vals['deployed_at']=datetime.datetime.now(datetime.timezone.utc).isoformat(); "
|
|
84
|
+
f"s['slots']['{slot}']=vals; "
|
|
78
85
|
f"f=open('{state_file}.tmp','w'); "
|
|
79
86
|
f"json.dump(s,f,indent=2); f.close(); "
|
|
80
|
-
f"
|
|
87
|
+
f"os.rename('{state_file}.tmp','{state_file}')"
|
|
81
88
|
f"\""
|
|
82
89
|
)
|
|
83
90
|
|
|
@@ -120,26 +127,3 @@ def print_slot_info_cmd(state_file: str, slot: str) -> str:
|
|
|
120
127
|
)
|
|
121
128
|
|
|
122
129
|
|
|
123
|
-
def print_status_cmd(state_file: str, app_name: str) -> str:
|
|
124
|
-
"""Shell command that prints full blue-green status."""
|
|
125
|
-
return (
|
|
126
|
-
f"python3 -c \""
|
|
127
|
-
f"import json; "
|
|
128
|
-
f"s=json.load(open('{state_file}')); "
|
|
129
|
-
f"active=s.get('active_slot') or 'none'; "
|
|
130
|
-
f"print(); "
|
|
131
|
-
f"print('Blue-Green Status for {app_name}'); "
|
|
132
|
-
f"print('=' * 40); "
|
|
133
|
-
f"print(f'Active slot: {{active}}'); "
|
|
134
|
-
f"print(); "
|
|
135
|
-
f"for slot in ('blue','green'): "
|
|
136
|
-
f" info=s['slots'].get(slot); "
|
|
137
|
-
f" tag='ACTIVE' if active==slot else 'inactive'; "
|
|
138
|
-
f" print(f'{{slot.upper()}} ({{tag}}):'); "
|
|
139
|
-
f" ["
|
|
140
|
-
f" (print(f' Release: {{info[k]}}') for k in ('release','commit','deployed_at','python_interpreter','venv_path'))"
|
|
141
|
-
f" if info else print(' (empty)')"
|
|
142
|
-
f" ]; "
|
|
143
|
-
f" print()"
|
|
144
|
-
f"\""
|
|
145
|
-
)
|
|
@@ -8,6 +8,31 @@ Handles server configuration, application deployment, post-deploy tasks
|
|
|
8
8
|
from djaploy.hooks import deploy_hook
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
def _update_nginx_upstream(host_data, app_name, target_slot):
|
|
12
|
+
"""Render and deploy the bluegreen nginx upstream config.
|
|
13
|
+
|
|
14
|
+
Skipped when nginx_conf.custom is set — custom setups handle
|
|
15
|
+
their own upstream via activate:post hooks.
|
|
16
|
+
"""
|
|
17
|
+
from io import StringIO
|
|
18
|
+
from pyinfra.operations import files
|
|
19
|
+
from djaploy.infra.templates import NGINX_UPSTREAM_BLUEGREEN, build_template_context
|
|
20
|
+
from jinja2 import Environment
|
|
21
|
+
|
|
22
|
+
ctx = build_template_context(host_data)
|
|
23
|
+
ctx["active_slot"] = target_slot
|
|
24
|
+
upstream_content = Environment().from_string(NGINX_UPSTREAM_BLUEGREEN).render(**ctx)
|
|
25
|
+
|
|
26
|
+
nginx_cfg = getattr(host_data, 'nginx_conf', None) or {}
|
|
27
|
+
if not nginx_cfg.get("custom"):
|
|
28
|
+
files.put(
|
|
29
|
+
name=f"Update nginx upstream to {target_slot} slot",
|
|
30
|
+
src=StringIO(upstream_content),
|
|
31
|
+
dest=f"/etc/nginx/sites-available/{app_name}-upstream.conf",
|
|
32
|
+
_sudo=True,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
11
36
|
def _read_slot_info_from_remote(host, slot, state_file):
|
|
12
37
|
"""Read a slot's deployment info from state.json on the remote server.
|
|
13
38
|
|
|
@@ -28,7 +53,7 @@ def _read_slot_info_from_remote(host, slot, state_file):
|
|
|
28
53
|
if status and output:
|
|
29
54
|
try:
|
|
30
55
|
# pyinfra returns OutputLine objects with a .line attribute
|
|
31
|
-
lines = [
|
|
56
|
+
lines = [line.line if hasattr(line, 'line') else str(line) for line in output]
|
|
32
57
|
raw = "\n".join(lines)
|
|
33
58
|
state_data = json.loads(raw)
|
|
34
59
|
return state_data.get("slots", {}).get(slot) or {}
|
|
@@ -657,13 +682,13 @@ def activate_release(host_data, artifact_path):
|
|
|
657
682
|
def _print_deploy_summary(state_f, t_slot, rel, sock):
|
|
658
683
|
info = _read_slot_info_from_remote(host, t_slot, state_f)
|
|
659
684
|
|
|
660
|
-
print(
|
|
685
|
+
print("\n=== Blue-Green Deploy Summary ===")
|
|
661
686
|
print(f" Slot: {t_slot}")
|
|
662
687
|
print(f" Release: {rel}")
|
|
663
688
|
print(f" Socket: {sock}")
|
|
664
689
|
print(f" Venv: {info.get('venv_path', 'unknown')}")
|
|
665
690
|
print(f" Python: {info.get('python_interpreter', 'unknown')}")
|
|
666
|
-
print(
|
|
691
|
+
print("\n Status: staged (run 'djaploy activate' to switch traffic)\n")
|
|
667
692
|
|
|
668
693
|
python_op.call(
|
|
669
694
|
name="Print deployment summary",
|
|
@@ -734,19 +759,7 @@ def rollback_release(host_data, release=None):
|
|
|
734
759
|
host.data._bluegreen_activated_slot = target_slot
|
|
735
760
|
|
|
736
761
|
# Update nginx upstream for non-custom setups
|
|
737
|
-
|
|
738
|
-
ctx["active_slot"] = target_slot
|
|
739
|
-
upstream_content = Environment().from_string(NGINX_UPSTREAM_BLUEGREEN).render(**ctx)
|
|
740
|
-
|
|
741
|
-
nginx_cfg = getattr(host_data, 'nginx_conf', None) or {}
|
|
742
|
-
if not nginx_cfg.get("custom"):
|
|
743
|
-
from pyinfra.operations import files
|
|
744
|
-
files.put(
|
|
745
|
-
name=f"Update nginx upstream to {target_slot} slot",
|
|
746
|
-
src=StringIO(upstream_content),
|
|
747
|
-
dest=f"/etc/nginx/sites-available/{app_name}-upstream.conf",
|
|
748
|
-
_sudo=True,
|
|
749
|
-
)
|
|
762
|
+
_update_nginx_upstream(host_data, app_name, target_slot)
|
|
750
763
|
|
|
751
764
|
# Reload nginx
|
|
752
765
|
server.shell(
|
|
@@ -768,7 +781,7 @@ def rollback_release(host_data, release=None):
|
|
|
768
781
|
|
|
769
782
|
def _print_rollback_summary(state_f, t_slot, p_slot):
|
|
770
783
|
info = _read_slot_info_from_remote(host, t_slot, state_f)
|
|
771
|
-
print(
|
|
784
|
+
print("\n=== Blue-Green Rollback ===")
|
|
772
785
|
print(f" Switched: {p_slot} -> {t_slot}")
|
|
773
786
|
print(f" Release: {info.get('release', 'unknown')}")
|
|
774
787
|
print(f" Python: {info.get('python_interpreter', 'unknown')}")
|
|
@@ -875,25 +888,7 @@ def activate_bluegreen(host_data):
|
|
|
875
888
|
host.data._bluegreen_activated_slot = target_slot
|
|
876
889
|
|
|
877
890
|
# Update nginx upstream config to point to target slot
|
|
878
|
-
|
|
879
|
-
from djaploy.infra.templates import NGINX_UPSTREAM_BLUEGREEN, build_template_context
|
|
880
|
-
from jinja2 import Environment
|
|
881
|
-
|
|
882
|
-
ctx = build_template_context(host_data)
|
|
883
|
-
ctx["active_slot"] = target_slot
|
|
884
|
-
upstream_content = Environment().from_string(NGINX_UPSTREAM_BLUEGREEN).render(**ctx)
|
|
885
|
-
|
|
886
|
-
# Write the upstream config. Custom nginx setups (e.g. bostad) handle
|
|
887
|
-
# their own upstream file via activate:post hooks.
|
|
888
|
-
from pyinfra.operations import files
|
|
889
|
-
nginx_cfg = getattr(host_data, 'nginx_conf', None) or {}
|
|
890
|
-
if not nginx_cfg.get("custom"):
|
|
891
|
-
files.put(
|
|
892
|
-
name=f"Update nginx upstream to {target_slot} slot",
|
|
893
|
-
src=StringIO(upstream_content),
|
|
894
|
-
dest=f"/etc/nginx/sites-available/{app_name}-upstream.conf",
|
|
895
|
-
_sudo=True,
|
|
896
|
-
)
|
|
891
|
+
_update_nginx_upstream(host_data, app_name, target_slot)
|
|
897
892
|
|
|
898
893
|
# Reload nginx
|
|
899
894
|
server.shell(
|
|
@@ -919,7 +914,7 @@ def activate_bluegreen(host_data):
|
|
|
919
914
|
new_info = _read_slot_info_from_remote(host, t_slot, state_f)
|
|
920
915
|
old_info = _read_slot_info_from_remote(host, p_slot, state_f) if p_slot != "none" else {}
|
|
921
916
|
|
|
922
|
-
print(
|
|
917
|
+
print("\n=== Blue-Green Activation ===")
|
|
923
918
|
print(f" Switched: {p_slot} -> {t_slot}")
|
|
924
919
|
print(f" Socket: {sock} (now serving production)")
|
|
925
920
|
print(f" Deployed at: {new_info.get('deployed_at', 'unknown')}")
|
|
@@ -953,11 +948,3 @@ def activate_bluegreen(host_data):
|
|
|
953
948
|
p_slot=prev_slot,
|
|
954
949
|
sock=socket_path,
|
|
955
950
|
)
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
@deploy_hook("activate")
|
|
959
|
-
def activate_bluegreen_status(host_data):
|
|
960
|
-
"""Print full blue-green status during status command."""
|
|
961
|
-
# This hook is intentionally a no-op during activate —
|
|
962
|
-
# the main activate_bluegreen hook handles everything.
|
|
963
|
-
pass
|
|
@@ -526,8 +526,8 @@ def get_manage_py_path(host_data) -> str:
|
|
|
526
526
|
def get_python_cmd(app_user: str, app_path: str, host_data) -> str:
|
|
527
527
|
"""Get the python command prefix for running management commands.
|
|
528
528
|
|
|
529
|
-
For zero-downtime deploys, uses the release's
|
|
530
|
-
For in-place deploys, uses 'poetry run python'.
|
|
529
|
+
For zero-downtime and blue-green deploys, uses the release/slot's
|
|
530
|
+
.venv/bin/python directly. For in-place deploys, uses 'poetry run python'.
|
|
531
531
|
"""
|
|
532
532
|
if is_zero_downtime(host_data) or is_bluegreen(host_data):
|
|
533
533
|
return f"{app_path}/.venv/bin/python"
|
|
@@ -217,6 +217,8 @@ class Command(BaseCommand):
|
|
|
217
217
|
|
|
218
218
|
# Pass --activate flag for bluegreen deploy+activate in one step
|
|
219
219
|
if options.get("activate"):
|
|
220
|
+
if command_name != "deploy":
|
|
221
|
+
raise CommandError("--activate is only valid with the 'deploy' command")
|
|
220
222
|
context["activate"] = True
|
|
221
223
|
context["pyinfra_data"]["activate"] = "true"
|
|
222
224
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: djaploy
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.6
|
|
4
4
|
Summary: Modular Django deployment system based on pyinfra
|
|
5
5
|
Author-email: Johanna Mae Dimayuga <johanna@techco.fi>
|
|
6
6
|
License: MIT License
|
|
@@ -289,6 +289,8 @@ python manage.py djaploy rollback --env production
|
|
|
289
289
|
3. **Activate** -- rewrites nginx upstream to point to the new slot, reloads nginx
|
|
290
290
|
4. **Rollback** (if needed) -- switches nginx back to the previous slot, which is still running
|
|
291
291
|
|
|
292
|
+
> **Note:** Migrations run during Step 1, before traffic switches. Both the old and new slots share the same database, so migrations must be **backward-compatible** (use the expand/contract pattern). Deploy new code that handles both old and new schema, activate, then clean up in a subsequent deploy.
|
|
293
|
+
|
|
292
294
|
### Server directory layout comparison
|
|
293
295
|
|
|
294
296
|
For `app_user="myapp-api"`, `app_name="myapp"`:
|
|
@@ -212,21 +212,22 @@ except ImportError:
|
|
|
212
212
|
class TestBluegreenTemplateRender(unittest.TestCase):
|
|
213
213
|
"""Render blue-green templates with Jinja2 and verify output."""
|
|
214
214
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
215
|
+
def _base_ctx(self):
|
|
216
|
+
return dict(
|
|
217
|
+
project_name="myapp",
|
|
218
|
+
app_user="app",
|
|
219
|
+
slot="blue",
|
|
220
|
+
slot_path="/home/app/apps/myapp/slots/blue",
|
|
221
|
+
manage_subdir="",
|
|
222
|
+
workers=4,
|
|
223
|
+
timeout=30,
|
|
224
|
+
umask="002",
|
|
225
|
+
wsgi_module="myapp.wsgi:application",
|
|
226
|
+
)
|
|
226
227
|
|
|
227
228
|
def _render(self, template_str, **overrides):
|
|
228
229
|
from jinja2 import Environment
|
|
229
|
-
ctx = {**self.
|
|
230
|
+
ctx = {**self._base_ctx(), **overrides}
|
|
230
231
|
return Environment().from_string(template_str).render(**ctx)
|
|
231
232
|
|
|
232
233
|
def test_systemd_renders_slot_in_socket_path(self):
|
|
@@ -245,6 +245,36 @@ class TestBorgBackupScriptGeneration(unittest.TestCase):
|
|
|
245
245
|
self.assertIn('MEDIA_DIR="/home/app/apps/myapp/media"', script)
|
|
246
246
|
self.assertNotIn("/shared/media", script)
|
|
247
247
|
|
|
248
|
+
def test_script_default_media_path_bluegreen(self):
|
|
249
|
+
"""Bluegreen deploys use shared/media by default (same as zero_downtime)."""
|
|
250
|
+
config = {"passphrase": "s", "repo_host": ""}
|
|
251
|
+
host_data = MagicMock()
|
|
252
|
+
host_data.app_name = "myapp"
|
|
253
|
+
host_data.db_dir = None
|
|
254
|
+
host_data.deployment_strategy = "bluegreen"
|
|
255
|
+
script = self._generate(config, app_user="app", host_data=host_data)
|
|
256
|
+
self.assertIn('MEDIA_DIR="/home/app/apps/myapp/shared/media"', script)
|
|
257
|
+
|
|
258
|
+
def test_script_default_db_path_bluegreen(self):
|
|
259
|
+
"""Bluegreen db_path defaults to host db_dir or /home/{user}/dbs."""
|
|
260
|
+
config = {"passphrase": "s", "repo_host": ""}
|
|
261
|
+
host_data = MagicMock()
|
|
262
|
+
host_data.app_name = "myapp"
|
|
263
|
+
host_data.db_dir = "/home/app/dbs/myapp"
|
|
264
|
+
host_data.deployment_strategy = "bluegreen"
|
|
265
|
+
script = self._generate(config, app_user="app", host_data=host_data)
|
|
266
|
+
self.assertIn('DB_DIR="/home/app/dbs/myapp"', script)
|
|
267
|
+
|
|
268
|
+
def test_script_default_media_path_bluegreen_in_place(self):
|
|
269
|
+
"""Bluegreen should NOT use the in_place media path."""
|
|
270
|
+
config = {"passphrase": "s", "repo_host": ""}
|
|
271
|
+
host_data = MagicMock()
|
|
272
|
+
host_data.app_name = "myapp"
|
|
273
|
+
host_data.db_dir = None
|
|
274
|
+
host_data.deployment_strategy = "bluegreen"
|
|
275
|
+
script = self._generate(config, app_user="app", host_data=host_data)
|
|
276
|
+
self.assertNotIn('MEDIA_DIR="/home/app/apps/myapp/media"', script)
|
|
277
|
+
|
|
248
278
|
def test_script_explicit_media_path_overrides_strategy(self):
|
|
249
279
|
"""Explicit media_path overrides the strategy-derived default."""
|
|
250
280
|
config = {"passphrase": "s", "repo_host": "", "media_path": "/custom/media"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|