djaploy 1.3.2__tar.gz → 1.4.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.3.2 → djaploy-1.4.1}/PKG-INFO +47 -1
- {djaploy-1.3.2 → djaploy-1.4.1}/README.md +46 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/janitor/infra/commands/createjanitoruser.py +1 -1
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/builtin_hooks.py +7 -2
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/commands/activate.py +1 -1
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/commands/configure.py +1 -1
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/commands/deploy.py +2 -2
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/commands/restore.py +1 -1
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/commands/rollback.py +2 -2
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/commands/sync_certs.py +1 -1
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/config.py +4 -3
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/hooks.py +36 -7
- djaploy-1.4.1/djaploy/infra/charts/django-app/Chart.yaml +5 -0
- djaploy-1.4.1/djaploy/infra/charts/django-app/templates/_helpers.tpl +125 -0
- djaploy-1.4.1/djaploy/infra/charts/django-app/templates/configmap.yaml +81 -0
- djaploy-1.4.1/djaploy/infra/charts/django-app/templates/cronjobs.yaml +36 -0
- djaploy-1.4.1/djaploy/infra/charts/django-app/templates/deployment.yaml +109 -0
- djaploy-1.4.1/djaploy/infra/charts/django-app/templates/ingress.yaml +28 -0
- djaploy-1.4.1/djaploy/infra/charts/django-app/templates/onepassword.yaml +30 -0
- djaploy-1.4.1/djaploy/infra/charts/django-app/templates/pvc.yaml +19 -0
- djaploy-1.4.1/djaploy/infra/charts/django-app/templates/service.yaml +13 -0
- djaploy-1.4.1/djaploy/infra/charts/django-app/templates/workers.yaml +41 -0
- djaploy-1.4.1/djaploy/infra/charts/django-app/values.yaml +105 -0
- djaploy-1.4.1/djaploy/infra/k3s.py +329 -0
- djaploy-1.4.1/djaploy/version.py +1 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy.egg-info/PKG-INFO +47 -1
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy.egg-info/SOURCES.txt +13 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/pyproject.toml +4 -1
- {djaploy-1.3.2 → djaploy-1.4.1}/tests/test_hooks.py +39 -0
- djaploy-1.4.1/tests/test_k3s.py +111 -0
- djaploy-1.3.2/djaploy/version.py +0 -1
- {djaploy-1.3.2 → djaploy-1.4.1}/LICENSE +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/app.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/borg/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/borg/infra/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/borg/infra/djaploy_hooks.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/janitor/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/janitor/apps.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/janitor/infra/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/janitor/infra/djaploy_hooks.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/nginx/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/nginx/apps.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/nginx/infra/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/nginx/infra/djaploy_hooks.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/rclone/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/rclone/apps.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/rclone/infra/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/rclone/infra/djaploy_hooks.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/sync_certs/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/sync_certs/apps.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/sync_certs/infra/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/sync_certs/infra/djaploy_hooks.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/systemd/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/systemd/apps.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/systemd/infra/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/systemd/infra/djaploy_hooks.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/tailscale/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/tailscale/apps.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/tailscale/infra/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/tailscale/infra/djaploy_hooks.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/versioning/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/versioning/apps.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/versioning/infra/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/apps/versioning/infra/djaploy_hooks.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/artifact.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/bin/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/bin/django_pyinfra.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/bin/gunicornherder.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/certificates.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/changelog.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/commands/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/commands/_utils.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/commands/status.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/deploy.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/discovery.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/infra/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/infra/bluegreen.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/infra/djaploy_hooks.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/infra/templates.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/infra/utils.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/management/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/management/commands/__init__.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/management/commands/djaploy.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/management/commands/restore_backup.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/management/commands/sync_certs.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/management/commands/update_certs.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/management/commands/verify.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/management/utils.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/notifications.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/utils.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy/versioning.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy.egg-info/dependency_links.txt +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy.egg-info/entry_points.txt +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy.egg-info/requires.txt +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/djaploy.egg-info/top_level.txt +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/setup.cfg +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/tests/test_bluegreen.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/tests/test_borg.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/tests/test_config.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/tests/test_deploy_scripts.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/tests/test_discovery.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/tests/test_gunicornherder.py +0 -0
- {djaploy-1.3.2 → djaploy-1.4.1}/tests/test_versioning.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: djaploy
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.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
|
|
@@ -258,6 +258,52 @@ python manage.py djaploy rollback --env production
|
|
|
258
258
|
> **Note:** Migrations run during deploy, before traffic switches. Both slots share the same
|
|
259
259
|
> database, so migrations must be **backward-compatible** (expand/contract pattern).
|
|
260
260
|
|
|
261
|
+
### k3s (`"k3s"`)
|
|
262
|
+
|
|
263
|
+
The app runs as a container on a [k3s](https://k3s.io) node instead of under
|
|
264
|
+
systemd. Server access works exactly as for the other strategies (SSH key and
|
|
265
|
+
sudo password from the inventory, e.g. `OpSecret`/`OpFilePath`); there is no
|
|
266
|
+
registry and no CI involved.
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
HostConfig(
|
|
270
|
+
"rancor",
|
|
271
|
+
ssh_hostname=str(OpSecret("/Infra/rancor/IP")),
|
|
272
|
+
ssh_user="janitor",
|
|
273
|
+
ssh_key=OpFilePath("/Infra/SSH Key/private key?ssh-format=openssh"),
|
|
274
|
+
_sudo_password=str(OpSecret("/Infra/rancor/password")),
|
|
275
|
+
deployment_strategy="k3s",
|
|
276
|
+
app_name="myapp", # namespace + Helm release (or k3s_conf["namespace"])
|
|
277
|
+
app_hostname="myapp.example.com",
|
|
278
|
+
manage_py_path="myapp/manage.py",
|
|
279
|
+
gunicorn_conf={"workers": 3, "timeout": 120, "wsgi_module": "myapp.wsgi:application"},
|
|
280
|
+
secret_key=OpSecret("/Infra/myapp/secret_key"),
|
|
281
|
+
data={"mistral_api_key": OpSecret("/Infra/Mistral/credential")}, # → env MISTRAL_API_KEY
|
|
282
|
+
k3s_conf={
|
|
283
|
+
"settings_module": "myapp.settings.production",
|
|
284
|
+
"static_root": "/app/myapp/public/static",
|
|
285
|
+
"extra_commands": ["seed_data --org acme"], # after migrate, every deploy
|
|
286
|
+
"storage_size": "20Gi",
|
|
287
|
+
"suspended": False, # True: scale to zero
|
|
288
|
+
},
|
|
289
|
+
)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`deploy` builds the image locally from the usual git artifact (the repo needs
|
|
293
|
+
a `Dockerfile`; build args `MANAGE_PY` and `SETTINGS_MODULE`), ships it over
|
|
294
|
+
SSH into k3s (skipped when the node already has that exact image), writes
|
|
295
|
+
`secret_key` and the scalar `data` entries into a Secret, and runs
|
|
296
|
+
`helm upgrade --install` with the bundled `django-app` chart
|
|
297
|
+
(`djaploy/infra/charts/django-app`): gunicorn + nginx in one pod, SQLite and
|
|
298
|
+
media on a local volume, a database snapshot and `migrate` before every
|
|
299
|
+
rollout. It waits for the new pod and fails with its logs if it doesn't come up.
|
|
300
|
+
|
|
301
|
+
`configure` installs helm on the node and gives the SSH user a kubeconfig;
|
|
302
|
+
`rollback` is `helm rollback` (optionally `--release <revision>`). Only hooks
|
|
303
|
+
registered with `strategies=("k3s",)` run for k3s hosts, so the systemd/nginx
|
|
304
|
+
hooks of the other strategies never touch them. The node needs cert-manager
|
|
305
|
+
with a `letsencrypt-prod` ClusterIssuer for TLS.
|
|
306
|
+
|
|
261
307
|
### Server directory layout comparison
|
|
262
308
|
|
|
263
309
|
For `app_user="myapp-api"`, `app_name="myapp"`:
|
|
@@ -196,6 +196,52 @@ python manage.py djaploy rollback --env production
|
|
|
196
196
|
> **Note:** Migrations run during deploy, before traffic switches. Both slots share the same
|
|
197
197
|
> database, so migrations must be **backward-compatible** (expand/contract pattern).
|
|
198
198
|
|
|
199
|
+
### k3s (`"k3s"`)
|
|
200
|
+
|
|
201
|
+
The app runs as a container on a [k3s](https://k3s.io) node instead of under
|
|
202
|
+
systemd. Server access works exactly as for the other strategies (SSH key and
|
|
203
|
+
sudo password from the inventory, e.g. `OpSecret`/`OpFilePath`); there is no
|
|
204
|
+
registry and no CI involved.
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
HostConfig(
|
|
208
|
+
"rancor",
|
|
209
|
+
ssh_hostname=str(OpSecret("/Infra/rancor/IP")),
|
|
210
|
+
ssh_user="janitor",
|
|
211
|
+
ssh_key=OpFilePath("/Infra/SSH Key/private key?ssh-format=openssh"),
|
|
212
|
+
_sudo_password=str(OpSecret("/Infra/rancor/password")),
|
|
213
|
+
deployment_strategy="k3s",
|
|
214
|
+
app_name="myapp", # namespace + Helm release (or k3s_conf["namespace"])
|
|
215
|
+
app_hostname="myapp.example.com",
|
|
216
|
+
manage_py_path="myapp/manage.py",
|
|
217
|
+
gunicorn_conf={"workers": 3, "timeout": 120, "wsgi_module": "myapp.wsgi:application"},
|
|
218
|
+
secret_key=OpSecret("/Infra/myapp/secret_key"),
|
|
219
|
+
data={"mistral_api_key": OpSecret("/Infra/Mistral/credential")}, # → env MISTRAL_API_KEY
|
|
220
|
+
k3s_conf={
|
|
221
|
+
"settings_module": "myapp.settings.production",
|
|
222
|
+
"static_root": "/app/myapp/public/static",
|
|
223
|
+
"extra_commands": ["seed_data --org acme"], # after migrate, every deploy
|
|
224
|
+
"storage_size": "20Gi",
|
|
225
|
+
"suspended": False, # True: scale to zero
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`deploy` builds the image locally from the usual git artifact (the repo needs
|
|
231
|
+
a `Dockerfile`; build args `MANAGE_PY` and `SETTINGS_MODULE`), ships it over
|
|
232
|
+
SSH into k3s (skipped when the node already has that exact image), writes
|
|
233
|
+
`secret_key` and the scalar `data` entries into a Secret, and runs
|
|
234
|
+
`helm upgrade --install` with the bundled `django-app` chart
|
|
235
|
+
(`djaploy/infra/charts/django-app`): gunicorn + nginx in one pod, SQLite and
|
|
236
|
+
media on a local volume, a database snapshot and `migrate` before every
|
|
237
|
+
rollout. It waits for the new pod and fails with its logs if it doesn't come up.
|
|
238
|
+
|
|
239
|
+
`configure` installs helm on the node and gives the SSH user a kubeconfig;
|
|
240
|
+
`rollback` is `helm rollback` (optionally `--release <revision>`). Only hooks
|
|
241
|
+
registered with `strategies=("k3s",)` run for k3s hosts, so the systemd/nginx
|
|
242
|
+
hooks of the other strategies never touch them. The node needs cert-manager
|
|
243
|
+
with a `letsencrypt-prod` ClusterIssuer for TLS.
|
|
244
|
+
|
|
199
245
|
### Server directory layout comparison
|
|
200
246
|
|
|
201
247
|
For `app_user="myapp-api"`, `app_name="myapp"`:
|
|
@@ -18,7 +18,7 @@ discover_hooks()
|
|
|
18
18
|
registry = get_registry()
|
|
19
19
|
|
|
20
20
|
for phase in ("createjanitoruser:pre", "createjanitoruser", "createjanitoruser:post"):
|
|
21
|
-
for hook in registry.get_remote_hooks(phase):
|
|
21
|
+
for hook in registry.get_remote_hooks(phase, host.data):
|
|
22
22
|
_deploy_decorator(hook.function.__name__)(hook.function)(
|
|
23
23
|
host.data
|
|
24
24
|
)
|
|
@@ -164,7 +164,12 @@ def _rollback_validate_strategy(context):
|
|
|
164
164
|
_, data = hosts[0]
|
|
165
165
|
strategy = (data.get("deployment_strategy") if isinstance(data, dict)
|
|
166
166
|
else getattr(data, "deployment_strategy", "zero_downtime"))
|
|
167
|
-
if strategy not in ("zero_downtime", "bluegreen"):
|
|
167
|
+
if strategy not in ("zero_downtime", "bluegreen", "k3s"):
|
|
168
168
|
raise ValueError(
|
|
169
|
-
"Rollback is only supported with deployment_strategy='zero_downtime' or '
|
|
169
|
+
"Rollback is only supported with deployment_strategy='zero_downtime', 'bluegreen' or 'k3s'"
|
|
170
170
|
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# The k3s strategy's hooks (djaploy/infra/k3s.py). Imported last so its local
|
|
174
|
+
# image build runs after the artifact and release info above.
|
|
175
|
+
import djaploy.infra.k3s # noqa: E402,F401
|
|
@@ -20,7 +20,7 @@ discover_hooks()
|
|
|
20
20
|
registry = get_registry()
|
|
21
21
|
|
|
22
22
|
for phase in ("activate:pre", "activate", "activate:post"):
|
|
23
|
-
for hook in registry.get_remote_hooks(phase):
|
|
23
|
+
for hook in registry.get_remote_hooks(phase, host.data):
|
|
24
24
|
_deploy_decorator(hook.function.__name__)(hook.function)(
|
|
25
25
|
host.data
|
|
26
26
|
)
|
|
@@ -16,7 +16,7 @@ discover_hooks()
|
|
|
16
16
|
registry = get_registry()
|
|
17
17
|
|
|
18
18
|
for phase in ("configure:pre", "configure", "configure:post"):
|
|
19
|
-
for hook in registry.get_remote_hooks(phase):
|
|
19
|
+
for hook in registry.get_remote_hooks(phase, host.data):
|
|
20
20
|
_deploy_decorator(hook.function.__name__)(hook.function)(
|
|
21
21
|
host.data
|
|
22
22
|
)
|
|
@@ -25,7 +25,7 @@ registry = get_registry()
|
|
|
25
25
|
artifact_path = Path(host.data.artifact_path)
|
|
26
26
|
|
|
27
27
|
for phase in ("deploy:upload", "deploy:configure", "deploy:pre", "deploy:start"):
|
|
28
|
-
for hook in registry.get_remote_hooks(phase):
|
|
28
|
+
for hook in registry.get_remote_hooks(phase, host.data):
|
|
29
29
|
_deploy_decorator(hook.function.__name__)(hook.function)(
|
|
30
30
|
host.data, artifact_path
|
|
31
31
|
)
|
|
@@ -34,7 +34,7 @@ for phase in ("deploy:upload", "deploy:configure", "deploy:pre", "deploy:start")
|
|
|
34
34
|
_activate_val = getattr(host.data, "activate", None)
|
|
35
35
|
if _activate_val in (True, "true", "True"):
|
|
36
36
|
for phase in ("activate:pre", "activate", "activate:post"):
|
|
37
|
-
_hooks = registry.get_remote_hooks(phase)
|
|
37
|
+
_hooks = registry.get_remote_hooks(phase, host.data)
|
|
38
38
|
print(f"[activate] phase={phase} hooks={[h.function.__name__ for h in _hooks]}")
|
|
39
39
|
for hook in _hooks:
|
|
40
40
|
_deploy_decorator(hook.function.__name__)(hook.function)(
|
|
@@ -33,7 +33,7 @@ if _source_borg:
|
|
|
33
33
|
restore_opts["source_borg_config"] = _source_borg
|
|
34
34
|
|
|
35
35
|
for phase in ("restore:pre", "restore", "restore:post"):
|
|
36
|
-
for hook in registry.get_remote_hooks(phase):
|
|
36
|
+
for hook in registry.get_remote_hooks(phase, host.data):
|
|
37
37
|
_deploy_decorator(hook.function.__name__)(hook.function)(
|
|
38
38
|
host.data, restore_opts
|
|
39
39
|
)
|
|
@@ -20,7 +20,7 @@ release = getattr(host.data, "release", None)
|
|
|
20
20
|
if release == "None" or release == "":
|
|
21
21
|
release = None
|
|
22
22
|
|
|
23
|
-
for hook in registry.get_remote_hooks("rollback"):
|
|
23
|
+
for hook in registry.get_remote_hooks("rollback", host.data):
|
|
24
24
|
_deploy_decorator(hook.function.__name__)(hook.function)(
|
|
25
25
|
host.data, release
|
|
26
26
|
)
|
|
@@ -30,7 +30,7 @@ for hook in registry.get_remote_hooks("rollback"):
|
|
|
30
30
|
from djaploy.infra.utils import is_bluegreen
|
|
31
31
|
if is_bluegreen(host.data):
|
|
32
32
|
for phase in ("activate:post",):
|
|
33
|
-
for hook in registry.get_remote_hooks(phase):
|
|
33
|
+
for hook in registry.get_remote_hooks(phase, host.data):
|
|
34
34
|
_deploy_decorator(hook.function.__name__)(hook.function)(
|
|
35
35
|
host.data
|
|
36
36
|
)
|
|
@@ -18,7 +18,7 @@ registry = get_registry()
|
|
|
18
18
|
|
|
19
19
|
# Only run sync_certs-specific hooks, not the full deploy lifecycle.
|
|
20
20
|
for phase in ("sync_certs:pre", "sync_certs", "sync_certs:post"):
|
|
21
|
-
for hook in registry.get_remote_hooks(phase):
|
|
21
|
+
for hook in registry.get_remote_hooks(phase, host.data):
|
|
22
22
|
_deploy_decorator(hook.function.__name__)(hook.function)(
|
|
23
23
|
host.data
|
|
24
24
|
)
|
|
@@ -171,7 +171,7 @@ class HostConfig(tuple, metaclass=HostConfigMetaclass):
|
|
|
171
171
|
# Deployment settings
|
|
172
172
|
python_version: str = "3.11"
|
|
173
173
|
python_compile: bool = False # Compile Python from source
|
|
174
|
-
deployment_strategy: str = "zero_downtime" # "in_place" or "
|
|
174
|
+
deployment_strategy: str = "zero_downtime" # "in_place", "zero_downtime", "bluegreen" or "k3s"
|
|
175
175
|
keep_releases: int = 5 # Releases to keep (zero_downtime only)
|
|
176
176
|
manage_py_path: str = "manage.py" # Relative path to manage.py in the artifact
|
|
177
177
|
db_dir: Optional[str] = None # External database directory template
|
|
@@ -187,6 +187,7 @@ class HostConfig(tuple, metaclass=HostConfigMetaclass):
|
|
|
187
187
|
notifications_conf: Optional[Dict[str, Any]] = None # webhook_url, display_name, changelog_generator, etc.
|
|
188
188
|
artifact_conf: Optional[Dict[str, Any]] = None # extra_files
|
|
189
189
|
http_hook_conf: Optional[Dict[str, Any]] = None # webroot_path
|
|
190
|
+
k3s_conf: Optional[Dict[str, Any]] = None # namespace, settings_module, static_root, values, ... (djaploy/infra/k3s.py)
|
|
190
191
|
|
|
191
192
|
def __new__(cls, name: str, **kwargs):
|
|
192
193
|
dict_typing = cls._dict_annotations
|
|
@@ -214,10 +215,10 @@ class HostConfig(tuple, metaclass=HostConfigMetaclass):
|
|
|
214
215
|
|
|
215
216
|
# Validate deployment_strategy
|
|
216
217
|
strategy = config.get("deployment_strategy", "zero_downtime")
|
|
217
|
-
if strategy not in ("in_place", "zero_downtime", "bluegreen"):
|
|
218
|
+
if strategy not in ("in_place", "zero_downtime", "bluegreen", "k3s"):
|
|
218
219
|
raise ValueError(
|
|
219
220
|
f"Invalid deployment_strategy: {strategy!r}. "
|
|
220
|
-
f"Must be 'in_place', 'zero_downtime', or '
|
|
221
|
+
f"Must be 'in_place', 'zero_downtime', 'bluegreen' or 'k3s'"
|
|
221
222
|
)
|
|
222
223
|
|
|
223
224
|
# Expand SSH key path if provided
|
|
@@ -23,6 +23,16 @@ warning::
|
|
|
23
23
|
# This replaces djaploy's built-in deploy_nginx
|
|
24
24
|
...
|
|
25
25
|
|
|
26
|
+
Deployment strategies:
|
|
27
|
+
|
|
28
|
+
Remote hooks run for the classic strategies (in_place, zero_downtime,
|
|
29
|
+
bluegreen) unless they say otherwise. Hosts with ``deployment_strategy="k3s"``
|
|
30
|
+
only run hooks that opt in::
|
|
31
|
+
|
|
32
|
+
@deploy_hook("deploy:start", strategies=("k3s",))
|
|
33
|
+
def helm_upgrade(host_data, artifact_path):
|
|
34
|
+
...
|
|
35
|
+
|
|
26
36
|
Ordering is controlled by assigning hooks to the correct phase. Each
|
|
27
37
|
command file calls phases in a fixed sequence (e.g. ``deploy:pre`` →
|
|
28
38
|
``deploy`` → ``deploy:post``). Within a single phase hooks run in
|
|
@@ -38,10 +48,17 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
38
48
|
log = logging.getLogger(__name__)
|
|
39
49
|
|
|
40
50
|
|
|
51
|
+
CLASSIC_STRATEGIES = ("in_place", "zero_downtime", "bluegreen")
|
|
52
|
+
|
|
53
|
+
|
|
41
54
|
@dataclass
|
|
42
55
|
class RemoteFunctionHook:
|
|
43
56
|
"""A remote hook backed by a standalone function (from @deploy_hook)."""
|
|
44
57
|
function: object # Callable
|
|
58
|
+
strategies: Optional[tuple] = None # None = the classic strategies
|
|
59
|
+
|
|
60
|
+
def applies_to(self, strategy: str) -> bool:
|
|
61
|
+
return strategy in (self.strategies or CLASSIC_STRATEGIES)
|
|
45
62
|
|
|
46
63
|
|
|
47
64
|
class HookRegistry:
|
|
@@ -59,7 +76,8 @@ class HookRegistry:
|
|
|
59
76
|
# Registration
|
|
60
77
|
# ------------------------------------------------------------------
|
|
61
78
|
|
|
62
|
-
def register(self, hook_name: str, fn: Callable, *, remote: bool = False, override: bool = False
|
|
79
|
+
def register(self, hook_name: str, fn: Callable, *, remote: bool = False, override: bool = False,
|
|
80
|
+
strategies: Optional[tuple] = None) -> None:
|
|
63
81
|
fn_name = fn.__name__
|
|
64
82
|
key = (hook_name, fn_name)
|
|
65
83
|
|
|
@@ -84,7 +102,7 @@ class HookRegistry:
|
|
|
84
102
|
self._overridden.add(key)
|
|
85
103
|
|
|
86
104
|
if remote:
|
|
87
|
-
hooks.append(RemoteFunctionHook(function=fn))
|
|
105
|
+
hooks.append(RemoteFunctionHook(function=fn, strategies=tuple(strategies) if strategies else None))
|
|
88
106
|
else:
|
|
89
107
|
hooks.append(fn)
|
|
90
108
|
|
|
@@ -101,16 +119,19 @@ class HookRegistry:
|
|
|
101
119
|
return fn
|
|
102
120
|
return decorator
|
|
103
121
|
|
|
104
|
-
def deploy_hook(self, name: str, *, override: bool = False
|
|
122
|
+
def deploy_hook(self, name: str, *, override: bool = False,
|
|
123
|
+
strategies: Optional[tuple] = None) -> Callable:
|
|
105
124
|
"""Decorator for remote hooks (run on target servers via pyinfra).
|
|
106
125
|
|
|
107
126
|
Args:
|
|
108
127
|
name: Hook phase name (e.g. "deploy:configure")
|
|
109
128
|
override: If True, silently replace a hook with the same
|
|
110
129
|
function name. If False (default), log a warning.
|
|
130
|
+
strategies: Deployment strategies this hook runs for. Default:
|
|
131
|
+
the classic strategies (not k3s).
|
|
111
132
|
"""
|
|
112
133
|
def decorator(fn: Callable) -> Callable:
|
|
113
|
-
self.register(name, fn, remote=True, override=override)
|
|
134
|
+
self.register(name, fn, remote=True, override=override, strategies=strategies)
|
|
114
135
|
return fn
|
|
115
136
|
return decorator
|
|
116
137
|
|
|
@@ -130,9 +151,17 @@ class HookRegistry:
|
|
|
130
151
|
results.append(result)
|
|
131
152
|
return results
|
|
132
153
|
|
|
133
|
-
def get_remote_hooks(self, hook_name: str) -> List[RemoteFunctionHook]:
|
|
134
|
-
"""Return the
|
|
135
|
-
|
|
154
|
+
def get_remote_hooks(self, hook_name: str, host_data=None) -> List[RemoteFunctionHook]:
|
|
155
|
+
"""Return the remote hook functions for *hook_name*.
|
|
156
|
+
|
|
157
|
+
With *host_data*, only the hooks that apply to that host's
|
|
158
|
+
deployment strategy.
|
|
159
|
+
"""
|
|
160
|
+
hooks = list(self._remote_hooks.get(hook_name, []))
|
|
161
|
+
if host_data is None:
|
|
162
|
+
return hooks
|
|
163
|
+
strategy = getattr(host_data, "deployment_strategy", None) or "zero_downtime"
|
|
164
|
+
return [h for h in hooks if h.applies_to(strategy)]
|
|
136
165
|
|
|
137
166
|
# ------------------------------------------------------------------
|
|
138
167
|
# Discovery
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
{{- define "django-app.fullname" -}}
|
|
2
|
+
{{- default .Release.Name .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
|
3
|
+
{{- end -}}
|
|
4
|
+
|
|
5
|
+
{{- define "django-app.labels" -}}
|
|
6
|
+
app.kubernetes.io/name: django-app
|
|
7
|
+
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
8
|
+
app.kubernetes.io/version: {{ .Values.image.tag | quote }}
|
|
9
|
+
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
10
|
+
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
|
|
11
|
+
{{- end -}}
|
|
12
|
+
|
|
13
|
+
{{/* Call with (dict "root" . "component" "web") */}}
|
|
14
|
+
{{- define "django-app.selectorLabels" -}}
|
|
15
|
+
app.kubernetes.io/name: django-app
|
|
16
|
+
app.kubernetes.io/instance: {{ .root.Release.Name }}
|
|
17
|
+
app.kubernetes.io/component: {{ .component }}
|
|
18
|
+
{{- end -}}
|
|
19
|
+
|
|
20
|
+
{{- define "django-app.image" -}}
|
|
21
|
+
{{ required "image.repository is required" .Values.image.repository }}:{{ required "image.tag is required (the app's CI sets it)" .Values.image.tag }}
|
|
22
|
+
{{- end -}}
|
|
23
|
+
|
|
24
|
+
{{- define "django-app.primaryHost" -}}
|
|
25
|
+
{{ required "hostnames needs at least one entry" (first .Values.hostnames) }}
|
|
26
|
+
{{- end -}}
|
|
27
|
+
|
|
28
|
+
{{/*
|
|
29
|
+
The environment contract every app reads (see templates/snippets/settings_env.py and docs/04-chart-reference.md).
|
|
30
|
+
*/}}
|
|
31
|
+
{{- define "django-app.env" -}}
|
|
32
|
+
{{- $origins := list -}}
|
|
33
|
+
{{- range .Values.hostnames }}{{ $origins = append $origins (printf "https://%s" .) }}{{ end -}}
|
|
34
|
+
- name: DJANGO_ALLOWED_HOSTS
|
|
35
|
+
value: {{ join "," .Values.hostnames | quote }}
|
|
36
|
+
- name: DJANGO_CSRF_TRUSTED_ORIGINS
|
|
37
|
+
value: {{ join "," $origins | quote }}
|
|
38
|
+
- name: DJANGO_BASE_URL
|
|
39
|
+
value: {{ printf "https://%s" (include "django-app.primaryHost" .) | quote }}
|
|
40
|
+
- name: DJANGO_DATA_DIR
|
|
41
|
+
value: /data
|
|
42
|
+
- name: MEDIA_ROOT
|
|
43
|
+
value: /data/media
|
|
44
|
+
{{- with .Values.django.settingsModule }}
|
|
45
|
+
- name: DJANGO_SETTINGS_MODULE
|
|
46
|
+
value: {{ . | quote }}
|
|
47
|
+
{{- end }}
|
|
48
|
+
{{- if eq .Values.database.engine "sqlite" }}
|
|
49
|
+
- name: SQLITE_PATH
|
|
50
|
+
value: {{ .Values.database.sqlite.path | quote }}
|
|
51
|
+
{{- else if eq .Values.database.engine "postgres" }}
|
|
52
|
+
- name: DATABASE_URL
|
|
53
|
+
valueFrom:
|
|
54
|
+
secretKeyRef:
|
|
55
|
+
name: {{ required "database.postgres.secretName is required" .Values.database.postgres.secretName }}
|
|
56
|
+
key: {{ .Values.database.postgres.secretKey }}
|
|
57
|
+
{{- else }}
|
|
58
|
+
{{- fail "database.engine must be sqlite or postgres" }}
|
|
59
|
+
{{- end }}
|
|
60
|
+
{{- range $name, $value := .Values.django.env }}
|
|
61
|
+
- name: {{ $name }}
|
|
62
|
+
value: {{ $value | quote }}
|
|
63
|
+
{{- end }}
|
|
64
|
+
{{- range $name, $ref := .Values.onePassword.env }}
|
|
65
|
+
- name: {{ $name }}
|
|
66
|
+
valueFrom:
|
|
67
|
+
secretKeyRef:
|
|
68
|
+
name: {{ include "django-app.opSecretName" (dict "root" $ "item" (include "django-app.opItem" $ref)) }}
|
|
69
|
+
key: {{ include "django-app.opField" $ref }}
|
|
70
|
+
{{- end }}
|
|
71
|
+
{{- end -}}
|
|
72
|
+
|
|
73
|
+
{{/* "<item>/<field>" → item / field. Item titles may themselves contain "/". */}}
|
|
74
|
+
{{- define "django-app.opItem" -}}
|
|
75
|
+
{{- $parts := splitList "/" . -}}
|
|
76
|
+
{{- if lt (len $parts) 2 }}{{ fail (printf "onePassword.env value %q must be \"<item>/<field>\"" .) }}{{ end -}}
|
|
77
|
+
{{- initial $parts | join "/" -}}
|
|
78
|
+
{{- end -}}
|
|
79
|
+
|
|
80
|
+
{{- define "django-app.opField" -}}
|
|
81
|
+
{{- last (splitList "/" .) -}}
|
|
82
|
+
{{- end -}}
|
|
83
|
+
|
|
84
|
+
{{/* Secret synced from one 1Password item. Call with (dict "root" . "item" "<title>"). */}}
|
|
85
|
+
{{- define "django-app.opSecretName" -}}
|
|
86
|
+
{{- printf "%s-op-%s" (include "django-app.fullname" .root) (regexReplaceAll "[^a-z0-9]+" (lower .item) "-" | trimAll "-") | trunc 63 | trimSuffix "-" -}}
|
|
87
|
+
{{- end -}}
|
|
88
|
+
|
|
89
|
+
{{/* env (+ envFrom) for a container, indented by the caller. */}}
|
|
90
|
+
{{- define "django-app.containerEnv" -}}
|
|
91
|
+
env:
|
|
92
|
+
{{- include "django-app.env" . | nindent 2 }}
|
|
93
|
+
{{- with .Values.envFromSecret }}
|
|
94
|
+
envFrom:
|
|
95
|
+
- secretRef:
|
|
96
|
+
name: {{ . }}
|
|
97
|
+
{{- end }}
|
|
98
|
+
{{- end -}}
|
|
99
|
+
|
|
100
|
+
{{- define "django-app.podCommon" -}}
|
|
101
|
+
{{- if .Values.onePassword.pullSecretItem }}
|
|
102
|
+
imagePullSecrets:
|
|
103
|
+
- name: ghcr-pull
|
|
104
|
+
{{- end }}
|
|
105
|
+
securityContext:
|
|
106
|
+
{{- toYaml .Values.podSecurityContext | nindent 2 }}
|
|
107
|
+
{{- end -}}
|
|
108
|
+
|
|
109
|
+
{{- define "django-app.dataVolume" -}}
|
|
110
|
+
- name: data
|
|
111
|
+
persistentVolumeClaim:
|
|
112
|
+
claimName: {{ default (printf "%s-data" (include "django-app.fullname" .)) .Values.storage.existingClaim }}
|
|
113
|
+
{{- end -}}
|
|
114
|
+
|
|
115
|
+
{{/* Host + X-Forwarded-Proto so ALLOWED_HOSTS and SECURE_SSL_REDIRECT accept the probe. */}}
|
|
116
|
+
{{- define "django-app.probe" -}}
|
|
117
|
+
httpGet:
|
|
118
|
+
path: {{ .Values.probes.path }}
|
|
119
|
+
port: gunicorn
|
|
120
|
+
httpHeaders:
|
|
121
|
+
- name: Host
|
|
122
|
+
value: {{ include "django-app.primaryHost" . | quote }}
|
|
123
|
+
- name: X-Forwarded-Proto
|
|
124
|
+
value: https
|
|
125
|
+
{{- end -}}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{{- $fullname := include "django-app.fullname" . -}}
|
|
2
|
+
apiVersion: v1
|
|
3
|
+
kind: ConfigMap
|
|
4
|
+
metadata:
|
|
5
|
+
name: {{ $fullname }}-nginx
|
|
6
|
+
labels:
|
|
7
|
+
{{- include "django-app.labels" . | nindent 4 }}
|
|
8
|
+
data:
|
|
9
|
+
default.conf: |
|
|
10
|
+
server {
|
|
11
|
+
listen 8080;
|
|
12
|
+
server_name _;
|
|
13
|
+
client_max_body_size {{ .Values.nginx.clientMaxBodySize }};
|
|
14
|
+
|
|
15
|
+
# Traefik is the only client: take the real client IP from its X-Forwarded-For,
|
|
16
|
+
# then hand it to Django as X-Real-IP (allauth rate limiting keys on it).
|
|
17
|
+
set_real_ip_from 10.42.0.0/16;
|
|
18
|
+
real_ip_header X-Forwarded-For;
|
|
19
|
+
real_ip_recursive on;
|
|
20
|
+
|
|
21
|
+
location /static/ {
|
|
22
|
+
alias /static/;
|
|
23
|
+
expires 30d;
|
|
24
|
+
add_header Cache-Control "public, immutable";
|
|
25
|
+
}
|
|
26
|
+
{{- if .Values.nginx.serveMedia }}
|
|
27
|
+
|
|
28
|
+
location /media/ {
|
|
29
|
+
alias /data/media/;
|
|
30
|
+
expires 30d;
|
|
31
|
+
}
|
|
32
|
+
{{- end }}
|
|
33
|
+
|
|
34
|
+
location / {
|
|
35
|
+
proxy_pass http://127.0.0.1:8000;
|
|
36
|
+
proxy_set_header Host $host;
|
|
37
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
38
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
39
|
+
# TLS ends at Traefik; pass its scheme through, not nginx's own (http).
|
|
40
|
+
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
|
41
|
+
proxy_redirect off;
|
|
42
|
+
proxy_read_timeout {{ .Values.nginx.proxyReadTimeout }};
|
|
43
|
+
proxy_send_timeout {{ .Values.nginx.proxyReadTimeout }};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
---
|
|
47
|
+
apiVersion: v1
|
|
48
|
+
kind: ConfigMap
|
|
49
|
+
metadata:
|
|
50
|
+
name: {{ $fullname }}-scripts
|
|
51
|
+
labels:
|
|
52
|
+
{{- include "django-app.labels" . | nindent 4 }}
|
|
53
|
+
data:
|
|
54
|
+
predeploy.sh: |
|
|
55
|
+
set -eu
|
|
56
|
+
mkdir -p /data/media /data/backups
|
|
57
|
+
{{- if and (eq .Values.database.engine "sqlite") .Values.database.sqlite.snapshotBeforeMigrate }}
|
|
58
|
+
python /scripts/snapshot.py {{ .Values.database.sqlite.keepSnapshots }}
|
|
59
|
+
{{- end }}
|
|
60
|
+
python {{ .Values.django.managePy }} migrate --noinput
|
|
61
|
+
{{- range .Values.migrations.extraCommands }}
|
|
62
|
+
python {{ $.Values.django.managePy }} {{ . }}
|
|
63
|
+
{{- end }}
|
|
64
|
+
snapshot.py: |
|
|
65
|
+
"""Consistent copy of the SQLite DB before migrating (the rollback point)."""
|
|
66
|
+
import glob, os, sqlite3, sys, time
|
|
67
|
+
|
|
68
|
+
src = os.environ["SQLITE_PATH"]
|
|
69
|
+
keep = int(sys.argv[1]) if len(sys.argv) > 1 else 10
|
|
70
|
+
if not os.path.exists(src):
|
|
71
|
+
print("snapshot: no database yet, skipping")
|
|
72
|
+
sys.exit(0)
|
|
73
|
+
dst = time.strftime("/data/backups/pre-migrate-%Y%m%d-%H%M%S.sqlite3")
|
|
74
|
+
source, target = sqlite3.connect(src), sqlite3.connect(dst)
|
|
75
|
+
source.backup(target)
|
|
76
|
+
source.close()
|
|
77
|
+
target.close()
|
|
78
|
+
print(f"snapshot: {dst}")
|
|
79
|
+
for old in sorted(glob.glob("/data/backups/pre-migrate-*.sqlite3"))[:-keep]:
|
|
80
|
+
os.remove(old)
|
|
81
|
+
print(f"snapshot: removed {old}")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{{- $root := . -}}
|
|
2
|
+
{{- range .Values.cronJobs }}
|
|
3
|
+
---
|
|
4
|
+
apiVersion: batch/v1
|
|
5
|
+
kind: CronJob
|
|
6
|
+
metadata:
|
|
7
|
+
name: {{ include "django-app.fullname" $root }}-{{ .name }}
|
|
8
|
+
labels:
|
|
9
|
+
{{- include "django-app.labels" $root | nindent 4 }}
|
|
10
|
+
spec:
|
|
11
|
+
schedule: {{ .schedule | quote }}
|
|
12
|
+
suspend: {{ $root.Values.suspended }}
|
|
13
|
+
concurrencyPolicy: Forbid
|
|
14
|
+
successfulJobsHistoryLimit: 3
|
|
15
|
+
failedJobsHistoryLimit: 3
|
|
16
|
+
jobTemplate:
|
|
17
|
+
spec:
|
|
18
|
+
backoffLimit: 1
|
|
19
|
+
template:
|
|
20
|
+
metadata:
|
|
21
|
+
labels:
|
|
22
|
+
{{- include "django-app.selectorLabels" (dict "root" $root "component" (printf "cron-%s" .name)) | nindent 12 }}
|
|
23
|
+
spec:
|
|
24
|
+
{{- include "django-app.podCommon" $root | trim | nindent 10 }}
|
|
25
|
+
restartPolicy: Never
|
|
26
|
+
containers:
|
|
27
|
+
- name: {{ .name }}
|
|
28
|
+
image: {{ include "django-app.image" $root }}
|
|
29
|
+
imagePullPolicy: {{ $root.Values.image.pullPolicy }}
|
|
30
|
+
command: {{ toJson .command }}
|
|
31
|
+
{{- include "django-app.containerEnv" $root | nindent 14 }}
|
|
32
|
+
volumeMounts:
|
|
33
|
+
- { name: data, mountPath: /data }
|
|
34
|
+
volumes:
|
|
35
|
+
{{- include "django-app.dataVolume" $root | nindent 12 }}
|
|
36
|
+
{{- end }}
|