djaploy 1.3.2__tar.gz → 1.4.0__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.
Files changed (105) hide show
  1. {djaploy-1.3.2 → djaploy-1.4.0}/PKG-INFO +47 -1
  2. {djaploy-1.3.2 → djaploy-1.4.0}/README.md +46 -0
  3. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/janitor/infra/commands/createjanitoruser.py +1 -1
  4. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/builtin_hooks.py +7 -2
  5. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/commands/activate.py +1 -1
  6. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/commands/configure.py +1 -1
  7. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/commands/deploy.py +2 -2
  8. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/commands/restore.py +1 -1
  9. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/commands/rollback.py +2 -2
  10. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/commands/sync_certs.py +1 -1
  11. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/config.py +4 -3
  12. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/hooks.py +36 -7
  13. djaploy-1.4.0/djaploy/infra/charts/django-app/Chart.yaml +5 -0
  14. djaploy-1.4.0/djaploy/infra/charts/django-app/templates/_helpers.tpl +125 -0
  15. djaploy-1.4.0/djaploy/infra/charts/django-app/templates/configmap.yaml +81 -0
  16. djaploy-1.4.0/djaploy/infra/charts/django-app/templates/cronjobs.yaml +36 -0
  17. djaploy-1.4.0/djaploy/infra/charts/django-app/templates/deployment.yaml +109 -0
  18. djaploy-1.4.0/djaploy/infra/charts/django-app/templates/ingress.yaml +28 -0
  19. djaploy-1.4.0/djaploy/infra/charts/django-app/templates/onepassword.yaml +30 -0
  20. djaploy-1.4.0/djaploy/infra/charts/django-app/templates/pvc.yaml +19 -0
  21. djaploy-1.4.0/djaploy/infra/charts/django-app/templates/service.yaml +13 -0
  22. djaploy-1.4.0/djaploy/infra/charts/django-app/templates/workers.yaml +41 -0
  23. djaploy-1.4.0/djaploy/infra/charts/django-app/values.yaml +105 -0
  24. djaploy-1.4.0/djaploy/infra/k3s.py +327 -0
  25. djaploy-1.4.0/djaploy/version.py +1 -0
  26. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy.egg-info/PKG-INFO +47 -1
  27. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy.egg-info/SOURCES.txt +13 -0
  28. {djaploy-1.3.2 → djaploy-1.4.0}/pyproject.toml +4 -1
  29. {djaploy-1.3.2 → djaploy-1.4.0}/tests/test_hooks.py +39 -0
  30. djaploy-1.4.0/tests/test_k3s.py +111 -0
  31. djaploy-1.3.2/djaploy/version.py +0 -1
  32. {djaploy-1.3.2 → djaploy-1.4.0}/LICENSE +0 -0
  33. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/__init__.py +0 -0
  34. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/app.py +0 -0
  35. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/__init__.py +0 -0
  36. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/borg/__init__.py +0 -0
  37. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/borg/infra/__init__.py +0 -0
  38. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/borg/infra/djaploy_hooks.py +0 -0
  39. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/janitor/__init__.py +0 -0
  40. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/janitor/apps.py +0 -0
  41. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/janitor/infra/__init__.py +0 -0
  42. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/janitor/infra/djaploy_hooks.py +0 -0
  43. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/nginx/__init__.py +0 -0
  44. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/nginx/apps.py +0 -0
  45. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/nginx/infra/__init__.py +0 -0
  46. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/nginx/infra/djaploy_hooks.py +0 -0
  47. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/rclone/__init__.py +0 -0
  48. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/rclone/apps.py +0 -0
  49. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/rclone/infra/__init__.py +0 -0
  50. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/rclone/infra/djaploy_hooks.py +0 -0
  51. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/sync_certs/__init__.py +0 -0
  52. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/sync_certs/apps.py +0 -0
  53. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/sync_certs/infra/__init__.py +0 -0
  54. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/sync_certs/infra/djaploy_hooks.py +0 -0
  55. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/systemd/__init__.py +0 -0
  56. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/systemd/apps.py +0 -0
  57. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/systemd/infra/__init__.py +0 -0
  58. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/systemd/infra/djaploy_hooks.py +0 -0
  59. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/tailscale/__init__.py +0 -0
  60. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/tailscale/apps.py +0 -0
  61. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/tailscale/infra/__init__.py +0 -0
  62. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/tailscale/infra/djaploy_hooks.py +0 -0
  63. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/versioning/__init__.py +0 -0
  64. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/versioning/apps.py +0 -0
  65. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/versioning/infra/__init__.py +0 -0
  66. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/apps/versioning/infra/djaploy_hooks.py +0 -0
  67. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/artifact.py +0 -0
  68. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/bin/__init__.py +0 -0
  69. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/bin/django_pyinfra.py +0 -0
  70. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/bin/gunicornherder.py +0 -0
  71. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/certificates.py +0 -0
  72. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/changelog.py +0 -0
  73. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/commands/__init__.py +0 -0
  74. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/commands/_utils.py +0 -0
  75. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/commands/status.py +0 -0
  76. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/deploy.py +0 -0
  77. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/discovery.py +0 -0
  78. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/infra/__init__.py +0 -0
  79. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/infra/bluegreen.py +0 -0
  80. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/infra/djaploy_hooks.py +0 -0
  81. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/infra/templates.py +0 -0
  82. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/infra/utils.py +0 -0
  83. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/management/__init__.py +0 -0
  84. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/management/commands/__init__.py +0 -0
  85. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/management/commands/djaploy.py +0 -0
  86. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/management/commands/restore_backup.py +0 -0
  87. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/management/commands/sync_certs.py +0 -0
  88. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/management/commands/update_certs.py +0 -0
  89. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/management/commands/verify.py +0 -0
  90. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/management/utils.py +0 -0
  91. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/notifications.py +0 -0
  92. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/utils.py +0 -0
  93. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy/versioning.py +0 -0
  94. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy.egg-info/dependency_links.txt +0 -0
  95. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy.egg-info/entry_points.txt +0 -0
  96. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy.egg-info/requires.txt +0 -0
  97. {djaploy-1.3.2 → djaploy-1.4.0}/djaploy.egg-info/top_level.txt +0 -0
  98. {djaploy-1.3.2 → djaploy-1.4.0}/setup.cfg +0 -0
  99. {djaploy-1.3.2 → djaploy-1.4.0}/tests/test_bluegreen.py +0 -0
  100. {djaploy-1.3.2 → djaploy-1.4.0}/tests/test_borg.py +0 -0
  101. {djaploy-1.3.2 → djaploy-1.4.0}/tests/test_config.py +0 -0
  102. {djaploy-1.3.2 → djaploy-1.4.0}/tests/test_deploy_scripts.py +0 -0
  103. {djaploy-1.3.2 → djaploy-1.4.0}/tests/test_discovery.py +0 -0
  104. {djaploy-1.3.2 → djaploy-1.4.0}/tests/test_gunicornherder.py +0 -0
  105. {djaploy-1.3.2 → djaploy-1.4.0}/tests/test_versioning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: djaploy
3
- Version: 1.3.2
3
+ Version: 1.4.0
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 'bluegreen'"
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 "zero_downtime"
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 'bluegreen'"
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) -> None:
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) -> Callable:
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 list of remote hook functions for *hook_name*."""
135
- return list(self._remote_hooks.get(hook_name, []))
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,5 @@
1
+ apiVersion: v2
2
+ name: django-app
3
+ description: A Django app the way djaploy ran it (gunicorn + nginx + SQLite/media on local disk), on k3s.
4
+ type: application
5
+ version: 0.2.1
@@ -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 }}