djaploy 1.2.5__tar.gz → 1.2.7__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.5 → djaploy-1.2.7}/PKG-INFO +3 -1
- {djaploy-1.2.5 → djaploy-1.2.7}/README.md +2 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/borg/infra/djaploy_hooks.py +3 -3
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/systemd/infra/djaploy_hooks.py +27 -8
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/builtin_hooks.py +3 -2
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/status.py +4 -2
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/infra/bluegreen.py +17 -33
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/infra/djaploy_hooks.py +78 -49
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/infra/utils.py +2 -2
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/management/commands/djaploy.py +2 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy.egg-info/PKG-INFO +3 -1
- {djaploy-1.2.5 → djaploy-1.2.7}/pyproject.toml +1 -1
- {djaploy-1.2.5 → djaploy-1.2.7}/tests/test_bluegreen.py +130 -12
- {djaploy-1.2.5 → djaploy-1.2.7}/tests/test_borg.py +30 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/LICENSE +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/app.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/borg/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/borg/infra/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/janitor/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/janitor/apps.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/janitor/infra/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/janitor/infra/commands/createjanitoruser.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/janitor/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/nginx/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/nginx/apps.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/nginx/infra/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/nginx/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/rclone/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/rclone/apps.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/rclone/infra/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/rclone/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/sync_certs/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/sync_certs/apps.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/sync_certs/infra/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/sync_certs/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/systemd/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/systemd/apps.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/systemd/infra/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/tailscale/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/tailscale/apps.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/tailscale/infra/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/tailscale/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/versioning/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/versioning/apps.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/versioning/infra/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/apps/versioning/infra/djaploy_hooks.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/artifact.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/bin/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/bin/django_pyinfra.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/bin/gunicornherder.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/certificates.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/changelog.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/_utils.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/activate.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/configure.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/deploy.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/restore.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/rollback.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/commands/sync_certs.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/config.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/deploy.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/discovery.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/hooks.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/infra/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/infra/templates.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/management/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/management/commands/__init__.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/management/commands/restore_backup.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/management/commands/sync_certs.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/management/commands/update_certs.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/management/commands/verify.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/management/utils.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/notifications.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/utils.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/version.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy/versioning.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy.egg-info/SOURCES.txt +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy.egg-info/dependency_links.txt +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy.egg-info/entry_points.txt +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy.egg-info/requires.txt +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/djaploy.egg-info/top_level.txt +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/setup.cfg +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/tests/test_config.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/tests/test_deploy_scripts.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/tests/test_discovery.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/tests/test_gunicornherder.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/tests/test_hooks.py +0 -0
- {djaploy-1.2.5 → djaploy-1.2.7}/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.7
|
|
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"`:
|
|
@@ -555,9 +555,9 @@ def restore_borg(host_data, restore_opts):
|
|
|
555
555
|
)
|
|
556
556
|
bluegreen_services = []
|
|
557
557
|
for svc in services:
|
|
558
|
-
bluegreen_services.append(f"{
|
|
559
|
-
bluegreen_services.append(f"{
|
|
560
|
-
# Also stop streaming coordinator instances
|
|
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
561
|
bluegreen_services.append(f"{app_name}-streaming-blue")
|
|
562
562
|
bluegreen_services.append(f"{app_name}-streaming-green")
|
|
563
563
|
services = bluegreen_services
|
|
@@ -34,18 +34,37 @@ def start_services(host_data, artifact_path):
|
|
|
34
34
|
if not target_slot:
|
|
35
35
|
return
|
|
36
36
|
|
|
37
|
+
from pyinfra.operations import server
|
|
37
38
|
for service in getattr(host_data, "services", []):
|
|
38
|
-
|
|
39
|
-
slot_service = get_slot_service_name(app_name, target_slot)
|
|
39
|
+
slot_service = get_slot_service_name(service, target_slot)
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
# Only start if the service unit file exists — custom services
|
|
42
|
+
# (e.g. streaming) may be rendered by project hooks, not djaploy core.
|
|
43
|
+
server.shell(
|
|
44
|
+
name=f"Start {slot_service} if unit exists",
|
|
45
|
+
commands=[
|
|
46
|
+
f"test -f /etc/systemd/system/{slot_service}.service && "
|
|
47
|
+
f"systemctl enable {slot_service} && "
|
|
48
|
+
f"systemctl restart {slot_service} || "
|
|
49
|
+
f"echo 'Unit {slot_service}.service not found, skipping'",
|
|
50
|
+
],
|
|
47
51
|
_sudo=True,
|
|
48
52
|
)
|
|
53
|
+
|
|
54
|
+
# Disable legacy single service from zero_downtime/in_place if present.
|
|
55
|
+
# Runs after the new slot service is started so there's no downtime gap.
|
|
56
|
+
app_name = getattr(host_data, 'app_name', None)
|
|
57
|
+
from pyinfra.operations import server
|
|
58
|
+
server.shell(
|
|
59
|
+
name="Disable legacy service (migration to bluegreen)",
|
|
60
|
+
commands=[
|
|
61
|
+
f"systemctl stop {app_name}.service 2>/dev/null || true",
|
|
62
|
+
f"systemctl disable {app_name}.service 2>/dev/null || true",
|
|
63
|
+
f"rm -f /etc/systemd/system/{app_name}.service",
|
|
64
|
+
f"systemctl daemon-reload 2>/dev/null || true",
|
|
65
|
+
],
|
|
66
|
+
_sudo=True,
|
|
67
|
+
)
|
|
49
68
|
elif is_zero_downtime(host_data):
|
|
50
69
|
for service in getattr(host_data, "services", []):
|
|
51
70
|
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 {}
|
|
@@ -160,7 +185,6 @@ def configure_server(host_data):
|
|
|
160
185
|
_sudo_user=app_user,
|
|
161
186
|
)
|
|
162
187
|
|
|
163
|
-
|
|
164
188
|
shared_resources = getattr(host_data, 'shared_resources', None) or []
|
|
165
189
|
if shared_resources:
|
|
166
190
|
mkdir_commands = [
|
|
@@ -624,8 +648,13 @@ def activate_release(host_data, artifact_path):
|
|
|
624
648
|
run_migrations(app_user, build_path, host_data)
|
|
625
649
|
collect_static(app_user, build_path, host_data)
|
|
626
650
|
|
|
627
|
-
# Update state.json on the remote server
|
|
651
|
+
# Update state.json on the remote server.
|
|
652
|
+
# Serialize release_name and commit with json.dumps to safely
|
|
653
|
+
# handle quotes and special characters in branch names / messages.
|
|
654
|
+
import json as _json
|
|
628
655
|
commit = getattr(host_data, 'commit', 'unknown')
|
|
656
|
+
safe_release = _json.dumps(release_name)
|
|
657
|
+
safe_commit = _json.dumps(commit)
|
|
629
658
|
socket_path = get_slot_socket_path(app_name, target_slot)
|
|
630
659
|
server.shell(
|
|
631
660
|
name="Update state.json with deployment info",
|
|
@@ -636,8 +665,8 @@ def activate_release(host_data, artifact_path):
|
|
|
636
665
|
f"python_path = venv_path + '/bin/python' if venv_path != 'unknown' else 'unknown'\n"
|
|
637
666
|
f"s = json.load(open('{state_file}'))\n"
|
|
638
667
|
f"s['slots']['{target_slot}'] = {{\n"
|
|
639
|
-
f" 'release':
|
|
640
|
-
f" 'commit':
|
|
668
|
+
f" 'release': {safe_release},\n"
|
|
669
|
+
f" 'commit': {safe_commit},\n"
|
|
641
670
|
f" 'deployed_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),\n"
|
|
642
671
|
f" 'python_interpreter': python_path,\n"
|
|
643
672
|
f" 'venv_path': venv_path,\n"
|
|
@@ -658,13 +687,13 @@ def activate_release(host_data, artifact_path):
|
|
|
658
687
|
def _print_deploy_summary(state_f, t_slot, rel, sock):
|
|
659
688
|
info = _read_slot_info_from_remote(host, t_slot, state_f)
|
|
660
689
|
|
|
661
|
-
print(
|
|
690
|
+
print("\n=== Blue-Green Deploy Summary ===")
|
|
662
691
|
print(f" Slot: {t_slot}")
|
|
663
692
|
print(f" Release: {rel}")
|
|
664
693
|
print(f" Socket: {sock}")
|
|
665
694
|
print(f" Venv: {info.get('venv_path', 'unknown')}")
|
|
666
695
|
print(f" Python: {info.get('python_interpreter', 'unknown')}")
|
|
667
|
-
print(
|
|
696
|
+
print("\n Status: staged (run 'djaploy activate' to switch traffic)\n")
|
|
668
697
|
|
|
669
698
|
python_op.call(
|
|
670
699
|
name="Print deployment summary",
|
|
@@ -735,19 +764,7 @@ def rollback_release(host_data, release=None):
|
|
|
735
764
|
host.data._bluegreen_activated_slot = target_slot
|
|
736
765
|
|
|
737
766
|
# Update nginx upstream for non-custom setups
|
|
738
|
-
|
|
739
|
-
ctx["active_slot"] = target_slot
|
|
740
|
-
upstream_content = Environment().from_string(NGINX_UPSTREAM_BLUEGREEN).render(**ctx)
|
|
741
|
-
|
|
742
|
-
nginx_cfg = getattr(host_data, 'nginx_conf', None) or {}
|
|
743
|
-
if not nginx_cfg.get("custom"):
|
|
744
|
-
from pyinfra.operations import files
|
|
745
|
-
files.put(
|
|
746
|
-
name=f"Update nginx upstream to {target_slot} slot",
|
|
747
|
-
src=StringIO(upstream_content),
|
|
748
|
-
dest=f"/etc/nginx/sites-available/{app_name}-upstream.conf",
|
|
749
|
-
_sudo=True,
|
|
750
|
-
)
|
|
767
|
+
_update_nginx_upstream(host_data, app_name, target_slot)
|
|
751
768
|
|
|
752
769
|
# Reload nginx
|
|
753
770
|
server.shell(
|
|
@@ -769,7 +786,7 @@ def rollback_release(host_data, release=None):
|
|
|
769
786
|
|
|
770
787
|
def _print_rollback_summary(state_f, t_slot, p_slot):
|
|
771
788
|
info = _read_slot_info_from_remote(host, t_slot, state_f)
|
|
772
|
-
print(
|
|
789
|
+
print("\n=== Blue-Green Rollback ===")
|
|
773
790
|
print(f" Switched: {p_slot} -> {t_slot}")
|
|
774
791
|
print(f" Release: {info.get('release', 'unknown')}")
|
|
775
792
|
print(f" Python: {info.get('python_interpreter', 'unknown')}")
|
|
@@ -823,6 +840,7 @@ def activate_bluegreen(host_data):
|
|
|
823
840
|
from pyinfra.facts.server import Command
|
|
824
841
|
from djaploy.infra.utils import (
|
|
825
842
|
is_bluegreen, get_app_path, get_slot_socket_path,
|
|
843
|
+
get_slot_service_name,
|
|
826
844
|
)
|
|
827
845
|
from djaploy.infra.bluegreen import (
|
|
828
846
|
read_active_slot_cmd, set_active_slot_cmd, other_slot,
|
|
@@ -875,27 +893,47 @@ def activate_bluegreen(host_data):
|
|
|
875
893
|
# Store on host.data so activate:post hooks can read it
|
|
876
894
|
host.data._bluegreen_activated_slot = target_slot
|
|
877
895
|
|
|
878
|
-
#
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
dest=f"/etc/nginx/sites-available/{app_name}-upstream.conf",
|
|
896
|
+
# Health check: verify all target slot services are running before
|
|
897
|
+
# switching nginx. Abort activation if any service is down.
|
|
898
|
+
services = getattr(host_data, "services", []) or []
|
|
899
|
+
failed_checks = []
|
|
900
|
+
for svc in services:
|
|
901
|
+
slot_svc = get_slot_service_name(svc, target_slot)
|
|
902
|
+
server.shell(
|
|
903
|
+
name=f"Health check: verify {slot_svc} is active",
|
|
904
|
+
commands=[
|
|
905
|
+
f"if systemctl is-active {slot_svc}.service > /dev/null 2>&1; then "
|
|
906
|
+
f"echo 'Health check passed: {slot_svc} is active'; "
|
|
907
|
+
f"else "
|
|
908
|
+
f"echo 'ACTIVATION ABORTED: {slot_svc} is not running.' && "
|
|
909
|
+
f"echo 'Check: systemctl status {slot_svc}.service' && "
|
|
910
|
+
f"exit 1; "
|
|
911
|
+
f"fi",
|
|
912
|
+
],
|
|
896
913
|
_sudo=True,
|
|
897
914
|
)
|
|
898
915
|
|
|
916
|
+
# Extra gunicorn-specific check: verify the socket is responding
|
|
917
|
+
socket_path = get_slot_socket_path(app_name, target_slot)
|
|
918
|
+
server.shell(
|
|
919
|
+
name=f"Health check: verify {target_slot} gunicorn socket responds",
|
|
920
|
+
commands=[
|
|
921
|
+
f"if curl -sf --max-time 5 --unix-socket {socket_path} http://localhost/ > /dev/null 2>&1; then "
|
|
922
|
+
f"echo 'Health check passed: gunicorn socket is responding'; "
|
|
923
|
+
f"elif [ -S {socket_path} ]; then "
|
|
924
|
+
f"echo 'Health check: socket exists, HTTP may be non-200 (proceeding)'; "
|
|
925
|
+
f"else "
|
|
926
|
+
f"echo 'ACTIVATION ABORTED: gunicorn socket not responding.' && "
|
|
927
|
+
f"echo 'Socket: {socket_path}' && "
|
|
928
|
+
f"exit 1; "
|
|
929
|
+
f"fi",
|
|
930
|
+
],
|
|
931
|
+
_sudo=True,
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
# Update nginx upstream config to point to target slot
|
|
935
|
+
_update_nginx_upstream(host_data, app_name, target_slot)
|
|
936
|
+
|
|
899
937
|
# Reload nginx
|
|
900
938
|
server.shell(
|
|
901
939
|
name="Test and reload nginx",
|
|
@@ -905,7 +943,6 @@ def activate_bluegreen(host_data):
|
|
|
905
943
|
|
|
906
944
|
# Update state.json on remote
|
|
907
945
|
prev_slot = active_slot or "none"
|
|
908
|
-
socket_path = get_slot_socket_path(app_name, target_slot)
|
|
909
946
|
server.shell(
|
|
910
947
|
name=f"Set active slot to {target_slot}",
|
|
911
948
|
commands=[set_active_slot_cmd(state_file, target_slot)],
|
|
@@ -920,7 +957,7 @@ def activate_bluegreen(host_data):
|
|
|
920
957
|
new_info = _read_slot_info_from_remote(host, t_slot, state_f)
|
|
921
958
|
old_info = _read_slot_info_from_remote(host, p_slot, state_f) if p_slot != "none" else {}
|
|
922
959
|
|
|
923
|
-
print(
|
|
960
|
+
print("\n=== Blue-Green Activation ===")
|
|
924
961
|
print(f" Switched: {p_slot} -> {t_slot}")
|
|
925
962
|
print(f" Socket: {sock} (now serving production)")
|
|
926
963
|
print(f" Deployed at: {new_info.get('deployed_at', 'unknown')}")
|
|
@@ -954,11 +991,3 @@ def activate_bluegreen(host_data):
|
|
|
954
991
|
p_slot=prev_slot,
|
|
955
992
|
sock=socket_path,
|
|
956
993
|
)
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
@deploy_hook("activate")
|
|
960
|
-
def activate_bluegreen_status(host_data):
|
|
961
|
-
"""Print full blue-green status during status command."""
|
|
962
|
-
# This hook is intentionally a no-op during activate —
|
|
963
|
-
# the main activate_bluegreen hook handles everything.
|
|
964
|
-
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.7
|
|
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):
|
|
@@ -482,5 +483,122 @@ class TestBluegreenHookDiscovery(unittest.TestCase):
|
|
|
482
483
|
global_registry._hooks.update(saved_hooks)
|
|
483
484
|
|
|
484
485
|
|
|
486
|
+
class TestBluegreenHealthCheck(unittest.TestCase):
|
|
487
|
+
"""Test health check logic in activate hook."""
|
|
488
|
+
|
|
489
|
+
def test_health_check_socket_path_uses_target_slot(self):
|
|
490
|
+
"""Health check should curl the target slot's socket."""
|
|
491
|
+
from djaploy.infra.utils import get_slot_socket_path
|
|
492
|
+
blue_socket = get_slot_socket_path("myapp", "blue")
|
|
493
|
+
green_socket = get_slot_socket_path("myapp", "green")
|
|
494
|
+
self.assertEqual(blue_socket, "/run/myapp-blue/myapp.sock")
|
|
495
|
+
self.assertEqual(green_socket, "/run/myapp-green/myapp.sock")
|
|
496
|
+
# Sockets must be different so health check targets the right slot
|
|
497
|
+
self.assertNotEqual(blue_socket, green_socket)
|
|
498
|
+
|
|
499
|
+
def test_health_check_service_name_uses_target_slot(self):
|
|
500
|
+
"""Health check fallback should check the target slot's service."""
|
|
501
|
+
from djaploy.infra.utils import get_slot_service_name
|
|
502
|
+
self.assertEqual(get_slot_service_name("myapp", "blue"), "myapp-blue")
|
|
503
|
+
self.assertEqual(get_slot_service_name("myapp", "green"), "myapp-green")
|
|
504
|
+
|
|
505
|
+
def test_health_check_command_structure(self):
|
|
506
|
+
"""Verify the health check shell command contains the expected elements."""
|
|
507
|
+
# Simulate what activate_bluegreen generates
|
|
508
|
+
app_name = "testapp"
|
|
509
|
+
target_slot = "green"
|
|
510
|
+
from djaploy.infra.utils import get_slot_socket_path
|
|
511
|
+
socket_path = get_slot_socket_path(app_name, target_slot)
|
|
512
|
+
|
|
513
|
+
# Build the command the same way the hook does
|
|
514
|
+
cmd = (
|
|
515
|
+
f"if curl -sf --max-time 5 --unix-socket {socket_path} http://localhost/ > /dev/null 2>&1; then "
|
|
516
|
+
f"echo 'Health check passed: {target_slot} slot is responding'; "
|
|
517
|
+
f"else "
|
|
518
|
+
f"if [ -S {socket_path} ] && systemctl is-active {app_name}-{target_slot}.service > /dev/null 2>&1; then "
|
|
519
|
+
f"echo 'Health check: {target_slot} slot service is active (HTTP response may be non-200, proceeding)'; "
|
|
520
|
+
f"else "
|
|
521
|
+
f"echo 'ACTIVATION ABORTED: {target_slot} slot is not responding.' && "
|
|
522
|
+
f"echo 'Socket: {socket_path}' && "
|
|
523
|
+
f"echo 'Check: systemctl status {app_name}-{target_slot}.service' && "
|
|
524
|
+
f"exit 1; "
|
|
525
|
+
f"fi; "
|
|
526
|
+
f"fi"
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
# Must curl the correct socket
|
|
530
|
+
self.assertIn(f"--unix-socket /run/testapp-green/testapp.sock", cmd)
|
|
531
|
+
# Must check the correct service on fallback
|
|
532
|
+
self.assertIn("systemctl is-active testapp-green.service", cmd)
|
|
533
|
+
# Must abort with exit 1 on failure
|
|
534
|
+
self.assertIn("exit 1", cmd)
|
|
535
|
+
# Must include the abort message
|
|
536
|
+
self.assertIn("ACTIVATION ABORTED", cmd)
|
|
537
|
+
|
|
538
|
+
def test_health_check_different_slots_produce_different_commands(self):
|
|
539
|
+
"""Blue and green health checks should target different sockets."""
|
|
540
|
+
from djaploy.infra.utils import get_slot_socket_path
|
|
541
|
+
blue_cmd = f"curl --unix-socket {get_slot_socket_path('app', 'blue')}"
|
|
542
|
+
green_cmd = f"curl --unix-socket {get_slot_socket_path('app', 'green')}"
|
|
543
|
+
self.assertIn("app-blue", blue_cmd)
|
|
544
|
+
self.assertIn("app-green", green_cmd)
|
|
545
|
+
self.assertNotEqual(blue_cmd, green_cmd)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
class TestBluegreenServiceExistenceCheck(unittest.TestCase):
|
|
549
|
+
"""Test that start_services checks unit file existence before starting."""
|
|
550
|
+
|
|
551
|
+
def test_service_check_command_structure(self):
|
|
552
|
+
"""The start command should test for the unit file before starting."""
|
|
553
|
+
from djaploy.infra.utils import get_slot_service_name
|
|
554
|
+
slot_service = get_slot_service_name("myapp", "blue")
|
|
555
|
+
|
|
556
|
+
# Build the command the same way start_services does
|
|
557
|
+
cmd = (
|
|
558
|
+
f"test -f /etc/systemd/system/{slot_service}.service && "
|
|
559
|
+
f"systemctl enable {slot_service} && "
|
|
560
|
+
f"systemctl restart {slot_service} || "
|
|
561
|
+
f"echo 'Unit {slot_service}.service not found, skipping'"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
self.assertIn("test -f /etc/systemd/system/myapp-blue.service", cmd)
|
|
565
|
+
self.assertIn("systemctl enable myapp-blue", cmd)
|
|
566
|
+
self.assertIn("systemctl restart myapp-blue", cmd)
|
|
567
|
+
self.assertIn("not found, skipping", cmd)
|
|
568
|
+
|
|
569
|
+
def test_streaming_service_gets_separate_slot_name(self):
|
|
570
|
+
"""bostad-streaming should produce bostad-streaming-blue, not bostad-blue."""
|
|
571
|
+
from djaploy.infra.utils import get_slot_service_name
|
|
572
|
+
self.assertEqual(
|
|
573
|
+
get_slot_service_name("bostad-streaming", "blue"),
|
|
574
|
+
"bostad-streaming-blue",
|
|
575
|
+
)
|
|
576
|
+
self.assertEqual(
|
|
577
|
+
get_slot_service_name("bostad-streaming", "green"),
|
|
578
|
+
"bostad-streaming-green",
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
class TestHeredocInjectionSafety(unittest.TestCase):
|
|
583
|
+
"""Test that state.json update safely handles special characters."""
|
|
584
|
+
|
|
585
|
+
def test_release_name_with_quotes_is_safe(self):
|
|
586
|
+
"""json.dumps should escape quotes in release names."""
|
|
587
|
+
import json
|
|
588
|
+
release = "feat/o'hare"
|
|
589
|
+
safe = json.dumps(release)
|
|
590
|
+
self.assertEqual(safe, '"feat/o\'hare"')
|
|
591
|
+
# Should be parseable back
|
|
592
|
+
self.assertEqual(json.loads(safe), release)
|
|
593
|
+
|
|
594
|
+
def test_commit_with_special_chars_is_safe(self):
|
|
595
|
+
"""json.dumps should handle commit messages with special chars."""
|
|
596
|
+
import json
|
|
597
|
+
commit = 'fix: handle "edge" case'
|
|
598
|
+
safe = json.dumps(commit)
|
|
599
|
+
self.assertIn('\\"edge\\"', safe)
|
|
600
|
+
self.assertEqual(json.loads(safe), commit)
|
|
601
|
+
|
|
602
|
+
|
|
485
603
|
if __name__ == "__main__":
|
|
486
604
|
unittest.main()
|
|
@@ -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
|