xflow.framework 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xflow/framework/__init__.py +0 -0
- xflow/framework/common.py +14 -0
- xflow/framework/container.py +292 -0
- xflow/framework/env.py +72 -0
- xflow/framework/errors.py +33 -0
- xflow/framework/main.py +112 -0
- xflow/framework/node.py +392 -0
- xflow/framework/pipeline.py +189 -0
- xflow/framework/ssh.py +372 -0
- xflow/framework/statics/initdir/.gitignore +215 -0
- xflow/framework/statics/initdir/README.md +0 -0
- xflow/framework/statics/initdir/env.yml +41 -0
- xflow/framework/statics/initdir/pipelines/__init__.py +0 -0
- xflow/framework/statics/initdir/pipelines/example.py +60 -0
- xflow/framework/statics/initdir/pipelines/template.py +32 -0
- xflow/framework/statics/initdir/requirements.txt +1 -0
- xflow/framework/statics/initdir/scripts/.gitkeep +0 -0
- xflow/framework/statics/initdir/workdir/.gitkeep +0 -0
- xflow/framework/utils.py +59 -0
- xflow/framework/version.py +4 -0
- xflow_framework-0.1.0.dist-info/METADATA +43 -0
- xflow_framework-0.1.0.dist-info/RECORD +26 -0
- xflow_framework-0.1.0.dist-info/WHEEL +5 -0
- xflow_framework-0.1.0.dist-info/entry_points.txt +2 -0
- xflow_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
- xflow_framework-0.1.0.dist-info/top_level.txt +1 -0
xflow/framework/node.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026, zhaowcheng <zhaowcheng@163.com>
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
节点。
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import inspect
|
|
9
|
+
import tempfile
|
|
10
|
+
|
|
11
|
+
from pathlib import PurePosixPath, Path
|
|
12
|
+
|
|
13
|
+
from typing import Generator, Dict, Optional, Union, Literal
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
|
|
16
|
+
from xflow.framework.ssh import SSHConnection
|
|
17
|
+
from xflow.framework.container import ContainerConnection
|
|
18
|
+
from xflow.framework.ssh import CommandResult
|
|
19
|
+
from xflow.framework.pipeline import Pipeline
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Node(object):
|
|
23
|
+
"""
|
|
24
|
+
节点基类。
|
|
25
|
+
"""
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
name: str,
|
|
29
|
+
user: str,
|
|
30
|
+
bwd: str,
|
|
31
|
+
conn: Union[SSHConnection, ContainerConnection],
|
|
32
|
+
envs: Optional[Dict[str, str]] = None
|
|
33
|
+
):
|
|
34
|
+
"""
|
|
35
|
+
:param name: 名称。
|
|
36
|
+
:param user: 用户名。
|
|
37
|
+
:param bwd: 基础工作目录。
|
|
38
|
+
:param envs: 环境变量。
|
|
39
|
+
"""
|
|
40
|
+
self.__name = name
|
|
41
|
+
self.__user = user
|
|
42
|
+
self.__envs = envs
|
|
43
|
+
self.__bwd: PurePosixPath = PurePosixPath(bwd)
|
|
44
|
+
self.__cwd: PurePosixPath = PurePosixPath('')
|
|
45
|
+
self.__conn = conn
|
|
46
|
+
self.__nixenv = {}
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def name(self) -> str:
|
|
50
|
+
"""
|
|
51
|
+
节点名。
|
|
52
|
+
"""
|
|
53
|
+
return self.__name
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def bwd(self) -> PurePosixPath:
|
|
57
|
+
"""
|
|
58
|
+
基础工作目录。
|
|
59
|
+
"""
|
|
60
|
+
return self.__bwd
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def cwd(self) -> PurePosixPath:
|
|
64
|
+
"""
|
|
65
|
+
当前工作目录(与 pipeline 相关)。
|
|
66
|
+
"""
|
|
67
|
+
# 如果已指定绝对路径,则直接返回,否则自动拼接。
|
|
68
|
+
if self.__cwd.is_absolute():
|
|
69
|
+
return self.__cwd
|
|
70
|
+
p = self.pipeline
|
|
71
|
+
return self.bwd.joinpath(p.name, f'{p.buildid}', self.__cwd)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def scriptdir(self) -> PurePosixPath:
|
|
75
|
+
"""
|
|
76
|
+
脚本存放目录。
|
|
77
|
+
"""
|
|
78
|
+
return self.cwd.joinpath('scripts')
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def pipeline(self) -> 'Pipeline':
|
|
82
|
+
"""
|
|
83
|
+
当前 pipeline。
|
|
84
|
+
"""
|
|
85
|
+
for frame_info in inspect.stack():
|
|
86
|
+
frame = frame_info.frame
|
|
87
|
+
for obj in frame.f_locals.values():
|
|
88
|
+
if hasattr(obj, '__class__') and issubclass(obj.__class__, Pipeline):
|
|
89
|
+
return obj
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def is_native(self) -> bool:
|
|
93
|
+
"""
|
|
94
|
+
是否原生节点。
|
|
95
|
+
"""
|
|
96
|
+
return self.__class__.__name__ == 'NativeNode'
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def is_container(self) -> bool:
|
|
100
|
+
"""
|
|
101
|
+
是否容器节点。
|
|
102
|
+
"""
|
|
103
|
+
return self.__class__.__name__ == 'ContainerNode'
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def existed(self) -> bool:
|
|
107
|
+
"""
|
|
108
|
+
是否一个已存在的容器(即不是从镜像临时创建的)。
|
|
109
|
+
"""
|
|
110
|
+
return self.__conn.existed
|
|
111
|
+
|
|
112
|
+
def remove(self, force: bool = False) -> None:
|
|
113
|
+
"""
|
|
114
|
+
删除本容器。
|
|
115
|
+
|
|
116
|
+
:param force: 是否强制删除正在运行的容器。
|
|
117
|
+
"""
|
|
118
|
+
self.__conn.remove(force=force)
|
|
119
|
+
|
|
120
|
+
def mkcwd(self) -> None:
|
|
121
|
+
"""
|
|
122
|
+
创建当前工作目录。
|
|
123
|
+
"""
|
|
124
|
+
self.__conn.exec(f'mkdir -p {self.cwd}')
|
|
125
|
+
|
|
126
|
+
def rmcwd(self) -> None:
|
|
127
|
+
"""
|
|
128
|
+
删除当前工作目录。
|
|
129
|
+
"""
|
|
130
|
+
self.__conn.exec(f'rm -rf {self.cwd}')
|
|
131
|
+
|
|
132
|
+
@contextmanager
|
|
133
|
+
def dir(self, path: str | PurePosixPath) -> Generator[None, None, None]:
|
|
134
|
+
"""
|
|
135
|
+
切换工作目录。
|
|
136
|
+
|
|
137
|
+
>>> with dir('/my/workdir'): # doctest: +SKIP
|
|
138
|
+
... d = exec('pwd') # doctest: +SKIP
|
|
139
|
+
... # doctest: +SKIP
|
|
140
|
+
>>> d # doctest: +SKIP
|
|
141
|
+
'/my/workdir' # doctest: +SKIP
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
self.__cwd = PurePosixPath(path)
|
|
145
|
+
yield
|
|
146
|
+
finally:
|
|
147
|
+
self.__cwd = PurePosixPath('')
|
|
148
|
+
|
|
149
|
+
@contextmanager
|
|
150
|
+
def nixenv(
|
|
151
|
+
self,
|
|
152
|
+
flake: str | PurePosixPath,
|
|
153
|
+
system: Optional[Literal['x86_64-linux',
|
|
154
|
+
'aarch64-linux',
|
|
155
|
+
'loongarch64-linux',
|
|
156
|
+
'mips64el-linux']] = None,
|
|
157
|
+
name: str = 'default',
|
|
158
|
+
options: Optional[str] = None
|
|
159
|
+
) -> Generator[None, None, None]:
|
|
160
|
+
"""
|
|
161
|
+
使用 nix develop 进入一个 nix shell 环境。
|
|
162
|
+
|
|
163
|
+
:param flake: flake(目录)路径。
|
|
164
|
+
:param system: 系统平台,为空则为当前系统平台。
|
|
165
|
+
:param name: 环境名称。
|
|
166
|
+
:param options: nix develop 命令选项。
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
self.__nixenv.update(
|
|
170
|
+
{
|
|
171
|
+
'flake': flake,
|
|
172
|
+
'system': system,
|
|
173
|
+
'name': name,
|
|
174
|
+
'options': options
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
yield
|
|
178
|
+
finally:
|
|
179
|
+
self.__nixenv.clear()
|
|
180
|
+
|
|
181
|
+
def exec(
|
|
182
|
+
self,
|
|
183
|
+
cmd: str,
|
|
184
|
+
envs: Optional[Dict[str, str]] = None
|
|
185
|
+
) -> CommandResult:
|
|
186
|
+
"""
|
|
187
|
+
执行命令。
|
|
188
|
+
|
|
189
|
+
:param cmd: 被执行的命令。
|
|
190
|
+
:param envs: 环境变量。
|
|
191
|
+
:return: 命令输出。
|
|
192
|
+
|
|
193
|
+
:raises:
|
|
194
|
+
`CommandError` -- 命令返回码不为 0。
|
|
195
|
+
|
|
196
|
+
>>> exec('ls /home') # successful # doctest: +SKIP
|
|
197
|
+
>>> exec('ls /errpath') # CommandError # doctest: +SKIP
|
|
198
|
+
"""
|
|
199
|
+
if self.__nixenv:
|
|
200
|
+
flake = self.__nixenv['flake']
|
|
201
|
+
system = self.__nixenv['system']
|
|
202
|
+
name = self.__nixenv['name']
|
|
203
|
+
options = self.__nixenv['options']
|
|
204
|
+
if system:
|
|
205
|
+
attr = f'devShells.{system}.{name}'
|
|
206
|
+
else:
|
|
207
|
+
attr = name
|
|
208
|
+
options = (options or '') + f' --log-format raw -c {cmd}'
|
|
209
|
+
cmd = f'nix develop {flake}#{attr} {options.strip()}'
|
|
210
|
+
with self.__conn.dir(self.cwd):
|
|
211
|
+
return self.__conn.exec(cmd, envs=envs)
|
|
212
|
+
|
|
213
|
+
def exec_script(
|
|
214
|
+
self,
|
|
215
|
+
script: str | Path,
|
|
216
|
+
argstr: str = '',
|
|
217
|
+
envs: Optional[Dict[str, str]] = None
|
|
218
|
+
) -> CommandResult:
|
|
219
|
+
"""
|
|
220
|
+
上传并执行脚本。
|
|
221
|
+
|
|
222
|
+
:param script: 本地脚本路径(相对项目目录)。
|
|
223
|
+
:param argstr: 脚本参数。
|
|
224
|
+
:param envs: 环境变量。
|
|
225
|
+
:return: 脚本输出。
|
|
226
|
+
|
|
227
|
+
:raises:
|
|
228
|
+
`CommandError` -- 返回码不为 0。
|
|
229
|
+
|
|
230
|
+
>>> exec_script('scripts/my_script.sh') # doctest: +SKIP
|
|
231
|
+
"""
|
|
232
|
+
lscript = Path(script).absolute()
|
|
233
|
+
rscript = self.scriptdir.joinpath(lscript.name)
|
|
234
|
+
rdir = rscript.parent
|
|
235
|
+
if not self.exists(rscript):
|
|
236
|
+
if not self.exists(rdir):
|
|
237
|
+
self.__conn.exec(f'mkdir -p {rdir}')
|
|
238
|
+
self.putfile(lscript, rdir)
|
|
239
|
+
self.__conn.exec(f'chmod +x {rscript}')
|
|
240
|
+
return self.exec(f'{rscript} {argstr}', envs=envs)
|
|
241
|
+
|
|
242
|
+
def getfile(
|
|
243
|
+
self,
|
|
244
|
+
rfile: Union[str, PurePosixPath],
|
|
245
|
+
ldir: Union[str, Path]
|
|
246
|
+
) -> None:
|
|
247
|
+
"""
|
|
248
|
+
从远端下载文件 `rfile` 到本地目录 `ldir`。
|
|
249
|
+
|
|
250
|
+
:param rfile: 远端文件。
|
|
251
|
+
:param ldir: 本地目录。
|
|
252
|
+
|
|
253
|
+
>>> getfile('/tmp/myfile', '/home') # /home/myfile
|
|
254
|
+
>>> getfile('/tmp/myfile', 'D:\\') # D:\\myfile
|
|
255
|
+
"""
|
|
256
|
+
self.__conn.getfile(rfile, ldir)
|
|
257
|
+
|
|
258
|
+
def putfile(
|
|
259
|
+
self,
|
|
260
|
+
lfile: Union[str, Path],
|
|
261
|
+
rdir: Union[str, PurePosixPath]
|
|
262
|
+
) -> None:
|
|
263
|
+
"""
|
|
264
|
+
上传本地文件 `lfile` 到远端目录 `rdir`。
|
|
265
|
+
|
|
266
|
+
:param lfile: 本地文件。
|
|
267
|
+
:param rdir: 远端目录。
|
|
268
|
+
|
|
269
|
+
>>> putfile('/home/myfile', '/tmp') # /tmp/myfile
|
|
270
|
+
>>> putfile('D:\\myfile', '/tmp') # /tmp/myfile
|
|
271
|
+
"""
|
|
272
|
+
return self.__conn.putfile(lfile, rdir)
|
|
273
|
+
|
|
274
|
+
def exists(self, path: Union[str, PurePosixPath]) -> bool:
|
|
275
|
+
"""
|
|
276
|
+
检查远端路径是否存在。
|
|
277
|
+
"""
|
|
278
|
+
return self.__conn.exists(path)
|
|
279
|
+
|
|
280
|
+
def write(self, text: str, rfile: Union[str, PurePosixPath]) -> None:
|
|
281
|
+
"""
|
|
282
|
+
写入一个文本文件到远端路径 `path`。
|
|
283
|
+
|
|
284
|
+
:param text: 要写入的文本。
|
|
285
|
+
:param rfile: 远端文件路径。
|
|
286
|
+
"""
|
|
287
|
+
rfile = PurePosixPath(rfile)
|
|
288
|
+
filename = rfile.name
|
|
289
|
+
rdir = rfile.parent
|
|
290
|
+
tmpdir = Path(tempfile.mkdtemp())
|
|
291
|
+
lfile = tmpdir.joinpath(filename)
|
|
292
|
+
with open(lfile, 'w') as f:
|
|
293
|
+
f.write(text)
|
|
294
|
+
self.putfile(lfile, rdir)
|
|
295
|
+
shutil.rmtree(tmpdir)
|
|
296
|
+
|
|
297
|
+
def git(
|
|
298
|
+
self,
|
|
299
|
+
repourl: str,
|
|
300
|
+
revision: Optional[str] = None,
|
|
301
|
+
directory: Optional[str | PurePosixPath] = None,
|
|
302
|
+
options: Optional[str] = None
|
|
303
|
+
) -> None:
|
|
304
|
+
"""
|
|
305
|
+
git clone 一个仓库。
|
|
306
|
+
|
|
307
|
+
:param repourl: 仓库地址。
|
|
308
|
+
:param revision: 分支、tag 或 commit。
|
|
309
|
+
:param directory: 克隆的目标目录。
|
|
310
|
+
:param options: git clone 命令选项。
|
|
311
|
+
"""
|
|
312
|
+
humanish = repourl.split('/')[-1].replace('.git', '')
|
|
313
|
+
directory = directory or humanish
|
|
314
|
+
self.exec(f'git clone {options or ""} {repourl} {directory}')
|
|
315
|
+
if revision:
|
|
316
|
+
with self.dir(directory):
|
|
317
|
+
self.exec(f'git checkout {revision}')
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class NativeNode(Node):
|
|
321
|
+
"""
|
|
322
|
+
原生节点(非容器)。
|
|
323
|
+
"""
|
|
324
|
+
def __init__(
|
|
325
|
+
self,
|
|
326
|
+
name: str,
|
|
327
|
+
ip: str,
|
|
328
|
+
sshport: int,
|
|
329
|
+
user: str,
|
|
330
|
+
password: str,
|
|
331
|
+
bwd: str,
|
|
332
|
+
envs: Optional[Dict[str, str]] = None
|
|
333
|
+
):
|
|
334
|
+
"""
|
|
335
|
+
:param name: 名称。
|
|
336
|
+
:param ip: IP 地址。
|
|
337
|
+
:param sshport: SSH 端口。
|
|
338
|
+
:param user: 用户名。
|
|
339
|
+
:param password: 用户密码。
|
|
340
|
+
:param bwd: 基础工作目录。
|
|
341
|
+
:param envs: 环境变量。
|
|
342
|
+
"""
|
|
343
|
+
conn = SSHConnection(ip, user, password, port=sshport, envs=envs)
|
|
344
|
+
super().__init__(name, user, bwd, conn, envs=envs)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class ContainerNode(Node):
|
|
348
|
+
"""
|
|
349
|
+
容器节点。
|
|
350
|
+
"""
|
|
351
|
+
def __init__(
|
|
352
|
+
self,
|
|
353
|
+
name: str,
|
|
354
|
+
ip: str,
|
|
355
|
+
port: int,
|
|
356
|
+
bwd: str,
|
|
357
|
+
user: Optional[str] = None,
|
|
358
|
+
container: Optional[str] = None,
|
|
359
|
+
image: Optional[str] = None,
|
|
360
|
+
runargs: Optional[Dict] = None,
|
|
361
|
+
cacert: Optional[str] = None,
|
|
362
|
+
clientcert: Optional[str] = None,
|
|
363
|
+
clientkey: Optional[str] = None,
|
|
364
|
+
envs: Optional[Dict[str, str]] = None
|
|
365
|
+
):
|
|
366
|
+
"""
|
|
367
|
+
:param name: 名称。
|
|
368
|
+
:param ip: IP 地址。
|
|
369
|
+
:param port: Docker 服务端口。
|
|
370
|
+
:param user: 用户名。
|
|
371
|
+
:param bwd: 基础工作目录。
|
|
372
|
+
:param container: 容器名,和 `image` 为互斥参数,必须且只能指定其中一个。
|
|
373
|
+
:param image: 镜像名,和 `container` 为互斥参数,必须且只能指定其中一个,
|
|
374
|
+
如果该参数指定,则每次连接用该镜像创建一个新容器。
|
|
375
|
+
:param runargs: 使用镜像启动容器时的参数。
|
|
376
|
+
:param cacert: CA 证书。
|
|
377
|
+
:param clientcert: 客户端证书,当指定 `clientkey` 参数时必须同时指定该参数。
|
|
378
|
+
:param clientkey: 客户端私钥,当指定 `clientcert` 参数时必须同时指定该参数。
|
|
379
|
+
:param envs: 环境变量。
|
|
380
|
+
"""
|
|
381
|
+
conn = ContainerConnection(ip,
|
|
382
|
+
port=port,
|
|
383
|
+
user=user,
|
|
384
|
+
name=container,
|
|
385
|
+
image=image,
|
|
386
|
+
runargs=runargs,
|
|
387
|
+
cacert=cacert,
|
|
388
|
+
clientcert=clientcert,
|
|
389
|
+
clientkey=clientkey,
|
|
390
|
+
envs=envs)
|
|
391
|
+
super().__init__(name, user, bwd, conn, envs=envs)
|
|
392
|
+
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026, zhaowcheng <zhaowcheng@163.com>
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
流水线模块。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import traceback
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from typing import List, Literal, Any, Optional, Iterable, TYPE_CHECKING, Union, get_args, get_origin
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from filelock import FileLock
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
from pydantic.fields import FieldInfo
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from xflow.framework.env import Env
|
|
21
|
+
|
|
22
|
+
TResult = Literal['FAILED', 'SUCCESSFUL']
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Pipeline(object):
|
|
26
|
+
"""
|
|
27
|
+
流水线。
|
|
28
|
+
"""
|
|
29
|
+
class Option(FieldInfo):
|
|
30
|
+
"""
|
|
31
|
+
流水线参数。
|
|
32
|
+
"""
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
desc: Optional[str] = None,
|
|
36
|
+
default: Any = None,
|
|
37
|
+
choices: Optional[Iterable] = None
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
:param desc: 描述。
|
|
41
|
+
:param default: 默认值。
|
|
42
|
+
:param choices: 枚举值。
|
|
43
|
+
"""
|
|
44
|
+
kwargs = {}
|
|
45
|
+
if desc is not None:
|
|
46
|
+
kwargs['description'] = desc
|
|
47
|
+
if default is not None:
|
|
48
|
+
kwargs['default'] = default
|
|
49
|
+
if choices is not None:
|
|
50
|
+
kwargs.update(
|
|
51
|
+
{
|
|
52
|
+
'json_schema_extra': {
|
|
53
|
+
'typed-settings': {
|
|
54
|
+
'click': {
|
|
55
|
+
'type': click.Choice(choices)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
super().__init__(**kwargs)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Options(BaseModel):
|
|
65
|
+
"""
|
|
66
|
+
流水线参数表。
|
|
67
|
+
"""
|
|
68
|
+
def __init__(self, **data):
|
|
69
|
+
data = self.__set_default_values(**data)
|
|
70
|
+
super().__init__(**data)
|
|
71
|
+
|
|
72
|
+
def __set_default_values(self, **data) -> dict:
|
|
73
|
+
"""
|
|
74
|
+
为 Optional 类型但未设置 default 的参数补充默认值 None。
|
|
75
|
+
"""
|
|
76
|
+
for field_name, field_info in self.__class__.model_fields.items():
|
|
77
|
+
field_type = field_info.annotation
|
|
78
|
+
origin = get_origin(field_type)
|
|
79
|
+
args = get_args(field_type)
|
|
80
|
+
if origin is Union and type(None) in args and field_name not in data:
|
|
81
|
+
data[field_name] = None
|
|
82
|
+
return data
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
projdir: str | Path,
|
|
87
|
+
env: 'Env',
|
|
88
|
+
nodename: str,
|
|
89
|
+
options: Options
|
|
90
|
+
):
|
|
91
|
+
"""
|
|
92
|
+
:param projdir: 项目目录。
|
|
93
|
+
:param env: 环境信息。
|
|
94
|
+
:param nodename: 执行节点名称。
|
|
95
|
+
:param options: 流水线参数。
|
|
96
|
+
"""
|
|
97
|
+
self.projdir = Path(projdir)
|
|
98
|
+
self.bwd = self.projdir.joinpath('workdir') # base workdir
|
|
99
|
+
self.env = env
|
|
100
|
+
self.node = self.env.get_node(nodename)
|
|
101
|
+
self.options = options
|
|
102
|
+
self.result: TResult = None
|
|
103
|
+
self.buildid: int = None
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def name(self) -> str:
|
|
107
|
+
"""
|
|
108
|
+
名称。
|
|
109
|
+
"""
|
|
110
|
+
return self.__class__.__name__
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def cwd(self) -> Path:
|
|
114
|
+
"""
|
|
115
|
+
当前工作目录。
|
|
116
|
+
"""
|
|
117
|
+
return self.bwd.joinpath(self.name, f'{self.buildid}')
|
|
118
|
+
|
|
119
|
+
def setup(self) -> None:
|
|
120
|
+
"""
|
|
121
|
+
前置步骤,如果失败将不会执行任何 stage,直接执行 teardown。
|
|
122
|
+
"""
|
|
123
|
+
# 获取 buildid
|
|
124
|
+
print(f'Getting BuildID: ', end='')
|
|
125
|
+
parent = self.bwd.joinpath(self.name)
|
|
126
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
idfile = parent.joinpath('buildid.txt')
|
|
128
|
+
idfile.touch()
|
|
129
|
+
with FileLock(f'{idfile}.lock'):
|
|
130
|
+
with open(idfile, 'r+') as f:
|
|
131
|
+
newid = int(f.read() or 0) + 1
|
|
132
|
+
with open(idfile, 'w') as f:
|
|
133
|
+
f.write(f'{newid}')
|
|
134
|
+
self.buildid = newid
|
|
135
|
+
print(self.buildid)
|
|
136
|
+
|
|
137
|
+
# 打印参数
|
|
138
|
+
print(f'options: {self.options}')
|
|
139
|
+
|
|
140
|
+
# 创建远端工作目录
|
|
141
|
+
self.node.mkcwd()
|
|
142
|
+
|
|
143
|
+
# 创建本地工作目录
|
|
144
|
+
self.cwd.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
|
|
146
|
+
def stage1(self) -> None:
|
|
147
|
+
"""
|
|
148
|
+
阶段 1,一个 pipeline 至少包含一个 stage1,增加则按照
|
|
149
|
+
stage2, stage3, ... 这样依次增加,执行时也将按此顺序。
|
|
150
|
+
"""
|
|
151
|
+
raise NotImplementedError
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def stages(self) -> List[str]:
|
|
155
|
+
"""
|
|
156
|
+
所有阶段名称。
|
|
157
|
+
"""
|
|
158
|
+
return sorted([n for n in dir(self.__class__) if re.match(r'stage\d+', n)])
|
|
159
|
+
|
|
160
|
+
def run(self) -> TResult:
|
|
161
|
+
"""
|
|
162
|
+
执行 pipeline。
|
|
163
|
+
"""
|
|
164
|
+
title = lambda t: print(t.center(80, '='))
|
|
165
|
+
try:
|
|
166
|
+
title('setup')
|
|
167
|
+
self.setup()
|
|
168
|
+
for stage in self.stages:
|
|
169
|
+
title(stage)
|
|
170
|
+
getattr(self, stage)()
|
|
171
|
+
self.result = 'SUCCESSFUL'
|
|
172
|
+
except:
|
|
173
|
+
self.result = 'FAILED'
|
|
174
|
+
traceback.print_exc()
|
|
175
|
+
finally:
|
|
176
|
+
title('teardown')
|
|
177
|
+
self.teardown()
|
|
178
|
+
return self.result
|
|
179
|
+
|
|
180
|
+
def teardown(self) -> None:
|
|
181
|
+
"""
|
|
182
|
+
后置步骤,不管成功与否,都会执行该步骤。
|
|
183
|
+
"""
|
|
184
|
+
if self.result == 'SUCCESSFUL':
|
|
185
|
+
if self.node.is_container and not self.node.existed:
|
|
186
|
+
self.node.remove(force=True)
|
|
187
|
+
else:
|
|
188
|
+
self.node.rmcwd()
|
|
189
|
+
|