sonata-tasks 0.6.1__py3-none-any.whl
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.
- sonata_tasks/__init__.py +6 -0
- sonata_tasks/ansible.py +83 -0
- sonata_tasks/archive.py +147 -0
- sonata_tasks/buildx.py +139 -0
- sonata_tasks/command.py +5 -0
- sonata_tasks/compensation.py +54 -0
- sonata_tasks/compose.py +178 -0
- sonata_tasks/composites.py +59 -0
- sonata_tasks/core/__init__.py +5 -0
- sonata_tasks/core/command.py +149 -0
- sonata_tasks/core/fingerprint.py +62 -0
- sonata_tasks/cosign.py +172 -0
- sonata_tasks/docker.py +122 -0
- sonata_tasks/errors.py +31 -0
- sonata_tasks/execution/__init__.py +5 -0
- sonata_tasks/execution/adapters.py +128 -0
- sonata_tasks/execution/bindings.py +55 -0
- sonata_tasks/execution/local.py +120 -0
- sonata_tasks/execution/models.py +132 -0
- sonata_tasks/execution/ports.py +86 -0
- sonata_tasks/execution/roles.py +3 -0
- sonata_tasks/gradle.py +51 -0
- sonata_tasks/helm.py +122 -0
- sonata_tasks/http.py +103 -0
- sonata_tasks/imagetools.py +85 -0
- sonata_tasks/k6.py +157 -0
- sonata_tasks/k6_models.py +50 -0
- sonata_tasks/kubectl.py +128 -0
- sonata_tasks/metrics.py +209 -0
- sonata_tasks/process.py +161 -0
- sonata_tasks/prometheus.py +196 -0
- sonata_tasks/py.typed +0 -0
- sonata_tasks/registry.py +193 -0
- sonata_tasks/registry_tunnel.py +126 -0
- sonata_tasks/shell.py +42 -0
- sonata_tasks/skopeo.py +91 -0
- sonata_tasks/syft.py +66 -0
- sonata_tasks/tasks/__init__.py +1 -0
- sonata_tasks/tasks/executors.py +22 -0
- sonata_tasks/tasks/models.py +10 -0
- sonata_tasks/testing.py +38 -0
- sonata_tasks/transfer.py +88 -0
- sonata_tasks/vm/__init__.py +53 -0
- sonata_tasks/vm/adapters.py +103 -0
- sonata_tasks/vm/azure.py +16 -0
- sonata_tasks/vm/models.py +82 -0
- sonata_tasks/vm/multipass.py +31 -0
- sonata_tasks/vm/ports.py +65 -0
- sonata_tasks/vm/providers/__init__.py +1 -0
- sonata_tasks/vm/providers/azure.py +429 -0
- sonata_tasks/vm/providers/multipass.py +352 -0
- sonata_tasks/vm/providers/proxmox.py +567 -0
- sonata_tasks/vm/proxmox.py +16 -0
- sonata_tasks/vm/results.py +14 -0
- sonata_tasks/vm/ssh.py +50 -0
- sonata_tasks/vm/tasks.py +50 -0
- sonata_tasks-0.6.1.dist-info/METADATA +44 -0
- sonata_tasks-0.6.1.dist-info/RECORD +61 -0
- sonata_tasks-0.6.1.dist-info/WHEEL +5 -0
- sonata_tasks-0.6.1.dist-info/licenses/LICENSE +21 -0
- sonata_tasks-0.6.1.dist-info/top_level.txt +1 -0
sonata_tasks/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Reusable tasks for the Sonata workflow engine."""
|
|
2
|
+
|
|
3
|
+
from sonata_tasks.core.command import CommandTask
|
|
4
|
+
from sonata_tasks.execution.models import CommandOptions, CommandTaskSpec, TaskResult
|
|
5
|
+
|
|
6
|
+
__all__ = ["CommandOptions", "CommandTask", "CommandTaskSpec", "TaskResult"]
|
sonata_tasks/ansible.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Run an Ansible playbook as a command task."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from sonata_tasks.command import CommandTask
|
|
10
|
+
from sonata_tasks.execution.models import CommandOptions
|
|
11
|
+
from sonata_tasks.execution.ports import CommandTaskExecutor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_ansible_argv(
|
|
15
|
+
*,
|
|
16
|
+
playbook: Path,
|
|
17
|
+
inventory: str,
|
|
18
|
+
user: str,
|
|
19
|
+
private_key_path: Path | None = None,
|
|
20
|
+
extra_vars: Mapping[str, str] | None = None,
|
|
21
|
+
) -> tuple[str, ...]:
|
|
22
|
+
"""Build the ``ansible-playbook`` argv for a playbook run.
|
|
23
|
+
|
|
24
|
+
``private_key_path`` becomes ``--private-key`` and each ``extra_vars`` entry
|
|
25
|
+
becomes an ``-e key=value`` pair. The playbook is appended last as an
|
|
26
|
+
absolute path, so the command does not depend on the caller's working
|
|
27
|
+
directory.
|
|
28
|
+
"""
|
|
29
|
+
argv = ["ansible-playbook", "-i", inventory, "-u", user]
|
|
30
|
+
if private_key_path is not None:
|
|
31
|
+
argv.extend(("--private-key", str(private_key_path)))
|
|
32
|
+
for key, value in (extra_vars or {}).items():
|
|
33
|
+
argv.extend(("-e", f"{key}={value}"))
|
|
34
|
+
argv.append(str(Path(playbook).absolute()))
|
|
35
|
+
return tuple(argv)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AnsiblePlaybookTask(CommandTask):
|
|
39
|
+
"""Run a playbook against an inventory, optionally under a custom config.
|
|
40
|
+
|
|
41
|
+
Command building is left to :func:`build_ansible_argv`; this class only
|
|
42
|
+
folds ``ansible_config`` into the environment as ``ANSIBLE_CONFIG`` before
|
|
43
|
+
handing everything to :class:`~sonata_tasks.command.CommandTask`.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
playbook: Path,
|
|
50
|
+
inventory: str,
|
|
51
|
+
user: str,
|
|
52
|
+
executor: CommandTaskExecutor,
|
|
53
|
+
role: str = "host",
|
|
54
|
+
private_key_path: Path | None = None,
|
|
55
|
+
extra_vars: Mapping[str, str] | None = None,
|
|
56
|
+
ansible_config: Path | None = None,
|
|
57
|
+
options: CommandOptions | None = None,
|
|
58
|
+
title: str | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Configure the playbook run.
|
|
61
|
+
|
|
62
|
+
``ansible_config`` is exported as ``ANSIBLE_CONFIG`` on top of any
|
|
63
|
+
environment already set in ``options``. ``title`` defaults to the
|
|
64
|
+
playbook's file name, and ``role`` selects the executor binding.
|
|
65
|
+
"""
|
|
66
|
+
current = options or CommandOptions()
|
|
67
|
+
if ansible_config is not None:
|
|
68
|
+
current = replace(
|
|
69
|
+
current, env={**current.env, "ANSIBLE_CONFIG": str(ansible_config)}
|
|
70
|
+
)
|
|
71
|
+
super().__init__(
|
|
72
|
+
title=title or f"Run Ansible playbook {Path(playbook).name}",
|
|
73
|
+
argv=build_ansible_argv(
|
|
74
|
+
playbook=playbook,
|
|
75
|
+
inventory=inventory,
|
|
76
|
+
user=user,
|
|
77
|
+
private_key_path=private_key_path,
|
|
78
|
+
extra_vars=extra_vars,
|
|
79
|
+
),
|
|
80
|
+
executor=executor,
|
|
81
|
+
role=role,
|
|
82
|
+
options=current,
|
|
83
|
+
)
|
sonata_tasks/archive.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Ship a git commit's tree to a remote host as an extracted source archive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import hashlib
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from sonata_engine import Resource, TaskInputs
|
|
12
|
+
from sonata_tasks.compensation import best_effort
|
|
13
|
+
from sonata_tasks.transfer import RemoteProvider
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _exec[RequestT](
|
|
17
|
+
provider: RemoteProvider[RequestT], request: RequestT, argv: tuple[str, ...]
|
|
18
|
+
) -> str:
|
|
19
|
+
result = provider.exec_argv(request, argv=argv)
|
|
20
|
+
if not isinstance(result.return_code, int) or isinstance(result.return_code, bool):
|
|
21
|
+
raise RuntimeError("remote command returned no integer return_code")
|
|
22
|
+
if result.return_code != 0:
|
|
23
|
+
detail = result.stderr or result.stdout
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
f"remote command failed (exit {result.return_code})"
|
|
26
|
+
+ (f": {detail}" if detail else "")
|
|
27
|
+
)
|
|
28
|
+
return result.stdout
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _transfer_archive[RequestT](
|
|
32
|
+
provider: RemoteProvider[RequestT],
|
|
33
|
+
request: RequestT,
|
|
34
|
+
archive_path: Path,
|
|
35
|
+
remote_archive: str,
|
|
36
|
+
) -> None:
|
|
37
|
+
result = provider.transfer_to(
|
|
38
|
+
request, source=archive_path, destination=remote_archive
|
|
39
|
+
)
|
|
40
|
+
if not isinstance(result.return_code, int) or isinstance(result.return_code, bool):
|
|
41
|
+
raise RuntimeError("transfer returned no integer return_code")
|
|
42
|
+
if result.return_code != 0:
|
|
43
|
+
detail = result.stderr or result.stdout
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
f"transfer failed (exit {result.return_code})"
|
|
46
|
+
+ (f": {detail}" if detail else "")
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _verify_remote_archive[RequestT](
|
|
51
|
+
provider: RemoteProvider[RequestT],
|
|
52
|
+
request: RequestT,
|
|
53
|
+
remote_archive: str,
|
|
54
|
+
local_checksum: str,
|
|
55
|
+
) -> None:
|
|
56
|
+
try:
|
|
57
|
+
remote_checksum = _exec(
|
|
58
|
+
provider, request, ("sha256sum", remote_archive)
|
|
59
|
+
).split()[0]
|
|
60
|
+
if remote_checksum != local_checksum:
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
f"sha256sum mismatch: local={local_checksum}, remote={remote_checksum}"
|
|
63
|
+
)
|
|
64
|
+
except BaseException as error:
|
|
65
|
+
best_effort(
|
|
66
|
+
error,
|
|
67
|
+
lambda: provider.exec_argv(request, argv=("rm", "-f", remote_archive)),
|
|
68
|
+
what="cleanup remote archive after failed verify",
|
|
69
|
+
)
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _extract_remote_archive[RequestT](
|
|
74
|
+
provider: RemoteProvider[RequestT],
|
|
75
|
+
request: RequestT,
|
|
76
|
+
remote_archive: str,
|
|
77
|
+
remote_source_dir: str,
|
|
78
|
+
) -> None:
|
|
79
|
+
try:
|
|
80
|
+
_exec(provider, request, ("mkdir", "-p", remote_source_dir))
|
|
81
|
+
_exec(
|
|
82
|
+
provider, request, ("tar", "-xf", remote_archive, "-C", remote_source_dir)
|
|
83
|
+
)
|
|
84
|
+
except BaseException as error:
|
|
85
|
+
best_effort(
|
|
86
|
+
error,
|
|
87
|
+
lambda: provider.exec_argv(
|
|
88
|
+
request, argv=("rm", "-rf", remote_source_dir, remote_archive)
|
|
89
|
+
),
|
|
90
|
+
what="cleanup after failed extract",
|
|
91
|
+
)
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def source_archive_resource[RequestT](
|
|
96
|
+
*,
|
|
97
|
+
repo_root: Path,
|
|
98
|
+
commit: str,
|
|
99
|
+
remote_source_dir: str,
|
|
100
|
+
remote_archive: str,
|
|
101
|
+
provider: RemoteProvider[RequestT],
|
|
102
|
+
request: RequestT,
|
|
103
|
+
) -> Resource[str]:
|
|
104
|
+
"""Package ``commit`` locally and unpack it on the remote host.
|
|
105
|
+
|
|
106
|
+
The commit is exported with ``git archive``, transferred, verified against
|
|
107
|
+
its local SHA-256, and extracted into ``remote_source_dir``. Acquiring
|
|
108
|
+
returns ``remote_source_dir``; releasing best-effort deletes that extracted
|
|
109
|
+
directory on the remote host.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def acquire(_inputs: TaskInputs) -> str:
|
|
113
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
114
|
+
archive_path = Path(tmp) / "source.tar"
|
|
115
|
+
subprocess.run(
|
|
116
|
+
[
|
|
117
|
+
"git",
|
|
118
|
+
"-C",
|
|
119
|
+
str(repo_root),
|
|
120
|
+
"archive",
|
|
121
|
+
"--format=tar",
|
|
122
|
+
commit,
|
|
123
|
+
"-o",
|
|
124
|
+
str(archive_path),
|
|
125
|
+
],
|
|
126
|
+
check=True,
|
|
127
|
+
capture_output=True,
|
|
128
|
+
text=True,
|
|
129
|
+
)
|
|
130
|
+
checksum = hashlib.sha256(archive_path.read_bytes()).hexdigest()
|
|
131
|
+
_exec(provider, request, ("mkdir", "-p", str(Path(remote_archive).parent)))
|
|
132
|
+
_transfer_archive(provider, request, archive_path, remote_archive)
|
|
133
|
+
_verify_remote_archive(provider, request, remote_archive, checksum)
|
|
134
|
+
_extract_remote_archive(
|
|
135
|
+
provider, request, remote_archive, remote_source_dir
|
|
136
|
+
)
|
|
137
|
+
return remote_source_dir
|
|
138
|
+
|
|
139
|
+
def release(_inputs: TaskInputs, _state: str) -> None:
|
|
140
|
+
with contextlib.suppress(RuntimeError):
|
|
141
|
+
provider.exec_argv(request, argv=("rm", "-rf", remote_source_dir))
|
|
142
|
+
|
|
143
|
+
return Resource(
|
|
144
|
+
title=f"Acquire source archive at {remote_source_dir}",
|
|
145
|
+
acquire=acquire,
|
|
146
|
+
release=release,
|
|
147
|
+
)
|
sonata_tasks/buildx.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Manage a docker buildx builder as a resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sonata_engine import Resource, TaskInputs
|
|
10
|
+
from sonata_tasks.command import CommandTask
|
|
11
|
+
from sonata_tasks.compensation import best_effort
|
|
12
|
+
from sonata_tasks.execution.models import CommandOptions, TaskResult
|
|
13
|
+
from sonata_tasks.execution.ports import CommandTaskExecutor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _run(
|
|
17
|
+
inputs: TaskInputs,
|
|
18
|
+
executor: CommandTaskExecutor,
|
|
19
|
+
role: str,
|
|
20
|
+
options: CommandOptions,
|
|
21
|
+
*args: str,
|
|
22
|
+
expected: frozenset[int] = frozenset({0}),
|
|
23
|
+
) -> TaskResult:
|
|
24
|
+
outcome = CommandTask(
|
|
25
|
+
title=f"docker buildx {' '.join(args)}",
|
|
26
|
+
argv=("docker", "buildx", *args),
|
|
27
|
+
executor=executor,
|
|
28
|
+
role=role,
|
|
29
|
+
options=replace(options, expected_exit_codes=expected),
|
|
30
|
+
).run(inputs)
|
|
31
|
+
if outcome.value is None:
|
|
32
|
+
raise RuntimeError("docker buildx returned no command result")
|
|
33
|
+
return outcome.value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _create_argv(
|
|
37
|
+
name: str,
|
|
38
|
+
buildkitd_config: str | None,
|
|
39
|
+
driver_options: Sequence[str],
|
|
40
|
+
) -> tuple[str, ...]:
|
|
41
|
+
argv = ["create", "--name", name, "--driver", "docker-container"]
|
|
42
|
+
if buildkitd_config is not None:
|
|
43
|
+
argv.extend(("--buildkitd-config", buildkitd_config))
|
|
44
|
+
for option in driver_options:
|
|
45
|
+
argv.extend(("--driver-opt", option))
|
|
46
|
+
argv.append("--use")
|
|
47
|
+
return tuple(argv)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _validate_output(validate: Callable[[str], None] | None, stdout: str) -> None:
|
|
51
|
+
if validate is not None:
|
|
52
|
+
validate(stdout)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def buildx_builder_resource(
|
|
56
|
+
*,
|
|
57
|
+
name: str,
|
|
58
|
+
executor: CommandTaskExecutor,
|
|
59
|
+
role: str = "host",
|
|
60
|
+
options: CommandOptions | None = None,
|
|
61
|
+
requires: tuple[Resource[Any], ...] = (),
|
|
62
|
+
buildkitd_config: str | None = None,
|
|
63
|
+
# Driver options the created builder gets, as `--driver-opt` arguments. A
|
|
64
|
+
# `docker-container` builder runs buildkitd in a container of its own, so
|
|
65
|
+
# its `localhost` is itself: reaching a registry on the host's loopback
|
|
66
|
+
# needs `network=host` here, and buildkitd's own `[worker.oci] networkMode`
|
|
67
|
+
# does not substitute, because that governs the network of build steps
|
|
68
|
+
# rather than the daemon that pushes.
|
|
69
|
+
driver_options: Sequence[str] = (),
|
|
70
|
+
validate: Callable[[str], None] | None = None,
|
|
71
|
+
validation_key: str | None = None,
|
|
72
|
+
replace_existing: bool = False,
|
|
73
|
+
) -> Resource[str]:
|
|
74
|
+
"""Acquire a named docker buildx builder, bootstrapping it when missing.
|
|
75
|
+
|
|
76
|
+
Acquiring inspects the builder. A missing one — or one that
|
|
77
|
+
``replace_existing`` asks to redo, which is removed first — is created as a
|
|
78
|
+
``docker-container`` builder with ``--use`` and any ``driver_options``,
|
|
79
|
+
bootstrapped, and checked through ``validate``. Acquiring returns the builder
|
|
80
|
+
name for a builder this resource created, or ``"existing"`` when it left a
|
|
81
|
+
pre-existing builder untouched; releasing removes only the former.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
ValueError: If ``validate`` is configured without a ``validation_key``.
|
|
85
|
+
|
|
86
|
+
"""
|
|
87
|
+
if validate is not None and not validation_key:
|
|
88
|
+
raise ValueError("validation_key is required when validate is configured")
|
|
89
|
+
current = options or CommandOptions()
|
|
90
|
+
|
|
91
|
+
def remove(inputs: TaskInputs) -> None:
|
|
92
|
+
_ = _run(inputs, executor, role, current, "rm", "--force", name)
|
|
93
|
+
|
|
94
|
+
def bootstrap(inputs: TaskInputs) -> None:
|
|
95
|
+
try:
|
|
96
|
+
_ = _run(
|
|
97
|
+
inputs,
|
|
98
|
+
executor,
|
|
99
|
+
role,
|
|
100
|
+
current,
|
|
101
|
+
*_create_argv(name, buildkitd_config, driver_options),
|
|
102
|
+
)
|
|
103
|
+
result = _run(
|
|
104
|
+
inputs, executor, role, current, "inspect", "--bootstrap", name
|
|
105
|
+
)
|
|
106
|
+
_validate_output(validate, result.stdout)
|
|
107
|
+
except BaseException as error:
|
|
108
|
+
best_effort(
|
|
109
|
+
error,
|
|
110
|
+
lambda: remove(inputs),
|
|
111
|
+
what=f"cleanup failed buildx builder {name}",
|
|
112
|
+
)
|
|
113
|
+
raise
|
|
114
|
+
|
|
115
|
+
def acquire(inputs: TaskInputs) -> str:
|
|
116
|
+
inspected = _run(
|
|
117
|
+
inputs, executor, role, current, "inspect", name, expected=frozenset({0, 1})
|
|
118
|
+
)
|
|
119
|
+
if inspected.return_code != 0:
|
|
120
|
+
bootstrap(inputs)
|
|
121
|
+
return name
|
|
122
|
+
if not replace_existing:
|
|
123
|
+
_validate_output(validate, inspected.stdout)
|
|
124
|
+
return "existing"
|
|
125
|
+
remove(inputs)
|
|
126
|
+
bootstrap(inputs)
|
|
127
|
+
return name
|
|
128
|
+
|
|
129
|
+
def release(inputs: TaskInputs, state: str) -> None:
|
|
130
|
+
if state != "existing":
|
|
131
|
+
remove(inputs)
|
|
132
|
+
|
|
133
|
+
return Resource(
|
|
134
|
+
title=f"Acquire {name} buildx builder",
|
|
135
|
+
acquire=acquire,
|
|
136
|
+
release=release,
|
|
137
|
+
requires=requires,
|
|
138
|
+
always_release=True,
|
|
139
|
+
)
|
sonata_tasks/command.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Helpers for undoing partial work without masking the original failure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sonata_engine import Resource, TaskInputs
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def best_effort(
|
|
12
|
+
error: BaseException, cleanup: Callable[[], object], *, what: str
|
|
13
|
+
) -> None:
|
|
14
|
+
"""Run cleanup without replacing the error that required it."""
|
|
15
|
+
try:
|
|
16
|
+
_ = cleanup()
|
|
17
|
+
except (OSError, RuntimeError) as cleanup_error:
|
|
18
|
+
error.add_note(
|
|
19
|
+
f"Best-effort {what} after a failed acquire failed: {cleanup_error}"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compensated_resource[T](
|
|
24
|
+
*,
|
|
25
|
+
title: str,
|
|
26
|
+
acquire: Callable[[TaskInputs], T],
|
|
27
|
+
compensate: Callable[[TaskInputs], object],
|
|
28
|
+
release: Callable[[TaskInputs, T], object] | None = None,
|
|
29
|
+
requires: tuple[Resource[Any], ...] = (),
|
|
30
|
+
always_release: bool = False,
|
|
31
|
+
revive: Callable[[Any], T] | None = None,
|
|
32
|
+
) -> Resource[T]:
|
|
33
|
+
"""Build a resource whose failed acquire performs best-effort compensation."""
|
|
34
|
+
|
|
35
|
+
def undo(inputs: TaskInputs, value: T) -> None:
|
|
36
|
+
_ = compensate(inputs) if release is None else release(inputs, value)
|
|
37
|
+
|
|
38
|
+
def acquire_with_compensation(inputs: TaskInputs) -> T:
|
|
39
|
+
try:
|
|
40
|
+
return acquire(inputs)
|
|
41
|
+
except BaseException as error:
|
|
42
|
+
best_effort(
|
|
43
|
+
error, lambda: compensate(inputs), what=f"compensation for {title}"
|
|
44
|
+
)
|
|
45
|
+
raise
|
|
46
|
+
|
|
47
|
+
return Resource(
|
|
48
|
+
title=title,
|
|
49
|
+
acquire=acquire_with_compensation,
|
|
50
|
+
release=undo,
|
|
51
|
+
requires=requires,
|
|
52
|
+
always_release=always_release,
|
|
53
|
+
revive=revive,
|
|
54
|
+
)
|
sonata_tasks/compose.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Deploy, wait for, and tear down a docker compose project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sonata_engine import Resource, Steps, TaskInputs
|
|
10
|
+
from sonata_tasks.command import CommandTask
|
|
11
|
+
from sonata_tasks.compensation import compensated_resource
|
|
12
|
+
from sonata_tasks.execution.models import CommandOptions
|
|
13
|
+
from sonata_tasks.execution.ports import CommandTaskExecutor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class DockerComposeProject:
|
|
18
|
+
"""A compose project to run: its name, file, and readiness endpoint."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
file: Path
|
|
22
|
+
ready_url: str
|
|
23
|
+
build: bool = True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DeployDockerCompose(CommandTask):
|
|
27
|
+
"""Bring a compose project up detached and wait for its services."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
project: DockerComposeProject,
|
|
32
|
+
*,
|
|
33
|
+
executor: CommandTaskExecutor,
|
|
34
|
+
role: str = "host",
|
|
35
|
+
options: CommandOptions | None = None,
|
|
36
|
+
title: str | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Configure the ``docker compose up -d --wait`` command.
|
|
39
|
+
|
|
40
|
+
``--build`` is added unless ``project.build`` is false. ``title``
|
|
41
|
+
defaults to one naming the project.
|
|
42
|
+
"""
|
|
43
|
+
super().__init__(
|
|
44
|
+
title=title or f"Deploy Docker Compose project {project.name}",
|
|
45
|
+
argv=(
|
|
46
|
+
"docker",
|
|
47
|
+
"compose",
|
|
48
|
+
"-f",
|
|
49
|
+
str(project.file),
|
|
50
|
+
"-p",
|
|
51
|
+
project.name,
|
|
52
|
+
"up",
|
|
53
|
+
"-d",
|
|
54
|
+
*(("--build",) if project.build else ()),
|
|
55
|
+
"--wait",
|
|
56
|
+
),
|
|
57
|
+
executor=executor,
|
|
58
|
+
role=role,
|
|
59
|
+
options=options,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class WaitForDockerCompose(CommandTask):
|
|
64
|
+
"""Poll the project's readiness URL until it answers."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
project: DockerComposeProject,
|
|
69
|
+
*,
|
|
70
|
+
executor: CommandTaskExecutor,
|
|
71
|
+
role: str = "host",
|
|
72
|
+
options: CommandOptions | None = None,
|
|
73
|
+
title: str | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Configure the ``curl`` retry loop against ``project.ready_url``.
|
|
76
|
+
|
|
77
|
+
``title`` defaults to one naming the project.
|
|
78
|
+
"""
|
|
79
|
+
super().__init__(
|
|
80
|
+
title=title or f"Wait for Docker Compose project {project.name}",
|
|
81
|
+
argv=(
|
|
82
|
+
"curl",
|
|
83
|
+
"-fsS",
|
|
84
|
+
"--retry",
|
|
85
|
+
"60",
|
|
86
|
+
"--retry-delay",
|
|
87
|
+
"1",
|
|
88
|
+
"--retry-connrefused",
|
|
89
|
+
"--retry-all-errors",
|
|
90
|
+
project.ready_url,
|
|
91
|
+
),
|
|
92
|
+
executor=executor,
|
|
93
|
+
role=role,
|
|
94
|
+
options=options,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class DestroyDockerCompose(CommandTask):
|
|
99
|
+
"""Bring a compose project down, optionally dropping volumes and orphans."""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
project: DockerComposeProject,
|
|
104
|
+
*,
|
|
105
|
+
executor: CommandTaskExecutor,
|
|
106
|
+
role: str = "host",
|
|
107
|
+
options: CommandOptions | None = None,
|
|
108
|
+
title: str | None = None,
|
|
109
|
+
remove_volumes: bool = False,
|
|
110
|
+
remove_orphans: bool = False,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Configure the ``docker compose down`` command.
|
|
113
|
+
|
|
114
|
+
``remove_volumes`` and ``remove_orphans`` add ``--volumes`` and
|
|
115
|
+
``--remove-orphans`` respectively; ``title`` defaults to one naming the
|
|
116
|
+
project.
|
|
117
|
+
"""
|
|
118
|
+
argv = [
|
|
119
|
+
"docker",
|
|
120
|
+
"compose",
|
|
121
|
+
"-f",
|
|
122
|
+
str(project.file),
|
|
123
|
+
"-p",
|
|
124
|
+
project.name,
|
|
125
|
+
"down",
|
|
126
|
+
]
|
|
127
|
+
if remove_volumes:
|
|
128
|
+
argv.append("--volumes")
|
|
129
|
+
if remove_orphans:
|
|
130
|
+
argv.append("--remove-orphans")
|
|
131
|
+
super().__init__(
|
|
132
|
+
title=title or f"Destroy Docker Compose project {project.name}",
|
|
133
|
+
argv=tuple(argv),
|
|
134
|
+
executor=executor,
|
|
135
|
+
role=role,
|
|
136
|
+
options=options,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def docker_compose_resource(
|
|
141
|
+
project: DockerComposeProject,
|
|
142
|
+
*,
|
|
143
|
+
executor: CommandTaskExecutor,
|
|
144
|
+
role: str = "host",
|
|
145
|
+
options: CommandOptions | None = None,
|
|
146
|
+
remove_volumes: bool = False,
|
|
147
|
+
remove_orphans: bool = False,
|
|
148
|
+
requires: tuple[Resource[Any], ...] = (),
|
|
149
|
+
) -> Resource[DockerComposeProject]:
|
|
150
|
+
"""Manage the supplied project; acquisition performs deploy then readiness."""
|
|
151
|
+
deploy = Steps(
|
|
152
|
+
title=f"Acquire Docker Compose project {project.name}",
|
|
153
|
+
steps=(
|
|
154
|
+
DeployDockerCompose(project, executor=executor, role=role, options=options),
|
|
155
|
+
WaitForDockerCompose(
|
|
156
|
+
project, executor=executor, role=role, options=options
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
destroy = DestroyDockerCompose(
|
|
161
|
+
project,
|
|
162
|
+
executor=executor,
|
|
163
|
+
role=role,
|
|
164
|
+
options=options,
|
|
165
|
+
remove_volumes=remove_volumes,
|
|
166
|
+
remove_orphans=remove_orphans,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def acquire(inputs: TaskInputs) -> DockerComposeProject:
|
|
170
|
+
_ = deploy.run(inputs)
|
|
171
|
+
return project
|
|
172
|
+
|
|
173
|
+
return compensated_resource(
|
|
174
|
+
title=f"Acquire Docker Compose project {project.name}",
|
|
175
|
+
acquire=acquire,
|
|
176
|
+
compensate=destroy.run,
|
|
177
|
+
requires=requires,
|
|
178
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Turn already-resolved command specs into a runnable step sequence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from sonata_engine import Steps
|
|
8
|
+
from sonata_tasks.command import CommandTask
|
|
9
|
+
from sonata_tasks.core.fingerprint import fingerprint_digest
|
|
10
|
+
from sonata_tasks.execution.models import CommandTaskSpec
|
|
11
|
+
from sonata_tasks.execution.ports import CommandTaskExecutor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def command_specs_composite(
|
|
15
|
+
commands: Sequence[CommandTaskSpec], executor: CommandTaskExecutor, *, title: str
|
|
16
|
+
) -> Steps:
|
|
17
|
+
"""Wrap each spec in a :class:`CommandTask` and collect them as ``Steps``.
|
|
18
|
+
|
|
19
|
+
``title`` labels the composite; a spec without a summary is titled by its
|
|
20
|
+
position in ``commands``.
|
|
21
|
+
"""
|
|
22
|
+
return Steps(
|
|
23
|
+
title=title,
|
|
24
|
+
steps=tuple(
|
|
25
|
+
CommandTask(
|
|
26
|
+
title=spec.summary or f"Command {index}",
|
|
27
|
+
argv=spec.argv,
|
|
28
|
+
executor=executor,
|
|
29
|
+
role=spec.role,
|
|
30
|
+
options=spec.options,
|
|
31
|
+
)
|
|
32
|
+
for index, spec in enumerate(commands)
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def command_specs_fingerprint(
|
|
38
|
+
commands: Sequence[CommandTaskSpec], executor: CommandTaskExecutor
|
|
39
|
+
) -> str:
|
|
40
|
+
"""Hash the same static command inputs used by CommandTask fingerprints."""
|
|
41
|
+
return fingerprint_digest(
|
|
42
|
+
{
|
|
43
|
+
"commands": tuple(
|
|
44
|
+
{
|
|
45
|
+
"argv": spec.argv,
|
|
46
|
+
"role": spec.role,
|
|
47
|
+
"binding_key": executor.binding_key(spec.role),
|
|
48
|
+
"options": {
|
|
49
|
+
"cwd": spec.options.cwd,
|
|
50
|
+
"env": spec.options.env,
|
|
51
|
+
"remote_dir": spec.options.remote_dir,
|
|
52
|
+
"expected_exit_codes": spec.options.expected_exit_codes,
|
|
53
|
+
"timeout_seconds": spec.options.timeout_seconds,
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
for spec in commands
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
)
|