pipeforge 1.0.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.
- pipeforge/__init__.py +1201 -0
- pipeforge/__main__.py +208 -0
- pipeforge/_internal/__init__.py +0 -0
- pipeforge/_internal/core_loop.py +700 -0
- pipeforge/_internal/database.py +515 -0
- pipeforge/_internal/drawing.py +544 -0
- pipeforge/_internal/file_loader.py +654 -0
- pipeforge/_internal/inspector.py +555 -0
- pipeforge/_internal/params.py +148 -0
- pipeforge/_internal/pipeline.py +212 -0
- pipeforge/_internal/script.py +1004 -0
- pipeforge/_internal/utils.py +122 -0
- pipeforge/examples/hello_jenkins.toml +16 -0
- pipeforge/examples/hello_world.toml +67 -0
- pipeforge/examples/merge_pull_request.toml +240 -0
- pipeforge/examples/multi_pipeline.toml +93 -0
- pipeforge/examples/multi_pipeline_2.toml +54 -0
- pipeforge/examples/nightly.toml +19 -0
- pipeforge/examples/retries.toml +43 -0
- pipeforge/examples/scripts/hello_world__get_purpose_in_life.py +34 -0
- pipeforge/examples/scripts/hello_world__print_summary.py +27 -0
- pipeforge/examples/scripts/hello_world__repeat_purpose.py +33 -0
- pipeforge/examples/scripts/retry.py +25 -0
- pipeforge/examples/scripts/timeout.py +25 -0
- pipeforge/examples/timeout.toml +37 -0
- pipeforge-1.0.0.dist-info/METADATA +198 -0
- pipeforge-1.0.0.dist-info/RECORD +29 -0
- pipeforge-1.0.0.dist-info/WHEEL +4 -0
- pipeforge-1.0.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
# vim: colorcolumn=101 textwidth=100
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import json
|
|
6
|
+
import copy
|
|
7
|
+
|
|
8
|
+
import toml # Not included in python's standard lib (ie. need to be "pip install"ed)
|
|
9
|
+
|
|
10
|
+
from .utils import log # Local module
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
####################################################################################################
|
|
15
|
+
# Auxiliary functions
|
|
16
|
+
####################################################################################################
|
|
17
|
+
|
|
18
|
+
def _find_line(file_path, regex):
|
|
19
|
+
"""
|
|
20
|
+
Find the first line in a file that matches the provided regular expression.
|
|
21
|
+
|
|
22
|
+
@param file_path: Path to the file to search
|
|
23
|
+
@param regex : Regular expression to search for. Note that if "regex" is a list, the function
|
|
24
|
+
will search for the first line that matches the first regex, then the next
|
|
25
|
+
line (starting from the just found line) that matches the second regex, and so
|
|
26
|
+
on. The return value will be the line number where the last regex was found.
|
|
27
|
+
|
|
28
|
+
@return: The line number where the first match was found. If no match was found, return -1
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
if not isinstance(regex, list):
|
|
32
|
+
regex = [regex]
|
|
33
|
+
|
|
34
|
+
with open(file_path) as f:
|
|
35
|
+
n = 0
|
|
36
|
+
for line_number, line in enumerate(f):
|
|
37
|
+
if re.search(regex[n], line):
|
|
38
|
+
if n == len(regex) - 1:
|
|
39
|
+
return line_number+1
|
|
40
|
+
else:
|
|
41
|
+
n += 1
|
|
42
|
+
else:
|
|
43
|
+
return -1
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
####################################################################################################
|
|
48
|
+
# API
|
|
49
|
+
####################################################################################################
|
|
50
|
+
|
|
51
|
+
class FileLoader:
|
|
52
|
+
|
|
53
|
+
def __init__(self, path_to_file, validation_mode=False):
|
|
54
|
+
|
|
55
|
+
self._validation_mode = validation_mode
|
|
56
|
+
|
|
57
|
+
extension = os.path.splitext(path_to_file)[1]
|
|
58
|
+
|
|
59
|
+
log("DEBUG", "")
|
|
60
|
+
log("DEBUG", f"Pipeline file read from disk ({path_to_file}):")
|
|
61
|
+
log("DEBUG", open(path_to_file).readlines())
|
|
62
|
+
|
|
63
|
+
if extension == ".json":
|
|
64
|
+
self._pipeline_data = json.load(open(path_to_file))
|
|
65
|
+
|
|
66
|
+
elif extension == ".toml":
|
|
67
|
+
self._pipeline_data = toml.load(path_to_file)
|
|
68
|
+
|
|
69
|
+
else:
|
|
70
|
+
# Try to figure out whether this is json or toml
|
|
71
|
+
try:
|
|
72
|
+
self._pipeline_data = json.load(open(path_to_file))
|
|
73
|
+
except Exception as e1:
|
|
74
|
+
try:
|
|
75
|
+
self._pipeline_data = toml.load(path_to_file)
|
|
76
|
+
except Exception as e2:
|
|
77
|
+
log("ERROR", "")
|
|
78
|
+
log("ERROR", f"The provided file ({path_to_file}) does not seem to contain valid JSON nor TOML data!")
|
|
79
|
+
log("ERROR", "")
|
|
80
|
+
log("ERROR", f"json loader: {e1}")
|
|
81
|
+
log("ERROR", "")
|
|
82
|
+
log("ERROR", f"toml loader: {e2}")
|
|
83
|
+
log("ERROR", "")
|
|
84
|
+
raise ValueError("Invalid file format")
|
|
85
|
+
|
|
86
|
+
log("DEBUG", "")
|
|
87
|
+
log("DEBUG", "Pipeline data once parsed into memory structures:")
|
|
88
|
+
log("DEBUG", self._pipeline_data)
|
|
89
|
+
|
|
90
|
+
self._file = path_to_file
|
|
91
|
+
|
|
92
|
+
if validation_mode:
|
|
93
|
+
self._file_str = ""
|
|
94
|
+
else:
|
|
95
|
+
self._file_str = f" ({self._file})"
|
|
96
|
+
|
|
97
|
+
self._expand_syntactic_sugar() # This will also fill "self._dependencies"
|
|
98
|
+
self._validate()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _calculate_dependencies(self, pipeline_name):
|
|
102
|
+
|
|
103
|
+
for p in self._pipeline_data["pipelines"]:
|
|
104
|
+
if p["name"] == pipeline_name:
|
|
105
|
+
pipeline = p
|
|
106
|
+
break
|
|
107
|
+
else:
|
|
108
|
+
log("ERROR", "")
|
|
109
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, re.escape(pipeline_name))})")
|
|
110
|
+
log("ERROR", f"Pipeline {pipeline_name} does not exist!")
|
|
111
|
+
log("ERROR", "")
|
|
112
|
+
raise ValueError("Malformed pipeline definition file")
|
|
113
|
+
|
|
114
|
+
job_pointers = {} # Direct access to each job object by name
|
|
115
|
+
job_dependencies = {} # List of other jobs a particular job depends on
|
|
116
|
+
|
|
117
|
+
for job in pipeline["jobs"]:
|
|
118
|
+
if job["name"] in job_pointers.keys():
|
|
119
|
+
log("ERROR", "")
|
|
120
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'name.*' + re.escape(job['name'])])}")
|
|
121
|
+
log("ERROR", f"Job <{job['name']}> is defined more than once in pipeline <{pipeline_name}>")
|
|
122
|
+
log("ERROR", "")
|
|
123
|
+
raise ValueError("Malformed pipeline definition file")
|
|
124
|
+
|
|
125
|
+
job_pointers [job["name"]] = job
|
|
126
|
+
job_dependencies[job["name"]] = []
|
|
127
|
+
|
|
128
|
+
for job in pipeline["jobs"]:
|
|
129
|
+
if "input" in job.keys():
|
|
130
|
+
for param, value in job["input"].items():
|
|
131
|
+
if not isinstance(value, str):
|
|
132
|
+
continue
|
|
133
|
+
for ref in re.findall("@{.*?}", value):
|
|
134
|
+
if "::" in ref[2:-1]:
|
|
135
|
+
# Reference to a parameter from another job
|
|
136
|
+
#
|
|
137
|
+
job_ref, param_ref = ref[2:-1].split("::")
|
|
138
|
+
|
|
139
|
+
if job_ref not in job_pointers.keys():
|
|
140
|
+
log("ERROR", "")
|
|
141
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'input',re.escape(job_ref)])}")
|
|
142
|
+
log("ERROR", f"Job <{job['name']}> is referencing a non existing job (<{job_ref}>) in its input parameters section")
|
|
143
|
+
log("ERROR", "")
|
|
144
|
+
raise ValueError("Malformed pipeline definition file")
|
|
145
|
+
|
|
146
|
+
if "output" not in job_pointers[job_ref] or \
|
|
147
|
+
param_ref not in job_pointers[job_ref]["output"].keys():
|
|
148
|
+
|
|
149
|
+
log("ERROR", "")
|
|
150
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'input',re.escape(job_ref)+'::'+re.escape(param_ref)])}")
|
|
151
|
+
log("ERROR", f"Job <{job['name']}> is referencing a non existing parameter (<{param_ref}>) from job <{job_ref}>")
|
|
152
|
+
log("ERROR", "")
|
|
153
|
+
raise ValueError("Malformed pipeline definition file")
|
|
154
|
+
else:
|
|
155
|
+
# Reference to the job itself (this is used to indicate that the current
|
|
156
|
+
# job must not start until the referenced one has finished)
|
|
157
|
+
#
|
|
158
|
+
job_ref = ref[2:-1]
|
|
159
|
+
|
|
160
|
+
if job_ref not in job_pointers.keys():
|
|
161
|
+
log("ERROR", "")
|
|
162
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'input',re.escape(job_ref)])}")
|
|
163
|
+
log("ERROR", f"Job <{job['name']}> is referencing a non existing job (<{job_ref}>) in its input parameters section")
|
|
164
|
+
log("ERROR", "")
|
|
165
|
+
raise ValueError("Malformed pipeline definition file")
|
|
166
|
+
|
|
167
|
+
if job_ref not in job_dependencies[job["name"]]:
|
|
168
|
+
job_dependencies[job["name"]].append(job_ref)
|
|
169
|
+
|
|
170
|
+
# Check for circular dependency
|
|
171
|
+
#
|
|
172
|
+
if job_ref in job_dependencies.keys() and \
|
|
173
|
+
job["name"] in job_dependencies[job_ref]:
|
|
174
|
+
log("ERROR", "")
|
|
175
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'input',re.escape(job_ref)])}")
|
|
176
|
+
log("ERROR", f"Job <{job['name']}> depends on job <{job_ref}> which in turn depends on job <{job['name']}> (circular dependency)")
|
|
177
|
+
log("ERROR", "")
|
|
178
|
+
raise ValueError("Malformed pipeline definition file")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
return job_dependencies
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _expand_syntactic_sugar(self):
|
|
185
|
+
|
|
186
|
+
# If there is no "pipelines" entry, create one with name = "main" and add all jobs to it
|
|
187
|
+
#
|
|
188
|
+
if "pipelines" not in self._pipeline_data:
|
|
189
|
+
if "jobs" not in self._pipeline_data:
|
|
190
|
+
log("ERROR", "")
|
|
191
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str}")
|
|
192
|
+
log("ERROR", "No jobs defined")
|
|
193
|
+
log("ERROR", "")
|
|
194
|
+
raise ValueError("Malformed pipeline definition file")
|
|
195
|
+
|
|
196
|
+
self._pipeline_data["pipelines"] = [{
|
|
197
|
+
"name" : "main",
|
|
198
|
+
"jobs" : copy.deepcopy(self._pipeline_data["jobs"])
|
|
199
|
+
}]
|
|
200
|
+
del self._pipeline_data["jobs"]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# Make sure all pipelines have a name and a jobs list
|
|
204
|
+
#
|
|
205
|
+
for pipeline in self._pipeline_data["pipelines"]:
|
|
206
|
+
if "name" not in pipeline or "jobs" not in pipeline:
|
|
207
|
+
log("ERROR", "")
|
|
208
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str}")
|
|
209
|
+
log("ERROR", "All pipelines must have a name and a list of jobs")
|
|
210
|
+
log("ERROR", "")
|
|
211
|
+
raise ValueError("Malformed pipeline definition file ")
|
|
212
|
+
|
|
213
|
+
if type(pipeline["jobs"]) is not list:
|
|
214
|
+
log("ERROR", "")
|
|
215
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(pipeline['name']),'jobs'])}")
|
|
216
|
+
log("ERROR", "The jobs entry of a pipeline must be a list")
|
|
217
|
+
log("ERROR", "")
|
|
218
|
+
raise ValueError("Malformed pipeline definition file")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# Replace "${PIPEFORGE__...}" references with values extracted from the environment
|
|
222
|
+
#
|
|
223
|
+
if self._validation_mode:
|
|
224
|
+
# In validation mode we don't care about environment variables. No need to process them.
|
|
225
|
+
#
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
else:
|
|
229
|
+
try:
|
|
230
|
+
if "meta" in self._pipeline_data:
|
|
231
|
+
raw_meta = json.dumps(self._pipeline_data["meta"])
|
|
232
|
+
|
|
233
|
+
for ref in re.findall(r"\${PIPEFORGE__.*?}", raw_meta):
|
|
234
|
+
resolved_ref = os.getenv(ref[2:-1])
|
|
235
|
+
|
|
236
|
+
if not resolved_ref:
|
|
237
|
+
log("ERROR", "")
|
|
238
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['meta',re.escape(ref)])}")
|
|
239
|
+
log("ERROR", f"'meta' section is referencing a non existing pipeline manager variable (<{ref}>)")
|
|
240
|
+
log("ERROR", "")
|
|
241
|
+
raise ValueError("Malformed pipeline definition file")
|
|
242
|
+
|
|
243
|
+
raw_meta = raw_meta.replace(ref, os.getenv(ref[2:-1]))
|
|
244
|
+
|
|
245
|
+
self._pipeline_data["meta"] = json.loads(raw_meta)
|
|
246
|
+
except Exception:
|
|
247
|
+
# We don't really care about the "meta" section
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
for pipeline in self._pipeline_data["pipelines"]:
|
|
251
|
+
for job in pipeline["jobs"]:
|
|
252
|
+
if "input" in job.keys():
|
|
253
|
+
for param, value in job["input"].items():
|
|
254
|
+
for ref in re.findall(r"\${PIPEFORGE__.*?}", value):
|
|
255
|
+
resolved_ref = os.getenv(ref[2:-1])
|
|
256
|
+
|
|
257
|
+
if not resolved_ref:
|
|
258
|
+
log("ERROR", "")
|
|
259
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'input',re.escape(ref)])}")
|
|
260
|
+
log("ERROR", f"Job <{job['name']}> is referencing a non existing pipeline manager variable (<{ref}>) in its input parameters section")
|
|
261
|
+
log("ERROR", "")
|
|
262
|
+
raise ValueError("Malformed pipeline definition file")
|
|
263
|
+
|
|
264
|
+
job["input"][param] = job["input"][param].replace(ref, resolved_ref)
|
|
265
|
+
|
|
266
|
+
log("DEBUG", "")
|
|
267
|
+
log("DEBUG", "Pipeline data after \"PIPEFORGE__...\" environment variables substitution:")
|
|
268
|
+
log("DEBUG", self._pipeline_data)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# Inject global parameters into each of the jobs
|
|
272
|
+
#
|
|
273
|
+
def _add_not_already_present_dictionary_entries(source, target):
|
|
274
|
+
#
|
|
275
|
+
# Insert (key,values) from dictionary "source" into dictionary "target", but only those
|
|
276
|
+
# which do not already exist in target.
|
|
277
|
+
#
|
|
278
|
+
# Some of the keys of "source" can contain nested dictionaried (of arbitrary depth). In
|
|
279
|
+
# these cases, if the key already exists in "target", this function will "descend" into
|
|
280
|
+
# the new dictionaries and start the process all over.
|
|
281
|
+
|
|
282
|
+
for param, value in source.items():
|
|
283
|
+
|
|
284
|
+
if param not in target.keys():
|
|
285
|
+
target[param] = copy.deepcopy(value)
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
if isinstance(value, str) and isinstance(target[param], str):
|
|
289
|
+
# Don't update an already existing value
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
if isinstance(value, dict) and isinstance(target[param], dict):
|
|
293
|
+
_add_not_already_present_dictionary_entries(value, target[param])
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
log("ERROR", "")
|
|
297
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str}")
|
|
298
|
+
log("ERROR", f"Incompatible types when applying global substitution on parameter {param}:")
|
|
299
|
+
log("ERROR", f"{type(value)} != {type(target[param])}")
|
|
300
|
+
log("ERROR", "")
|
|
301
|
+
raise ValueError("Malformed pipeline definition file")
|
|
302
|
+
|
|
303
|
+
if "global" in self._pipeline_data:
|
|
304
|
+
for pipeline in self._pipeline_data["pipelines"]:
|
|
305
|
+
for job in pipeline["jobs"]:
|
|
306
|
+
_add_not_already_present_dictionary_entries(self._pipeline_data["global"], job)
|
|
307
|
+
|
|
308
|
+
del self._pipeline_data["global"]
|
|
309
|
+
|
|
310
|
+
log("DEBUG", "")
|
|
311
|
+
log("DEBUG", "Pipeline data after globals injection:")
|
|
312
|
+
log("DEBUG", self._pipeline_data)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# Process the "run_always" "fake" parameter
|
|
316
|
+
#
|
|
317
|
+
if "config" in self._pipeline_data and "run_always" in self._pipeline_data["config"]:
|
|
318
|
+
|
|
319
|
+
jobs_always = re.findall("@{.*?}", self._pipeline_data["config"]["run_always"])
|
|
320
|
+
jobs_always = [x[2:-1] for x in jobs_always]
|
|
321
|
+
|
|
322
|
+
for pipeline in self._pipeline_data["pipelines"]:
|
|
323
|
+
if set([x["name"] for x in pipeline["jobs"]]) & set(jobs_always):
|
|
324
|
+
# There is at least one job that must always run thus:
|
|
325
|
+
#
|
|
326
|
+
# 1. Change the "on_failure" property of all jobs in this pipeline to
|
|
327
|
+
# "continue"
|
|
328
|
+
#
|
|
329
|
+
# 2. Change the "on_input_err" property of all jobs in this pipeline:
|
|
330
|
+
# ...to "skip" for jobs not in the jobs_always list
|
|
331
|
+
# ...to "run" for jobs not in jobs_always list
|
|
332
|
+
|
|
333
|
+
for job in pipeline["jobs"]:
|
|
334
|
+
job["on_failure"] = "continue"
|
|
335
|
+
job["on_input_err"] = "skip"
|
|
336
|
+
|
|
337
|
+
if job["name"] in jobs_always:
|
|
338
|
+
job["on_input_err"] = "run"
|
|
339
|
+
|
|
340
|
+
log("DEBUG", "")
|
|
341
|
+
log("DEBUG", "Pipeline data after 'run_always' expansion:")
|
|
342
|
+
log("DEBUG", self._pipeline_data)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# Resolve dependencies
|
|
346
|
+
#
|
|
347
|
+
self._dependencies = {}
|
|
348
|
+
for pipeline in self._pipeline_data["pipelines"]:
|
|
349
|
+
self._dependencies[pipeline["name"]] = self._calculate_dependencies(pipeline["name"])
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# Create new pipelines for "retrigger without" on_failure conditions.
|
|
353
|
+
#
|
|
354
|
+
auto_pipeline = ["auto_pipeline_", 0]
|
|
355
|
+
for pipeline in self._pipeline_data["pipelines"]:
|
|
356
|
+
for job in pipeline["jobs"]:
|
|
357
|
+
if "on_failure" not in job.keys():
|
|
358
|
+
log("ERROR", "")
|
|
359
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name'])])}")
|
|
360
|
+
log("ERROR", f"Job <{job['name']}> does not have an <on_failure> property")
|
|
361
|
+
log("ERROR", "")
|
|
362
|
+
raise ValueError("Malformed pipeline definition file")
|
|
363
|
+
|
|
364
|
+
if job["on_failure"].startswith("retrigger without"):
|
|
365
|
+
|
|
366
|
+
# Create new pipeline for when this job fails
|
|
367
|
+
|
|
368
|
+
auto_pipeline = [auto_pipeline[0], auto_pipeline[1] + 1]
|
|
369
|
+
new_pipeline = {
|
|
370
|
+
"name" : auto_pipeline[0] + str(auto_pipeline[1]),
|
|
371
|
+
"jobs" : []
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
self._dependencies[new_pipeline["name"]] = self._dependencies[pipeline["name"]]
|
|
375
|
+
|
|
376
|
+
skipped_jobs = re.findall("@{([^}]*)}", job["on_failure"])
|
|
377
|
+
|
|
378
|
+
job["on_failure"] = f"trigger pipeline : {new_pipeline['name']}"
|
|
379
|
+
|
|
380
|
+
# Add to the new pipeline a copy of all the jobs in the current pipeline that do
|
|
381
|
+
# not appear in the skipped_jobs list:
|
|
382
|
+
#
|
|
383
|
+
for job2 in pipeline["jobs"]:
|
|
384
|
+
if job2["name"] not in skipped_jobs:
|
|
385
|
+
new_job = copy.deepcopy(job2)
|
|
386
|
+
|
|
387
|
+
# If one of the jobs to add depends on a skipped_job, replace the input
|
|
388
|
+
# parameter value for a reference to the status of the jobs the
|
|
389
|
+
# skipped_job depends on.
|
|
390
|
+
#
|
|
391
|
+
if "input" in new_job.keys():
|
|
392
|
+
for input_param, input_value in new_job["input"].items():
|
|
393
|
+
for skipped in skipped_jobs:
|
|
394
|
+
|
|
395
|
+
def scan_dependencies(replacement, deps):
|
|
396
|
+
for dep in deps:
|
|
397
|
+
if dep not in skipped:
|
|
398
|
+
replacement.append("@{" + dep + "}")
|
|
399
|
+
else:
|
|
400
|
+
scan_dependencies(
|
|
401
|
+
replacement,
|
|
402
|
+
self._dependencies[pipeline["name"]][dep])
|
|
403
|
+
|
|
404
|
+
replacement = []
|
|
405
|
+
scan_dependencies(
|
|
406
|
+
replacement,
|
|
407
|
+
self._dependencies[pipeline["name"]][skipped])
|
|
408
|
+
|
|
409
|
+
for x in re.findall(
|
|
410
|
+
"(@{"+re.escape(skipped)+" *(::)*[^}]*})",
|
|
411
|
+
input_value):
|
|
412
|
+
|
|
413
|
+
input_value = input_value.replace(
|
|
414
|
+
x[0],
|
|
415
|
+
"!!" + "+".join(replacement) + "!!")
|
|
416
|
+
|
|
417
|
+
new_job["input"][input_param] = input_value
|
|
418
|
+
|
|
419
|
+
new_pipeline["jobs"].append(new_job)
|
|
420
|
+
|
|
421
|
+
self._pipeline_data["pipelines"].append(new_pipeline)
|
|
422
|
+
|
|
423
|
+
log("DEBUG", "")
|
|
424
|
+
log("DEBUG", "Pipeline data after implicit pipelines creation:")
|
|
425
|
+
log("DEBUG", self._pipeline_data)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# Resolve dependencies of newly created pipelines
|
|
429
|
+
#
|
|
430
|
+
for i in range(auto_pipeline[1]):
|
|
431
|
+
new_pipeline_name = auto_pipeline[0] + str(i+1)
|
|
432
|
+
self._dependencies[new_pipeline_name] = self._calculate_dependencies(new_pipeline_name)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _validate(self):
|
|
436
|
+
|
|
437
|
+
# Make sure there is a pipeline called "main"
|
|
438
|
+
#
|
|
439
|
+
if "pipelines" not in self._pipeline_data or \
|
|
440
|
+
"main" not in [pipeline["name"] for pipeline in self._pipeline_data["pipelines"]]:
|
|
441
|
+
log("ERROR", "")
|
|
442
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str}")
|
|
443
|
+
log("ERROR", "Could not find a pipeline with name = 'main'")
|
|
444
|
+
log("ERROR", "")
|
|
445
|
+
raise ValueError("Malformed pipeline definition file")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# Make sure the name of pipelines is unique
|
|
449
|
+
#
|
|
450
|
+
all_pipeline_names = [pipeline["name"] for pipeline in self._pipeline_data["pipelines"]]
|
|
451
|
+
if len(all_pipeline_names) != len(set(all_pipeline_names)):
|
|
452
|
+
repeated = list(set([x for x in all_pipeline_names if all_pipeline_names.count(x) > 1]))[0]
|
|
453
|
+
log("ERROR", "")
|
|
454
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(repeated),'name.*' + re.escape(repeated)])}")
|
|
455
|
+
log("ERROR", "Pipeline names must be unique")
|
|
456
|
+
log("ERROR", "")
|
|
457
|
+
raise ValueError("Malformed pipeline definition file")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# Make sure the name of jobs is unique within a pipeline
|
|
461
|
+
#
|
|
462
|
+
for pipeline in self._pipeline_data["pipelines"]:
|
|
463
|
+
all_pipeline_jobs_names = [job["name"] for job in pipeline["jobs"]]
|
|
464
|
+
if len(all_pipeline_jobs_names) != len(set(all_pipeline_jobs_names)):
|
|
465
|
+
repeated = list(set([x for x in all_pipeline_jobs_names if all_pipeline_jobs_names.count(x) > 1]))[0]
|
|
466
|
+
log("ERROR", "")
|
|
467
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(pipeline['name']),'name.*' + re.escape(repeated),'name.*' + re.escape(repeated)])}")
|
|
468
|
+
log("ERROR", f"Job names within pipeline <{pipeline['name']}> must be unique")
|
|
469
|
+
log("ERROR", "")
|
|
470
|
+
raise ValueError("Malformed pipeline definition file")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# Make sure jobs only include mandatory fields and that their value is a string.
|
|
474
|
+
#
|
|
475
|
+
for pipeline in self._pipeline_data["pipelines"]:
|
|
476
|
+
for job in pipeline["jobs"]:
|
|
477
|
+
for field in job.keys():
|
|
478
|
+
if field in ["input", "output"]:
|
|
479
|
+
pass
|
|
480
|
+
elif field in ["name", "script", "runner", "detached", "timeout", "retries", "on_failure", "on_input_err"]:
|
|
481
|
+
if not isinstance(job[field],str):
|
|
482
|
+
log("ERROR", "")
|
|
483
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),re.escape(field)])}")
|
|
484
|
+
log("ERROR", f"Job <{job['name']}> is not defining field <{field}> as a string")
|
|
485
|
+
log("ERROR", "")
|
|
486
|
+
raise ValueError("Malformed pipeline definition file")
|
|
487
|
+
else:
|
|
488
|
+
log("ERROR", "")
|
|
489
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),re.escape(field)])}")
|
|
490
|
+
log("ERROR", f"Job <{job['name']}> is defining an invalid field: <{field}>")
|
|
491
|
+
log("ERROR", "")
|
|
492
|
+
raise ValueError("Malformed pipeline definition file")
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# Make sure all input and output job parameters (if present) are strings.
|
|
496
|
+
# Make sure all output job parameters (if present) are set to "?"
|
|
497
|
+
#
|
|
498
|
+
for pipeline in self._pipeline_data["pipelines"]:
|
|
499
|
+
for job in pipeline["jobs"]:
|
|
500
|
+
if "input" in job.keys():
|
|
501
|
+
for param, value in job["input"].items():
|
|
502
|
+
if not isinstance(value, str):
|
|
503
|
+
log("ERROR", "")
|
|
504
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),re.escape(param)])}")
|
|
505
|
+
log("ERROR", f"Job <{job['name']}> is providing a non-string value to input parameter <{param}>")
|
|
506
|
+
log("ERROR", "")
|
|
507
|
+
raise ValueError("Malformed pipeline definition file")
|
|
508
|
+
if "output" in job.keys():
|
|
509
|
+
for param, value in job["output"].items():
|
|
510
|
+
if not isinstance(value, str) or value != "?":
|
|
511
|
+
log("ERROR", "")
|
|
512
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),re.escape(param)])}")
|
|
513
|
+
log("ERROR", f"Job <{job['name']}> output parameter <{param}> must be set to special string \"?\" (question mark). This is true for all output parameters.")
|
|
514
|
+
log("ERROR", "")
|
|
515
|
+
raise ValueError("Malformed pipeline definition file")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# Make sure all output job parameters (if present) are being used as input parameters
|
|
519
|
+
# somewhere. This is not an error, just a warning.
|
|
520
|
+
#
|
|
521
|
+
for pipeline in self._pipeline_data["pipelines"]:
|
|
522
|
+
for job in pipeline["jobs"]:
|
|
523
|
+
if "output" in job.keys():
|
|
524
|
+
for param, value in job["output"].items():
|
|
525
|
+
if not any([f"@{{{job['name']}::{param}}}" in y for x in pipeline["jobs"] for y in x["input"].values()]):
|
|
526
|
+
|
|
527
|
+
if self._validation_mode:
|
|
528
|
+
loglevel = "NORMAL"
|
|
529
|
+
else:
|
|
530
|
+
loglevel = "DEBUG"
|
|
531
|
+
|
|
532
|
+
log(loglevel, "WARNING:")
|
|
533
|
+
log(loglevel, f"WARNING: Possibly malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),re.escape(param)])}")
|
|
534
|
+
log(loglevel, f"WARNING: Job <{job['name']}> output parameter <{param}> is not being used as input parameter in any other job")
|
|
535
|
+
log(loglevel, "WARNING:")
|
|
536
|
+
|
|
537
|
+
else:
|
|
538
|
+
# Check for direct references to the job status.
|
|
539
|
+
#
|
|
540
|
+
if not any([f"@{{{job['name']}}}" in y for x in pipeline["jobs"] for y in x["input"].values()]):
|
|
541
|
+
|
|
542
|
+
if self._validation_mode:
|
|
543
|
+
loglevel = "NORMAL"
|
|
544
|
+
else:
|
|
545
|
+
loglevel = "DEBUG"
|
|
546
|
+
|
|
547
|
+
log(loglevel, "WARNING:")
|
|
548
|
+
log(loglevel, f"WARNING: Possibly malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name'])])}")
|
|
549
|
+
log(loglevel, f"WARNING: Job <{job['name']}> is not being used as input for any other job")
|
|
550
|
+
log(loglevel, "WARNING:")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
# Make sure that "detached" is either "true" or "false".
|
|
554
|
+
# In the former case, make sure "timeout", "retries" and "on_failure" are all set to special
|
|
555
|
+
# string "N/A".
|
|
556
|
+
# In the latter case, make sure one of the other valid values are being used.
|
|
557
|
+
#
|
|
558
|
+
for pipeline in self._pipeline_data["pipelines"]:
|
|
559
|
+
for job in pipeline["jobs"]:
|
|
560
|
+
|
|
561
|
+
if job["detached"] == "true":
|
|
562
|
+
if job["timeout"] != "N/A" or job["retries"] != "N/A" or job["on_failure"] != "N/A":
|
|
563
|
+
log("ERROR", "")
|
|
564
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'detached'])}")
|
|
565
|
+
log("ERROR", f"Job <{job['name']}> has property <detached> set to \"true\". When this happens, properties <timeout>, <retries> and <on_failure> *must* be set to special string \"N/A\".")
|
|
566
|
+
log("ERROR", "")
|
|
567
|
+
raise ValueError("Malformed pipeline definition file")
|
|
568
|
+
|
|
569
|
+
elif job["detached"] == "false":
|
|
570
|
+
|
|
571
|
+
# Check <timeout> property
|
|
572
|
+
#
|
|
573
|
+
error = False
|
|
574
|
+
|
|
575
|
+
if not error:
|
|
576
|
+
try:
|
|
577
|
+
number, word = job["timeout"].split()
|
|
578
|
+
except Exception:
|
|
579
|
+
error = True
|
|
580
|
+
|
|
581
|
+
if not error:
|
|
582
|
+
try:
|
|
583
|
+
_ = int(number)
|
|
584
|
+
except Exception:
|
|
585
|
+
error = True
|
|
586
|
+
|
|
587
|
+
if not error:
|
|
588
|
+
if word not in ["hour", "hours", "minute", "minutes", "second", "seconds"]:
|
|
589
|
+
error = True
|
|
590
|
+
|
|
591
|
+
if error:
|
|
592
|
+
log("ERROR", "")
|
|
593
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'timeout'])}")
|
|
594
|
+
log("ERROR", f"Job <{job['name']}> property <timeout> value is not valid ({job['timeout']}). It must be a number followed by either \"second\", \"seconds\", \"minute\", \"minutes\", \"hour\" or \"hours\" (examples: \"5 minutes\", \"1 hour\", ...)")
|
|
595
|
+
log("ERROR", "")
|
|
596
|
+
raise ValueError("Malformed pipeline definition file")
|
|
597
|
+
|
|
598
|
+
# Check <retries> property
|
|
599
|
+
#
|
|
600
|
+
try:
|
|
601
|
+
_ = int(job["retries"])
|
|
602
|
+
except Exception:
|
|
603
|
+
log("ERROR", "")
|
|
604
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'retries'])}")
|
|
605
|
+
log("ERROR", f"Job <{job['name']}> property <retries> value is not valid ({job['retries']}). It must be a string containing a valid integer number")
|
|
606
|
+
log("ERROR", "")
|
|
607
|
+
raise ValueError("Malformed pipeline definition file")
|
|
608
|
+
|
|
609
|
+
# Check <on_failure> property
|
|
610
|
+
#
|
|
611
|
+
if job["on_failure"] in ["stop pipeline", "continue", "restart pipeline"]:
|
|
612
|
+
pass
|
|
613
|
+
elif job["on_failure"].startswith("restart from") and \
|
|
614
|
+
job["on_failure"].split(":")[1].strip() in [x["name"] for x in
|
|
615
|
+
self.pipeline["jobs"]]:
|
|
616
|
+
pass
|
|
617
|
+
elif job["on_failure"].startswith("trigger pipeline") and \
|
|
618
|
+
job["on_failure"].split(":")[1].strip() in [x["name"] for x in
|
|
619
|
+
self._pipeline_data["pipelines"]]:
|
|
620
|
+
pass
|
|
621
|
+
elif job["on_failure"].startswith("retrigger without"):
|
|
622
|
+
pass
|
|
623
|
+
else:
|
|
624
|
+
log("ERROR", "")
|
|
625
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'on_failure'])}")
|
|
626
|
+
log("ERROR", f"Job <{job['name']}> property <on_failure> value is not valid ({job['on_failure']}). It must one of these strings: \"stop pipeline\", \"continue\", \"restart pipeline\", \"restart from : <job name>\", \"trigger pipeline : <pipeline name>\", \"retrigger without : <job_1>, <job_2>, ...\"")
|
|
627
|
+
log("ERROR", "")
|
|
628
|
+
raise ValueError("Malformed pipeline definition file")
|
|
629
|
+
|
|
630
|
+
# Check <on_input_err> property
|
|
631
|
+
#
|
|
632
|
+
if job["on_input_err"] in ["run", "fail", "succeed", "skip"]:
|
|
633
|
+
pass
|
|
634
|
+
else:
|
|
635
|
+
log("ERROR", "")
|
|
636
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'on_input_err'])}")
|
|
637
|
+
log("ERROR", f"Job <{job['name']}> property <on_input_err> value is not valid ({job['on_input_err']}). It must one of these strings: \"run\", \"fail\", \"succeed\", \"skip\"")
|
|
638
|
+
log("ERROR", "")
|
|
639
|
+
raise ValueError("Malformed pipeline definition file")
|
|
640
|
+
|
|
641
|
+
else:
|
|
642
|
+
log("ERROR", "")
|
|
643
|
+
log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'detached'])}")
|
|
644
|
+
log("ERROR", f"Job <{job['name']}> must set param <detached> to string \"true\" or \"false\". Other values are not valid.")
|
|
645
|
+
log("ERROR", "")
|
|
646
|
+
raise ValueError("Malformed pipeline definition file")
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def get_pipeline(self):
|
|
650
|
+
return self._pipeline_data
|
|
651
|
+
|
|
652
|
+
def get_dependencies(self):
|
|
653
|
+
return self._dependencies
|
|
654
|
+
|