xflow.framework 0.1.0__tar.gz → 0.1.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.
- xflow_framework-0.1.1/PKG-INFO +29 -0
- xflow_framework-0.1.1/pyproject.toml +45 -0
- xflow_framework-0.1.1/setup.py +32 -0
- xflow_framework-0.1.1/tests/test_cli.py +137 -0
- xflow_framework-0.1.1/tests/test_command_result.py +39 -0
- xflow_framework-0.1.1/tests/test_container.py +41 -0
- xflow_framework-0.1.1/tests/test_env.py +126 -0
- xflow_framework-0.1.1/tests/test_node.py +182 -0
- xflow_framework-0.1.1/tests/test_pipeline.py +172 -0
- xflow_framework-0.1.1/tests/test_utils.py +35 -0
- xflow_framework-0.1.1/xflow/framework/statics/initdir/pipelines/__pycache__/__init__.cpython-310.pyc +0 -0
- xflow_framework-0.1.1/xflow/framework/statics/initdir/pipelines/__pycache__/__init__.cpython-312.pyc +0 -0
- xflow_framework-0.1.1/xflow/framework/statics/initdir/pipelines/__pycache__/example.cpython-310.pyc +0 -0
- xflow_framework-0.1.1/xflow/framework/statics/initdir/pipelines/__pycache__/example.cpython-312.pyc +0 -0
- xflow_framework-0.1.1/xflow/framework/statics/initdir/pipelines/__pycache__/postgres.cpython-310.pyc +0 -0
- xflow_framework-0.1.1/xflow/framework/statics/initdir/pipelines/__pycache__/template.cpython-310.pyc +0 -0
- xflow_framework-0.1.1/xflow/framework/statics/initdir/pipelines/__pycache__/template.cpython-312.pyc +0 -0
- xflow_framework-0.1.1/xflow/framework/version.py +5 -0
- xflow_framework-0.1.1/xflow.framework.egg-info/PKG-INFO +29 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow.framework.egg-info/SOURCES.txt +14 -5
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow.framework.egg-info/dependency_links.txt +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow.framework.egg-info/entry_points.txt +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow.framework.egg-info/requires.txt +0 -2
- xflow_framework-0.1.1/xflow.framework.egg-info/top_level.txt +1 -0
- xflow_framework-0.1.0/MANIFEST.in +0 -5
- xflow_framework-0.1.0/PKG-INFO +0 -43
- xflow_framework-0.1.0/pyproject.toml +0 -3
- xflow_framework-0.1.0/requirements.txt +0 -6
- xflow_framework-0.1.0/setup.py +0 -67
- xflow_framework-0.1.0/xflow/framework/statics/initdir/.gitignore +0 -215
- xflow_framework-0.1.0/xflow/framework/statics/initdir/scripts/.gitkeep +0 -0
- xflow_framework-0.1.0/xflow/framework/statics/initdir/workdir/.gitkeep +0 -0
- xflow_framework-0.1.0/xflow/framework/version.py +0 -4
- xflow_framework-0.1.0/xflow.framework.egg-info/PKG-INFO +0 -43
- xflow_framework-0.1.0/xflow.framework.egg-info/top_level.txt +0 -3
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/LICENSE +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/README.md +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/setup.cfg +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/__init__.py +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/common.py +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/container.py +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/env.py +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/errors.py +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/main.py +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/node.py +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/pipeline.py +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/ssh.py +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/statics/initdir/README.md +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/statics/initdir/env.yml +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/statics/initdir/pipelines/__init__.py +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/statics/initdir/pipelines/example.py +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/statics/initdir/pipelines/template.py +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/statics/initdir/requirements.txt +0 -0
- {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/utils.py +0 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xflow.framework
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: 可以用 Python 写 Pipeline 的 CICD 框架。
|
|
5
|
+
Author-email: zhaowcheng <zhaowcheng@163.com>
|
|
6
|
+
License-Expression: BSD-2-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/zhaowcheng/xflow.framework
|
|
8
|
+
Project-URL: Issues, https://github.com/zhaowcheng/xflow.framework/issues
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: ruamel.yaml
|
|
18
|
+
Requires-Dist: paramiko
|
|
19
|
+
Requires-Dist: decorator
|
|
20
|
+
Requires-Dist: filelock
|
|
21
|
+
Requires-Dist: docker
|
|
22
|
+
Requires-Dist: typed-settings[click,option-groups,pydantic]
|
|
23
|
+
Dynamic: description
|
|
24
|
+
Dynamic: description-content-type
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# xflow.framework
|
|
28
|
+
|
|
29
|
+
可以用 Python 写 Pipeline 的 CICD 框架。
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xflow.framework"
|
|
7
|
+
description = "可以用 Python 写 Pipeline 的 CICD 框架。"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "zhaowcheng", email = "zhaowcheng@163.com" },
|
|
10
|
+
]
|
|
11
|
+
license = "BSD-2-Clause"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"ruamel.yaml",
|
|
16
|
+
"paramiko",
|
|
17
|
+
"decorator",
|
|
18
|
+
"filelock",
|
|
19
|
+
"docker",
|
|
20
|
+
"typed-settings[pydantic,click,option-groups]",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
]
|
|
29
|
+
dynamic = ["version", "readme"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/zhaowcheng/xflow.framework"
|
|
33
|
+
Issues = "https://github.com/zhaowcheng/xflow.framework/issues"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
xflow = "xflow.framework.main:main"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.dynamic]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
include = ["xflow*"]
|
|
42
|
+
exclude = ["tests", "tests.*"]
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.package-data]
|
|
45
|
+
"xflow.framework" = ["statics/initdir/**"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Build hook: read version from xflow/framework/version.py,
|
|
2
|
+
# generate long_description from README files.
|
|
3
|
+
# All other metadata lives in pyproject.toml.
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from setuptools import setup
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_VERSION_FILE = "xflow/framework/version.py"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _find_version() -> str:
|
|
13
|
+
with open(_VERSION_FILE, encoding="utf8") as f:
|
|
14
|
+
m = re.search(r'''__version__\s*=\s*['"]([^'"]+)''', f.read())
|
|
15
|
+
return m.group(1) if m else "0.0.0"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _build_long_description(version: str) -> str:
|
|
19
|
+
with open("README.md", encoding="utf8") as f:
|
|
20
|
+
desc = f.read()
|
|
21
|
+
desc = desc.replace("/blob/master/", f"/blob/v{version}/")
|
|
22
|
+
desc = desc.replace("/tree/master/", f"/tree/v{version}/")
|
|
23
|
+
return desc
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_VERSION = _find_version()
|
|
27
|
+
|
|
28
|
+
setup(
|
|
29
|
+
version=_VERSION,
|
|
30
|
+
long_description=_build_long_description(_VERSION),
|
|
31
|
+
long_description_content_type="text/markdown",
|
|
32
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import tempfile
|
|
4
|
+
import textwrap
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from click.testing import CliRunner
|
|
11
|
+
|
|
12
|
+
from xflow.framework.main import RunGroup, main
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FakeNode:
|
|
16
|
+
is_container = False
|
|
17
|
+
existed = True
|
|
18
|
+
|
|
19
|
+
def mkcwd(self):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def rmcwd(self):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
def remove(self, force=False):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FakeEnv:
|
|
30
|
+
def __init__(self, envfile):
|
|
31
|
+
self.envfile = envfile
|
|
32
|
+
|
|
33
|
+
def get_node(self, name):
|
|
34
|
+
return FakeNode()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CliTest(unittest.TestCase):
|
|
38
|
+
def setUp(self):
|
|
39
|
+
self.old_cwd = os.getcwd()
|
|
40
|
+
self.drop_pipeline_modules()
|
|
41
|
+
|
|
42
|
+
def tearDown(self):
|
|
43
|
+
os.chdir(self.old_cwd)
|
|
44
|
+
self.drop_pipeline_modules()
|
|
45
|
+
|
|
46
|
+
def drop_pipeline_modules(self):
|
|
47
|
+
for name in list(sys.modules):
|
|
48
|
+
if name == "pipelines" or name.startswith("pipelines."):
|
|
49
|
+
sys.modules.pop(name)
|
|
50
|
+
|
|
51
|
+
def test_init_copies_template_into_empty_project(self):
|
|
52
|
+
runner = CliRunner()
|
|
53
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
54
|
+
projdir = Path(tmpdir).joinpath("project")
|
|
55
|
+
|
|
56
|
+
result = runner.invoke(main, ["-p", str(projdir), "init"])
|
|
57
|
+
|
|
58
|
+
self.assertEqual(result.exit_code, 0, result.output)
|
|
59
|
+
self.assertTrue(projdir.joinpath("env.yml").is_file())
|
|
60
|
+
self.assertTrue(projdir.joinpath("pipelines", "example.py").is_file())
|
|
61
|
+
self.assertIn(f"Initialized {projdir}", result.output)
|
|
62
|
+
|
|
63
|
+
def test_init_rejects_non_empty_project_directory(self):
|
|
64
|
+
runner = CliRunner()
|
|
65
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
66
|
+
projdir = Path(tmpdir).joinpath("project")
|
|
67
|
+
projdir.mkdir()
|
|
68
|
+
projdir.joinpath("keep.txt").write_text("data")
|
|
69
|
+
|
|
70
|
+
result = runner.invoke(main, ["-p", str(projdir), "init"])
|
|
71
|
+
|
|
72
|
+
self.assertEqual(result.exit_code, 1)
|
|
73
|
+
self.assertIn("exists and is not empty", result.output)
|
|
74
|
+
|
|
75
|
+
def test_run_group_lists_pipeline_modules_only(self):
|
|
76
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
77
|
+
pipelines = Path(tmpdir).joinpath("pipelines")
|
|
78
|
+
pipelines.mkdir()
|
|
79
|
+
pipelines.joinpath("__init__.py").write_text("")
|
|
80
|
+
pipelines.joinpath("template.py").write_text("")
|
|
81
|
+
pipelines.joinpath("demo.py").write_text("")
|
|
82
|
+
pipelines.joinpath("build.py").write_text("")
|
|
83
|
+
pipelines.joinpath("notes.txt").write_text("")
|
|
84
|
+
group = RunGroup("run")
|
|
85
|
+
ctx = click.Context(group, obj={"projdir": tmpdir})
|
|
86
|
+
|
|
87
|
+
self.assertCountEqual(group.list_commands(ctx), ["demo", "build"])
|
|
88
|
+
|
|
89
|
+
def test_run_executes_pipeline_command(self):
|
|
90
|
+
runner = CliRunner()
|
|
91
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
92
|
+
projdir = Path(tmpdir)
|
|
93
|
+
pipelines = projdir.joinpath("pipelines")
|
|
94
|
+
pipelines.mkdir()
|
|
95
|
+
pipelines.joinpath("__init__.py").write_text("")
|
|
96
|
+
pipelines.joinpath("demo.py").write_text(
|
|
97
|
+
textwrap.dedent(
|
|
98
|
+
"""
|
|
99
|
+
from pathlib import Path
|
|
100
|
+
from xflow.framework.pipeline import Pipeline
|
|
101
|
+
|
|
102
|
+
class demo(Pipeline):
|
|
103
|
+
def stage1(self):
|
|
104
|
+
Path(self.projdir).joinpath("ran.txt").write_text("ok")
|
|
105
|
+
"""
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
with patch("xflow.framework.main.Env", FakeEnv):
|
|
110
|
+
result = runner.invoke(main, ["-p", str(projdir), "run", "-n", "local", "demo"])
|
|
111
|
+
|
|
112
|
+
self.assertEqual(result.exit_code, 0, result.output)
|
|
113
|
+
self.assertEqual(projdir.joinpath("ran.txt").read_text(), "ok")
|
|
114
|
+
|
|
115
|
+
def test_failed_pipeline_command_returns_nonzero(self):
|
|
116
|
+
runner = CliRunner()
|
|
117
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
118
|
+
projdir = Path(tmpdir)
|
|
119
|
+
pipelines = projdir.joinpath("pipelines")
|
|
120
|
+
pipelines.mkdir()
|
|
121
|
+
pipelines.joinpath("__init__.py").write_text("")
|
|
122
|
+
pipelines.joinpath("broken.py").write_text(
|
|
123
|
+
textwrap.dedent(
|
|
124
|
+
"""
|
|
125
|
+
from xflow.framework.pipeline import Pipeline
|
|
126
|
+
|
|
127
|
+
class broken(Pipeline):
|
|
128
|
+
def stage1(self):
|
|
129
|
+
raise RuntimeError("boom")
|
|
130
|
+
"""
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
with patch("xflow.framework.main.Env", FakeEnv):
|
|
135
|
+
result = runner.invoke(main, ["-p", str(projdir), "run", "-n", "local", "broken"])
|
|
136
|
+
|
|
137
|
+
self.assertEqual(result.exit_code, 1)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from xflow.framework.ssh import CommandResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CommandResultTest(unittest.TestCase):
|
|
7
|
+
def test_normalizes_output_and_keeps_metadata(self):
|
|
8
|
+
result = CommandResult("\x1b[31m hello\x1b[0m\xe9\r\nworld\n", rc=7, cmd="echo hello")
|
|
9
|
+
|
|
10
|
+
self.assertEqual(str(result), "hello\nworld")
|
|
11
|
+
self.assertEqual(result.rc, 7)
|
|
12
|
+
self.assertEqual(result.cmd, "echo hello")
|
|
13
|
+
|
|
14
|
+
def test_getfield_finds_last_matching_line(self):
|
|
15
|
+
result = CommandResult(
|
|
16
|
+
"""\
|
|
17
|
+
UID PID CMD
|
|
18
|
+
postgres 45 /opt/pgsql/bin/postgres
|
|
19
|
+
postgres 51 postgres: checkpointer process
|
|
20
|
+
postgres 52 postgres: writer process
|
|
21
|
+
"""
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
self.assertEqual(result.getfield("postgres", 2), "52")
|
|
25
|
+
self.assertEqual(result.getfield("checkpointer", 1, sep=":"), "postgres 51 postgres")
|
|
26
|
+
self.assertEqual(result.getfield(1, 2), "PID")
|
|
27
|
+
self.assertIsNone(result.getfield("missing", 1))
|
|
28
|
+
|
|
29
|
+
def test_getcol_returns_available_fields(self):
|
|
30
|
+
result = CommandResult(
|
|
31
|
+
"""\
|
|
32
|
+
name: value
|
|
33
|
+
other: second
|
|
34
|
+
lonely
|
|
35
|
+
"""
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
self.assertEqual(result.getcol(1, sep=":"), ["name", "other", "lonely"])
|
|
39
|
+
self.assertEqual(result.getcol(2, sep=":"), [" value", " second"])
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from xflow.framework.container import ContainerConnection
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DummyContainer:
|
|
7
|
+
name = "created"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ContainerConnectionTest(unittest.TestCase):
|
|
11
|
+
def test_requires_exactly_one_container_name_or_image(self):
|
|
12
|
+
with self.assertRaisesRegex(ValueError, "only one"):
|
|
13
|
+
ContainerConnection("127.0.0.1")
|
|
14
|
+
|
|
15
|
+
with self.assertRaisesRegex(ValueError, "only one"):
|
|
16
|
+
ContainerConnection("127.0.0.1", name="existing", image="busybox")
|
|
17
|
+
|
|
18
|
+
def test_client_certificate_and_key_must_be_paired(self):
|
|
19
|
+
with self.assertRaisesRegex(ValueError, "clientcert"):
|
|
20
|
+
ContainerConnection("127.0.0.1", name="existing", clientcert="cert.pem")
|
|
21
|
+
|
|
22
|
+
with self.assertRaisesRegex(ValueError, "clientcert"):
|
|
23
|
+
ContainerConnection("127.0.0.1", name="existing", clientkey="key.pem")
|
|
24
|
+
|
|
25
|
+
def test_connection_string_and_existed_flag(self):
|
|
26
|
+
existing = ContainerConnection("127.0.0.1", name="existing")
|
|
27
|
+
self.assertTrue(existing.existed)
|
|
28
|
+
self.assertEqual(existing._connstr, "docker://127.0.0.1:2375:existing")
|
|
29
|
+
|
|
30
|
+
created = ContainerConnection("127.0.0.1", user="root", image="busybox")
|
|
31
|
+
self.assertFalse(created.existed)
|
|
32
|
+
self.assertEqual(created._connstr, "docker://root@127.0.0.1:2375:busybox->...")
|
|
33
|
+
|
|
34
|
+
created._container = DummyContainer()
|
|
35
|
+
self.assertEqual(created._connstr, "docker://root@127.0.0.1:2375:busybox->created")
|
|
36
|
+
|
|
37
|
+
def test_default_locale_environment_is_set(self):
|
|
38
|
+
conn = ContainerConnection("127.0.0.1", name="existing", envs={"LANG": "C.UTF-8"})
|
|
39
|
+
|
|
40
|
+
self.assertEqual(conn._envs["LANG"], "C.UTF-8")
|
|
41
|
+
self.assertEqual(conn._envs["LANGUAGE"], "en_US.UTF-8")
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import textwrap
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
from xflow.framework.env import Env
|
|
8
|
+
from xflow.framework.errors import NoSuchDockerError, NoSuchNodeError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeNode:
|
|
12
|
+
kind = "base"
|
|
13
|
+
|
|
14
|
+
def __init__(self, **kwargs):
|
|
15
|
+
self.kwargs = kwargs
|
|
16
|
+
self.name = kwargs["name"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FakeNativeNode(FakeNode):
|
|
20
|
+
kind = "native"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FakeContainerNode(FakeNode):
|
|
24
|
+
kind = "container"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EnvTest(unittest.TestCase):
|
|
28
|
+
def write_env(self, content):
|
|
29
|
+
tmpdir = tempfile.TemporaryDirectory()
|
|
30
|
+
self.addCleanup(tmpdir.cleanup)
|
|
31
|
+
envfile = Path(tmpdir.name).joinpath("env.yml")
|
|
32
|
+
envfile.write_text(textwrap.dedent(content), encoding="utf8")
|
|
33
|
+
return envfile
|
|
34
|
+
|
|
35
|
+
def test_loads_native_and_container_nodes(self):
|
|
36
|
+
envfile = self.write_env(
|
|
37
|
+
"""
|
|
38
|
+
nodes:
|
|
39
|
+
- name: native
|
|
40
|
+
docker:
|
|
41
|
+
ip: 192.0.2.10
|
|
42
|
+
sshport: 22
|
|
43
|
+
user: builder
|
|
44
|
+
password: secret
|
|
45
|
+
workdir: /work/native
|
|
46
|
+
envs:
|
|
47
|
+
A: B
|
|
48
|
+
- name: container
|
|
49
|
+
docker: local-docker
|
|
50
|
+
user: root
|
|
51
|
+
workdir: /work/container
|
|
52
|
+
container: buildbox
|
|
53
|
+
image:
|
|
54
|
+
runargs: {}
|
|
55
|
+
envs:
|
|
56
|
+
C: D
|
|
57
|
+
dockers:
|
|
58
|
+
- name: local-docker
|
|
59
|
+
ip: 127.0.0.1
|
|
60
|
+
port: 2375
|
|
61
|
+
tls:
|
|
62
|
+
cacert:
|
|
63
|
+
clientcert:
|
|
64
|
+
clientkey:
|
|
65
|
+
"""
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
with patch("xflow.framework.env.NativeNode", FakeNativeNode), patch(
|
|
69
|
+
"xflow.framework.env.ContainerNode", FakeContainerNode
|
|
70
|
+
):
|
|
71
|
+
env = Env(envfile)
|
|
72
|
+
|
|
73
|
+
native = env.get_node("native")
|
|
74
|
+
container = env.get_node("container")
|
|
75
|
+
|
|
76
|
+
self.assertIsInstance(native, FakeNativeNode)
|
|
77
|
+
self.assertEqual(native.kwargs["ip"], "192.0.2.10")
|
|
78
|
+
self.assertEqual(native.kwargs["envs"], {"A": "B"})
|
|
79
|
+
|
|
80
|
+
self.assertIsInstance(container, FakeContainerNode)
|
|
81
|
+
self.assertEqual(container.kwargs["ip"], "127.0.0.1")
|
|
82
|
+
self.assertEqual(container.kwargs["container"], "buildbox")
|
|
83
|
+
self.assertIsNone(container.kwargs["image"])
|
|
84
|
+
self.assertEqual(container.kwargs["envs"], {"C": "D"})
|
|
85
|
+
|
|
86
|
+
def test_missing_docker_reference_raises(self):
|
|
87
|
+
envfile = self.write_env(
|
|
88
|
+
"""
|
|
89
|
+
nodes:
|
|
90
|
+
- name: container
|
|
91
|
+
docker: missing
|
|
92
|
+
user: root
|
|
93
|
+
workdir: /work/container
|
|
94
|
+
container: buildbox
|
|
95
|
+
image:
|
|
96
|
+
runargs: {}
|
|
97
|
+
envs: {}
|
|
98
|
+
dockers: []
|
|
99
|
+
"""
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
with patch("xflow.framework.env.ContainerNode", FakeContainerNode):
|
|
103
|
+
with self.assertRaisesRegex(NoSuchDockerError, "missing"):
|
|
104
|
+
Env(envfile)
|
|
105
|
+
|
|
106
|
+
def test_get_node_raises_for_unknown_name(self):
|
|
107
|
+
envfile = self.write_env(
|
|
108
|
+
"""
|
|
109
|
+
nodes:
|
|
110
|
+
- name: native
|
|
111
|
+
docker:
|
|
112
|
+
ip: 192.0.2.10
|
|
113
|
+
sshport: 22
|
|
114
|
+
user: builder
|
|
115
|
+
password: secret
|
|
116
|
+
workdir: /work/native
|
|
117
|
+
envs: {}
|
|
118
|
+
dockers: []
|
|
119
|
+
"""
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
with patch("xflow.framework.env.NativeNode", FakeNativeNode):
|
|
123
|
+
env = Env(envfile)
|
|
124
|
+
|
|
125
|
+
with self.assertRaisesRegex(NoSuchNodeError, "unknown"):
|
|
126
|
+
env.get_node("unknown")
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import unittest
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from pathlib import Path, PurePosixPath
|
|
5
|
+
|
|
6
|
+
from xflow.framework.node import Node
|
|
7
|
+
from xflow.framework.pipeline import Pipeline
|
|
8
|
+
from xflow.framework.ssh import CommandResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeConnection:
|
|
12
|
+
existed = True
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.current_dir = ""
|
|
16
|
+
self.dir_calls = []
|
|
17
|
+
self.exec_calls = []
|
|
18
|
+
self.getfile_calls = []
|
|
19
|
+
self.putfile_calls = []
|
|
20
|
+
self.remove_calls = []
|
|
21
|
+
self.existing = set()
|
|
22
|
+
self.uploaded_text = {}
|
|
23
|
+
|
|
24
|
+
@contextmanager
|
|
25
|
+
def dir(self, path):
|
|
26
|
+
self.dir_calls.append(path)
|
|
27
|
+
previous = self.current_dir
|
|
28
|
+
self.current_dir = str(path)
|
|
29
|
+
try:
|
|
30
|
+
yield
|
|
31
|
+
finally:
|
|
32
|
+
self.current_dir = previous
|
|
33
|
+
|
|
34
|
+
def exec(self, cmd, envs=None):
|
|
35
|
+
self.exec_calls.append((cmd, envs, self.current_dir))
|
|
36
|
+
if cmd.startswith("mkdir -p "):
|
|
37
|
+
self.existing.add(cmd.removeprefix("mkdir -p "))
|
|
38
|
+
return CommandResult("ok", rc=0, cmd=cmd)
|
|
39
|
+
|
|
40
|
+
def getfile(self, rfile, ldir):
|
|
41
|
+
self.getfile_calls.append((rfile, ldir))
|
|
42
|
+
|
|
43
|
+
def putfile(self, lfile, rdir):
|
|
44
|
+
lfile = Path(lfile)
|
|
45
|
+
rdir = PurePosixPath(rdir)
|
|
46
|
+
rfile = rdir.joinpath(lfile.name)
|
|
47
|
+
self.putfile_calls.append((lfile, rdir))
|
|
48
|
+
self.existing.add(str(rfile))
|
|
49
|
+
try:
|
|
50
|
+
self.uploaded_text[str(rfile)] = lfile.read_text()
|
|
51
|
+
except (FileNotFoundError, UnicodeDecodeError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
def exists(self, path):
|
|
55
|
+
return str(path) in self.existing
|
|
56
|
+
|
|
57
|
+
def remove(self, force=False):
|
|
58
|
+
self.remove_calls.append(force)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DummyPipeline(Pipeline):
|
|
62
|
+
def stage1(self):
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FakeEnv:
|
|
67
|
+
def __init__(self, node):
|
|
68
|
+
self.node = node
|
|
69
|
+
|
|
70
|
+
def get_node(self, name):
|
|
71
|
+
return self.node
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class NodeTest(unittest.TestCase):
|
|
75
|
+
def make_node_and_pipeline(self):
|
|
76
|
+
conn = FakeConnection()
|
|
77
|
+
node = Node("local", "builder", "/base", conn)
|
|
78
|
+
tmpdir = tempfile.TemporaryDirectory()
|
|
79
|
+
self.addCleanup(tmpdir.cleanup)
|
|
80
|
+
pipeline = DummyPipeline(tmpdir.name, FakeEnv(node), "local", DummyPipeline.Options())
|
|
81
|
+
pipeline.buildid = 42
|
|
82
|
+
return node, conn, pipeline
|
|
83
|
+
|
|
84
|
+
def test_cwd_uses_current_pipeline_and_dir_context(self):
|
|
85
|
+
node, _, pipeline = self.make_node_and_pipeline()
|
|
86
|
+
|
|
87
|
+
self.assertEqual(node.cwd, PurePosixPath("/base/DummyPipeline/42"))
|
|
88
|
+
|
|
89
|
+
with node.dir("src"):
|
|
90
|
+
self.assertEqual(node.cwd, PurePosixPath("/base/DummyPipeline/42/src"))
|
|
91
|
+
|
|
92
|
+
with node.dir("/absolute"):
|
|
93
|
+
self.assertEqual(node.cwd, PurePosixPath("/absolute"))
|
|
94
|
+
|
|
95
|
+
self.assertEqual(node.cwd, PurePosixPath("/base/DummyPipeline/42"))
|
|
96
|
+
self.assertIs(pipeline.node, node)
|
|
97
|
+
|
|
98
|
+
def test_mkcwd_and_rmcwd_execute_against_pipeline_workdir(self):
|
|
99
|
+
node, conn, pipeline = self.make_node_and_pipeline()
|
|
100
|
+
|
|
101
|
+
node.mkcwd()
|
|
102
|
+
node.rmcwd()
|
|
103
|
+
|
|
104
|
+
self.assertEqual(
|
|
105
|
+
conn.exec_calls,
|
|
106
|
+
[
|
|
107
|
+
("mkdir -p /base/DummyPipeline/42", None, ""),
|
|
108
|
+
("rm -rf /base/DummyPipeline/42", None, ""),
|
|
109
|
+
],
|
|
110
|
+
)
|
|
111
|
+
self.assertIs(pipeline.node, node)
|
|
112
|
+
|
|
113
|
+
def test_exec_runs_inside_current_workdir(self):
|
|
114
|
+
node, conn, pipeline = self.make_node_and_pipeline()
|
|
115
|
+
|
|
116
|
+
with node.dir("src"):
|
|
117
|
+
result = node.exec("make test", envs={"A": "B"})
|
|
118
|
+
|
|
119
|
+
self.assertEqual(result, "ok")
|
|
120
|
+
self.assertEqual(conn.dir_calls, [PurePosixPath("/base/DummyPipeline/42/src")])
|
|
121
|
+
self.assertEqual(conn.exec_calls, [("make test", {"A": "B"}, "/base/DummyPipeline/42/src")])
|
|
122
|
+
self.assertIs(pipeline.node, node)
|
|
123
|
+
|
|
124
|
+
def test_nixenv_wraps_command_and_clears_after_context(self):
|
|
125
|
+
node, conn, pipeline = self.make_node_and_pipeline()
|
|
126
|
+
|
|
127
|
+
with node.nixenv("/flake", system="x86_64-linux", name="dev", options="--impure"):
|
|
128
|
+
node.exec("make")
|
|
129
|
+
node.exec("pwd")
|
|
130
|
+
|
|
131
|
+
self.assertEqual(
|
|
132
|
+
conn.exec_calls[0][0],
|
|
133
|
+
"nix develop /flake#devShells.x86_64-linux.dev --impure --log-format raw -c make",
|
|
134
|
+
)
|
|
135
|
+
self.assertEqual(conn.exec_calls[1][0], "pwd")
|
|
136
|
+
self.assertIs(pipeline.node, node)
|
|
137
|
+
|
|
138
|
+
def test_exec_script_uploads_missing_script_once(self):
|
|
139
|
+
node, conn, pipeline = self.make_node_and_pipeline()
|
|
140
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
141
|
+
script = Path(tmpdir).joinpath("build.sh")
|
|
142
|
+
script.write_text("#!/bin/sh\necho ok\n")
|
|
143
|
+
|
|
144
|
+
node.exec_script(script, "arg1")
|
|
145
|
+
node.exec_script(script, "arg2")
|
|
146
|
+
|
|
147
|
+
remote_script = "/base/DummyPipeline/42/scripts/build.sh"
|
|
148
|
+
self.assertEqual(conn.exec_calls[0][0], "mkdir -p /base/DummyPipeline/42/scripts")
|
|
149
|
+
self.assertEqual(conn.exec_calls[1][0], f"chmod +x {remote_script}")
|
|
150
|
+
self.assertEqual(conn.exec_calls[2][0], f"{remote_script} arg1")
|
|
151
|
+
self.assertEqual(conn.exec_calls[3][0], f"{remote_script} arg2")
|
|
152
|
+
self.assertEqual(len(conn.putfile_calls), 1)
|
|
153
|
+
self.assertIs(pipeline.node, node)
|
|
154
|
+
|
|
155
|
+
def test_file_helpers_delegate_to_connection(self):
|
|
156
|
+
node, conn, pipeline = self.make_node_and_pipeline()
|
|
157
|
+
|
|
158
|
+
node.getfile("/remote/out.txt", "/local")
|
|
159
|
+
node.putfile("/local/in.txt", "/remote")
|
|
160
|
+
self.assertFalse(node.exists("/missing"))
|
|
161
|
+
|
|
162
|
+
self.assertEqual(conn.getfile_calls, [("/remote/out.txt", "/local")])
|
|
163
|
+
self.assertEqual(conn.putfile_calls[-1], (Path("/local/in.txt"), PurePosixPath("/remote")))
|
|
164
|
+
self.assertIs(pipeline.node, node)
|
|
165
|
+
|
|
166
|
+
def test_write_uploads_text_file_to_remote_directory(self):
|
|
167
|
+
node, conn, pipeline = self.make_node_and_pipeline()
|
|
168
|
+
|
|
169
|
+
node.write("hello", "/remote/message.txt")
|
|
170
|
+
|
|
171
|
+
self.assertEqual(conn.uploaded_text["/remote/message.txt"], "hello")
|
|
172
|
+
self.assertIs(pipeline.node, node)
|
|
173
|
+
|
|
174
|
+
def test_git_clones_and_checks_out_revision(self):
|
|
175
|
+
node, conn, pipeline = self.make_node_and_pipeline()
|
|
176
|
+
|
|
177
|
+
node.git("https://example.invalid/repo.git", "v1.0", options="--depth 1")
|
|
178
|
+
|
|
179
|
+
self.assertEqual(conn.exec_calls[0][0], "git clone --depth 1 https://example.invalid/repo.git repo")
|
|
180
|
+
self.assertEqual(conn.dir_calls[1], PurePosixPath("/base/DummyPipeline/42/repo"))
|
|
181
|
+
self.assertEqual(conn.exec_calls[1][0], "git checkout v1.0")
|
|
182
|
+
self.assertIs(pipeline.node, node)
|