djaploy 1.6.0__tar.gz → 1.6.2__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 (107) hide show
  1. {djaploy-1.6.0 → djaploy-1.6.2}/PKG-INFO +12 -8
  2. {djaploy-1.6.0 → djaploy-1.6.2}/README.md +11 -7
  3. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/deploy.py +9 -1
  4. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/gitops.py +121 -36
  5. djaploy-1.6.2/djaploy/version.py +1 -0
  6. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy.egg-info/PKG-INFO +12 -8
  7. {djaploy-1.6.0 → djaploy-1.6.2}/pyproject.toml +1 -1
  8. {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_gitops.py +150 -0
  9. djaploy-1.6.0/djaploy/version.py +0 -1
  10. {djaploy-1.6.0 → djaploy-1.6.2}/LICENSE +0 -0
  11. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/__init__.py +0 -0
  12. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/app.py +0 -0
  13. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/__init__.py +0 -0
  14. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/borg/__init__.py +0 -0
  15. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/borg/infra/__init__.py +0 -0
  16. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/borg/infra/djaploy_hooks.py +0 -0
  17. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/janitor/__init__.py +0 -0
  18. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/janitor/apps.py +0 -0
  19. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/janitor/infra/__init__.py +0 -0
  20. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/janitor/infra/commands/createjanitoruser.py +0 -0
  21. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/janitor/infra/djaploy_hooks.py +0 -0
  22. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/nginx/__init__.py +0 -0
  23. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/nginx/apps.py +0 -0
  24. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/nginx/infra/__init__.py +0 -0
  25. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/nginx/infra/djaploy_hooks.py +0 -0
  26. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/rclone/__init__.py +0 -0
  27. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/rclone/apps.py +0 -0
  28. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/rclone/infra/__init__.py +0 -0
  29. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/rclone/infra/djaploy_hooks.py +0 -0
  30. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/sync_certs/__init__.py +0 -0
  31. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/sync_certs/apps.py +0 -0
  32. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/sync_certs/infra/__init__.py +0 -0
  33. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/sync_certs/infra/djaploy_hooks.py +0 -0
  34. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/systemd/__init__.py +0 -0
  35. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/systemd/apps.py +0 -0
  36. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/systemd/infra/__init__.py +0 -0
  37. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/systemd/infra/djaploy_hooks.py +0 -0
  38. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/tailscale/__init__.py +0 -0
  39. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/tailscale/apps.py +0 -0
  40. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/tailscale/infra/__init__.py +0 -0
  41. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/tailscale/infra/djaploy_hooks.py +0 -0
  42. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/versioning/__init__.py +0 -0
  43. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/versioning/apps.py +0 -0
  44. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/versioning/infra/__init__.py +0 -0
  45. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/versioning/infra/djaploy_hooks.py +0 -0
  46. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/artifact.py +0 -0
  47. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/bin/__init__.py +0 -0
  48. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/bin/django_pyinfra.py +0 -0
  49. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/bin/gunicornherder.py +0 -0
  50. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/builtin_hooks.py +0 -0
  51. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/certificates.py +0 -0
  52. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/changelog.py +0 -0
  53. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/__init__.py +0 -0
  54. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/_utils.py +0 -0
  55. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/activate.py +0 -0
  56. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/configure.py +0 -0
  57. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/deploy.py +0 -0
  58. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/restore.py +0 -0
  59. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/rollback.py +0 -0
  60. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/status.py +0 -0
  61. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/sync_certs.py +0 -0
  62. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/config.py +0 -0
  63. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/discovery.py +0 -0
  64. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/hooks.py +0 -0
  65. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/__init__.py +0 -0
  66. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/bluegreen.py +0 -0
  67. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/Chart.yaml +0 -0
  68. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/_helpers.tpl +0 -0
  69. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/configmap.yaml +0 -0
  70. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/cronjobs.yaml +0 -0
  71. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/deployment.yaml +0 -0
  72. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/ingress.yaml +0 -0
  73. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/onepassword.yaml +0 -0
  74. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/pvc.yaml +0 -0
  75. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/service.yaml +0 -0
  76. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/workers.yaml +0 -0
  77. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/values.yaml +0 -0
  78. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/djaploy_hooks.py +0 -0
  79. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/k3s.py +0 -0
  80. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/templates.py +0 -0
  81. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/utils.py +0 -0
  82. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/management/__init__.py +0 -0
  83. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/management/commands/__init__.py +0 -0
  84. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/management/commands/djaploy.py +0 -0
  85. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/management/commands/restore_backup.py +0 -0
  86. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/management/commands/sync_certs.py +0 -0
  87. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/management/commands/update_certs.py +0 -0
  88. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/management/commands/verify.py +0 -0
  89. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/management/utils.py +0 -0
  90. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/notifications.py +0 -0
  91. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/utils.py +0 -0
  92. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/versioning.py +0 -0
  93. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy.egg-info/SOURCES.txt +0 -0
  94. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy.egg-info/dependency_links.txt +0 -0
  95. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy.egg-info/entry_points.txt +0 -0
  96. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy.egg-info/requires.txt +0 -0
  97. {djaploy-1.6.0 → djaploy-1.6.2}/djaploy.egg-info/top_level.txt +0 -0
  98. {djaploy-1.6.0 → djaploy-1.6.2}/setup.cfg +0 -0
  99. {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_bluegreen.py +0 -0
  100. {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_borg.py +0 -0
  101. {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_config.py +0 -0
  102. {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_deploy_scripts.py +0 -0
  103. {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_discovery.py +0 -0
  104. {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_gunicornherder.py +0 -0
  105. {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_hooks.py +0 -0
  106. {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_k3s.py +0 -0
  107. {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_versioning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: djaploy
3
- Version: 1.6.0
3
+ Version: 1.6.2
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
@@ -318,11 +318,11 @@ cluster. The inventory host is the **build node**.
318
318
 
319
319
  ```python
320
320
  HostConfig(
321
- "extor", # build node: a cluster node with BuildKit
322
- ssh_hostname="65.109.151.140",
321
+ "monster", # build node: runs the BuildKit service
322
+ ssh_hostname="167.235.97.139",
323
323
  ssh_user="janitor", # needs passwordless sudo
324
324
  ssh_key=OpFilePath("/Infra/SSH Key/private key?ssh-format=openssh"),
325
- ssh_known_hosts_file=OpFilePath("/Infra/extor/SSH Identity"),
325
+ ssh_known_hosts_file="~/src/hetzner-management/servers/monster/known_hosts-167.235.97.139",
326
326
  deployment_strategy="gitops",
327
327
  app_name="docms",
328
328
  manage_py_path="docms/manage.py",
@@ -344,11 +344,15 @@ HostConfig(
344
344
  `deploy` (all from the deploying machine, after the artifact is created):
345
345
 
346
346
  1. checks the GitOps checkout has no local edits to the manifest and pulls it;
347
- 2. builds the artifact on the build node with a temporary BuildKit daemon
348
- (`/opt/techco-buildkit`, root-only socket, cache under `/data/djaploy-build`)
349
- and pushes `<image>:<commit>` using a one-hour token for the namespace's
347
+ 2. builds the artifact with the build node's BuildKit and pushes
348
+ `<image>:<commit>` using a one-hour token for the namespace's
350
349
  `registry-builder` service account; build args `MANAGE_PY` and
351
- `SETTINGS_MODULE`, as for k3s;
350
+ `SETTINGS_MODULE`, as for k3s. By default (`builder.mode="shared"`) the job
351
+ runs as the `builder` user against the node's always-running rootless
352
+ BuildKit service (`/run/techco-buildkit/buildkitd.sock`, jobs under
353
+ `/var/lib/techco-buildkit/jobs`, shared cache); `builder.mode="temporary"`
354
+ instead starts a root BuildKit daemon for the one build (djaploy 1.6.0's
355
+ behaviour). The job directory and credentials are removed afterwards;
352
356
  3. applies the env Secret (`secret_key` + scalar `data`), so 1Password stays
353
357
  the source of secrets and none are in Git;
354
358
  4. rewrites the digest pin in the manifest (a kustomize `images:` entry or
@@ -256,11 +256,11 @@ cluster. The inventory host is the **build node**.
256
256
 
257
257
  ```python
258
258
  HostConfig(
259
- "extor", # build node: a cluster node with BuildKit
260
- ssh_hostname="65.109.151.140",
259
+ "monster", # build node: runs the BuildKit service
260
+ ssh_hostname="167.235.97.139",
261
261
  ssh_user="janitor", # needs passwordless sudo
262
262
  ssh_key=OpFilePath("/Infra/SSH Key/private key?ssh-format=openssh"),
263
- ssh_known_hosts_file=OpFilePath("/Infra/extor/SSH Identity"),
263
+ ssh_known_hosts_file="~/src/hetzner-management/servers/monster/known_hosts-167.235.97.139",
264
264
  deployment_strategy="gitops",
265
265
  app_name="docms",
266
266
  manage_py_path="docms/manage.py",
@@ -282,11 +282,15 @@ HostConfig(
282
282
  `deploy` (all from the deploying machine, after the artifact is created):
283
283
 
284
284
  1. checks the GitOps checkout has no local edits to the manifest and pulls it;
285
- 2. builds the artifact on the build node with a temporary BuildKit daemon
286
- (`/opt/techco-buildkit`, root-only socket, cache under `/data/djaploy-build`)
287
- and pushes `<image>:<commit>` using a one-hour token for the namespace's
285
+ 2. builds the artifact with the build node's BuildKit and pushes
286
+ `<image>:<commit>` using a one-hour token for the namespace's
288
287
  `registry-builder` service account; build args `MANAGE_PY` and
289
- `SETTINGS_MODULE`, as for k3s;
288
+ `SETTINGS_MODULE`, as for k3s. By default (`builder.mode="shared"`) the job
289
+ runs as the `builder` user against the node's always-running rootless
290
+ BuildKit service (`/run/techco-buildkit/buildkitd.sock`, jobs under
291
+ `/var/lib/techco-buildkit/jobs`, shared cache); `builder.mode="temporary"`
292
+ instead starts a root BuildKit daemon for the one build (djaploy 1.6.0's
293
+ behaviour). The job directory and credentials are removed afterwards;
290
294
  3. applies the env Secret (`secret_key` + scalar `data`), so 1Password stays
291
295
  the source of secrets and none are in Git;
292
296
  4. rewrites the digest pin in the manifest (a kustomize `images:` entry or
@@ -61,7 +61,15 @@ def run_command(context: Dict[str, Any]) -> None:
61
61
  call_hook(f"{command_name}:precommand", context)
62
62
  call_hook("precommand", context)
63
63
 
64
- # 2. Run pyinfra
64
+ # 2. Run pyinfra (unless a precommand hook already did all the work,
65
+ # e.g. the gitops strategy, which has no remote hooks)
66
+ if context.get("skip_remote"):
67
+ print(f"No remote execution needed ({context['skip_remote']}).", flush=True)
68
+ context["success"] = True
69
+ call_hook(f"{command_name}:postcommand", context)
70
+ call_hook("postcommand", context)
71
+ return
72
+
65
73
  print("Starting remote execution...", flush=True)
66
74
  processed_inventory = _preprocess_inventory(str(context["inventory_file"]))
67
75
 
@@ -7,15 +7,17 @@ pins its digest in the GitOps repository and syncs the Argo CD application:
7
7
 
8
8
  deploy:precommand (local, after the artifact is created)
9
9
  1. check the GitOps checkout is clean and up to date
10
- 2. build the artifact on the build node with a temporary BuildKit daemon
11
- and push it with a one-hour registry token for the app's namespace
10
+ 2. build the artifact with the build node's BuildKit (its shared rootless
11
+ service, or a temporary daemon) and push it with a one-hour registry
12
+ token for the app's namespace
12
13
  3. apply the env Secret (SECRET_KEY + data, e.g. from OpSecret)
13
14
  4. pin the pushed digest in the GitOps repository, commit and push
14
15
  5. sync that commit in Argo CD and wait until Synced and Healthy
15
16
 
16
17
  The inventory host is the build node: its SSH connection (ssh_hostname,
17
- ssh_user, ssh_key, ssh_known_hosts_file) is used for the build, and no remote
18
- pyinfra hooks run. Kubernetes access uses the local kubeconfig.
18
+ ssh_user, ssh_key, ssh_known_hosts_file) is used for the build over one
19
+ multiplexed connection, retried if the connection drops. pyinfra is not run
20
+ for gitops-only inventories. Kubernetes access uses the local kubeconfig.
19
21
 
20
22
  HostConfig fields used: app_name, manage_py_path, secret_key, data, and
21
23
  ``gitops_conf``::
@@ -29,9 +31,11 @@ HostConfig fields used: app_name, manage_py_path, secret_key, data, and
29
31
  "kubeconfig": "~/.kube/techco-me.kubeconfig", # default: $KUBECONFIG
30
32
  "env_secret": "docms-env", # default: <app_name>-env; None: leave Secrets alone
31
33
  "settings_module": "docms.settings.production", # Dockerfile build arg
32
- "builder": { # build node layout (defaults shown)
33
- "buildkit_dir": "/opt/techco-buildkit",
34
- "work_dir": "/data/djaploy-build",
34
+ "builder": { # defaults shown
35
+ "mode": "shared", # node's BuildKit service; or "temporary"
36
+ "user": "builder",
37
+ "socket": "unix:///run/techco-buildkit/buildkitd.sock",
38
+ "work_dir": "/var/lib/techco-buildkit/jobs",
35
39
  },
36
40
  "registry_audience": "registry.techco.fi",
37
41
  "sync_timeout": 900, # seconds to wait for Argo CD
@@ -47,7 +51,9 @@ import json
47
51
  import os
48
52
  import re
49
53
  import shlex
54
+ import shutil
50
55
  import subprocess
56
+ import tempfile
51
57
  import time
52
58
  import uuid
53
59
  from pathlib import Path
@@ -200,11 +206,23 @@ def check_infra_repo(repo: Path, manifest: str) -> str:
200
206
 
201
207
 
202
208
  class BuildNode:
203
- """SSH to the build node with the inventory's connection settings."""
209
+ """SSH to the build node with the inventory's connection settings.
210
+
211
+ All commands share one multiplexed connection, so a build makes one SSH
212
+ handshake. A failed connection (ssh exit status 255) is retried: every
213
+ build-job command is safe to repeat, and an input stream is rewound first.
214
+ """
215
+
216
+ RETRIES = 3
204
217
 
205
218
  def __init__(self, host_data):
206
219
  self.target = f"{_get(host_data, 'ssh_user')}@{_get(host_data, 'ssh_hostname')}"
207
- self.opts = ["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes"]
220
+ # Short private directory: Unix socket paths are limited (~104 bytes on macOS).
221
+ self.control_dir = tempfile.mkdtemp(prefix="djaploy-ssh-", dir="/tmp")
222
+ self.opts = ["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
223
+ "-o", "ConnectTimeout=20", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=4",
224
+ "-o", "ControlMaster=auto", "-o", f"ControlPath={self.control_dir}/%C",
225
+ "-o", "ControlPersist=120"]
208
226
  if _get(host_data, "ssh_port"):
209
227
  self.opts += ["-p", str(_get(host_data, "ssh_port"))]
210
228
  if _get(host_data, "ssh_key"):
@@ -213,22 +231,88 @@ class BuildNode:
213
231
  self.opts += ["-o", f"UserKnownHostsFile={_get(host_data, 'ssh_known_hosts_file')}"]
214
232
 
215
233
  def run(self, command: str, **kwargs):
216
- return _run(["ssh", *self.opts, self.target, command], **kwargs)
234
+ stdin = kwargs.get("stdin")
235
+ for attempt in range(1, self.RETRIES + 1):
236
+ if attempt > 1 and hasattr(stdin, "seek"):
237
+ stdin.seek(0)
238
+ try:
239
+ return _run(["ssh", *self.opts, self.target, command], **kwargs)
240
+ except subprocess.CalledProcessError as exc:
241
+ if exc.returncode != 255 or attempt == self.RETRIES:
242
+ raise
243
+ print(f"[gitops] SSH connection to {self.target} failed; retrying ({attempt}/{self.RETRIES - 1})",
244
+ flush=True)
245
+ time.sleep(2 * attempt)
246
+
247
+ def close(self):
248
+ _run(["ssh", *self.opts, "-O", "exit", self.target], check=False,
249
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
250
+ shutil.rmtree(self.control_dir, ignore_errors=True)
251
+
252
+
253
+ BUILDER_DEFAULTS = {
254
+ # The build node's always-running rootless BuildKit service (hetzner-management
255
+ # kubernetes/builder on Monster): jobs run as its user against its socket.
256
+ "shared": {"user": "builder", "buildkit_dir": "/opt/techco-buildkit",
257
+ "socket": "unix:///run/techco-buildkit/buildkitd.sock",
258
+ "work_dir": "/var/lib/techco-buildkit/jobs"},
259
+ # A root BuildKit daemon started for each build (systemd-run), as djaploy 1.6.0 did.
260
+ "temporary": {"buildkit_dir": "/opt/techco-buildkit", "work_dir": "/data/djaploy-build"},
261
+ }
262
+
263
+
264
+ def builder_conf(host_data) -> dict:
265
+ given = dict(_get(conf_of(host_data), "builder", {}))
266
+ mode = given.get("mode", "shared")
267
+ if mode not in BUILDER_DEFAULTS:
268
+ raise ValueError(f'gitops_conf["builder"]["mode"] must be "shared" or "temporary", not {mode!r}')
269
+ return {"mode": mode, **BUILDER_DEFAULTS[mode], **given}
270
+
271
+
272
+ def build_commands(builder: dict, work: str, run_id: str, ns: str) -> dict:
273
+ """Shell commands for one build job on the build node."""
274
+ q = shlex.quote
275
+ buildkit = builder["buildkit_dir"]
276
+ if builder["mode"] == "shared":
277
+ as_user = f"sudo -u {q(builder['user'])}"
278
+ sock = builder["socket"]
279
+ return {
280
+ "prepare": f"{as_user} mkdir -p {work}/source {work}/auth",
281
+ "extract": f"{as_user} tar --no-same-owner -xzf - -C {work}/source",
282
+ "auth": f"{as_user} sh -c {q(f'umask 077; cat > {work}/auth/config.json')}",
283
+ "start": None,
284
+ "ready": None,
285
+ "build": f"{as_user} env DOCKER_CONFIG={work}/auth {buildkit}/buildctl --addr {q(sock)} build",
286
+ "result": f"{as_user} cat {work}/result.json",
287
+ "cleanup": f"{as_user} rm -rf -- {work}",
288
+ }
289
+ unit = f"djaploy-buildkit-{run_id}"
290
+ sock = f"unix:///run/{unit}/buildkitd.sock"
291
+ cache = f"{builder['work_dir']}/cache-{ns}"
292
+ return {
293
+ "prepare": f"sudo install -d -m 700 {work}/source {work}/auth {cache} /run/{unit}",
294
+ "extract": f"sudo tar -xzf - -C {work}/source",
295
+ "auth": f"sudo sh -c {q(f'umask 077; cat > {work}/auth/config.json')}",
296
+ "start": (f"sudo systemd-run --collect --unit={unit} --property=UMask=0077 {buildkit}/buildkitd "
297
+ f"--addr {sock} --root {cache} --oci-worker=true --containerd-worker=false "
298
+ f"--oci-worker-binary {buildkit}/buildkit-runc"),
299
+ "ready": f"sudo {buildkit}/buildctl --addr {sock} debug workers",
300
+ "build": f"sudo env DOCKER_CONFIG={work}/auth {buildkit}/buildctl --addr {sock} build",
301
+ "result": f"sudo cat {work}/result.json",
302
+ "cleanup": f"sudo systemctl stop {unit} 2>/dev/null; sudo rm -rf -- {work}",
303
+ }
217
304
 
218
305
 
219
306
  def build_and_push(host_data, artifact_path: Path, image: str, tag: str) -> str:
220
307
  """Build on the build node and push; return the pushed manifest digest."""
221
308
  conf = conf_of(host_data)
222
309
  ns = namespace(host_data)
223
- builder = dict(_get(conf, "builder", {}))
224
- buildkit = builder.get("buildkit_dir", "/opt/techco-buildkit")
310
+ builder = builder_conf(host_data)
225
311
  run_id = f"{ns}-{tag}-{uuid.uuid4().hex[:8]}"
226
- work = f"{builder.get('work_dir', '/data/djaploy-build')}/{run_id}"
227
- cache = f"{builder.get('work_dir', '/data/djaploy-build')}/cache-{ns}"
228
- unit = f"djaploy-buildkit-{run_id}"
229
- sock = f"unix:///run/{unit}/buildkitd.sock"
230
312
  if not re.fullmatch(r"[a-z0-9][a-z0-9.-]*", run_id):
231
313
  raise ValueError(f"Unsafe build id {run_id!r}")
314
+ work = f"{builder['work_dir']}/{run_id}"
315
+ cmd = build_commands(builder, work, run_id, ns)
232
316
  node = BuildNode(host_data)
233
317
 
234
318
  token = _run(kubectl(host_data) + ["-n", ns, "create", "token", "registry-builder",
@@ -242,31 +326,29 @@ def build_and_push(host_data, artifact_path: Path, image: str, tag: str) -> str:
242
326
  if _get(conf, "settings_module"):
243
327
  args.append(f"--opt build-arg:SETTINGS_MODULE={shlex.quote(conf['settings_module'])}")
244
328
 
245
- print(f"[gitops] Building {image}:{tag} on {node.target}", flush=True)
246
- node.run(f"sudo install -d -m 700 {work}/source {work}/auth {cache} /run/{unit}")
329
+ print(f"[gitops] Building {image}:{tag} on {node.target} ({builder['mode']} BuildKit)", flush=True)
330
+ node.run(cmd["prepare"])
247
331
  try:
248
332
  with open(artifact_path, "rb") as archive:
249
- node.run(f"sudo tar -xzf - -C {work}/source", stdin=archive)
250
- node.run(f"sudo tee {work}/auth/config.json >/dev/null", input=docker_config, text=True,
251
- stdout=subprocess.DEVNULL)
252
- node.run(f"sudo systemd-run --collect --unit={unit} --property=UMask=0077 {buildkit}/buildkitd "
253
- f"--addr {sock} --root {cache} --oci-worker=true --containerd-worker=false "
254
- f"--oci-worker-binary {buildkit}/buildkit-runc", stdout=subprocess.DEVNULL)
255
- for _ in range(60):
256
- ready = node.run(f"sudo {buildkit}/buildctl --addr {sock} debug workers",
257
- check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
258
- if ready.returncode == 0:
259
- break
260
- time.sleep(0.5)
261
- else:
262
- raise RuntimeError("BuildKit did not become ready on the build node")
263
- node.run(f"sudo env DOCKER_CONFIG={work}/auth {buildkit}/buildctl --addr {sock} build --progress=plain "
264
- f"--frontend dockerfile.v0 --local context={work}/source --local dockerfile={work}/source "
333
+ node.run(cmd["extract"], stdin=archive)
334
+ node.run(cmd["auth"], input=docker_config, text=True, stdout=subprocess.DEVNULL)
335
+ if cmd["start"]:
336
+ node.run(cmd["start"], stdout=subprocess.DEVNULL)
337
+ for _ in range(60):
338
+ ready = node.run(cmd["ready"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
339
+ if ready.returncode == 0:
340
+ break
341
+ time.sleep(0.5)
342
+ else:
343
+ raise RuntimeError("BuildKit did not become ready on the build node")
344
+ node.run(f"{cmd['build']} --progress=plain --frontend dockerfile.v0 "
345
+ f"--local context={work}/source --local dockerfile={work}/source "
265
346
  f"{' '.join(args)} --output type=image,name={image}:{tag},push=true "
266
347
  f"--metadata-file {work}/result.json")
267
- result = json.loads(node.run(f"sudo cat {work}/result.json", capture_output=True, text=True).stdout)
348
+ result = json.loads(node.run(cmd["result"], capture_output=True, text=True).stdout)
268
349
  finally:
269
- node.run(f"sudo systemctl stop {unit} 2>/dev/null; sudo rm -rf {work}", check=False)
350
+ node.run(cmd["cleanup"], check=False)
351
+ node.close()
270
352
  digest = result.get("containerimage.digest", "")
271
353
  if not DIGEST_RE.fullmatch(digest):
272
354
  raise RuntimeError(f"Build finished without a pushed digest: {result}")
@@ -349,6 +431,9 @@ def _gitops_deploy(context):
349
431
  gitops_hosts = [data for _, data in hosts if _get(data, "deployment_strategy") == GITOPS]
350
432
  if not gitops_hosts:
351
433
  return
434
+ if len(gitops_hosts) == len(hosts):
435
+ # No remote pyinfra hooks run for gitops hosts: don't connect at all.
436
+ context["skip_remote"] = "gitops deploys from this machine"
352
437
  if len(gitops_hosts) > 1:
353
438
  raise ValueError("gitops inventories deploy one app per environment; use one host")
354
439
  host_data = gitops_hosts[0]
@@ -0,0 +1 @@
1
+ __version__ = "1.6.2"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: djaploy
3
- Version: 1.6.0
3
+ Version: 1.6.2
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
@@ -318,11 +318,11 @@ cluster. The inventory host is the **build node**.
318
318
 
319
319
  ```python
320
320
  HostConfig(
321
- "extor", # build node: a cluster node with BuildKit
322
- ssh_hostname="65.109.151.140",
321
+ "monster", # build node: runs the BuildKit service
322
+ ssh_hostname="167.235.97.139",
323
323
  ssh_user="janitor", # needs passwordless sudo
324
324
  ssh_key=OpFilePath("/Infra/SSH Key/private key?ssh-format=openssh"),
325
- ssh_known_hosts_file=OpFilePath("/Infra/extor/SSH Identity"),
325
+ ssh_known_hosts_file="~/src/hetzner-management/servers/monster/known_hosts-167.235.97.139",
326
326
  deployment_strategy="gitops",
327
327
  app_name="docms",
328
328
  manage_py_path="docms/manage.py",
@@ -344,11 +344,15 @@ HostConfig(
344
344
  `deploy` (all from the deploying machine, after the artifact is created):
345
345
 
346
346
  1. checks the GitOps checkout has no local edits to the manifest and pulls it;
347
- 2. builds the artifact on the build node with a temporary BuildKit daemon
348
- (`/opt/techco-buildkit`, root-only socket, cache under `/data/djaploy-build`)
349
- and pushes `<image>:<commit>` using a one-hour token for the namespace's
347
+ 2. builds the artifact with the build node's BuildKit and pushes
348
+ `<image>:<commit>` using a one-hour token for the namespace's
350
349
  `registry-builder` service account; build args `MANAGE_PY` and
351
- `SETTINGS_MODULE`, as for k3s;
350
+ `SETTINGS_MODULE`, as for k3s. By default (`builder.mode="shared"`) the job
351
+ runs as the `builder` user against the node's always-running rootless
352
+ BuildKit service (`/run/techco-buildkit/buildkitd.sock`, jobs under
353
+ `/var/lib/techco-buildkit/jobs`, shared cache); `builder.mode="temporary"`
354
+ instead starts a root BuildKit daemon for the one build (djaploy 1.6.0's
355
+ behaviour). The job directory and credentials are removed afterwards;
352
356
  3. applies the env Secret (`secret_key` + scalar `data`), so 1Password stays
353
357
  the source of secrets and none are in Git;
354
358
  4. rewrites the digest pin in the manifest (a kustomize `images:` entry or
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "djaploy"
3
- version = "1.6.0"
3
+ version = "1.6.2"
4
4
  description = "Modular Django deployment system based on pyinfra"
5
5
  authors = [
6
6
  {name = "Johanna Mae Dimayuga", email = "johanna@techco.fi"}
@@ -181,6 +181,7 @@ class SyncOperationTests(unittest.TestCase):
181
181
  class BuildNodeTests(unittest.TestCase):
182
182
  def test_ssh_options_from_inventory(self):
183
183
  node = gitops.BuildNode(host(ssh_port=2222, ssh_known_hosts_file="/tmp/kh"))
184
+ self.addCleanup(lambda: __import__("shutil").rmtree(node.control_dir, ignore_errors=True))
184
185
  self.assertEqual(node.target, "janitor@203.0.113.10")
185
186
  self.assertIn("StrictHostKeyChecking=yes", node.opts)
186
187
  self.assertIn("UserKnownHostsFile=/tmp/kh", node.opts)
@@ -188,6 +189,124 @@ class BuildNodeTests(unittest.TestCase):
188
189
  self.assertEqual(node.opts[node.opts.index("-i") + 1], "/tmp/key")
189
190
 
190
191
 
192
+ class FakeNode:
193
+ """Records build-node commands; answers result.json with a digest."""
194
+
195
+ def __init__(self, fail_on=None):
196
+ self.commands, self.fail_on, self.target = [], fail_on, "janitor@node"
197
+
198
+ def run(self, command, **kwargs):
199
+ self.commands.append(command)
200
+ if self.fail_on and self.fail_on in command:
201
+ raise subprocess.CalledProcessError(1, command)
202
+ stdout = '{"containerimage.digest": "%s"}' % NEW if command.endswith("result.json") else ""
203
+ return subprocess.CompletedProcess(command, 0, stdout=stdout)
204
+
205
+ def close(self):
206
+ self.commands.append("<close>")
207
+
208
+
209
+ class BuilderTests(unittest.TestCase):
210
+ def setUp(self):
211
+ tmp = tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False)
212
+ tmp.close()
213
+ self.addCleanup(Path(tmp.name).unlink)
214
+ self.artifact = Path(tmp.name)
215
+
216
+ def build(self, node, **builder):
217
+ conf = {**host()["gitops_conf"], **({"builder": builder} if builder else {})}
218
+ token = subprocess.CompletedProcess([], 0, stdout="tok\n")
219
+ with patch.object(gitops, "BuildNode", return_value=node), patch.object(gitops, "_run", return_value=token):
220
+ return gitops.build_and_push(host(gitops_conf=conf), self.artifact, IMAGE, "abc1234")
221
+
222
+ def test_defaults_to_shared_builder(self):
223
+ b = gitops.builder_conf(host())
224
+ self.assertEqual(b["mode"], "shared")
225
+ self.assertEqual(b["user"], "builder")
226
+ self.assertEqual(b["socket"], "unix:///run/techco-buildkit/buildkitd.sock")
227
+
228
+ def test_invalid_mode(self):
229
+ with self.assertRaisesRegex(ValueError, "shared"):
230
+ gitops.builder_conf(host(gitops_conf={**host()["gitops_conf"], "builder": {"mode": "docker"}}))
231
+
232
+ def test_shared_build_sequence(self):
233
+ node = FakeNode()
234
+ self.assertEqual(self.build(node), NEW)
235
+ self.assertEqual(node.commands[-1], "<close>")
236
+ self.assertTrue(all(c.startswith("sudo -u builder ") for c in node.commands[:-1]), node.commands)
237
+ self.assertFalse(any("systemd-run" in c for c in node.commands))
238
+ steps = ["mkdir -p", "tar --no-same-owner", "umask 077", "buildctl --addr", "/result.json", "rm -rf --"]
239
+ positions = [next(i for i, c in enumerate(node.commands) if step in c and (step != "/result.json" or c.endswith(step))) for step in steps]
240
+ self.assertEqual(positions, sorted(positions))
241
+ build = next(c for c in node.commands if " build " in c)
242
+ self.assertIn("/var/lib/techco-buildkit/jobs/docms-prod-abc1234-", build)
243
+ self.assertIn(f"name={IMAGE}:abc1234,push=true", build)
244
+ self.assertIn("build-arg:SETTINGS_MODULE=docms.settings.production", build)
245
+
246
+ def test_shared_cleanup_after_failed_build(self):
247
+ node = FakeNode(fail_on=" build ")
248
+ with self.assertRaises(subprocess.CalledProcessError):
249
+ self.build(node)
250
+ self.assertTrue(node.commands[-2].startswith("sudo -u builder rm -rf -- /var/lib/techco-buildkit/jobs/"))
251
+ self.assertEqual(node.commands[-1], "<close>")
252
+
253
+ def test_temporary_mode_starts_its_own_daemon(self):
254
+ node = FakeNode()
255
+ self.assertEqual(self.build(node, mode="temporary"), NEW)
256
+ self.assertTrue(any("systemd-run" in c for c in node.commands))
257
+ self.assertTrue(any("/data/djaploy-build/docms-prod-abc1234-" in c for c in node.commands))
258
+ self.assertIn("systemctl stop djaploy-buildkit-", node.commands[-2])
259
+
260
+
261
+ class BuildNodeConnectionTests(unittest.TestCase):
262
+ def setUp(self):
263
+ self.node = gitops.BuildNode(host())
264
+ self.addCleanup(lambda: __import__("shutil").rmtree(self.node.control_dir, ignore_errors=True))
265
+ patcher = patch.object(gitops.time, "sleep")
266
+ patcher.start()
267
+ self.addCleanup(patcher.stop)
268
+
269
+ def test_one_multiplexed_connection_with_keepalives(self):
270
+ self.assertTrue(self.node.control_dir.startswith("/tmp/djaploy-ssh-"))
271
+ self.assertIn(f"ControlPath={self.node.control_dir}/%C", self.node.opts)
272
+ for opt in ("ControlMaster=auto", "ServerAliveInterval=15", "ConnectTimeout=20"):
273
+ self.assertIn(opt, self.node.opts)
274
+
275
+ def test_connection_failure_is_retried_and_input_rewound(self):
276
+ failures = [subprocess.CalledProcessError(255, "ssh")]
277
+ seen = []
278
+
279
+ def fake_run(cmd, **kwargs):
280
+ seen.append(kwargs["stdin"].read())
281
+ if failures:
282
+ raise failures.pop()
283
+ return subprocess.CompletedProcess(cmd, 0)
284
+
285
+ with tempfile.TemporaryFile() as archive, patch.object(gitops, "_run", side_effect=fake_run):
286
+ archive.write(b"source")
287
+ archive.seek(0)
288
+ self.node.run("tar -xzf -", stdin=archive)
289
+ self.assertEqual(seen, [b"source", b"source"])
290
+
291
+ def test_command_failure_is_not_retried(self):
292
+ with patch.object(gitops, "_run", side_effect=subprocess.CalledProcessError(1, "ssh")) as run:
293
+ with self.assertRaises(subprocess.CalledProcessError):
294
+ self.node.run("false")
295
+ self.assertEqual(run.call_count, 1)
296
+
297
+ def test_gives_up_after_retries(self):
298
+ with patch.object(gitops, "_run", side_effect=subprocess.CalledProcessError(255, "ssh")) as run:
299
+ with self.assertRaises(subprocess.CalledProcessError):
300
+ self.node.run("true")
301
+ self.assertEqual(run.call_count, gitops.BuildNode.RETRIES)
302
+
303
+ def test_close_stops_master_and_removes_socket_dir(self):
304
+ with patch.object(gitops, "_run") as run:
305
+ self.node.close()
306
+ self.assertIn("-O", run.call_args[0][0])
307
+ self.assertFalse(Path(self.node.control_dir).exists())
308
+
309
+
191
310
  def git(cwd, *args):
192
311
  return subprocess.run(["git", "-C", str(cwd), *args], check=True, capture_output=True, text=True).stdout.strip()
193
312
 
@@ -246,6 +365,7 @@ class HookTests(unittest.TestCase):
246
365
  context = self.context([("extor", host())])
247
366
  gitops._gitops_deploy(context)
248
367
  self.assertEqual(calls, ["check", "build", "secret", "pin", "sync"])
368
+ self.assertTrue(context["skip_remote"])
249
369
  self.assertEqual(context["gitops"], {"image": f"{IMAGE}@{NEW}", "revision": "f" * 40})
250
370
 
251
371
  def test_build_failure_publishes_nothing(self):
@@ -262,6 +382,14 @@ class HookTests(unittest.TestCase):
262
382
  gitops._gitops_deploy(self.context([("web", host(deployment_strategy="zero_downtime"))]))
263
383
  check.assert_not_called()
264
384
 
385
+ def test_mixed_inventory_keeps_remote_stage(self):
386
+ with patch.object(gitops, "check_infra_repo", return_value="main"), \
387
+ patch.object(gitops, "build_and_push", return_value=NEW), patch.object(gitops, "apply_env_secret"), \
388
+ patch.object(gitops, "publish_pin", return_value="f" * 40), patch.object(gitops, "sync_and_wait"):
389
+ context = self.context([("extor", host()), ("web", host(deployment_strategy="zero_downtime"))])
390
+ gitops._gitops_deploy(context)
391
+ self.assertNotIn("skip_remote", context)
392
+
265
393
  def test_one_host_only(self):
266
394
  with self.assertRaisesRegex(ValueError, "one host"):
267
395
  gitops._gitops_deploy(self.context([("a", host()), ("b", host())]))
@@ -269,3 +397,25 @@ class HookTests(unittest.TestCase):
269
397
 
270
398
  if __name__ == "__main__":
271
399
  unittest.main()
400
+
401
+
402
+ class RunCommandSkipTests(unittest.TestCase):
403
+ def test_skip_remote_runs_no_pyinfra_but_postcommand(self):
404
+ from djaploy import deploy
405
+ called = []
406
+
407
+ def fake_call_hook(name, context):
408
+ called.append(name)
409
+ if name == "deploy:precommand":
410
+ context["skip_remote"] = "gitops deploys from this machine"
411
+ return []
412
+
413
+ context = {"command": "deploy", "env": "dev", "command_file": "x", "inventory_file": "inv.py",
414
+ "pyinfra_data": {}}
415
+ with patch("djaploy.hooks.discover_hooks"), patch("djaploy.hooks.call_hook", side_effect=fake_call_hook), \
416
+ patch.object(deploy, "_run_pyinfra") as pyinfra, patch.object(deploy, "_preprocess_inventory") as pre:
417
+ deploy.run_command(context)
418
+ pyinfra.assert_not_called()
419
+ pre.assert_not_called()
420
+ self.assertTrue(context["success"])
421
+ self.assertEqual(called, ["deploy:precommand", "precommand", "deploy:postcommand", "postcommand"])
@@ -1 +0,0 @@
1
- __version__ = "1.6.0"
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