sonata-tasks 0.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. sonata_tasks-0.6.1/LICENSE +21 -0
  2. sonata_tasks-0.6.1/PKG-INFO +44 -0
  3. sonata_tasks-0.6.1/README.md +5 -0
  4. sonata_tasks-0.6.1/pyproject.toml +181 -0
  5. sonata_tasks-0.6.1/setup.cfg +4 -0
  6. sonata_tasks-0.6.1/src/sonata_tasks/__init__.py +6 -0
  7. sonata_tasks-0.6.1/src/sonata_tasks/ansible.py +83 -0
  8. sonata_tasks-0.6.1/src/sonata_tasks/archive.py +147 -0
  9. sonata_tasks-0.6.1/src/sonata_tasks/buildx.py +139 -0
  10. sonata_tasks-0.6.1/src/sonata_tasks/command.py +5 -0
  11. sonata_tasks-0.6.1/src/sonata_tasks/compensation.py +54 -0
  12. sonata_tasks-0.6.1/src/sonata_tasks/compose.py +178 -0
  13. sonata_tasks-0.6.1/src/sonata_tasks/composites.py +59 -0
  14. sonata_tasks-0.6.1/src/sonata_tasks/core/__init__.py +5 -0
  15. sonata_tasks-0.6.1/src/sonata_tasks/core/command.py +149 -0
  16. sonata_tasks-0.6.1/src/sonata_tasks/core/fingerprint.py +62 -0
  17. sonata_tasks-0.6.1/src/sonata_tasks/cosign.py +172 -0
  18. sonata_tasks-0.6.1/src/sonata_tasks/docker.py +122 -0
  19. sonata_tasks-0.6.1/src/sonata_tasks/errors.py +31 -0
  20. sonata_tasks-0.6.1/src/sonata_tasks/execution/__init__.py +5 -0
  21. sonata_tasks-0.6.1/src/sonata_tasks/execution/adapters.py +128 -0
  22. sonata_tasks-0.6.1/src/sonata_tasks/execution/bindings.py +55 -0
  23. sonata_tasks-0.6.1/src/sonata_tasks/execution/local.py +120 -0
  24. sonata_tasks-0.6.1/src/sonata_tasks/execution/models.py +132 -0
  25. sonata_tasks-0.6.1/src/sonata_tasks/execution/ports.py +86 -0
  26. sonata_tasks-0.6.1/src/sonata_tasks/execution/roles.py +3 -0
  27. sonata_tasks-0.6.1/src/sonata_tasks/gradle.py +51 -0
  28. sonata_tasks-0.6.1/src/sonata_tasks/helm.py +122 -0
  29. sonata_tasks-0.6.1/src/sonata_tasks/http.py +103 -0
  30. sonata_tasks-0.6.1/src/sonata_tasks/imagetools.py +85 -0
  31. sonata_tasks-0.6.1/src/sonata_tasks/k6.py +157 -0
  32. sonata_tasks-0.6.1/src/sonata_tasks/k6_models.py +50 -0
  33. sonata_tasks-0.6.1/src/sonata_tasks/kubectl.py +128 -0
  34. sonata_tasks-0.6.1/src/sonata_tasks/metrics.py +209 -0
  35. sonata_tasks-0.6.1/src/sonata_tasks/process.py +161 -0
  36. sonata_tasks-0.6.1/src/sonata_tasks/prometheus.py +196 -0
  37. sonata_tasks-0.6.1/src/sonata_tasks/py.typed +0 -0
  38. sonata_tasks-0.6.1/src/sonata_tasks/registry.py +193 -0
  39. sonata_tasks-0.6.1/src/sonata_tasks/registry_tunnel.py +126 -0
  40. sonata_tasks-0.6.1/src/sonata_tasks/shell.py +42 -0
  41. sonata_tasks-0.6.1/src/sonata_tasks/skopeo.py +91 -0
  42. sonata_tasks-0.6.1/src/sonata_tasks/syft.py +66 -0
  43. sonata_tasks-0.6.1/src/sonata_tasks/tasks/__init__.py +1 -0
  44. sonata_tasks-0.6.1/src/sonata_tasks/tasks/executors.py +22 -0
  45. sonata_tasks-0.6.1/src/sonata_tasks/tasks/models.py +10 -0
  46. sonata_tasks-0.6.1/src/sonata_tasks/testing.py +38 -0
  47. sonata_tasks-0.6.1/src/sonata_tasks/transfer.py +88 -0
  48. sonata_tasks-0.6.1/src/sonata_tasks/vm/__init__.py +53 -0
  49. sonata_tasks-0.6.1/src/sonata_tasks/vm/adapters.py +103 -0
  50. sonata_tasks-0.6.1/src/sonata_tasks/vm/azure.py +16 -0
  51. sonata_tasks-0.6.1/src/sonata_tasks/vm/models.py +82 -0
  52. sonata_tasks-0.6.1/src/sonata_tasks/vm/multipass.py +31 -0
  53. sonata_tasks-0.6.1/src/sonata_tasks/vm/ports.py +65 -0
  54. sonata_tasks-0.6.1/src/sonata_tasks/vm/providers/__init__.py +1 -0
  55. sonata_tasks-0.6.1/src/sonata_tasks/vm/providers/azure.py +429 -0
  56. sonata_tasks-0.6.1/src/sonata_tasks/vm/providers/multipass.py +352 -0
  57. sonata_tasks-0.6.1/src/sonata_tasks/vm/providers/proxmox.py +567 -0
  58. sonata_tasks-0.6.1/src/sonata_tasks/vm/proxmox.py +16 -0
  59. sonata_tasks-0.6.1/src/sonata_tasks/vm/results.py +14 -0
  60. sonata_tasks-0.6.1/src/sonata_tasks/vm/ssh.py +50 -0
  61. sonata_tasks-0.6.1/src/sonata_tasks/vm/tasks.py +50 -0
  62. sonata_tasks-0.6.1/src/sonata_tasks.egg-info/PKG-INFO +44 -0
  63. sonata_tasks-0.6.1/src/sonata_tasks.egg-info/SOURCES.txt +93 -0
  64. sonata_tasks-0.6.1/src/sonata_tasks.egg-info/dependency_links.txt +1 -0
  65. sonata_tasks-0.6.1/src/sonata_tasks.egg-info/requires.txt +22 -0
  66. sonata_tasks-0.6.1/src/sonata_tasks.egg-info/top_level.txt +1 -0
  67. sonata_tasks-0.6.1/tests/test_adapters.py +108 -0
  68. sonata_tasks-0.6.1/tests/test_archive.py +394 -0
  69. sonata_tasks-0.6.1/tests/test_bindings.py +33 -0
  70. sonata_tasks-0.6.1/tests/test_buildx.py +195 -0
  71. sonata_tasks-0.6.1/tests/test_command.py +158 -0
  72. sonata_tasks-0.6.1/tests/test_compensation.py +28 -0
  73. sonata_tasks-0.6.1/tests/test_compose.py +204 -0
  74. sonata_tasks-0.6.1/tests/test_composites.py +59 -0
  75. sonata_tasks-0.6.1/tests/test_cosign.py +365 -0
  76. sonata_tasks-0.6.1/tests/test_executors.py +129 -0
  77. sonata_tasks-0.6.1/tests/test_fingerprint.py +75 -0
  78. sonata_tasks-0.6.1/tests/test_gradle.py +98 -0
  79. sonata_tasks-0.6.1/tests/test_helm.py +183 -0
  80. sonata_tasks-0.6.1/tests/test_imagetools.py +191 -0
  81. sonata_tasks-0.6.1/tests/test_kubectl.py +113 -0
  82. sonata_tasks-0.6.1/tests/test_local_executor.py +82 -0
  83. sonata_tasks-0.6.1/tests/test_metrics.py +152 -0
  84. sonata_tasks-0.6.1/tests/test_models.py +55 -0
  85. sonata_tasks-0.6.1/tests/test_package_boundaries.py +40 -0
  86. sonata_tasks-0.6.1/tests/test_process.py +287 -0
  87. sonata_tasks-0.6.1/tests/test_prometheus.py +164 -0
  88. sonata_tasks-0.6.1/tests/test_public_api.py +20 -0
  89. sonata_tasks-0.6.1/tests/test_registry.py +126 -0
  90. sonata_tasks-0.6.1/tests/test_registry_tunnel.py +225 -0
  91. sonata_tasks-0.6.1/tests/test_shell.py +77 -0
  92. sonata_tasks-0.6.1/tests/test_skopeo.py +180 -0
  93. sonata_tasks-0.6.1/tests/test_syft.py +134 -0
  94. sonata_tasks-0.6.1/tests/test_tool_catalog.py +288 -0
  95. sonata_tasks-0.6.1/tests/test_transfer.py +73 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 miciav
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: sonata-tasks
3
+ Version: 0.6.1
4
+ Summary: Reusable task catalogue for the Sonata workflow engine
5
+ Author-email: miciav <5889596+miciav@users.noreply.github.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Nanofaas/sonata
8
+ Project-URL: Repository, https://github.com/Nanofaas/sonata
9
+ Project-URL: Issues, https://github.com/Nanofaas/sonata/issues
10
+ Keywords: workflow,tasks,catalogue,orchestration,devops
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Topic :: System :: Distributed Computing
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: sonata-engine==0.6.1
22
+ Provides-Extra: shell
23
+ Requires-Dist: subprocess-toolkit==0.6.0; extra == "shell"
24
+ Provides-Extra: prometheus
25
+ Requires-Dist: httpx>=0.27; extra == "prometheus"
26
+ Provides-Extra: multipass
27
+ Requires-Dist: multipass-vm-sdk==0.6.0; extra == "multipass"
28
+ Requires-Dist: pydantic>=2.9.2; extra == "multipass"
29
+ Requires-Dist: subprocess-toolkit==0.6.0; extra == "multipass"
30
+ Provides-Extra: azure
31
+ Requires-Dist: azure-vm-sdk==0.6.0; extra == "azure"
32
+ Requires-Dist: pydantic>=2.9.2; extra == "azure"
33
+ Requires-Dist: subprocess-toolkit==0.6.0; extra == "azure"
34
+ Provides-Extra: proxmox
35
+ Requires-Dist: proxmox-vm-sdk==0.6.0; extra == "proxmox"
36
+ Requires-Dist: pydantic>=2.9.2; extra == "proxmox"
37
+ Requires-Dist: subprocess-toolkit==0.6.0; extra == "proxmox"
38
+ Dynamic: license-file
39
+
40
+ # sonata-tasks
41
+
42
+ Reusable, product-independent tasks for `sonata-engine`. The base installation
43
+ contains command execution contracts and common command-line tool tasks.
44
+ Provider and transport integrations are installed through optional extras.
@@ -0,0 +1,5 @@
1
+ # sonata-tasks
2
+
3
+ Reusable, product-independent tasks for `sonata-engine`. The base installation
4
+ contains command execution contracts and common command-line tool tasks.
5
+ Provider and transport integrations are installed through optional extras.
@@ -0,0 +1,181 @@
1
+ [project]
2
+ name = "sonata-tasks"
3
+ license = "MIT"
4
+ license-files = ["LICENSE"]
5
+ version = "0.6.1"
6
+ description = "Reusable task catalogue for the Sonata workflow engine"
7
+ readme = "README.md"
8
+ requires-python = ">=3.12"
9
+ authors = [
10
+ { name = "miciav", email = "5889596+miciav@users.noreply.github.com" }
11
+ ]
12
+ keywords = ["workflow", "tasks", "catalogue", "orchestration", "devops"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Software Development :: Libraries :: Python Modules",
20
+ "Topic :: System :: Distributed Computing",
21
+ ]
22
+ dependencies = [
23
+ "sonata-engine==0.6.1",
24
+ ]
25
+
26
+ # PyPI shows a package page with no link home at all without these.
27
+ [project.urls]
28
+ Homepage = "https://github.com/Nanofaas/sonata"
29
+ Repository = "https://github.com/Nanofaas/sonata"
30
+ Issues = "https://github.com/Nanofaas/sonata/issues"
31
+
32
+ [project.optional-dependencies]
33
+ shell = [
34
+ "subprocess-toolkit==0.6.0",
35
+ ]
36
+ prometheus = ["httpx>=0.27"]
37
+ multipass = [
38
+ "multipass-vm-sdk==0.6.0",
39
+ "pydantic>=2.9.2",
40
+ "subprocess-toolkit==0.6.0",
41
+ ]
42
+ azure = [
43
+ "azure-vm-sdk==0.6.0",
44
+ "pydantic>=2.9.2",
45
+ "subprocess-toolkit==0.6.0",
46
+ ]
47
+ proxmox = [
48
+ "proxmox-vm-sdk==0.6.0",
49
+ "pydantic>=2.9.2",
50
+ "subprocess-toolkit==0.6.0",
51
+ ]
52
+
53
+ [build-system]
54
+ requires = ["setuptools>=77"]
55
+ build-backend = "setuptools.build_meta"
56
+
57
+ [tool.setuptools]
58
+ package-dir = {"" = "src"}
59
+
60
+ [tool.setuptools.packages.find]
61
+ where = ["src"]
62
+
63
+ [tool.setuptools.package-data]
64
+ sonata_tasks = ["py.typed"]
65
+
66
+ [dependency-groups]
67
+ dev = [
68
+ "basedpyright>=1.39.3",
69
+ "httpx>=0.27",
70
+ "grimp>=3.14",
71
+ "import-linter>=2.11",
72
+ "pytest>=9.0.3",
73
+ "pytest-cov>=6.0",
74
+ # Pinned exactly to match the ruff-pre-commit revision in
75
+ # .pre-commit-config.yaml. A newer ruff in either place makes the hook and a
76
+ # local `ruff check` disagree about what a violation is. Bump both together.
77
+ "ruff==0.16.6",
78
+ "bandit>=1.8",
79
+ "pre-commit>=4.0",
80
+ ]
81
+
82
+ [tool.pytest.ini_options]
83
+ testpaths = ["tests"]
84
+ # The coverage threshold lives in [tool.coverage.report] and is deliberately
85
+ # not repeated as --cov-fail-under here: stating it twice is how two values come
86
+ # to disagree, with the command-line flag silently winning.
87
+ addopts = "-q --cov=sonata_tasks --cov-report=term-missing"
88
+
89
+ # =============================================================================
90
+ # Ruff - linting + formatting
91
+ # =============================================================================
92
+ # Same rule set as the engine and the sibling projects.
93
+
94
+ [tool.ruff]
95
+ target-version = "py312"
96
+ line-length = 88
97
+ # See the same note in the engine's pyproject.toml: ruff does not exclude
98
+ # `build` by default, and a stale build tree must not be linted as source.
99
+ extend-exclude = ["build", "dist"]
100
+
101
+ [tool.ruff.lint]
102
+ select = [
103
+ "ANN", # flake8-annotations
104
+ "E", # pycodestyle errors
105
+ "W", # pycodestyle warnings
106
+ "F", # Pyflakes
107
+ "I", # isort
108
+ "N", # pep8-naming
109
+ "UP", # pyupgrade
110
+ "B", # flake8-bugbear
111
+ "C4", # flake8-comprehensions
112
+ "SIM", # flake8-simplify
113
+ "TID", # flake8-tidy-imports
114
+ "RUF", # Ruff-specific rules
115
+ "PERF", # Perflint
116
+ "PT", # flake8-pytest-style
117
+ "RET", # flake8-return
118
+ "PTH", # flake8-use-pathlib
119
+ "ERA", # eradicate - commented-out code
120
+ "D", # pydocstyle - this is a published package with a public API
121
+ ]
122
+ ignore = [
123
+ # Mutually exclusive summary conventions; see the same note in the engine's
124
+ # pyproject.toml.
125
+ "D203",
126
+ "D213",
127
+ "PT018",
128
+ ]
129
+
130
+ [tool.ruff.lint.isort]
131
+ # Declared rather than inferred. Ruff derives first-party packages from the
132
+ # project root, which changes with --config: run with this file as the config
133
+ # and `sonata_tasks` was inferred as third-party, so the blank line separating
134
+ # it from pytest was reported as an unsorted import, while running with the
135
+ # engine's config reported nothing. Naming the packages makes both invocations
136
+ # agree, and the catalogue legitimately imports both.
137
+ known-first-party = ["sonata_tasks", "sonata_engine"]
138
+
139
+ [tool.ruff.lint.per-file-ignores]
140
+ "tests/**/*.py" = [
141
+ "ANN",
142
+ "D100", "D101", "D102", "D103", "D104", "D105", "D107",
143
+ ]
144
+ "**/tests/**/*.py" = [
145
+ "ANN",
146
+ "D100", "D101", "D102", "D103", "D104", "D105", "D107",
147
+ ]
148
+
149
+ # =============================================================================
150
+ # Coverage
151
+ # =============================================================================
152
+
153
+ [tool.coverage.report]
154
+ show_missing = true
155
+ fail_under = 90
156
+
157
+ # =============================================================================
158
+ # basedpyright - type checking
159
+ # =============================================================================
160
+
161
+ [tool.basedpyright]
162
+ include = ["src/sonata_tasks", "tests"]
163
+ typeCheckingMode = "standard"
164
+ reportImplicitOverride = true
165
+ reportMissingTypeStubs = false
166
+ venvPath = "../.."
167
+ venv = ".venv"
168
+
169
+ # =============================================================================
170
+ # Bandit - security static analysis
171
+ # =============================================================================
172
+ # This package drives VMs and containers through external CLIs, so subprocess is
173
+ # its subject matter rather than a smell.
174
+
175
+ [tool.bandit]
176
+ exclude_dirs = ["tests", ".venv"]
177
+ skips = [
178
+ "B404",
179
+ "B603",
180
+ "B607",
181
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )
@@ -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"]