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.
Files changed (61) hide show
  1. sonata_tasks/__init__.py +6 -0
  2. sonata_tasks/ansible.py +83 -0
  3. sonata_tasks/archive.py +147 -0
  4. sonata_tasks/buildx.py +139 -0
  5. sonata_tasks/command.py +5 -0
  6. sonata_tasks/compensation.py +54 -0
  7. sonata_tasks/compose.py +178 -0
  8. sonata_tasks/composites.py +59 -0
  9. sonata_tasks/core/__init__.py +5 -0
  10. sonata_tasks/core/command.py +149 -0
  11. sonata_tasks/core/fingerprint.py +62 -0
  12. sonata_tasks/cosign.py +172 -0
  13. sonata_tasks/docker.py +122 -0
  14. sonata_tasks/errors.py +31 -0
  15. sonata_tasks/execution/__init__.py +5 -0
  16. sonata_tasks/execution/adapters.py +128 -0
  17. sonata_tasks/execution/bindings.py +55 -0
  18. sonata_tasks/execution/local.py +120 -0
  19. sonata_tasks/execution/models.py +132 -0
  20. sonata_tasks/execution/ports.py +86 -0
  21. sonata_tasks/execution/roles.py +3 -0
  22. sonata_tasks/gradle.py +51 -0
  23. sonata_tasks/helm.py +122 -0
  24. sonata_tasks/http.py +103 -0
  25. sonata_tasks/imagetools.py +85 -0
  26. sonata_tasks/k6.py +157 -0
  27. sonata_tasks/k6_models.py +50 -0
  28. sonata_tasks/kubectl.py +128 -0
  29. sonata_tasks/metrics.py +209 -0
  30. sonata_tasks/process.py +161 -0
  31. sonata_tasks/prometheus.py +196 -0
  32. sonata_tasks/py.typed +0 -0
  33. sonata_tasks/registry.py +193 -0
  34. sonata_tasks/registry_tunnel.py +126 -0
  35. sonata_tasks/shell.py +42 -0
  36. sonata_tasks/skopeo.py +91 -0
  37. sonata_tasks/syft.py +66 -0
  38. sonata_tasks/tasks/__init__.py +1 -0
  39. sonata_tasks/tasks/executors.py +22 -0
  40. sonata_tasks/tasks/models.py +10 -0
  41. sonata_tasks/testing.py +38 -0
  42. sonata_tasks/transfer.py +88 -0
  43. sonata_tasks/vm/__init__.py +53 -0
  44. sonata_tasks/vm/adapters.py +103 -0
  45. sonata_tasks/vm/azure.py +16 -0
  46. sonata_tasks/vm/models.py +82 -0
  47. sonata_tasks/vm/multipass.py +31 -0
  48. sonata_tasks/vm/ports.py +65 -0
  49. sonata_tasks/vm/providers/__init__.py +1 -0
  50. sonata_tasks/vm/providers/azure.py +429 -0
  51. sonata_tasks/vm/providers/multipass.py +352 -0
  52. sonata_tasks/vm/providers/proxmox.py +567 -0
  53. sonata_tasks/vm/proxmox.py +16 -0
  54. sonata_tasks/vm/results.py +14 -0
  55. sonata_tasks/vm/ssh.py +50 -0
  56. sonata_tasks/vm/tasks.py +50 -0
  57. sonata_tasks-0.6.1.dist-info/METADATA +44 -0
  58. sonata_tasks-0.6.1.dist-info/RECORD +61 -0
  59. sonata_tasks-0.6.1.dist-info/WHEEL +5 -0
  60. sonata_tasks-0.6.1.dist-info/licenses/LICENSE +21 -0
  61. sonata_tasks-0.6.1.dist-info/top_level.txt +1 -0
@@ -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"]
@@ -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
+ )
@@ -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
+ )
@@ -0,0 +1,5 @@
1
+ """Compatibility import for the public command task."""
2
+
3
+ from sonata_tasks.core.command import Argv, CommandTask
4
+
5
+ __all__ = ["Argv", "CommandTask"]
@@ -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
+ )
@@ -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
+ )
@@ -0,0 +1,5 @@
1
+ """Core task mechanisms."""
2
+
3
+ from sonata_tasks.core.command import CommandTask
4
+
5
+ __all__ = ["CommandTask"]