Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85711283
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
49 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/lilybuild/lilybuild/ci_steps.py b/lilybuild/lilybuild/ci_steps.py
index 0f68270..fda1496 100644
--- a/lilybuild/lilybuild/ci_steps.py
+++ b/lilybuild/lilybuild/ci_steps.py
@@ -1,453 +1,488 @@
from buildbot.plugins import *
from buildbot.process import buildstep, logobserver
from buildbot.interfaces import IRenderable
from twisted.internet import defer
from .ci_syntax import ci_file
+from .ci_syntax import rules as ci_rules
from .helpers import rsync_rules_from_artifacts, get_job_script, normalize_image
from .phorge import SendCoverageToPhorge
import re
import sys
import json
SAFETAR_EXEC = '/lilybuild/lilybuild/safetar.py'
COVERAGE_EXEC = '/lilybuild/lilybuild/coverage.py'
def on_success(step):
return step.build.results == util.SUCCESS
def on_always(_step):
return True
def fill_list(*args):
return list(args)
class RunCIJobStep(steps.BuildStep):
# 200 MiB
artifact_max_size = 200 * 1024 * 1024
default_image = 'alpine'
master_job_artifact_dir_pattern = '%(kw:st)s/repos/%(prop:lilybuild_repo_id)s/builds/%(prop:lilybuild_root_build_id)s/jobs/%(kw:job)s/artifacts'
artifact_file_name = 'artifacts.tar'
master_job_artifact_file_name_pattern = master_job_artifact_dir_pattern + '/' + artifact_file_name
reports_file_name = 'reports.tar'
master_reports_file_name_pattern = master_job_artifact_dir_pattern + '/' + reports_file_name
master_pages_dir_pattern = '%(kw:st)s/repos/%(prop:lilybuild_repo_id)s/pages'
phorge_coverage_file_name = 'coverage-phorge.json'
def __init__(
self,
lbc,
src_relative=None,
src_dir=None,
storage_dir=None,
repo_id=None,
result_relative=None,
result_dir=None,
artifact_stage_relative=None,
artifact_stage_dir=None,
job_prop=None,
artifact_link_base=None,
**kwargs):
self.lbc = lbc
self.src_relative = src_relative
self.src_dir = src_dir
self.work_root_dir = kwargs['workdir']
self.script_dir = 'script'
self.storage_dir = storage_dir
self.repo_id = repo_id
self.artifact_stage_relative = artifact_stage_relative
self.artifact_stage_dir = artifact_stage_dir
self.result_relative = result_relative
self.result_dir = result_dir
self.artifact_link_base = artifact_link_base
super().__init__(name='Run step', **kwargs)
@defer.inlineCallbacks
def run(self):
job_prop = self.getProperty('lilybuild_job_prop')
job = ci_file.CIJob.from_prop(job_prop)
job_index = self.getProperty('lilybuild_job_index')
+ variables = yield self.get_ci_variables(job)
- next_steps = yield self.job_to_steps(job, job_index)
-
- self.build.addStepsAfterCurrentStep(next_steps)
- return util.SUCCESS
+ should_run = True
+ if len(job.rules):
+ should_run = False
+ for r in job.rules:
+ try:
+ rule_str = r.get('if')
+ if not rule_str:
+ continue
+
+ res = ci_rules.evaluate_rule(ci_rules.parse_rule(rule_str), variables)
+ if not res:
+ continue
+ when = r.get('when', 'on_success')
+ if when == 'never':
+ should_run = False
+ else:
+ should_run = True
+ break
+ except SyntaxError:
+ self.addCompleteLog('error', f'Rule "{rule_str}" has syntax errors')
+ except:
+ pass
+
+ if should_run:
+ next_steps = self.job_to_steps(job, job_index, variables)
+
+ self.build.addStepsAfterCurrentStep(next_steps)
+ return util.SUCCESS
+ else:
+ self.addCompleteLog('info', 'Job skipped by a rule')
+ return util.SKIPPED
@defer.inlineCallbacks
def get_ci_variables(self, job):
res = {}
res.update(job.get_predefined_ci_variables())
res.update(self.getProperty('lilybuild_pipeline_vars'))
res['CI_JOB_IMAGE'] = job.image or self.default_image
res['CI_JOB_URL'] = yield self.build.getUrl()
res['CI_JOB_ID'] = self.build.buildid
res['CI_PROJECT_DIR'] = '/build'
try:
repo = self.lbc.repos[self.getProperty('lilybuild_repo_id')]
variables = yield repo['variables_getter'](self.build)
res_vars = {}
for var in variables:
value = variables[var]
if IRenderable.providedBy(value):
value = yield self.build.render(value)
res_vars[var] = value
res.update(res_vars)
except Exception as e:
self.addCompleteLog('exception', f'{e}')
return res
def get_upload_artifacts_jobs(self, short_name, artifact_type, artifact_name, base_dir, paths, exclude, master_pattern, job_index, doStepIf=on_success, has_pages=False):
archive_artifact_step = steps.ShellCommand(
name=f'Archive artifacts: {short_name}',
command=[
SAFETAR_EXEC,
],
initialStdin=json.dumps({
'op': 'create',
'archive_file': artifact_name,
'base_dir': base_dir,
'content': paths,
'items_to_exclude': exclude,
}),
workdir=self.work_root_dir,
doStepIf=doStepIf,
)
masterdest = util.Interpolate(
master_pattern,
st=self.storage_dir,
job=job_index,
doStepIf=doStepIf,
)
parent_build_id = self.getProperty('lilybuild_pipeline_vars')['CI_PIPELINE_ID']
artifact_url = f'{self.artifact_link_base}/plugins/lilybuild_artifacts/builds/{parent_build_id}/jobs/{job_index}/artifacts/{artifact_type}' if self.artifact_link_base else None
upload_artifact_step = steps.FileUpload(
workersrc=artifact_name,
maxsize=self.artifact_max_size,
name=f'Upload artifacts: {short_name}',
masterdest=masterdest,
workdir=self.work_root_dir,
url=artifact_url,
doStepIf=doStepIf,
)
r = [archive_artifact_step, upload_artifact_step]
if has_pages:
r.append(steps.MasterShellCommand(
command=util.Transform(fill_list,
sys.executable,
'-m', 'lilybuild.pages',
util.Interpolate(
self.master_pages_dir_pattern,
st=self.storage_dir,
),
masterdest,
),
name='Deploy pages',
logEnviron=False,
doStepIf=doStepIf,
))
return r
- @defer.inlineCallbacks
- def job_to_steps(self, job, job_index):
+ def job_to_steps(self, job, job_index, variables):
script_name = self.script_dir + '/run.sh'
- variables = yield self.get_ci_variables(job)
+
+ source_step = self.lbc.create_source_step()
+
script_step = steps.StringDownload(
get_job_script(variables, job),
name='Set up script',
workerdest=script_name,
workdir=self.work_root_dir,
doStepIf=on_success,
)
chmod_step = steps.ShellCommand(
name='Make script executable',
command=['chmod', '+x', script_name],
workdir=self.work_root_dir,
doStepIf=on_success,
)
artifact_steps = []
dep_job_indices = self.getProperty('lilybuild_dependency_job_indices')
if dep_job_indices:
for i in dep_job_indices:
+ # The steps may not run or may not have an artifact even if it runs
download_job = steps.FileDownload(
mastersrc=util.Interpolate(
self.master_job_artifact_file_name_pattern,
st=self.storage_dir,
job=i,
),
maxsize=self.artifact_max_size,
name=f'Download artifacts from job #{i}',
workerdest=self.artifact_file_name,
workdir=self.work_root_dir,
doStepIf=on_success,
+ haltOnFailure=False,
+ flunkOnFailure=False,
)
unarchive_job = steps.ShellCommand(
name=f'Unarchive artifacts from job #{i}',
command=[
SAFETAR_EXEC,
],
initialStdin=json.dumps({
'op': 'extract',
'archive_file': self.artifact_file_name,
'target_dir': self.src_relative,
}),
workdir=self.work_root_dir,
doStepIf=on_success,
+ haltOnFailure=False,
+ flunkOnFailure=False,
)
artifact_steps += [download_job, unarchive_job]
run_step = steps.ShellCommand(
name='Run script in container',
command=[
'/lilybuild/podman-helper',
normalize_image(job.image or self.default_image),
self.src_relative,
self.script_dir,
self.result_relative,
],
workdir=self.work_root_dir,
doStepIf=on_success,
)
clean_script_step = steps.ShellCommand(
name='Clean script dir',
command=[
'rm',
'-rf',
self.script_dir,
],
workdir=self.work_root_dir,
alwaysRun=True,
)
- steps_to_run = [script_step, chmod_step] + artifact_steps + [run_step, clean_script_step]
+ steps_to_run = [source_step, script_step, chmod_step] + artifact_steps + [run_step, clean_script_step]
if 'paths' in job.artifacts:
steps_to_run += self.get_upload_artifacts_jobs(
'files',
'archive',
self.artifact_file_name,
self.result_relative,
job.artifacts.get('paths', []),
job.artifacts.get('exclude', []),
self.master_job_artifact_file_name_pattern,
job_index,
has_pages=job.is_pages()
)
if job.has_supported_coverage_report():
steps_to_run += [steps.ShellCommand(
name='Process reports',
command=[
COVERAGE_EXEC,
],
initialStdin=json.dumps({
'source_dir': self.src_relative,
'result_dir': self.result_relative,
'untrusted_coverage_file': job.artifacts['reports']['coverage_report']['path'],
'output_dir': self.artifact_stage_relative,
}),
workdir=self.work_root_dir,
doStepIf=on_always,
)] + self.get_upload_artifacts_jobs(
'reports',
'reports',
self.reports_file_name,
self.artifact_stage_relative,
['*'],
[],
self.master_reports_file_name_pattern,
job_index,
doStepIf=on_always
) + [SendCoverageToPhorge(
self.lbc,
self.artifact_stage_relative + '/' + self.phorge_coverage_file_name,
workdir=self.work_root_dir,
)]
clean_stage_dir_again_step = steps.ShellCommand(
name='Clean stage, result and artifact',
command=[
'rm',
'-rf',
self.result_relative,
self.artifact_file_name,
self.artifact_stage_relative,
self.reports_file_name,
],
workdir=self.work_root_dir,
alwaysRun=True,
)
steps_to_run.append(clean_stage_dir_again_step)
return steps_to_run
class TriggerMultipleJobsStep(steps.Trigger):
properties_to_keep = [
'branch',
'revision',
'repository',
'harbormaster_build_target_phid',
'harbormaster_variable_buildable.diff',
'harbormaster_variable_repository.staging.ref',
'harbormaster_variable_repository.staging.uri',
'harbormaster_variable_repository.uri',
'lilybuild_repo',
'lilybuild_repo_id',
'lilybuild_pipeline_vars',
]
def __init__(self, lbc, jobs_with_data, **kwargs):
self.lbc = lbc
self.jobs = jobs_with_data
super().__init__(schedulerNames=[self.lbc.triggerable_scheduler_name], **kwargs)
def getSchedulersAndProperties(self):
ret = []
common_properties = {
'lilybuild_root_build_id': self.build.buildid,
}
for prop in self.properties_to_keep:
if self.hasProperty(prop):
common_properties[prop] = self.getProperty(prop)
for (job, i, dep_job_indices) in self.jobs:
properties = common_properties.copy()
properties['lilybuild_job_prop'] = job.to_prop()
properties['lilybuild_job_index'] = i
properties['virtual_builder_name'] = 'lilybuild-job - ' + common_properties['lilybuild_repo'] + ' - ' + job.name
properties['lilybuild_dependency_job_indices'] = dep_job_indices
ret.append({
'sched_name': self.lbc.triggerable_scheduler_name,
'props_to_set': properties,
'unimportant': False,
})
return ret
class AnalyzeCIFileCommand(buildstep.ShellMixin, steps.BuildStep):
ci_def_file = '.gitlab-ci.yml'
build_target_prop_name = 'harbormaster_build_target_phid'
def __init__(
self,
lbc,
src_relative=None,
src_dir=None,
storage_dir=None,
repo_id=None,
result_relative=None,
result_dir=None,
artifact_stage_relative=None,
artifact_stage_dir=None,
**kwargs):
kwargs['name'] = 'Analyze CI file'
kwargs['command'] = ['cat', self.ci_def_file]
self.lbc = lbc
self.src_relative = src_relative
self.src_dir = src_dir
self.work_root_dir = kwargs['workdir']
self.script_dir = 'script'
self.storage_dir = storage_dir
self.repo_id = repo_id
self.artifact_stage_relative = artifact_stage_relative
self.artifact_stage_dir = artifact_stage_dir
self.result_relative = result_relative
self.result_dir = result_dir
kwargs['workdir'] = self.src_dir
kwargs = self.setupShellMixin(kwargs)
super().__init__(**kwargs)
self.observer = logobserver.BufferLogObserver()
self.addLogObserver('stdio', self.observer)
def stage_to_step(self, stage_name, stage_jobs, job_name_to_index_map, ci_file):
jobs_with_data = []
for job in stage_jobs:
dep_job_names = [
jn
for jn in ci_file.get_jobs_to_pull_artifacts_from(job.name)
if ci_file.jobs[jn].has_artifacts_archive()
]
dep_job_indices = [job_name_to_index_map[jn] for jn in dep_job_names]
jobs_with_data.append((job, job_name_to_index_map[job.name], dep_job_indices))
trigger = TriggerMultipleJobsStep(
name=stage_name,
lbc=self.lbc,
jobs_with_data=jobs_with_data,
waitForFinish=True,
doStepIf=on_success,
)
return trigger
def get_steps(self, stdout):
f = ci_file.CIFile(stdout)
stages = f.get_grouped_jobs()
jobs = [job for (stage, js) in f.get_grouped_jobs() for job in js]
job_names = [job.name for job in jobs]
job_name_to_index_map = {}
for (i, j) in enumerate(jobs):
job_name_to_index_map[j.name] = i
steps = [self.stage_to_step(stage_name, stage_jobs, job_name_to_index_map, f) for (stage_name, stage_jobs) in stages]
print('steps:', steps)
return steps
def get_is_phorge(self):
return not not self.getProperty(self.build_target_prop_name)
def get_ref_and_type(self):
ref_type = 'branch'
ref = self.getProperty('branch')
if self.getProperty('category') == 'tag':
ref_type = 'tag'
if ref is not None:
m = re.match(r'^refs/(heads|tags)/(.+)$', ref)
if m:
ref = m.group(2)
return (ref, ref_type)
@defer.inlineCallbacks
def get_pipeline_ci_vars(self):
url = yield self.build.getUrl()
res = {
'CI_PIPELINE_ID': self.build.buildid,
'CI_PIPELINE_IID': self.build.buildid,
'CI_PIPELINE_URL': url,
'CI_PROJECT_ID': self.getProperty('lilybuild_repo_id'),
'CI_CONFIG_PATH': self.ci_def_file,
}
if not self.get_is_phorge():
res['CI_COMMIT_SHA'] = self.getProperty('got_revision')
res['CI_COMMIT_SHORT_SHA'] = res['CI_COMMIT_SHA'][:8]
(ref, ref_type) = self.get_ref_and_type()
res['CI_COMMIT_REF_NAME'] = ref
res['CI_COMMIT_REF_SLUG'] = ci_file.ci_slugify(ref)
res['CI_COMMIT_REF_PROTECTED'] = 'false'
if ref_type == 'tag':
res['CI_COMMIT_TAG'] = ref
elif ref_type == 'branch':
res['CI_COMMIT_BRANCH'] = ref
return res
@defer.inlineCallbacks
def run(self):
# run './build.sh --list-stages' to generate the list of stages
cmd = yield self.makeRemoteShellCommand()
yield self.runCommand(cmd)
# if the command passes extract the list of stages
result = cmd.results()
if result == util.SUCCESS:
pipeline_vars = yield self.get_pipeline_ci_vars()
self.setProperty('lilybuild_pipeline_vars', pipeline_vars, self.__class__.__name__)
# create a ShellCommand for each stage and add them to the build
self.build.addStepsAfterCurrentStep(self.get_steps(self.observer.getStdout()))
return result
diff --git a/lilybuild/lilybuild/ci_syntax/ci_file.py b/lilybuild/lilybuild/ci_syntax/ci_file.py
index 7d8e8fe..a3b10dd 100644
--- a/lilybuild/lilybuild/ci_syntax/ci_file.py
+++ b/lilybuild/lilybuild/ci_syntax/ci_file.py
@@ -1,237 +1,240 @@
import yaml
import jsonschema
import json
import os
import re
# https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/assets/javascripts/editor/schema/ci.json
schema_file = os.path.join(os.path.dirname(__file__), 'ci.json')
schema = None
extend_limit = 11
class CIValidationError(Exception):
pass
def get_schema():
global schema
if schema is not None:
return schema
else:
with open(schema_file, 'r') as f:
schema = json.loads(f.read())
return schema
def toplevel_entries():
return get_schema()['properties']
def normalize_script(script):
if script is None:
return []
elif isinstance(script, str):
return [script]
else:
res = []
for l in script:
if isinstance(l, str):
res.append(l)
else:
res += l
return res
slug_re = re.compile('[^0-9a-z]')
def ci_slugify(s):
return re.sub(slug_re, '-', s.lower()[:63]).strip('-')
def get_job_extend_seq(job_name, all_jobs, depth=0):
# DFS traversal; child-first, self-last order
res = []
if job_name not in all_jobs:
raise CIValidationError(f'Job "{job_name}" does not exist, but occurs in `extends`.')
job_struct = all_jobs.get(job_name) or {}
parents = job_struct.get('extends', [])
if depth > extend_limit:
raise CIValidationError(f'`extends` depth is over the limit of {extend_limit}.')
if isinstance(parents, str):
parents = [parents]
for p in parents:
res += get_job_extend_seq(p, all_jobs, depth + 1)
res.append(job_name)
return res
depth_limit = 20
def merge_job_deep(res, parent, depth=0):
res_keys = set(res.keys())
if depth > depth_limit:
raise CIValidationError(f'depth is over the limit when merging job.')
for k in parent:
if k not in res_keys:
res[k] = parent[k]
elif isinstance(parent[k], dict) and isinstance(res[k], dict):
child = res[k]
# Necessary to avoid changing anything inside res[k] that was shallow-copied
res[k] = {}
merge_job_deep(res[k], child, depth + 1)
merge_job_deep(res[k], parent[k], depth + 1)
else:
# Presenting in both parent and child, and it's not a dict,
# so the child should take preference.
pass
def expand_job(job_name, all_jobs, defaults):
seq = get_job_extend_seq(job_name, all_jobs)
res = {}
for ancestor_name in reversed(seq):
merge_job_deep(res, all_jobs[ancestor_name])
merge_job_deep(res, defaults)
if 'extends' in res:
del res['extends']
return res
class CIJob:
def __init__(self, job_name, job_stage, job_struct):
self.name = job_name
self.stage = job_stage
self.struct_raw = job_struct
self.image = job_struct.get('image')
self.before_script = normalize_script(job_struct.get('before_script'))
self.script = normalize_script(job_struct.get('script'))
self.after_script = normalize_script(job_struct.get('after_script'))
self.artifacts = job_struct.get('artifacts') or {}
+ self.rules = job_struct.get('rules') or []
self.dependencies = job_struct.get('dependencies')
def get_predefined_ci_variables(self):
- return {
+ vs = {
'CI': 'true',
'CI_JOB_NAME': self.name,
'CI_JOB_NAME_SLUG': ci_slugify(self.name),
'CI_JOB_STAGE': self.stage,
}
+ vs.update(self.struct_raw.get('variables', {}))
+ return vs
def has_artifacts_archive(self):
return self.artifacts and 'paths' in self.artifacts
def to_prop(self):
return {
'name': self.name,
'stage': self.stage,
'struct_raw': self.struct_raw,
}
@classmethod
def from_prop(cls, prop):
return cls(
prop['name'],
prop['stage'],
prop['struct_raw']
)
def is_pages(self):
return self.name == 'pages' or self.struct_raw.get('pages', False)
def has_supported_coverage_report(self):
return (
'reports' in self.artifacts
and self.artifacts['reports'].get('coverage_report')
and self.artifacts['reports']['coverage_report'].get('coverage_format') == 'cobertura'
and self.artifacts['reports']['coverage_report'].get('path')
)
OLD_TOPLEVEL_DEFAULTS = ['image', 'services', 'cache', 'before_script', 'after_script']
DEFAULT_STAGES = ['.pre', 'build', 'test', 'deploy', '.post']
DEFAULT_JOB_STAGE = 'test'
class CIFile:
'''
Class for parsing CI file.
'''
def __init__(self, file_content):
'''
Construct a CI File from its text content.
'''
f = yaml.safe_load(file_content)
if f is None:
self.stages = []
self.jobs = {}
return
jsonschema.validate(instance=f, schema=get_schema())
self.stages = f.get('stages', DEFAULT_STAGES)
self.jobs = {}
defaults = f.get('default', {})
if f.get('variables'):
defaults['variables'] = f['variables']
for kw in OLD_TOPLEVEL_DEFAULTS:
if kw not in defaults and kw in f:
defaults[kw] = f[kw]
all_jobs = {}
for job_name, job_struct in f.items():
# 'pages' is a special job
if job_name != 'pages' and job_name in toplevel_entries():
continue
all_jobs[job_name] = job_struct
for job_name in all_jobs:
# jobs starting with . will only be used for base jobs of other jobs,
# they themselves are not run
if job_name.startswith('.'):
continue
job_struct = expand_job(job_name, all_jobs, defaults)
job_stage = job_struct.get('stage', DEFAULT_JOB_STAGE)
if job_stage not in self.stages:
raise CIValidationError(f'Job "{job_name}": Stage "{job_stage}" is not specified in CI file')
self.jobs[job_name] = CIJob(job_name, job_stage, job_struct)
self.validate_logic()
def validate_logic(self):
for job_name in self.jobs:
self.validate_job(job_name)
def validate_job(self, job_name):
j = self.jobs[job_name]
my_stage_order = self.stage_order(job_name)
if j.dependencies is not None:
for d in j.dependencies:
try:
stage_order = self.stage_order(d)
except KeyError:
raise CIValidationError(f'Dependency "{d}" of job "{job_name}" does not exist')
if stage_order >= my_stage_order:
raise CIValidationError(f'Dependency "{d}" of job "{job_name}" is not before the job in stage')
def stage_order(self, jn):
return self.stages.index(self.jobs[jn].stage)
def get_grouped_jobs(self):
groups = {}
for job_name in self.jobs:
job = self.jobs[job_name]
if job.stage not in groups:
groups[job.stage] = []
groups[job.stage].append(job)
res = []
for stage in self.stages:
if stage in groups:
res.append((stage, groups[stage]))
return res
def get_jobs_to_pull_artifacts_from(self, job_name):
'''
Get a list of names of jobs of which the artifacts will be pulled
from for the job named `job_name`.
'''
dependencies = self.jobs[job_name].dependencies
if dependencies is None:
dependencies = []
cur_job_stage_order = self.stage_order(job_name)
for jn in self.jobs:
so = self.stage_order(jn)
if so < cur_job_stage_order:
dependencies.append(jn)
return dependencies
diff --git a/lilybuild/lilybuild/config.py b/lilybuild/lilybuild/config.py
index 924b298..63e2369 100644
--- a/lilybuild/lilybuild/config.py
+++ b/lilybuild/lilybuild/config.py
@@ -1,259 +1,258 @@
from buildbot.plugins import *
from buildbot.process import buildstep, logobserver
from twisted.internet import defer
from .ci_steps import RunCIJobStep, AnalyzeCIFileCommand, on_success
from .phorge import CheckoutSourceFromPhorge, SendArtifactLinkToPhorge, SendBuildStatusToPhorge, MaybeRequireApprovalForPhorge
from .helpers import normalize_base_url, phorge_token_to_arcrc
import yaml
def to_params(d):
res = []
for n in d:
res.append(util.FixedParameter(name=n, default=d[n]))
return res
work_root = util.Interpolate('repos/%(prop:lilybuild_repo_id)s')
src_relative = 'sources'
src_dir = util.Interpolate('repos/%(prop:lilybuild_repo_id)s/sources')
result_relative = 'result'
result_dir = util.Interpolate('repos/%(prop:lilybuild_repo_id)s/result')
artifact_stage_relative = 'stage'
artifact_stage_dir = util.Interpolate('repos/%(prop:lilybuild_repo_id)s/stage')
default_storage_dir = '/buildbot/storage'
report_phorge = 'phorge'
report_forgejo = 'forgejo'
class LilyBuildConfig:
builder_name = 'lilybuild'
builder_name_run_job = 'lilybuild-job'
builder_name_force = 'lilybuild-force'
main_builder_tag = 'lilybuild-pipeline'
triggerable_scheduler_name = 'lilybuild-triggerable'
force_triggerable_scheduler_name = 'lilybuild-force-triggerable'
def __init__(self, c, workernames, reports=None, phorge_base_url=None, phorge_token=None, ssh_priv_key=None, ssh_known_hosts=None, storage_dir=default_storage_dir, artifact_link_base=None):
self.c = c
self.poll_interval = 300
self.workernames = workernames
if reports is None:
reports = [report_phorge, report_forgejo]
self.reports = reports
self.repos = {}
self.repo_id_by_url = {}
self.branch_skip_re = '^phabricator/'
self.phorge_base_url = normalize_base_url(phorge_base_url)
self.phorge_token = phorge_token
self.ssh_priv_key = ssh_priv_key
self.ssh_known_hosts = ssh_known_hosts
self.storage_dir = storage_dir
self.artifact_link_base = normalize_base_url(artifact_link_base)
def configure_factory_and_builder(self):
self.add_lilybuild_builder()
self.add_lilybuild_job_builder()
def create_source_step(self):
return CheckoutSourceFromPhorge(
lbc=self,
repourl=util.Property('lilybuild_repo'),
mode='full',
workdir=src_dir,
submodules=True
)
def get_arcrc_for_repo(self, repo_id):
return util.Transform(
phorge_token_to_arcrc,
self.repos[repo_id]['phorge_base_url'],
self.repos[repo_id]['phorge_token'],
)
def get_phorge_base_url_for_repo(self, repo_id):
return self.repos[repo_id]['phorge_base_url']
def add_lilybuild_builder(self):
factory = util.BuildFactory()
factory.addStep(self.create_source_step())
if report_phorge in self.reports:
factory.addStep(MaybeRequireApprovalForPhorge(lbc=self, workdir=src_dir))
factory.addStep(SendArtifactLinkToPhorge(lbc=self, workdir=src_dir))
factory.addStep(AnalyzeCIFileCommand(
lbc=self,
workdir=work_root,
src_relative=src_relative,
src_dir=src_dir,
storage_dir=self.storage_dir,
artifact_stage_relative=artifact_stage_relative,
artifact_stage_dir=artifact_stage_dir,
result_relative=result_relative,
result_dir=result_dir,
doStepIf=on_success,
))
if report_phorge in self.reports:
factory.addStep(SendBuildStatusToPhorge(lbc=self, workdir=src_dir))
builder = util.BuilderConfig(
name=self.builder_name,
workernames=self.workernames,
factory=factory,
tags=[self.main_builder_tag],
)
self.c['builders'].append(builder)
def add_lilybuild_job_builder(self):
factory_job = util.BuildFactory()
- factory_job.addStep(self.create_source_step())
factory_job.addStep(RunCIJobStep(
lbc=self,
workdir=work_root,
src_relative=src_relative,
src_dir=src_dir,
storage_dir=self.storage_dir,
artifact_stage_relative=artifact_stage_relative,
artifact_stage_dir=artifact_stage_dir,
result_relative=result_relative,
result_dir=result_dir,
artifact_link_base=self.artifact_link_base
))
builder_job = util.BuilderConfig(
name=self.builder_name_run_job,
workernames=self.workernames,
factory=factory_job,
# Tell buildbot to never collapse build requests for this one
# because otherwise triggering multiple jobs will cause it to only run one
# https://docs.buildbot.net/current/manual/configuration/builders.html#collapsing-build-requests
collapseRequests=False,
)
self.c['builders'].append(builder_job)
s = schedulers.Triggerable(self.triggerable_scheduler_name, builderNames=[self.builder_name_run_job])
self.c['schedulers'].append(s)
def record_url(self, repo_id, repo_url):
if repo_url in self.repo_id_by_url:
raise Exception(f'repo url "{repo_url}" already recorded')
self.repo_id_by_url[repo_url] = repo_id
def add_repo(
self,
repo_id,
repo_url,
do_poll=False,
alternative_urls=None,
phorge_base_url=None,
phorge_token=None,
# Function(build -> dict(str -> str | renderable<str>))
variables_getter=None,
):
if not alternative_urls:
alternative_urls = []
self.repos[repo_id] = {
'repo_id': repo_id,
'repo_url': repo_url,
'do_poll': do_poll,
'alternative_urls': alternative_urls,
'phorge_base_url': normalize_base_url(phorge_base_url) or self.phorge_base_url,
'phorge_token': phorge_token or self.phorge_token,
'variables_getter': variables_getter or (lambda _build: {}),
}
self.record_url(repo_id, repo_url)
for u in alternative_urls:
self.record_url(repo_id, u)
def get_builder_name_for_repo(self, repo_def):
main_repo_url = repo_def['repo_url']
return f'lilybuild - {main_repo_url}'
def add_one_repo_def(self, repo_def):
print('Adding repo', repo_def)
repo_urls = [repo_def['repo_url']] + repo_def['alternative_urls']
repo_id = repo_def['repo_id']
do_poll = repo_def['do_poll']
main_repo_url = repo_def['repo_url']
properties = {
'lilybuild_repo': main_repo_url,
'lilybuild_repo_id': repo_id,
'virtual_builder_name': self.get_builder_name_for_repo(repo_def),
'virtual_builder_tags': [self.main_builder_tag],
}
self.c['schedulers'].append(schedulers.AnyBranchScheduler(
name=f'lilybuild-anybranch - {main_repo_url}',
change_filter=util.ChangeFilter(
repository=repo_urls,
),
treeStableTimer=None,
builderNames=[self.builder_name],
properties=properties))
if do_poll:
for repo in repo_urls:
print('Adding poller')
self.c['change_source'].append(changes.GitPoller(
repo,
workdir=f'gitpoller/{repo_id}', branches=True,
pollInterval=self.poll_interval))
def configure_pipeline_defs(self):
for repo_id in self.repos:
self.add_one_repo_def(self.repos[repo_id])
self.add_force_builder()
self.add_force_scheduler()
def add_force_builder(self):
def get_repo_id_by_url(url):
return self.repo_id_by_url[url]
factory_force = util.BuildFactory()
factory_force.addStep(steps.Trigger(
name='trigger lilybuild',
schedulerNames=[self.force_triggerable_scheduler_name],
waitForFinish=True,
set_properties={
'lilybuild_repo': util.Property('lilybuild_repo'),
'lilybuild_repo_id': util.Transform(get_repo_id_by_url, util.Property('lilybuild_repo')),
'virtual_builder_name': util.Interpolate('lilybuild - %(prop:lilybuild_repo)s'),
'virtual_builder_tags': [self.main_builder_tag],
}
))
builder_force = util.BuilderConfig(
name=self.builder_name_force,
workernames=self.workernames,
factory=factory_force,
)
self.c['builders'].append(builder_force)
# this is triggered by lilybuild-force, and it triggers lilybuild
# via its virtual builder
s = schedulers.Triggerable(
self.force_triggerable_scheduler_name,
builderNames=[self.builder_name],
)
self.c['schedulers'].append(s)
def add_force_scheduler(self):
repo_urls = []
for r in self.repos.values():
repo_urls.append(r['repo_url'])
repo_param = util.ChoiceStringParameter(
name='lilybuild_repo',
required=True,
strict=True,
choices=repo_urls,
)
self.c['schedulers'].append(schedulers.ForceScheduler(
name="force",
builderNames=[self.builder_name_force],
properties=[repo_param],
))
diff --git a/lilybuild/lilybuild/tests/ci_syntax/ci_file_test.py b/lilybuild/lilybuild/tests/ci_syntax/ci_file_test.py
index 79ef83a..bffd8c6 100644
--- a/lilybuild/lilybuild/tests/ci_syntax/ci_file_test.py
+++ b/lilybuild/lilybuild/tests/ci_syntax/ci_file_test.py
@@ -1,311 +1,315 @@
import unittest
import yaml
from lilybuild.ci_syntax.ci_file import CIFile, CIValidationError, get_job_extend_seq, merge_job_deep
from lilybuild.tests.resources import get_res
class CIFileTest(unittest.TestCase):
def test_empty(self):
r = CIFile('')
self.assertEqual(r.stages, [])
self.assertEqual(r.jobs, {})
def test_invalid(self):
with self.assertRaises(yaml.composer.ComposerError) as m:
r = CIFile('*xxx')
def test_simple(self):
r = CIFile(get_res('simple'))
self.assertEqual(r.stages, ['build'])
self.assertEqual(list(r.jobs), ['build-a'])
self.assertEqual(r.jobs['build-a'].image, 'test')
self.assertEqual(r.jobs['build-a'].before_script, ['ls'])
self.assertEqual(r.jobs['build-a'].script, ['make', 'make install'])
self.assertEqual(r.jobs['build-a'].after_script, ['find', 'echo ok'])
def test_noscript(self):
r = CIFile(get_res('noscript'))
self.assertEqual(r.stages, ['build'])
self.assertEqual(list(r.jobs), ['build-a'])
self.assertEqual(r.jobs['build-a'].image, 'test')
self.assertEqual(r.jobs['build-a'].script, [])
def test_defaults(self):
r = CIFile(get_res('defaults'))
self.assertEqual(r.stages, ['build'])
self.assertEqual(list(r.jobs), ['build-a'])
self.assertEqual(r.jobs['build-a'].image, 'test')
self.assertEqual(r.jobs['build-a'].before_script, ['ls'])
self.assertEqual(r.jobs['build-a'].script, ['make', 'make install'])
self.assertEqual(r.jobs['build-a'].after_script, ['find', 'echo ok'])
def test_pages(self):
r = CIFile(get_res('pages'))
self.assertEqual(r.stages, ['test'])
self.assertEqual(list(r.jobs), ['pages'])
self.assertEqual(r.jobs['pages'].script, ['make docs'])
self.assertTrue(r.jobs['pages'].is_pages())
def test_pages_attr(self):
r = CIFile(get_res('pages_attr'))
self.assertFalse(r.jobs['not-pages'].is_pages())
self.assertTrue(r.jobs['is-pages'].is_pages())
def test_dotjobs(self):
r = CIFile(get_res('dotjobs'))
self.assertEqual(list(r.jobs), [])
def test_no_such_stage(self):
with self.assertRaises(CIValidationError) as m:
r = CIFile(get_res('no_such_stage'))
def test_default_stages(self):
r = CIFile(get_res('default_stages'))
self.assertEqual(r.jobs['a'].stage, 'build')
self.assertEqual(r.jobs['b'].stage, 'test')
self.assertEqual(r.jobs['c'].stage, 'deploy')
self.assertEqual(r.jobs['d'].stage, '.pre')
self.assertEqual(r.jobs['e'].stage, '.post')
self.assertEqual(r.jobs['f'].stage, 'test')
def test_group_jobs(self):
r = CIFile(get_res('group_jobs'))
groups = r.get_grouped_jobs()
self.assertEqual(len(groups), 2)
self.assertEqual(groups[0][0], 'build')
self.assertEqual(len(groups[0][1]), 2)
self.assertTrue(next(j for j in groups[0][1] if j.name == 'a'))
self.assertTrue(next(j for j in groups[0][1] if j.name == 'b'))
self.assertEqual(groups[1][0], 'test')
self.assertEqual(len(groups[1][1]), 2)
self.assertTrue(next(j for j in groups[1][1] if j.name == 'c'))
self.assertTrue(next(j for j in groups[1][1] if j.name == 'd'))
def test_dependencies(self):
r = CIFile(get_res('dependencies'))
self.assertEqual(r.get_jobs_to_pull_artifacts_from('a'), [])
self.assertEqual(r.get_jobs_to_pull_artifacts_from('a2'), [])
self.assertEqual(r.get_jobs_to_pull_artifacts_from('b'), ['a'])
self.assertEqual(set(r.get_jobs_to_pull_artifacts_from('b2')), set(['a', 'a2']))
def test_nonexistent_dep(self):
with self.assertRaises(CIValidationError) as m:
r = CIFile(get_res('nonexistent_dep'))
self.assertEqual(str(m.exception), 'Dependency "a3" of job "b" does not exist')
def test_late_dep(self):
with self.assertRaises(CIValidationError) as m:
r = CIFile(get_res('late_dep'))
self.assertEqual(str(m.exception), 'Dependency "a2" of job "a" is not before the job in stage')
def test_artifacts(self):
r = CIFile(get_res('has_artifacts_archive'))
self.assertEqual(r.jobs['has_archive'].artifacts, { 'paths': ['a'] })
self.assertEqual(r.jobs['no_archive'].artifacts, {})
self.assertEqual(r.jobs['no_artifacts'].artifacts, {})
def test_has_artifacts_archive(self):
r = CIFile(get_res('has_artifacts_archive'))
self.assertTrue(r.jobs['has_archive'].has_artifacts_archive())
self.assertFalse(r.jobs['no_archive'].has_artifacts_archive())
self.assertFalse(r.jobs['no_artifacts'].has_artifacts_archive())
def test_artifacts_reports(self):
r = CIFile(get_res('artifacts_reports'))
self.assertFalse(r.jobs['has_archive'].has_supported_coverage_report())
self.assertFalse(r.jobs['empty_reports'].has_supported_coverage_report())
self.assertTrue(r.jobs['yes_reports'].has_supported_coverage_report())
self.assertFalse(r.jobs['no_supported_reports'].has_supported_coverage_report())
def test_extends(self):
r = CIFile(get_res('extends'))
self.assertEqual(r.jobs['build-a'].struct_raw, {
'image': 'alpine',
'variables': { 'SECRET': 'abcdef', 'CC': 'gcc' },
'after_script': ['rm -r cache'],
'stage': 'build',
'before_script': ['apk add gcc'],
'script': ['make a', 'make install'],
})
self.assertEqual(r.jobs['build-b'].struct_raw, {
'image': 'alpine',
'variables': { 'SECRET': 'abcdef', 'CC': 'cc' },
'after_script': ['rm -r cache'],
'stage': 'build',
'before_script': ['apk add gcc'],
'script': ['make b', 'make install'],
})
self.assertEqual(r.jobs['test'].struct_raw, {
'image': 'tester',
'variables': { 'SECRET': 'abcdef' },
'after_script': ['rm -r cache'],
'stage': 'test',
'script': ['make test'],
})
self.assertEqual(r.jobs['container-amd64'].struct_raw, {
'image': 'podman',
'variables': { 'SECRET': 'abcdef', 'DOCKER': 'podman', 'PLATFORM': 'amd64', 'LOGIN': 'defghi' },
'after_script': ['rm -r cache'],
'stage': 'deploy',
'script': ['podman login', 'make docker'],
'tags': ['podman']
})
self.assertEqual(r.jobs['container-aarch64'].struct_raw, {
'image': 'podman',
'variables': { 'SECRET': 'abcdef', 'DOCKER': 'podman', 'PLATFORM': 'aarch64', 'LOGIN': 'defghi' },
'after_script': ['rm -r cache'],
'stage': 'deploy',
'script': ['podman login', 'make docker'],
'tags': ['podman']
})
self.assertEqual(r.jobs['appimage-amd64'].struct_raw, {
'image': 'alpine',
'variables': { 'SECRET': 'abcdef', 'PLATFORM': 'amd64', 'LOGIN': 'defghi' },
'after_script': ['rm -r cache'],
'stage': 'deploy',
'script': ['make appimage'],
'tags': ['amd64']
})
self.assertEqual(r.jobs['appimage-aarch64'].struct_raw, {
'image': 'alpine',
'variables': { 'SECRET': 'abcdef', 'PLATFORM': 'aarch64', 'LOGIN': 'defghi' },
'after_script': ['rm -r cache'],
'stage': 'deploy',
'script': ['make appimage'],
'tags': ['aarch64']
})
+ def test_variables(self):
+ r = CIFile(get_res('variables'))
+ self.assertEqual(r.jobs['build-a'].get_predefined_ci_variables()['foo'], 'bar')
+
class GetJobExtendSeqTest(unittest.TestCase):
def test_no_extends(self):
all_jobs = {
'nothing': {}
}
self.assertEqual(get_job_extend_seq('nothing', all_jobs), ['nothing'])
def test_multi_extends(self):
all_jobs = {
'a': { 'extends': ['b', 'c'] },
'b': { 'extends': ['d', 'e'] },
'c': { 'extends': ['f', 'g'] },
'd': {},
'e': {},
'f': {},
'g': {},
}
self.assertEqual(get_job_extend_seq('a', all_jobs), ['d', 'e', 'b', 'f', 'g', 'c', 'a'])
def test_common_ancestor(self):
all_jobs = {
'a': { 'extends': ['b', 'c'] },
'b': { 'extends': ['d', 'e'] },
'c': { 'extends': ['e', 'd'] },
'd': {},
'e': {},
'f': {},
'g': {},
}
self.assertEqual(get_job_extend_seq('a', all_jobs), ['d', 'e', 'b', 'e', 'd', 'c', 'a'])
def test_self_cycle(self):
all_jobs = {
'a': { 'extends': ['a'] },
}
with self.assertRaises(CIValidationError) as m:
r = get_job_extend_seq('a', all_jobs)
self.assertTrue(str(m.exception).startswith('`extends` depth is over the limit of'))
def test_complex_cycle(self):
all_jobs = {
'a': { 'extends': ['b', 'c'] },
'b': { 'extends': ['d', 'c'] },
'c': { 'extends': ['d'] },
'd': { 'extends': ['a'] },
}
with self.assertRaises(CIValidationError) as m:
r = get_job_extend_seq('a', all_jobs)
self.assertTrue(str(m.exception).startswith('`extends` depth is over the limit of'))
def test_nonexistent_parent(self):
all_jobs = {
'a': { 'extends': ['b', 'c'] },
'b': { 'extends': ['d', 'c'] },
'c': { 'extends': ['d'] },
}
with self.assertRaises(CIValidationError) as m:
r = get_job_extend_seq('a', all_jobs)
self.assertEqual(str(m.exception), 'Job "d" does not exist, but occurs in `extends`.')
def test_null_parent(self):
all_jobs = {
'a': { 'extends': ['b', 'c'] },
'b': { 'extends': ['d', 'c'] },
'c': { 'extends': ['d'] },
'd': None,
}
self.assertEqual(get_job_extend_seq('a', all_jobs), ['d', 'd', 'c', 'b', 'd', 'c', 'a'])
class MergeJobTest(unittest.TestCase):
def test_simple(self):
parent = {
'a': {
'a/1': {
'a/1/1': '3',
'a/1/2': 4,
},
'a/2': {},
},
'b': 1,
}
child = {
'a': {
'a/1': {
'a/1/2': '6',
'a/1/3': 5,
},
'a/2': 'mew',
},
'c': 'test',
}
merge_job_deep(child, parent)
self.assertEqual(parent, {
'a': {
'a/1': {
'a/1/1': '3',
'a/1/2': 4,
},
'a/2': {},
},
'b': 1,
})
self.assertEqual(child, {
'a': {
'a/1': {
'a/1/1': '3',
'a/1/2': '6',
'a/1/3': 5,
},
'a/2': 'mew',
},
'b': 1,
'c': 'test',
})
def test_no_overflow(self):
a = {
'1': '2',
}
b = {
'2': '4',
'a': a,
}
a['a'] = b
res = {}
merge_job_deep(res, b)
with self.assertRaises(CIValidationError) as m:
merge_job_deep(res, a)
self.assertEqual(str(m.exception), 'depth is over the limit when merging job.')
if __name__ == '__main__':
unittest.main()
diff --git a/lilybuild/lilybuild/tests/ci_syntax/res/variables.yaml b/lilybuild/lilybuild/tests/ci_syntax/res/variables.yaml
new file mode 100644
index 0000000..590a7c3
--- /dev/null
+++ b/lilybuild/lilybuild/tests/ci_syntax/res/variables.yaml
@@ -0,0 +1,15 @@
+stages:
+ - build
+
+build-a:
+ image: test
+ stage: build
+ variables:
+ foo: bar
+ before_script: ls
+ script:
+ - make
+ - make install
+ after_script:
+ - - find
+ - echo ok
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Sep 19, 5:00 AM (1 d, 9 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1768879
Default Alt Text
(49 KB)
Attached To
Mode
rB lilybuild
Attached
Detach File
Event Timeline
Log In to Comment