Coverage for src/parsers/gitlab.py: 100.00%
508 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-18 15:02 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-18 15:02 +0000
1#!/usr/bin/env python3
3# Standard libraries
4from argparse import Namespace
5from collections import OrderedDict
6from itertools import product
7from os import environ
8from pathlib import Path
9from re import match
10from time import sleep
11from typing import Any, Dict, List, NamedTuple, Optional
13# Modules libraries
14from packaging.version import parse as parse_version
16# Components
17from ..models.flags import Flags
18from ..models.images import Image, Images
19from ..models.jobs import Job, JobData
20from ..models.pipelines import Pipeline
21from ..models.services import Service
22from ..models.variables import VariablesParser
23from ..package.bundle import Bundle
24from ..package.updates import Updates
25from ..package.version import Version
26from ..prints.colors import Colors
27from ..system.platform import Platform
28from ..types.environment import Environment
29from ..types.paths import Paths
30from ..types.volumes import Volumes
31from ..types.yaml import YAML
33# GitLab class, pylint: disable=too-many-lines
34class GitLab:
36 # Constants
37 LOCAL_NODE: str = '.local'
39 # Specifications
40 JOB_IMAGE_DEFAULT: str = 'ruby:3.1'
41 JOB_STAGE_DEFAULT: str = 'test'
42 STAGE_POST: str = '.post'
43 STAGE_PRE: str = '.pre'
44 STAGES_DEFAULT: Dict[str, int] = {
45 STAGE_PRE: 1,
46 'build': 2,
47 'test': 3,
48 'deploy': 4,
49 STAGE_POST: 5,
50 }
52 # Environment
53 ENV_BUILDS_DIR: str = 'CI_BUILDS_DIR'
54 ENV_GIT_CLONE_PATH: str = 'GIT_CLONE_PATH'
55 ENV_JOB_NAME: str = 'CI_JOB_NAME'
56 ENV_JOB_NAME_SLUG: str = 'CI_JOB_NAME_SLUG'
57 ENV_PROJECT_DIR: str = 'CI_PROJECT_DIR'
59 # Variant type
60 class Variant(NamedTuple):
62 # Properties
63 name: str
64 variables: Dict[str, str]
66 # Members
67 __options: Namespace
69 # Constructor
70 def __init__(
71 self,
72 options: Namespace,
73 ) -> None:
75 # Initialize members
76 self.__options = options
78 # Merges
79 @staticmethod
80 def __merges(
81 data: YAML.Data,
82 additions: YAML.Data,
83 ) -> None:
85 # Validate additions
86 if not data or (not isinstance(data, dict)
87 and not isinstance(data, list)): # pragma: no cover
88 return
90 # Validate additions
91 if not additions or not isinstance(additions, dict): # pragma: no cover
92 return
94 # Agregate data
95 base = data.copy()
96 data.clear()
98 # Merge data additions
99 for key in additions:
101 # Handle included expanding dict data
102 if key in base and isinstance(additions[key], dict) and key in [
103 'variables',
104 ]:
105 data[key] = base[key]
106 data[key].update(additions[key])
108 # Handle included expanding list data
109 elif key in base and isinstance(additions[key], list) and key in [
110 'volumes',
111 ]:
112 data[key] = list(set(base[key] + additions[key]))
114 # Handle included expanding dict data
115 elif key in base and isinstance(additions[key], dict):
116 data[key] = base[key]
117 GitLab.__merges(data[key], additions[key])
119 # Handle included overriding data
120 else:
121 data[key] = additions[key]
123 # Merge data base
124 for key in base:
126 # Handle unique base data
127 if key not in data:
128 data[key] = base[key]
130 # Scripts
131 @staticmethod
132 def __scripts(items: str | List[str]) -> List[str]:
134 # Variables
135 scripts = []
137 # Parse scripts data
138 if isinstance(items, str):
139 scripts = [items]
140 elif isinstance(items, list):
141 scripts = []
142 for item in items:
143 if isinstance(item, str):
144 scripts += [item]
145 elif isinstance(item, list):
146 scripts += item[:]
148 # Result
149 return scripts
151 # Pipeline, pylint: disable=too-many-branches
152 @staticmethod
153 def __pipeline(
154 pipeline: Pipeline,
155 data: YAML.Data,
156 stages: Dict[str, int],
157 ) -> None:
159 # Parse variables node
160 if 'variables' in data:
161 GitLab.__pipeline_variables(pipeline, data['variables'])
163 # Parse image node
164 if 'image' in data:
165 GitLab.__pipeline_image(data['image'], pipeline)
167 # Parse before_script node
168 if 'before_script' in data:
169 pipeline.before_script = GitLab.__scripts(data['before_script'])
171 # Parse after_script node
172 if 'after_script' in data:
173 pipeline.after_script = GitLab.__scripts(data['after_script'])
175 # Parse services node
176 if 'services' in data and isinstance(data['services'], list):
177 GitLab.__pipeline_services(data['services'], pipeline)
179 # Parse stages node
180 if 'stages' in data:
181 stages.clear()
182 stages[GitLab.STAGE_PRE] = len(stages) + 1
183 for _, stage in enumerate(data['stages']):
184 if stage is not GitLab.STAGE_PRE and stage is not GitLab.STAGE_POST:
185 stages[stage] = len(stages) + 1
186 stages[GitLab.STAGE_POST] = len(stages) + 1
188 # Parse default node
189 if 'default' in data:
191 # Parse default image node
192 if 'image' in data['default']:
193 if 'image' in data:
194 raise SyntaxError(
195 'image is defined in top-level and `default:` entry')
196 GitLab.__pipeline_image(data['default']['image'], pipeline)
198 # Parse default before_script node
199 if 'before_script' in data['default']:
200 if 'before_script' in data:
201 raise SyntaxError(
202 'before_script is defined in top-level and `default:` entry')
203 pipeline.before_script = GitLab.__scripts(
204 data['default']['before_script'])
206 # Parse default after_script node
207 if 'after_script' in data['default']:
208 if 'after_script' in data:
209 raise SyntaxError(
210 'after_script is defined in top-level and `default:` entry')
211 pipeline.after_script = GitLab.__scripts(data['default']['after_script'])
213 # Parse default services node
214 if 'services' in data['default'] and isinstance(data['default']['services'],
215 list):
216 if 'services' in data:
217 raise SyntaxError(
218 'services is defined in top-level and `default:` entry')
219 GitLab.__pipeline_services(data['default']['services'], pipeline)
221 # Pipeline image
222 @staticmethod
223 def __pipeline_image(
224 image_data: Dict[str, Any] | str,
225 pipeline: Pipeline,
226 ) -> None:
228 # Parse image data
229 if not pipeline.image:
230 if isinstance(image_data, dict):
231 pipeline.image = Image(
232 pipeline.variables.expand(
233 image_data['name'], types=[
234 VariablesParser.Types.PARAMETERS,
235 VariablesParser.Types.LOCALS,
236 VariablesParser.Types.GLOBALS,
237 VariablesParser.Types.ENV_FILES,
238 ]))
239 if not pipeline.entrypoint:
240 if 'entrypoint' in image_data and len(image_data['entrypoint']) > 0:
241 pipeline.entrypoint = image_data['entrypoint'][:]
242 else:
243 pipeline.entrypoint = None
244 else:
245 pipeline.image = Image(
246 pipeline.variables.expand(
247 image_data, types=[
248 VariablesParser.Types.PARAMETERS,
249 VariablesParser.Types.LOCALS,
250 VariablesParser.Types.GLOBALS,
251 VariablesParser.Types.ENV_FILES,
252 ]))
253 if not pipeline.entrypoint:
254 pipeline.entrypoint = None
256 # Pipeline services
257 @staticmethod
258 def __pipeline_services(
259 services_data: List[Any],
260 pipeline: Pipeline,
261 ) -> None:
263 # Parse services data
264 pipeline.services = []
265 for item in services_data:
266 if isinstance(item, dict):
267 pipeline.services += [
268 Service(
269 image=Environment.expand(item.get('name', '')),
270 alias=item.get('alias', ''),
271 )
272 ]
273 elif isinstance(item, str):
274 pipeline.services += [
275 Service(
276 image=Environment.expand(item),
277 alias='',
278 )
279 ]
281 # Pipeline variables
282 @staticmethod
283 def __pipeline_variables(
284 pipeline: Pipeline,
285 variables_data: YAML.Data,
286 ) -> None:
288 # Parse variables data
289 for variable in variables_data:
290 if variable not in pipeline.variables.globals:
291 if variables_data[variable] is None:
292 pipeline.variables.globals[variable] = ''
293 else:
294 variable_data = variables_data[variable]
295 if isinstance(variable_data, dict):
296 variable_value = str(variable_data['value'])
297 else:
298 variable_value = str(variable_data)
299 pipeline.variables.globals[variable] = variable_value
301 # Include
302 @staticmethod
303 def __include(
304 data: YAML.Data,
305 stack: List[str],
306 root_directory: Path,
307 working_directory: Path,
308 parent_path: str,
309 ) -> None:
311 # Parse nested include
312 if data and 'include' in data and data['include']:
314 # Prepare includes nodes
315 data_include_list = []
316 if isinstance(data['include'], dict):
317 data_include_list = [data['include']]
318 elif isinstance(data['include'], list):
319 data_include_list = data['include']
320 elif isinstance(data['include'], str):
321 data_include_list = [{'local': data['include']}]
323 # Iterate through includes nodes
324 for include_node in data_include_list:
326 # Adapt include nodes
327 include_dict: dict = {} # type: ignore[type-arg]
328 if isinstance(include_node, dict):
329 include_dict = include_node
330 elif isinstance(include_node, str):
331 include_dict = {'local': include_node}
333 # Parse local nodes
334 if 'local' in include_dict:
335 GitLab.__include_local(
336 data,
337 stack,
338 root_directory,
339 working_directory,
340 include_dict,
341 parent_path,
342 )
344 # Parse project node
345 elif 'project' in include_dict:
346 GitLab.__include_project(
347 data,
348 stack,
349 working_directory,
350 include_dict,
351 parent_path,
352 )
354 # Parse component node
355 elif 'component' in include_dict:
356 GitLab.__include_component(
357 data,
358 stack,
359 working_directory,
360 include_dict,
361 parent_path,
362 )
364 # Include local, pylint: disable=too-many-arguments,too-many-locals,too-many-positional-arguments
365 @staticmethod
366 def __include_local(
367 data: YAML.Data,
368 stack: List[str],
369 root_directory: Path,
370 working_directory: Path,
371 include_dict: dict, # type: ignore[type-arg]
372 parent_path: str,
373 ) -> None:
375 # Variables
376 include_path: str = include_dict['local']
378 # Handle include relative paths
379 if include_path.startswith('/'):
380 include_path = include_path.lstrip('/')
381 resolved_path = Paths.resolve(root_directory / include_path)
383 # Handle include relative paths
384 else:
385 resolved_path = Paths.resolve(working_directory / include_path)
387 # Already included file
388 if resolved_path in stack:
389 return
391 # Parse inputs
392 file_inputs: YAML.Data = {}
393 if 'inputs' in include_dict and isinstance(include_dict['inputs'], dict):
394 file_inputs = include_dict['inputs']
396 # Existing file inclusion
397 file_paths: Path = Path(working_directory) / resolved_path
398 for file_path in Paths.wildcard(str(file_paths), strict=True):
399 if file_path.is_file():
401 # Register included file
402 stack.append(resolved_path)
404 # Load included file
405 with open(file_path, encoding='utf8', mode='r') as include_data:
406 data_additions: Optional[YAML.Data] = YAML.load(
407 include_data,
408 inputs=file_inputs,
409 configure=False,
410 )
411 if data_additions:
413 # Nested includes (follow working directory)
414 GitLab.__include(
415 data_additions,
416 stack,
417 root_directory,
418 file_path.parent,
419 resolved_path,
420 )
422 # Agregate data
423 GitLab.__merges(data_additions, data)
424 data.clear()
425 data.update(data_additions)
427 # Empty included file
428 else:
429 raise SyntaxError(
430 f'Empty "{file_path}" file included in "{parent_path}"')
432 # Missing file failure
433 else:
434 raise FileNotFoundError(
435 f'Missing "{file_path}" file included in "{parent_path}"')
437 # Include project
438 @staticmethod
439 def __include_project(
440 data: YAML.Data,
441 stack: List[str],
442 working_directory: Path,
443 include_dict: dict, # type: ignore[type-arg]
444 parent_path: str,
445 ) -> None:
447 # Variables
448 project_path: str = ''
449 project_url: str = include_dict['project']
451 # Acquire local node
452 if GitLab.LOCAL_NODE in data and data[GitLab.LOCAL_NODE]:
453 local = data[GitLab.LOCAL_NODE]
455 # Acquire local include node
456 if 'include' in local and project_url in local['include'] and isinstance(
457 local['include'][project_url], str):
458 project_path = Paths.expand(
459 local['include'][project_url],
460 env=True,
461 home=True,
462 )
464 # Exclude missing include node
465 if not project_path:
466 return
468 # Parse file paths
469 file_items: List[str] = []
470 if 'file' in include_dict:
471 if isinstance(include_dict['file'], list):
472 file_items = include_dict['file'][:]
473 elif isinstance(include_dict['file'], str):
474 file_items = [include_dict['file']]
476 # Parse inputs
477 file_inputs: YAML.Data = {}
478 if 'inputs' in include_dict and isinstance(include_dict['inputs'], dict):
479 file_inputs = include_dict['inputs']
481 # Iterate through file paths
482 for file_item in file_items:
484 # Already included file
485 resolved_path = Paths.resolve(
486 working_directory / project_path / file_item.lstrip('/'))
487 if resolved_path in stack:
488 continue
490 # Existing file inclusion
491 file_paths: Path = Path(working_directory) / resolved_path
492 for file_path in Paths.wildcard(str(file_paths), strict=True):
493 if file_path.is_file():
495 # Register included file
496 stack.append(resolved_path)
498 # Load included file
499 with open(file_path, encoding='utf8', mode='r') as include_data:
500 data_additions: Optional[YAML.Data] = YAML.load(
501 include_data,
502 inputs=file_inputs,
503 configure=False,
504 )
505 if data_additions:
507 # Nested includes (follow working directory)
508 project_working_directory: Path = Path(
509 working_directory) / Paths.resolve(
510 working_directory / project_path)
511 GitLab.__include(
512 data_additions,
513 stack,
514 project_working_directory,
515 project_working_directory,
516 str(file_path),
517 )
519 # Agregate data
520 GitLab.__merges(data_additions, data)
521 data.clear()
522 data.update(data_additions)
524 # Empty included file
525 else:
526 raise SyntaxError(
527 f'Empty "{file_path}" file included in "{parent_path}"')
529 # Missing file failure
530 else:
531 raise FileNotFoundError(
532 f'Missing "{file_path}" file included in "{parent_path}"')
534 # Include component
535 @staticmethod
536 def __include_component(
537 data: YAML.Data,
538 stack: List[str],
539 working_directory: Path,
540 include_dict: dict, # type: ignore[type-arg]
541 parent_path: str,
542 ) -> None:
544 # Variables
545 component_path: str = ''
546 component_url: str = include_dict['component']
548 # Acquire local node
549 if GitLab.LOCAL_NODE in data and data[GitLab.LOCAL_NODE]:
550 local = data[GitLab.LOCAL_NODE]
552 # Acquire local include node
553 if 'include' in local and component_url in local['include'] and isinstance(
554 local['include'][component_url], str):
555 component_path = Paths.expand(
556 local['include'][component_url],
557 env=True,
558 home=True,
559 )
561 # Exclude missing include node
562 if not component_path:
563 return
565 # Parse inputs
566 file_inputs: YAML.Data = {}
567 if 'inputs' in include_dict and isinstance(include_dict['inputs'], dict):
568 file_inputs = include_dict['inputs']
570 # Already included file
571 resolved_path = Paths.resolve(working_directory / component_path)
572 if resolved_path in stack:
573 return
575 # Existing file inclusion
576 file_paths: Path = Path(working_directory) / resolved_path
577 for file_path in Paths.wildcard(str(file_paths), strict=True):
578 if file_path.is_file():
580 # Register included file
581 stack.append(resolved_path)
583 # Load included file
584 with open(file_path, encoding='utf8', mode='r') as include_data:
585 data_additions: Optional[YAML.Data] = YAML.load(
586 include_data,
587 inputs=file_inputs,
588 configure=False,
589 )
590 if data_additions:
592 # Nested includes (follow working directory)
593 component_working_directory: Path = Path(
594 working_directory) / Paths.resolve(
595 working_directory / component_path)
596 GitLab.__include(
597 data_additions,
598 stack,
599 component_working_directory,
600 component_working_directory,
601 str(file_path),
602 )
604 # Agregate data
605 GitLab.__merges(data_additions, data)
606 data.clear()
607 data.update(data_additions)
609 # Empty included file
610 else:
611 raise SyntaxError(
612 f'Empty "{file_path}" file included in "{parent_path}"')
614 # Missing file failure
615 else:
616 raise FileNotFoundError(
617 f'Missing "{file_path}" file included in "{parent_path}"')
619 # Local, pylint: disable=too-many-branches,too-many-statements
620 @staticmethod
621 def __local(
622 options: Namespace,
623 pipeline: Pipeline,
624 data: YAML.Data,
625 ) -> None:
627 # Variables
628 local_unsupported: List[str]
629 names_local = False
631 # Filter local node, pylint: disable=too-many-nested-blocks
632 if GitLab.LOCAL_NODE in data and data[GitLab.LOCAL_NODE]:
633 local = data[GitLab.LOCAL_NODE]
635 # Parse local all
636 if 'all' in local and not options.all:
637 options.all = local['all']
639 # Parse local env
640 if 'env' in local:
641 pipeline.variables.local_parse_env(local['env'])
643 # Parse local image
644 if 'image' in local and not options.image:
645 options.image = local['image']
647 # Parse local names
648 if 'names' in local and not options.names and not options.pipeline:
649 names_local = True
650 options.names = local['names']
652 # Parse local no_regex
653 if 'no_regex' in local and not options.no_regex:
654 options.no_regex = local['no_regex']
656 # Parse local pipeline
657 if 'pipeline' in local and not options.pipeline and (not options.names
658 or names_local):
659 options.pipeline = local['pipeline']
661 # Parse local tags
662 if 'tags' in local and options.tags_default:
663 options.tags = local['tags'][:]
664 options.tags_default = False
666 # Parse local variables
667 if 'variables' in local:
668 pipeline.variables.local_parse_variables(local['variables'])
670 # Parse local version
671 if 'version' in local:
672 version: str = str(local['version'])
674 # Newer local recommended version
675 package_version: str = Version.get()
676 if package_version != '0.0.0' and parse_version(
677 package_version) < parse_version(version):
678 Updates.message(name=Bundle.PACKAGE, recommended=version)
679 sleep(2)
681 # Parse local volumes
682 if 'volumes' in local:
683 if not options.volume:
684 options.volume = []
685 for volume in local['volumes']:
686 options.volume += [Volumes.LOCAL_FLAG + volume]
688 # Detect unsupported local keys
689 local_unsupported = [
690 key for key in local if key not in [
691 'all',
692 'env',
693 'image',
694 'include',
695 'names',
696 'no_regex',
697 'pipeline',
698 'tags',
699 'variables',
700 'version',
701 'volumes',
702 ]
703 ]
704 if local_unsupported:
705 print(' ')
706 print(
707 f' {Colors.YELLOW}{Colors.ARROW} WARNING: '
708 f'{Colors.RED}.local: {", ".join(local_unsupported)}: '
709 f'{Colors.BOLD}YAML configuration '
710 f'{"are" if len(local_unsupported) > 1 else "is"} unsupported, see: '
711 f'{Colors.GREEN}{Bundle.REPOSITORY}/-/issues/292'
712 f'{Colors.RESET}')
713 Platform.flush()
714 if Platform.IS_TTY_STDIN:
715 sleep(5)
717 # Variants, pylint: disable=too-many-nested-blocks
718 @staticmethod
719 def __variants(
720 pipeline: Pipeline,
721 data: YAML.Data,
722 node: str,
723 ) -> List[Variant]:
725 # Variables
726 variants: List[GitLab.Variant] = []
728 # Handle parallel variants integer
729 if 'parallel' in data[node] and isinstance(data[node]['parallel'], int):
731 # Register all combinations
732 for parallel_index in range(1, data[node]['parallel'] + 1):
733 variants += [
734 GitLab.Variant(
735 name=f"{node} {parallel_index}/{data[node]['parallel']}",
736 variables={},
737 )
738 ]
740 # Handle matrix variants list
741 elif 'parallel' in data[node] and isinstance(data[node]['parallel'], dict) \
742 and 'matrix' in data[node]['parallel']:
744 # Iterate through matrix items
745 for matrix_item in data[node]['parallel']['matrix']:
747 # Prepare matrix map
748 matrix_item_map: Dict[str, List[str]] = {}
750 # Iterate through matrix item
751 for matrix_variable, matrix_values in matrix_item.items():
753 # Already defined environment variable
754 if matrix_variable in pipeline.variables.parameters:
755 matrix_item_map[matrix_variable] = [
756 pipeline.variables.parameters[matrix_variable]
757 ]
759 # Already defined environment variable
760 elif matrix_variable in environ:
761 matrix_item_map[matrix_variable] = [environ[matrix_variable]]
763 # Matrix defined environment variable
764 else:
765 matrix_item_map[matrix_variable] = []
766 if isinstance(matrix_values, str):
767 matrix_item_map[matrix_variable] += [matrix_values]
768 elif isinstance(matrix_values, list):
769 for matrix_value in matrix_values:
770 matrix_item_map[matrix_variable] += [matrix_value]
772 # Extract all combinations
773 keys, values = zip(*matrix_item_map.items())
774 matrix_item_environments: List[Dict[str, str]] = [
775 dict(zip(keys, v)) for v in product(*values)
776 ]
778 # Register all combinations
779 for matrix_item_variables in matrix_item_environments:
780 variants += [
781 GitLab.Variant(
782 name=
783 f"{node}: [{', '.join(list(matrix_item_variables.values()))}]",
784 variables=matrix_item_variables,
785 )
786 ]
788 # Prepare default variants list
789 else:
790 variants = [GitLab.Variant(
791 name=node,
792 variables={},
793 )]
795 # Result
796 return variants
798 # Job, pylint: disable=too-many-arguments,too-many-branches,too-many-locals,too-many-positional-arguments,too-many-statements
799 @staticmethod
800 def job(
801 options: Namespace,
802 pipeline: Pipeline,
803 job_node: str,
804 job_name: str,
805 data: YAML.Data,
806 extend: bool = False,
807 ) -> JobData:
809 # Variables
810 job: JobData = JobData(name=job_name)
811 job_data = data[job_node]
813 # Prepare options
814 job.options.env_builds_path = GitLab.ENV_BUILDS_DIR
815 job.options.env_job_name = GitLab.ENV_JOB_NAME
816 job.options.env_job_name_slug = GitLab.ENV_JOB_NAME_SLUG
817 job.options.env_job_path = GitLab.ENV_PROJECT_DIR
819 # Extract job extends
820 if 'extends' in job_data and job_data['extends']:
821 if isinstance(job_data['extends'], list):
822 job_extends = job_data['extends']
823 else:
824 job_extends = [job_data['extends']]
826 # Iterate through extended jobs
827 for job_extend in reversed(job_extends):
829 # Validate extended job
830 if job_extend not in data:
831 job.options.extends_unknown += [f'{job_extend} unknown']
832 continue
834 # Parse extended job
835 job_extended: JobData = GitLab.job(
836 options,
837 pipeline,
838 job_extend,
839 job_extend,
840 data,
841 True,
842 )
844 # List available extended job
845 job.options.extends_available += [job_extend]
847 # Extract extended job
848 if job.stage is None:
849 job.stage = job_extended.stage
850 if job.image is None:
851 job.image = job_extended.image
852 if job.entrypoint is None:
853 job.entrypoint = job_extended.entrypoint
854 if job_extended.variables:
855 for variable, value in job_extended.variables.items():
856 if variable not in job.variables:
857 job.variables[variable] = value
858 if job.before_script is None:
859 job.before_script = job_extended.before_script
860 if job.script is None:
861 job.script = job_extended.script
862 if job.after_script is None:
863 job.after_script = job_extended.after_script
864 if job.retry is None:
865 job.retry = job_extended.retry
866 if job.when is None:
867 job.when = job_extended.when
868 if job.allow_failure is None:
869 job.allow_failure = job_extended.allow_failure
870 if job.services is None:
871 job.services = job_extended.services
872 if job.tags is None:
873 job.tags = job_extended.tags
874 if job.trigger is None:
875 job.trigger = job_extended.trigger
877 # Apply pipeline values
878 if not extend:
879 if job.image is None:
880 job.image = pipeline.image
881 if job.entrypoint is None:
882 job.entrypoint = pipeline.entrypoint[:] if pipeline.entrypoint else None
883 if job.before_script is None:
884 job.before_script = pipeline.before_script[:]
885 if job.script is None:
886 job.script = []
887 if job.after_script is None:
888 job.after_script = pipeline.after_script[:]
889 if job.retry is None:
890 job.retry = 0
891 if job.services is None:
892 job.services = pipeline.services[:]
893 if job.when is None:
894 job.when = 'on_success'
895 if job.allow_failure is None:
896 job.allow_failure = False
898 # Extract job stage
899 if 'stage' in job_data and job_data['stage']:
900 job.stage = job_data['stage']
901 elif job.stage is None and not extend:
902 job.stage = GitLab.JOB_STAGE_DEFAULT
904 # Extract job image
905 if 'image' in job_data and job_data['image']:
906 image_data = job_data['image']
907 if isinstance(image_data, dict):
908 job.image = Environment.expand(image_data['name'])
909 if 'entrypoint' in image_data and len(image_data['entrypoint']) > 0:
910 job.entrypoint = image_data['entrypoint'][:]
911 else:
912 job.entrypoint = None
913 else:
914 job.image = Environment.expand(image_data)
915 job.entrypoint = None
917 # Extract job variables
918 if 'variables' in job_data and job_data['variables']:
919 job.variables.update(job_data['variables'])
921 # Prepare job variables
922 job.variables = pipeline.variables.evaluate_job(job.variables)
924 # Extract job before_script
925 if 'before_script' in job_data:
926 job.before_script = GitLab.__scripts(job_data['before_script'])
928 # Extract job script
929 if 'script' in job_data:
930 if options.commands:
931 job.script = GitLab.__scripts(options.commands)
932 else:
933 job.script = GitLab.__scripts(job_data['script'])
935 # Extract job after_script
936 if 'after_script' in job_data:
937 job.after_script = GitLab.__scripts(job_data['after_script'])
939 # Extract job retry
940 if 'retry' in job_data:
941 retry_data = job_data['retry']
942 if isinstance(retry_data, dict):
943 job.retry = int(retry_data['max'])
944 else:
945 job.retry = int(retry_data)
947 # Extract job when
948 if 'when' in job_data and job_data['when'] in [
949 'on_success', 'on_failure', 'always', 'manual'
950 ]:
951 job.when = job_data['when']
953 # Extract job allow_failure
954 if 'allow_failure' in job_data and job_data['allow_failure'] in [True, False]:
955 job.allow_failure = job_data['allow_failure']
957 # Extract job services
958 if 'services' in job_data and isinstance(job_data['services'], list):
959 job.services = []
960 for item in job_data['services']:
961 if isinstance(item, dict):
962 job.services += [
963 Service(
964 image=Environment.expand(item.get('name', '')),
965 alias=item.get('alias', ''),
966 )
967 ]
968 elif isinstance(item, str):
969 job.services += [Service(
970 image=Environment.expand(item),
971 alias='',
972 )]
974 # Extract job tags
975 if 'tags' in job_data and isinstance(job_data['tags'], list):
976 job.tags = job_data['tags'][:]
977 assert job.tags is not None
978 for index, tag in enumerate(job.tags):
979 job.tags[index] = Environment.expand(tag)
981 # Finalize job extends
982 if 'extends' in job_data and job_data['extends']:
984 # Allowed incomplete extended job
985 if Flags.enabled(Bundle.FLAG_EXTENDS_INCOMPLETE, job.variables):
986 pass
988 # Detect incomplete extended job
989 elif job.options.extends_unknown and \
990 (len(job_extends) == 1 or len(job.options.extends_available) == 0):
991 job.options.disabled = ', '.join(job.options.extends_unknown)
993 # Extract job trigger
994 if 'trigger' in job_data and job_data['trigger']:
995 job.options.disabled = 'trigger only'
996 if isinstance(job_data['trigger'], (dict, str)):
997 job.trigger = job_data['trigger']
999 # Finalize pipeline values
1000 if not extend:
1002 # Configure job tags
1003 if job.tags and (set(job.tags) & set(options.tags)):
1004 job.when = 'manual'
1006 # Default GitLab image
1007 if not job.image and not extend:
1008 if environ.get(Bundle.ENV_IMAGE_DEFAULT, ''):
1009 job.image = environ[Bundle.ENV_IMAGE_DEFAULT][:]
1010 else:
1011 job.image = GitLab.JOB_IMAGE_DEFAULT[:]
1013 # Detect GIT_CLONE_PATH
1014 if GitLab.ENV_GIT_CLONE_PATH in job.variables:
1015 job.options.git_clone_path = job.variables[GitLab.ENV_GIT_CLONE_PATH]
1017 # Detect host jobs
1018 if Flags.check_bool(
1019 option=options.host,
1020 flag=Bundle.FLAG_HOST,
1021 variables=job.variables,
1022 default=False,
1023 ):
1024 job.options.host = True
1025 elif job.image:
1026 job.options.host = Images.host(job.image)
1027 if Images.quiet(job.image):
1028 job.variables[Bundle.FLAG_QUIET] = Flags.string(Images.quiet(job.image))
1029 job.options.silent = Images.silent(job.image)
1031 # Apply verbose option
1032 job.options.verbose = not Flags.check_bool(
1033 option=options.no_verbose,
1034 flag=Bundle.FLAG_NO_VERBOSE,
1035 variables=job.variables,
1036 default=False,
1037 ) and not job.options.silent
1039 # Detect sockets services
1040 if job.services:
1041 for service in job.services:
1042 if match(Images.DOCKER_DIND_REGEX, service.image):
1043 job.options.sockets = True
1045 # Apply sockets option
1046 if not job.options.sockets:
1047 job.options.sockets = Flags.check_bool(
1048 option=options.sockets,
1049 flag=Bundle.FLAG_SOCKETS,
1050 variables=job.variables,
1051 default=False,
1052 )
1054 # Apply ssh option
1055 if not job.options.ssh:
1056 job.options.ssh = Flags.check_strbool(
1057 option=options.ssh,
1058 variables=job.variables,
1059 flag=Bundle.FLAG_SSH,
1060 default='',
1061 const=Bundle.ARGUMENT_SSH_USER_DEFAULT,
1062 )
1064 # Result
1065 return job
1067 # Parse
1068 def parse(
1069 self,
1070 data: YAML.Data,
1071 ) -> Pipeline:
1073 # Variables
1074 pipeline: Pipeline = Pipeline(self.__options)
1075 stages: Dict[str, int] = GitLab.STAGES_DEFAULT.copy()
1077 # Cache environment
1078 pipeline.variables.environment_cache()
1080 # Parse nested include
1081 GitLab.__include(
1082 data,
1083 [Paths.resolve(self.__options.path / self.__options.configuration)],
1084 self.__options.path,
1085 self.__options.path,
1086 Paths.resolve(self.__options.path / self.__options.configuration),
1087 )
1089 # Resolve YAML nodes
1090 YAML.resolve(data)
1092 # Filter local node
1093 self.__local(self.__options, pipeline, data)
1095 # Apply variables
1096 pipeline.variables.apply(types=[
1097 VariablesParser.Types.PARAMETERS,
1098 VariablesParser.Types.LOCALS,
1099 VariablesParser.Types.ENV_FILES,
1100 ])
1102 # Prepare pipeline image
1103 if self.__options.image:
1104 if isinstance(self.__options.image, dict):
1105 if 'name' in self.__options.image:
1106 pipeline.image = Environment.expand(self.__options.image['name'])
1107 if 'entrypoint' in self.__options.image and len(
1108 self.__options.image['entrypoint']) > 0:
1109 pipeline.entrypoint = self.__options.image['entrypoint'][:]
1110 else:
1111 pipeline.entrypoint = None
1112 else:
1113 pipeline.image = Environment.expand(self.__options.image)
1114 pipeline.entrypoint = None
1116 # Parse pipeline nodes
1117 GitLab.__pipeline(
1118 pipeline,
1119 data,
1120 stages,
1121 )
1123 # Iterate through nodes
1124 for node in data:
1126 # Ignore pipeline nodes
1127 if node in [
1128 'after_script',
1129 'before_script',
1130 'image',
1131 'include',
1132 'services',
1133 'stages',
1134 'variables',
1135 ]:
1136 continue
1138 # Validate job node
1139 if 'script' not in data[node] and 'extends' not in data[node]:
1140 continue
1142 # Ignore template stage
1143 if node[0:1] == '.':
1144 continue
1146 # Nodes variants
1147 variants = GitLab.__variants(
1148 pipeline,
1149 data,
1150 node,
1151 )
1153 # Iterate through node
1154 for variant in variants:
1156 # Restore environment
1157 pipeline.variables.environment_restore()
1159 # Apply variables
1160 pipeline.variables.apply(types=[
1161 VariablesParser.Types.PARAMETERS,
1162 VariablesParser.Types.LOCALS,
1163 VariablesParser.Types.GLOBALS,
1164 VariablesParser.Types.ENV_FILES,
1165 ])
1167 # Acquire variant name
1168 name = variant.name
1170 # Prepare variant variables
1171 if variant.variables:
1172 VariablesParser.environment_update(variant.variables)
1174 # Register job
1175 pipeline.jobs[name] = Job(
1176 self.job(
1177 self.__options,
1178 pipeline,
1179 node,
1180 name,
1181 data,
1182 ))
1184 # Prepare variant variables
1185 if variant.variables:
1186 pipeline.jobs[name].variables.update(variant.variables)
1188 # Validate job script
1189 if not pipeline.jobs[name].options.disabled and not pipeline.jobs[
1190 name].script:
1191 if self.__options.no_script_fail:
1192 raise ValueError(
1193 f"Missing \"script\" key for \"{pipeline.jobs[name].stage}" \
1194 f" / {pipeline.jobs[name].name}\""
1195 )
1196 pipeline.jobs[name].options.disabled = 'Missing "script" key'
1198 # Append unknown stage if required
1199 if pipeline.jobs[name].options.disabled \
1200 and pipeline.jobs[name].stage == GitLab.JOB_STAGE_DEFAULT \
1201 and GitLab.JOB_STAGE_DEFAULT not in stages:
1202 stages[GitLab.JOB_STAGE_DEFAULT] = list(stages.values())[-1] + 1
1204 # Validate job stage
1205 if pipeline.jobs[name].stage not in stages:
1206 raise ValueError(
1207 f"Unknown stage \"{pipeline.jobs[name].stage}\"" \
1208 f" for \"{pipeline.jobs[name].name}\""
1209 )
1211 # Restore environment
1212 pipeline.variables.environment_restore()
1214 # Sort pipeline jobs based on stages
1215 pipeline.jobs = OrderedDict(
1216 sorted(
1217 pipeline.jobs.items(),
1218 key=lambda x: stages[x[1].stage],
1219 ))
1221 # Result
1222 return pipeline