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.
- sonata_tasks-0.6.1/LICENSE +21 -0
- sonata_tasks-0.6.1/PKG-INFO +44 -0
- sonata_tasks-0.6.1/README.md +5 -0
- sonata_tasks-0.6.1/pyproject.toml +181 -0
- sonata_tasks-0.6.1/setup.cfg +4 -0
- sonata_tasks-0.6.1/src/sonata_tasks/__init__.py +6 -0
- sonata_tasks-0.6.1/src/sonata_tasks/ansible.py +83 -0
- sonata_tasks-0.6.1/src/sonata_tasks/archive.py +147 -0
- sonata_tasks-0.6.1/src/sonata_tasks/buildx.py +139 -0
- sonata_tasks-0.6.1/src/sonata_tasks/command.py +5 -0
- sonata_tasks-0.6.1/src/sonata_tasks/compensation.py +54 -0
- sonata_tasks-0.6.1/src/sonata_tasks/compose.py +178 -0
- sonata_tasks-0.6.1/src/sonata_tasks/composites.py +59 -0
- sonata_tasks-0.6.1/src/sonata_tasks/core/__init__.py +5 -0
- sonata_tasks-0.6.1/src/sonata_tasks/core/command.py +149 -0
- sonata_tasks-0.6.1/src/sonata_tasks/core/fingerprint.py +62 -0
- sonata_tasks-0.6.1/src/sonata_tasks/cosign.py +172 -0
- sonata_tasks-0.6.1/src/sonata_tasks/docker.py +122 -0
- sonata_tasks-0.6.1/src/sonata_tasks/errors.py +31 -0
- sonata_tasks-0.6.1/src/sonata_tasks/execution/__init__.py +5 -0
- sonata_tasks-0.6.1/src/sonata_tasks/execution/adapters.py +128 -0
- sonata_tasks-0.6.1/src/sonata_tasks/execution/bindings.py +55 -0
- sonata_tasks-0.6.1/src/sonata_tasks/execution/local.py +120 -0
- sonata_tasks-0.6.1/src/sonata_tasks/execution/models.py +132 -0
- sonata_tasks-0.6.1/src/sonata_tasks/execution/ports.py +86 -0
- sonata_tasks-0.6.1/src/sonata_tasks/execution/roles.py +3 -0
- sonata_tasks-0.6.1/src/sonata_tasks/gradle.py +51 -0
- sonata_tasks-0.6.1/src/sonata_tasks/helm.py +122 -0
- sonata_tasks-0.6.1/src/sonata_tasks/http.py +103 -0
- sonata_tasks-0.6.1/src/sonata_tasks/imagetools.py +85 -0
- sonata_tasks-0.6.1/src/sonata_tasks/k6.py +157 -0
- sonata_tasks-0.6.1/src/sonata_tasks/k6_models.py +50 -0
- sonata_tasks-0.6.1/src/sonata_tasks/kubectl.py +128 -0
- sonata_tasks-0.6.1/src/sonata_tasks/metrics.py +209 -0
- sonata_tasks-0.6.1/src/sonata_tasks/process.py +161 -0
- sonata_tasks-0.6.1/src/sonata_tasks/prometheus.py +196 -0
- sonata_tasks-0.6.1/src/sonata_tasks/py.typed +0 -0
- sonata_tasks-0.6.1/src/sonata_tasks/registry.py +193 -0
- sonata_tasks-0.6.1/src/sonata_tasks/registry_tunnel.py +126 -0
- sonata_tasks-0.6.1/src/sonata_tasks/shell.py +42 -0
- sonata_tasks-0.6.1/src/sonata_tasks/skopeo.py +91 -0
- sonata_tasks-0.6.1/src/sonata_tasks/syft.py +66 -0
- sonata_tasks-0.6.1/src/sonata_tasks/tasks/__init__.py +1 -0
- sonata_tasks-0.6.1/src/sonata_tasks/tasks/executors.py +22 -0
- sonata_tasks-0.6.1/src/sonata_tasks/tasks/models.py +10 -0
- sonata_tasks-0.6.1/src/sonata_tasks/testing.py +38 -0
- sonata_tasks-0.6.1/src/sonata_tasks/transfer.py +88 -0
- sonata_tasks-0.6.1/src/sonata_tasks/vm/__init__.py +53 -0
- sonata_tasks-0.6.1/src/sonata_tasks/vm/adapters.py +103 -0
- sonata_tasks-0.6.1/src/sonata_tasks/vm/azure.py +16 -0
- sonata_tasks-0.6.1/src/sonata_tasks/vm/models.py +82 -0
- sonata_tasks-0.6.1/src/sonata_tasks/vm/multipass.py +31 -0
- sonata_tasks-0.6.1/src/sonata_tasks/vm/ports.py +65 -0
- sonata_tasks-0.6.1/src/sonata_tasks/vm/providers/__init__.py +1 -0
- sonata_tasks-0.6.1/src/sonata_tasks/vm/providers/azure.py +429 -0
- sonata_tasks-0.6.1/src/sonata_tasks/vm/providers/multipass.py +352 -0
- sonata_tasks-0.6.1/src/sonata_tasks/vm/providers/proxmox.py +567 -0
- sonata_tasks-0.6.1/src/sonata_tasks/vm/proxmox.py +16 -0
- sonata_tasks-0.6.1/src/sonata_tasks/vm/results.py +14 -0
- sonata_tasks-0.6.1/src/sonata_tasks/vm/ssh.py +50 -0
- sonata_tasks-0.6.1/src/sonata_tasks/vm/tasks.py +50 -0
- sonata_tasks-0.6.1/src/sonata_tasks.egg-info/PKG-INFO +44 -0
- sonata_tasks-0.6.1/src/sonata_tasks.egg-info/SOURCES.txt +93 -0
- sonata_tasks-0.6.1/src/sonata_tasks.egg-info/dependency_links.txt +1 -0
- sonata_tasks-0.6.1/src/sonata_tasks.egg-info/requires.txt +22 -0
- sonata_tasks-0.6.1/src/sonata_tasks.egg-info/top_level.txt +1 -0
- sonata_tasks-0.6.1/tests/test_adapters.py +108 -0
- sonata_tasks-0.6.1/tests/test_archive.py +394 -0
- sonata_tasks-0.6.1/tests/test_bindings.py +33 -0
- sonata_tasks-0.6.1/tests/test_buildx.py +195 -0
- sonata_tasks-0.6.1/tests/test_command.py +158 -0
- sonata_tasks-0.6.1/tests/test_compensation.py +28 -0
- sonata_tasks-0.6.1/tests/test_compose.py +204 -0
- sonata_tasks-0.6.1/tests/test_composites.py +59 -0
- sonata_tasks-0.6.1/tests/test_cosign.py +365 -0
- sonata_tasks-0.6.1/tests/test_executors.py +129 -0
- sonata_tasks-0.6.1/tests/test_fingerprint.py +75 -0
- sonata_tasks-0.6.1/tests/test_gradle.py +98 -0
- sonata_tasks-0.6.1/tests/test_helm.py +183 -0
- sonata_tasks-0.6.1/tests/test_imagetools.py +191 -0
- sonata_tasks-0.6.1/tests/test_kubectl.py +113 -0
- sonata_tasks-0.6.1/tests/test_local_executor.py +82 -0
- sonata_tasks-0.6.1/tests/test_metrics.py +152 -0
- sonata_tasks-0.6.1/tests/test_models.py +55 -0
- sonata_tasks-0.6.1/tests/test_package_boundaries.py +40 -0
- sonata_tasks-0.6.1/tests/test_process.py +287 -0
- sonata_tasks-0.6.1/tests/test_prometheus.py +164 -0
- sonata_tasks-0.6.1/tests/test_public_api.py +20 -0
- sonata_tasks-0.6.1/tests/test_registry.py +126 -0
- sonata_tasks-0.6.1/tests/test_registry_tunnel.py +225 -0
- sonata_tasks-0.6.1/tests/test_shell.py +77 -0
- sonata_tasks-0.6.1/tests/test_skopeo.py +180 -0
- sonata_tasks-0.6.1/tests/test_syft.py +134 -0
- sonata_tasks-0.6.1/tests/test_tool_catalog.py +288 -0
- 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,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,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
|
+
)
|