bitbucket-pipeline-runner 0.0.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.
Potentially problematic release.
This version of bitbucket-pipeline-runner might be problematic. Click here for more details.
- bitbucket_pipeline_runner-0.0.1/LICENSE +21 -0
- bitbucket_pipeline_runner-0.0.1/PKG-INFO +80 -0
- bitbucket_pipeline_runner-0.0.1/README.md +47 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/__init__.py +1 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/__main__.py +4 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/artifacts.py +89 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/cache.py +224 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/cli.py +207 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/config.py +92 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/container.py +507 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/context.py +172 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/errors.py +13 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/models.py +443 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/parse.py +15 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/py.typed +0 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/repository.py +133 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/runner.py +436 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/service.py +284 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/static/__init__.py +0 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/static/known_hosts +6 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/static/runit.sh +15 -0
- bitbucket_pipeline_runner-0.0.1/pipeline_runner/utils.py +166 -0
- bitbucket_pipeline_runner-0.0.1/pyproject.toml +112 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Mathieu Lemay
|
|
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,80 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: bitbucket-pipeline-runner
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Run a bitbucket pipeline locally
|
|
5
|
+
Home-page: https://github.com/mathieu-lemay/pipeline-runner
|
|
6
|
+
License: MIT
|
|
7
|
+
Author: Mathieu Lemay
|
|
8
|
+
Author-email: acidrain1@gmail.com
|
|
9
|
+
Requires-Python: >=3.10,<4.0
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Dist: GitPython (>=3.1.12,<4.0.0)
|
|
16
|
+
Requires-Dist: PyYAML (>=6.0,<7.0)
|
|
17
|
+
Requires-Dist: appdirs (>=1.4.4,<2.0.0)
|
|
18
|
+
Requires-Dist: boto3 (>=1.28.65,<2.0.0)
|
|
19
|
+
Requires-Dist: click (>=8.0.1,<9.0.0)
|
|
20
|
+
Requires-Dist: coloredlogs (>=15.0,<16.0)
|
|
21
|
+
Requires-Dist: cryptography (>=42.0.2,<43.0.0)
|
|
22
|
+
Requires-Dist: docker (>=7.0.0,<8.0.0)
|
|
23
|
+
Requires-Dist: pydantic (>=2.0,<3.0)
|
|
24
|
+
Requires-Dist: pydantic-settings (>=2.0.3,<3.0.0)
|
|
25
|
+
Requires-Dist: pyfzf (>=0.3.0,<0.4.0)
|
|
26
|
+
Requires-Dist: python-dotenv (>=1.0.0,<2.0.0)
|
|
27
|
+
Requires-Dist: python-slugify (>=8.0.0,<9.0.0)
|
|
28
|
+
Requires-Dist: requests (>=2.31.0,<3.0.0)
|
|
29
|
+
Requires-Dist: tenacity (>=8.0.1,<9.0.0)
|
|
30
|
+
Project-URL: Repository, https://github.com/mathieu-lemay/pipeline-runner
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# Bitbucket Pipeline Runner
|
|
34
|
+
|
|
35
|
+
Tool to run Bitbucket Pipelines locally.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
```shell
|
|
39
|
+
pip install bitbucket-pipeline-runner
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Basic usage
|
|
43
|
+
To run a pipeline
|
|
44
|
+
```shell
|
|
45
|
+
cd <project-directory>
|
|
46
|
+
pipeline-runner run <pipeline-name>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
To list available pipelines
|
|
50
|
+
```shell
|
|
51
|
+
cd <project-directory>
|
|
52
|
+
pipeline-runner list
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Environment variables
|
|
56
|
+
bitbucket pipeline runner already sets all `BITBUCKET_*` enviromnent variables in the step's run enviromnent.
|
|
57
|
+
It will also source any `.env` file in the current directory, for all project specific enviromnent variables.
|
|
58
|
+
|
|
59
|
+
## Artifacts and logs
|
|
60
|
+
Persistent data like artifacts generated from your pipelines and execution logs can be found in your user's data directory.
|
|
61
|
+
|
|
62
|
+
On Linux:
|
|
63
|
+
|
|
64
|
+
${XDG_DATA_HOME:-~/.local/share}/pipeline-runner
|
|
65
|
+
|
|
66
|
+
On macOS:
|
|
67
|
+
|
|
68
|
+
~/Library/Application Support/pipeline-runner
|
|
69
|
+
|
|
70
|
+
## Caches
|
|
71
|
+
Caches defined in your pipelines are stored in your user's cache directory.
|
|
72
|
+
|
|
73
|
+
On Linux:
|
|
74
|
+
|
|
75
|
+
${XDG_CACHE_HOME:-~/.cache}/pipeline-runner
|
|
76
|
+
|
|
77
|
+
On macOS:
|
|
78
|
+
|
|
79
|
+
~/Library/Caches/pipeline-runner
|
|
80
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Bitbucket Pipeline Runner
|
|
2
|
+
|
|
3
|
+
Tool to run Bitbucket Pipelines locally.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
```shell
|
|
7
|
+
pip install bitbucket-pipeline-runner
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Basic usage
|
|
11
|
+
To run a pipeline
|
|
12
|
+
```shell
|
|
13
|
+
cd <project-directory>
|
|
14
|
+
pipeline-runner run <pipeline-name>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
To list available pipelines
|
|
18
|
+
```shell
|
|
19
|
+
cd <project-directory>
|
|
20
|
+
pipeline-runner list
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Environment variables
|
|
24
|
+
bitbucket pipeline runner already sets all `BITBUCKET_*` enviromnent variables in the step's run enviromnent.
|
|
25
|
+
It will also source any `.env` file in the current directory, for all project specific enviromnent variables.
|
|
26
|
+
|
|
27
|
+
## Artifacts and logs
|
|
28
|
+
Persistent data like artifacts generated from your pipelines and execution logs can be found in your user's data directory.
|
|
29
|
+
|
|
30
|
+
On Linux:
|
|
31
|
+
|
|
32
|
+
${XDG_DATA_HOME:-~/.local/share}/pipeline-runner
|
|
33
|
+
|
|
34
|
+
On macOS:
|
|
35
|
+
|
|
36
|
+
~/Library/Application Support/pipeline-runner
|
|
37
|
+
|
|
38
|
+
## Caches
|
|
39
|
+
Caches defined in your pipelines are stored in your user's cache directory.
|
|
40
|
+
|
|
41
|
+
On Linux:
|
|
42
|
+
|
|
43
|
+
${XDG_CACHE_HOME:-~/.cache}/pipeline-runner
|
|
44
|
+
|
|
45
|
+
On macOS:
|
|
46
|
+
|
|
47
|
+
~/Library/Caches/pipeline-runner
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
APP_NAME = "pipeline-runner"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import logging
|
|
3
|
+
import os.path
|
|
4
|
+
import tarfile
|
|
5
|
+
from tarfile import TarInfo
|
|
6
|
+
from time import time as ts
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from .config import config
|
|
10
|
+
from .container import ContainerRunner
|
|
11
|
+
from .utils import FileStreamer, get_human_readable_size, safe_extract_tar
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ArtifactManager:
|
|
17
|
+
def __init__(self, container: ContainerRunner, artifact_directory: str, step_uuid: UUID) -> None:
|
|
18
|
+
self._container = container
|
|
19
|
+
self._artifact_directory = artifact_directory
|
|
20
|
+
self._step_uuid = step_uuid
|
|
21
|
+
|
|
22
|
+
def upload(self) -> None:
|
|
23
|
+
logger.info("Loading artifacts")
|
|
24
|
+
|
|
25
|
+
t = ts()
|
|
26
|
+
|
|
27
|
+
tar_data = io.BytesIO()
|
|
28
|
+
|
|
29
|
+
with tarfile.open(fileobj=tar_data, mode="w|") as tar:
|
|
30
|
+
for root, _, files in os.walk(self._artifact_directory):
|
|
31
|
+
for af in files:
|
|
32
|
+
full_path = os.path.join(root, af)
|
|
33
|
+
|
|
34
|
+
relpath = os.path.relpath(full_path, self._artifact_directory)
|
|
35
|
+
ti = TarInfo(relpath)
|
|
36
|
+
|
|
37
|
+
stat = os.stat(full_path)
|
|
38
|
+
ti.size = stat.st_size
|
|
39
|
+
ti.mode = stat.st_mode
|
|
40
|
+
|
|
41
|
+
with open(full_path, "rb") as f:
|
|
42
|
+
tar.addfile(ti, f)
|
|
43
|
+
|
|
44
|
+
res = self._container.put_archive(config.build_dir, tar_data.getvalue())
|
|
45
|
+
if not res:
|
|
46
|
+
raise Exception(f"Error loading artifact: {af}")
|
|
47
|
+
|
|
48
|
+
t = ts() - t
|
|
49
|
+
|
|
50
|
+
logger.info("Artifacts loaded in %.3fs", t)
|
|
51
|
+
|
|
52
|
+
def download(self, artifacts: list[str]) -> None:
|
|
53
|
+
if not artifacts:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
artifact_file = f"artifacts-{self._step_uuid}.tar"
|
|
57
|
+
artifact_remote_path = os.path.join(config.build_dir, artifact_file)
|
|
58
|
+
artifact_local_directory = self._artifact_directory
|
|
59
|
+
|
|
60
|
+
logger.info("Collecting artifacts")
|
|
61
|
+
|
|
62
|
+
t = ts()
|
|
63
|
+
|
|
64
|
+
path_filters = " -o ".join(f"-path './{a}'" for a in artifacts)
|
|
65
|
+
prepare_artifacts_cmd = ["find", "-type", "f", r"\(", path_filters, r"\)"]
|
|
66
|
+
prepare_artifacts_cmd += ["|", "tar", "cf", artifact_file, "-C", config.build_dir, "-T", "-"]
|
|
67
|
+
|
|
68
|
+
self._container.run_command(prepare_artifacts_cmd)
|
|
69
|
+
if not self._container.path_exists(artifact_remote_path):
|
|
70
|
+
logger.info("No artifacts found. Skipping")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
data, stats = self._container.get_archive(artifact_remote_path, encode_stream=True)
|
|
74
|
+
logger.debug("artifacts stats: %s", stats)
|
|
75
|
+
|
|
76
|
+
# FileStreamer only implements `read` which is all that is needed.
|
|
77
|
+
with tarfile.open(fileobj=FileStreamer(data), mode="r|") as wrapper_tar: # type: ignore[abstract]
|
|
78
|
+
for entry in wrapper_tar:
|
|
79
|
+
with tarfile.open(fileobj=wrapper_tar.extractfile(entry), mode="r|") as tar:
|
|
80
|
+
safe_extract_tar(tar, artifact_local_directory)
|
|
81
|
+
|
|
82
|
+
t = ts() - t
|
|
83
|
+
|
|
84
|
+
logger.info(
|
|
85
|
+
"Artifacts saved %s to %s in %.3fs",
|
|
86
|
+
get_human_readable_size(stats["size"]),
|
|
87
|
+
artifact_local_directory,
|
|
88
|
+
t,
|
|
89
|
+
)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os.path
|
|
3
|
+
from tempfile import NamedTemporaryFile
|
|
4
|
+
from time import time as ts
|
|
5
|
+
|
|
6
|
+
from . import utils
|
|
7
|
+
from .config import config
|
|
8
|
+
from .container import ContainerRunner
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
DOCKER_IMAGES_ARCHIVE_FILE_NAME = "images.tar"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CacheManager:
|
|
16
|
+
def __init__(self, container: ContainerRunner, cache_directory: str, cache_definitions: dict[str, str]) -> None:
|
|
17
|
+
self._container = container
|
|
18
|
+
self._cache_directory = cache_directory
|
|
19
|
+
self._cache_definitions = cache_definitions
|
|
20
|
+
|
|
21
|
+
self._ignored_caches = {"docker"}
|
|
22
|
+
|
|
23
|
+
def upload(self, cache_names: list[str]) -> None:
|
|
24
|
+
for name in cache_names:
|
|
25
|
+
cu = CacheRestoreFactory.get(self._container, self._cache_directory, self._cache_definitions, name)
|
|
26
|
+
cu.restore()
|
|
27
|
+
|
|
28
|
+
def download(self, cache_names: list[str]) -> None:
|
|
29
|
+
for name in cache_names:
|
|
30
|
+
cd = CacheSaveFactory.get(self._container, self._cache_directory, self._cache_definitions, name)
|
|
31
|
+
cd.save()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CacheRestore:
|
|
35
|
+
def __init__(
|
|
36
|
+
self, container: ContainerRunner, cache_directory: str, cache_definitions: dict[str, str], cache_name: str
|
|
37
|
+
) -> None:
|
|
38
|
+
self._container = container
|
|
39
|
+
self._cache_directory = cache_directory
|
|
40
|
+
self._cache_definitions = cache_definitions
|
|
41
|
+
self._cache_name = cache_name
|
|
42
|
+
|
|
43
|
+
def restore(self) -> None:
|
|
44
|
+
cache_file = self._get_local_cache_file()
|
|
45
|
+
|
|
46
|
+
if not cache_file:
|
|
47
|
+
logger.info("Cache '%s': Not found: Skipping", self._cache_name)
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
self._upload_cache(cache_file)
|
|
51
|
+
self._restore_cache()
|
|
52
|
+
|
|
53
|
+
def _get_local_cache_file(self) -> str | None:
|
|
54
|
+
local_cache_archive_path = get_local_cache_archive_path(self._cache_directory, self._cache_name)
|
|
55
|
+
if not os.path.exists(local_cache_archive_path):
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
return local_cache_archive_path
|
|
59
|
+
|
|
60
|
+
def _upload_cache(self, cache_file: str) -> None:
|
|
61
|
+
remote_cache_directory = get_remote_temp_directory(self._cache_name)
|
|
62
|
+
remote_cache_parent_directory = os.path.dirname(remote_cache_directory)
|
|
63
|
+
|
|
64
|
+
cache_archive_size = os.path.getsize(cache_file)
|
|
65
|
+
|
|
66
|
+
logger.info("Cache '%s': Uploading", self._cache_name)
|
|
67
|
+
|
|
68
|
+
t = ts()
|
|
69
|
+
|
|
70
|
+
prepare_cache_dir_cmd = (
|
|
71
|
+
f'[ -d "{remote_cache_directory}" ] && rm -rf "{remote_cache_directory}"; '
|
|
72
|
+
f'mkdir -p "{remote_cache_parent_directory}"'
|
|
73
|
+
)
|
|
74
|
+
res, output = self._container.run_command(prepare_cache_dir_cmd)
|
|
75
|
+
if res != 0:
|
|
76
|
+
logger.error("Remote command failed: %s", output.decode())
|
|
77
|
+
raise Exception(f"Error uploading cache: {self._cache_name}")
|
|
78
|
+
|
|
79
|
+
with open(cache_file, "rb") as f:
|
|
80
|
+
success = self._container.put_archive(remote_cache_parent_directory, f)
|
|
81
|
+
if not success:
|
|
82
|
+
raise Exception(f"Error uploading cache: {self._cache_name}")
|
|
83
|
+
|
|
84
|
+
t = ts() - t
|
|
85
|
+
|
|
86
|
+
logger.info(
|
|
87
|
+
"Cache '%s': Uploaded %s in %.3fs", self._cache_name, utils.get_human_readable_size(cache_archive_size), t
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _restore_cache(self) -> None:
|
|
91
|
+
temp_dir = get_remote_temp_directory(self._cache_name)
|
|
92
|
+
target_dir = sanitize_remote_path(self._cache_definitions[self._cache_name])
|
|
93
|
+
|
|
94
|
+
logger.info("Cache '%s': Restoring", self._cache_name)
|
|
95
|
+
|
|
96
|
+
t = ts()
|
|
97
|
+
|
|
98
|
+
restore_cache_script = [
|
|
99
|
+
f'if [ -e "{target_dir}" ]; then rm -rf "{target_dir}"; fi',
|
|
100
|
+
f'mkdir -p "$(dirname "{target_dir}")"',
|
|
101
|
+
f'mv "{temp_dir}" "{target_dir}"',
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
exit_code, output = self._container.run_command("\n".join(restore_cache_script))
|
|
105
|
+
if exit_code != 0:
|
|
106
|
+
raise Exception(f"Error restoring cache: {self._cache_name}: {output.decode()}")
|
|
107
|
+
|
|
108
|
+
t = ts() - t
|
|
109
|
+
|
|
110
|
+
logger.info("Cache '%s': Restored in %.3fs", self._cache_name, t)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class NullCacheRestore(CacheRestore):
|
|
114
|
+
def restore(self) -> None:
|
|
115
|
+
logger.info("Cache '%s': Ignoring", self._cache_name)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class CacheRestoreFactory:
|
|
119
|
+
@staticmethod
|
|
120
|
+
def get(
|
|
121
|
+
container: ContainerRunner, cache_directory: str, cache_definitions: dict[str, str], cache_name: str
|
|
122
|
+
) -> CacheRestore:
|
|
123
|
+
cls: type[CacheRestore | NullCacheRestore]
|
|
124
|
+
|
|
125
|
+
cls = NullCacheRestore if cache_name == "docker" else CacheRestore
|
|
126
|
+
|
|
127
|
+
return cls(container, cache_directory, cache_definitions, cache_name)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class CacheSave:
|
|
131
|
+
def __init__(
|
|
132
|
+
self, container: ContainerRunner, cache_directory: str, cache_definitions: dict[str, str], cache_name: str
|
|
133
|
+
) -> None:
|
|
134
|
+
self._container = container
|
|
135
|
+
self._cache_directory = cache_directory
|
|
136
|
+
self._cache_definitions = cache_definitions
|
|
137
|
+
self._cache_name = cache_name
|
|
138
|
+
|
|
139
|
+
def save(self) -> None:
|
|
140
|
+
remote_cache_directory = self._prepare()
|
|
141
|
+
|
|
142
|
+
local_cache_archive_path = get_local_cache_archive_path(self._cache_directory, self._cache_name)
|
|
143
|
+
self._download(remote_cache_directory, local_cache_archive_path)
|
|
144
|
+
|
|
145
|
+
def _prepare(self) -> str:
|
|
146
|
+
remote_dir = sanitize_remote_path(self._cache_definitions[self._cache_name])
|
|
147
|
+
target_dir = get_remote_temp_directory(self._cache_name)
|
|
148
|
+
|
|
149
|
+
logger.info("Cache '%s': Preparing", self._cache_name)
|
|
150
|
+
|
|
151
|
+
t = ts()
|
|
152
|
+
|
|
153
|
+
prepare_cache_cmd = f'if [ -e "{remote_dir}" ]; then mv "{remote_dir}" "{target_dir}"; fi'
|
|
154
|
+
|
|
155
|
+
exit_code, output = self._container.run_command(prepare_cache_cmd)
|
|
156
|
+
if exit_code != 0:
|
|
157
|
+
raise Exception(f"Error preparing cache: {self._cache_name}: {output.decode()}")
|
|
158
|
+
|
|
159
|
+
t = ts() - t
|
|
160
|
+
|
|
161
|
+
logger.info("Cache '%s': Prepared in %.3fs", self._cache_name, t)
|
|
162
|
+
|
|
163
|
+
return target_dir
|
|
164
|
+
|
|
165
|
+
def _download(self, src: str, dst: str) -> None:
|
|
166
|
+
if not self._container.path_exists(src):
|
|
167
|
+
logger.info("Cache '%s': Not found", self._cache_name)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
logger.info("Cache '%s': Downloading", self._cache_name)
|
|
171
|
+
|
|
172
|
+
t = ts()
|
|
173
|
+
|
|
174
|
+
with NamedTemporaryFile(dir=self._cache_directory, delete=False) as f:
|
|
175
|
+
try:
|
|
176
|
+
logger.debug("Downloading cache folder '%s' to '%s'", src, f.name)
|
|
177
|
+
data, _ = self._container.get_archive(src)
|
|
178
|
+
size = 0
|
|
179
|
+
for chunk in data:
|
|
180
|
+
size += len(chunk)
|
|
181
|
+
f.write(chunk)
|
|
182
|
+
except Exception:
|
|
183
|
+
logger.exception("Error getting cache from container: %s", self._cache_name)
|
|
184
|
+
os.unlink(f.name)
|
|
185
|
+
return
|
|
186
|
+
else:
|
|
187
|
+
logger.debug("Moving temp cache archive %s to %s", f.name, dst)
|
|
188
|
+
os.rename(f.name, dst)
|
|
189
|
+
|
|
190
|
+
t = ts() - t
|
|
191
|
+
|
|
192
|
+
logger.info("Cache '%s': Downloaded %s in %.3fs", self._cache_name, utils.get_human_readable_size(size), t)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class NullCacheSave(CacheSave):
|
|
196
|
+
def save(self) -> None:
|
|
197
|
+
logger.info("Cache '%s': Ignoring", self._cache_name)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class CacheSaveFactory:
|
|
201
|
+
@staticmethod
|
|
202
|
+
def get(
|
|
203
|
+
container: ContainerRunner, cache_directory: str, cache_definitions: dict[str, str], cache_name: str
|
|
204
|
+
) -> CacheSave:
|
|
205
|
+
cls: type[CacheSave | NullCacheSave]
|
|
206
|
+
|
|
207
|
+
cls = NullCacheSave if cache_name == "docker" else CacheSave
|
|
208
|
+
|
|
209
|
+
return cls(container, cache_directory, cache_definitions, cache_name)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_local_cache_archive_path(cache_directory: str, cache_name: str) -> str:
|
|
213
|
+
return os.path.join(cache_directory, f"{cache_name}.tar")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def get_remote_temp_directory(cache_name: str) -> str:
|
|
217
|
+
return os.path.join(config.caches_dir, cache_name)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def sanitize_remote_path(path: str) -> str:
|
|
221
|
+
if path.startswith("~"):
|
|
222
|
+
path = path.replace("~", "$HOME", 1)
|
|
223
|
+
|
|
224
|
+
return path
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import logging.config
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from pyfzf import FzfPrompt # type: ignore[import-untyped]
|
|
10
|
+
|
|
11
|
+
from pipeline_runner.errors import InvalidPipelineError
|
|
12
|
+
|
|
13
|
+
from . import utils
|
|
14
|
+
from .config import config
|
|
15
|
+
from .parse import parse_pipeline_file
|
|
16
|
+
from .runner import PipelineRunner, PipelineRunRequest
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _init_logger() -> None:
|
|
22
|
+
logging.config.dictConfig(config.log_config)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_pipelines_list(pipeline_file: str) -> list[str]:
|
|
26
|
+
pipelines_definition = parse_pipeline_file(pipeline_file)
|
|
27
|
+
|
|
28
|
+
return pipelines_definition.get_available_pipelines()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _prompt_for_pipeline(pipeline_file: str) -> str | None:
|
|
32
|
+
pipeline = None
|
|
33
|
+
pipelines = _get_pipelines_list(pipeline_file)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
fzf = FzfPrompt()
|
|
37
|
+
pipeline = next(iter(fzf.prompt(pipelines)), None)
|
|
38
|
+
if not pipeline:
|
|
39
|
+
logger.warning("No pipeline selected")
|
|
40
|
+
except SystemError:
|
|
41
|
+
logger.warning("fzf executable not found, disabling interactive pipeline selection.")
|
|
42
|
+
|
|
43
|
+
return pipeline
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@click.group("Pipeline Runner", invoke_without_command=True)
|
|
47
|
+
@click.option(
|
|
48
|
+
"-V",
|
|
49
|
+
"--version",
|
|
50
|
+
"show_version",
|
|
51
|
+
is_flag=True,
|
|
52
|
+
help="Print project version and exit.",
|
|
53
|
+
)
|
|
54
|
+
@click.pass_context
|
|
55
|
+
def main(ctx: click.Context, *, show_version: bool) -> None:
|
|
56
|
+
if show_version:
|
|
57
|
+
click.echo(f"Pipeline Runner {version('bitbucket_pipeline_runner')}")
|
|
58
|
+
ctx.exit()
|
|
59
|
+
|
|
60
|
+
if not ctx.invoked_subcommand:
|
|
61
|
+
click.echo(ctx.get_help())
|
|
62
|
+
ctx.exit(1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@main.command()
|
|
66
|
+
@click.argument("pipeline", required=False)
|
|
67
|
+
@click.option(
|
|
68
|
+
"-r",
|
|
69
|
+
"--repository-path",
|
|
70
|
+
help="Path to the git repository. Defaults to current directory.",
|
|
71
|
+
)
|
|
72
|
+
@click.option(
|
|
73
|
+
"-s",
|
|
74
|
+
"--step",
|
|
75
|
+
"steps",
|
|
76
|
+
multiple=True,
|
|
77
|
+
help="Steps to run. If none are specified, they will all be run. Can be specified multiple times.",
|
|
78
|
+
)
|
|
79
|
+
@click.option(
|
|
80
|
+
"-e",
|
|
81
|
+
"--env-file",
|
|
82
|
+
"env_files",
|
|
83
|
+
multiple=True,
|
|
84
|
+
help="Read in a file of environment variables. Can be specified multiple times.",
|
|
85
|
+
)
|
|
86
|
+
@click.option(
|
|
87
|
+
"-c",
|
|
88
|
+
"--color/--no-color",
|
|
89
|
+
default=True,
|
|
90
|
+
help="Enable colored output. Default: True",
|
|
91
|
+
)
|
|
92
|
+
@click.option(
|
|
93
|
+
"--cpu-limits/--no-cpu-limits",
|
|
94
|
+
default=False,
|
|
95
|
+
help="Enable to enforce cpu limits for the runner. Default: False",
|
|
96
|
+
)
|
|
97
|
+
@click.option(
|
|
98
|
+
"--ssh/--no-ssh",
|
|
99
|
+
"expose_ssh_agent",
|
|
100
|
+
default=False,
|
|
101
|
+
help="Expose the local ssh agent to the container. Default: False",
|
|
102
|
+
)
|
|
103
|
+
def run(
|
|
104
|
+
pipeline: str | None,
|
|
105
|
+
repository_path: str,
|
|
106
|
+
steps: list[str],
|
|
107
|
+
env_files: list[str],
|
|
108
|
+
*,
|
|
109
|
+
color: bool,
|
|
110
|
+
cpu_limits: bool,
|
|
111
|
+
expose_ssh_agent: bool,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""
|
|
114
|
+
Runs the pipeline PIPELINE.
|
|
115
|
+
|
|
116
|
+
PIPELINE is the full path to the pipeline to run. Ex: branches.master
|
|
117
|
+
"""
|
|
118
|
+
config.color = color
|
|
119
|
+
config.cpu_limits = cpu_limits
|
|
120
|
+
config.expose_ssh_agent = expose_ssh_agent
|
|
121
|
+
|
|
122
|
+
_init_logger()
|
|
123
|
+
|
|
124
|
+
if not pipeline:
|
|
125
|
+
pipeline = _prompt_for_pipeline(os.path.join(repository_path or ".", "bitbucket-pipelines.yml"))
|
|
126
|
+
|
|
127
|
+
if not pipeline:
|
|
128
|
+
logger.error("pipeline not specified")
|
|
129
|
+
sys.exit(2)
|
|
130
|
+
|
|
131
|
+
req = PipelineRunRequest(pipeline, repository_path, steps, env_files)
|
|
132
|
+
|
|
133
|
+
runner = PipelineRunner(req)
|
|
134
|
+
try:
|
|
135
|
+
runner.run()
|
|
136
|
+
except Exception:
|
|
137
|
+
logger.exception("Error running pipeline")
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@main.command("list")
|
|
142
|
+
@click.option(
|
|
143
|
+
"-r",
|
|
144
|
+
"--repository-path",
|
|
145
|
+
help="Path to the git repository. Defaults to current directory.",
|
|
146
|
+
)
|
|
147
|
+
@click.option(
|
|
148
|
+
"-c",
|
|
149
|
+
"--color/--no-color",
|
|
150
|
+
default=True,
|
|
151
|
+
help="Enable colored output",
|
|
152
|
+
)
|
|
153
|
+
def list_(repository_path: str, *, color: bool) -> None:
|
|
154
|
+
"""
|
|
155
|
+
List the available pipelines.
|
|
156
|
+
"""
|
|
157
|
+
config.color = color
|
|
158
|
+
|
|
159
|
+
_init_logger()
|
|
160
|
+
|
|
161
|
+
pipelines = _get_pipelines_list(os.path.join(repository_path or ".", "bitbucket-pipelines.yml"))
|
|
162
|
+
|
|
163
|
+
logger.info("Available pipelines:\n\t%s", "\n\t".join(sorted(pipelines)))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@main.command()
|
|
167
|
+
@click.argument("pipeline", required=False)
|
|
168
|
+
@click.option(
|
|
169
|
+
"-r",
|
|
170
|
+
"--repository-path",
|
|
171
|
+
help="Path to the git repository. Defaults to current directory.",
|
|
172
|
+
)
|
|
173
|
+
def parse(pipeline: str | None, repository_path: str) -> None:
|
|
174
|
+
"""
|
|
175
|
+
Parse the pipeline file.
|
|
176
|
+
"""
|
|
177
|
+
pipeline_file = os.path.join(repository_path or ".", "bitbucket-pipelines.yml")
|
|
178
|
+
|
|
179
|
+
pipelines_definition = parse_pipeline_file(pipeline_file)
|
|
180
|
+
|
|
181
|
+
if pipeline:
|
|
182
|
+
parsed = pipelines_definition.get_pipeline(pipeline)
|
|
183
|
+
if not parsed:
|
|
184
|
+
raise InvalidPipelineError(pipeline)
|
|
185
|
+
click.echo(parsed.model_dump_json(indent=2))
|
|
186
|
+
else:
|
|
187
|
+
click.echo(pipelines_definition.model_dump_json(indent=2))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@main.command()
|
|
191
|
+
@click.argument("action", type=click.Choice(["clear", "list"]))
|
|
192
|
+
def cache(action: str) -> None:
|
|
193
|
+
cache_dir = utils.get_cache_directory()
|
|
194
|
+
if not os.path.isdir(cache_dir):
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
projects = sorted(os.listdir(cache_dir))
|
|
198
|
+
if action == "list":
|
|
199
|
+
click.echo("Caches:")
|
|
200
|
+
click.echo("\n".join(f"\t{p}" for p in projects))
|
|
201
|
+
elif action == "clear":
|
|
202
|
+
for p in projects:
|
|
203
|
+
shutil.rmtree(os.path.join(cache_dir, p))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
main()
|