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.
- {djaploy-1.6.0 → djaploy-1.6.2}/PKG-INFO +12 -8
- {djaploy-1.6.0 → djaploy-1.6.2}/README.md +11 -7
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/deploy.py +9 -1
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/gitops.py +121 -36
- djaploy-1.6.2/djaploy/version.py +1 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy.egg-info/PKG-INFO +12 -8
- {djaploy-1.6.0 → djaploy-1.6.2}/pyproject.toml +1 -1
- {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_gitops.py +150 -0
- djaploy-1.6.0/djaploy/version.py +0 -1
- {djaploy-1.6.0 → djaploy-1.6.2}/LICENSE +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/app.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/borg/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/borg/infra/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/borg/infra/djaploy_hooks.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/janitor/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/janitor/apps.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/janitor/infra/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/janitor/infra/commands/createjanitoruser.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/janitor/infra/djaploy_hooks.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/nginx/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/nginx/apps.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/nginx/infra/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/nginx/infra/djaploy_hooks.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/rclone/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/rclone/apps.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/rclone/infra/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/rclone/infra/djaploy_hooks.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/sync_certs/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/sync_certs/apps.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/sync_certs/infra/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/sync_certs/infra/djaploy_hooks.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/systemd/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/systemd/apps.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/systemd/infra/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/systemd/infra/djaploy_hooks.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/tailscale/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/tailscale/apps.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/tailscale/infra/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/tailscale/infra/djaploy_hooks.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/versioning/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/versioning/apps.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/versioning/infra/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/apps/versioning/infra/djaploy_hooks.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/artifact.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/bin/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/bin/django_pyinfra.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/bin/gunicornherder.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/builtin_hooks.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/certificates.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/changelog.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/_utils.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/activate.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/configure.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/deploy.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/restore.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/rollback.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/status.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/commands/sync_certs.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/config.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/discovery.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/hooks.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/bluegreen.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/Chart.yaml +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/_helpers.tpl +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/configmap.yaml +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/cronjobs.yaml +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/deployment.yaml +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/ingress.yaml +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/onepassword.yaml +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/pvc.yaml +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/service.yaml +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/templates/workers.yaml +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/charts/django-app/values.yaml +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/djaploy_hooks.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/k3s.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/templates.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/infra/utils.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/management/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/management/commands/__init__.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/management/commands/djaploy.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/management/commands/restore_backup.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/management/commands/sync_certs.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/management/commands/update_certs.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/management/commands/verify.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/management/utils.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/notifications.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/utils.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy/versioning.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy.egg-info/SOURCES.txt +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy.egg-info/dependency_links.txt +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy.egg-info/entry_points.txt +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy.egg-info/requires.txt +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/djaploy.egg-info/top_level.txt +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/setup.cfg +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_bluegreen.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_borg.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_config.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_deploy_scripts.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_discovery.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_gunicornherder.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_hooks.py +0 -0
- {djaploy-1.6.0 → djaploy-1.6.2}/tests/test_k3s.py +0 -0
- {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.
|
|
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
|
-
"
|
|
322
|
-
ssh_hostname="
|
|
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=
|
|
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
|
|
348
|
-
|
|
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
|
-
"
|
|
260
|
-
ssh_hostname="
|
|
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=
|
|
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
|
|
286
|
-
|
|
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
|
|
11
|
-
and push it with a one-hour registry
|
|
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
|
|
18
|
-
|
|
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": { #
|
|
33
|
-
"
|
|
34
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
250
|
-
node.run(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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(
|
|
348
|
+
result = json.loads(node.run(cmd["result"], capture_output=True, text=True).stdout)
|
|
268
349
|
finally:
|
|
269
|
-
node.run(
|
|
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.
|
|
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
|
-
"
|
|
322
|
-
ssh_hostname="
|
|
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=
|
|
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
|
|
348
|
-
|
|
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
|
|
@@ -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"])
|
djaploy-1.6.0/djaploy/version.py
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|