Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85803110
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
56 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/lilybuild/lilybuild/ci_steps.py b/lilybuild/lilybuild/ci_steps.py
index 85cfcbb..6d76179 100644
--- a/lilybuild/lilybuild/ci_steps.py
+++ b/lilybuild/lilybuild/ci_steps.py
@@ -1,577 +1,578 @@
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, normalize_services
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)
def get_cur_repo_config(self):
return self.lbc.repos[self.getProperty('lilybuild_repo_id')]
@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)
should_run = True
if len(job.rules):
should_run = False
+ default_when = job.struct_raw.get('when', 'on_success')
for r in job.rules:
try:
- when = r.get('when', 'on_success')
- if when == 'never':
+ when = r.get('when', default_when)
+ if when == 'never' or when == 'manual':
should_run_to_set = False
else:
should_run_to_set = True
rule_str = r.get('if')
if not rule_str:
# No condition == always true
# TODO: `changes` rule
should_run = should_run_to_set
break
res = ci_rules.evaluate_rule(ci_rules.parse_rule(rule_str), variables)
if not res:
continue
should_run = should_run_to_set
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,
'compression': 'gz',
'limit_bytes': self.get_cur_repo_config()['artifact_uncompressed_limit'],
}),
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.get_cur_repo_config()['artifact_compressed_limit'],
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
def job_to_steps(self, job, job_index, variables):
script_name = self.script_dir + '/run.sh'
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.get_cur_repo_config()['artifact_compressed_limit'],
name=f'Download artifacts from job #{i}',
workerdest=self.artifact_file_name,
workdir=self.work_root_dir,
doStepIf=on_success,
haltOnFailure=False,
flunkOnFailure=False,
# https://github.com/buildbot/buildbot/issues/3709
blocksize=256 * 1024,
)
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,
normalize_services(job.services),
],
workdir=self.work_root_dir,
doStepIf=on_success,
# 2h timeout by default
# TODO support timeout by each job
timeout=60 * 60 * 2,
)
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 = [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 LatestMixin:
latest_build_dir_pattern = '%(kw:st)s/repos/%(prop:lilybuild_repo_id)s/latest'
latest_good_build_dir_pattern = '%(kw:st)s/repos/%(prop:lilybuild_repo_id)s/latest-good'
latest_name_pattern = '%(kw:st)s/repos/%(prop:lilybuild_repo_id)s/latest/%(kw:name)s'
latest_good_name_pattern = '%(kw:st)s/repos/%(prop:lilybuild_repo_id)s/latest-good/%(kw:name)s'
class EnsureLatestDirs(steps.MasterShellCommand, LatestMixin):
def __init__(self, lbc, storage_dir, **kwargs):
self.lbc = lbc
self.storage_dir = storage_dir
command = util.Transform(
fill_list,
'mkdir',
'-pv',
util.Interpolate(self.latest_build_dir_pattern, st=self.storage_dir),
util.Interpolate(self.latest_good_build_dir_pattern, st=self.storage_dir))
super().__init__(
name='Ensure latest dirs',
command=command,
logEnviron=False,
doStepIf=on_always,
**kwargs
)
class MarkLatest(steps.MasterShellCommand, LatestMixin):
def __init__(self, lbc, storage_dir, buildid, ref_name, is_good, **kwargs):
self.lbc = lbc
self.storage_dir = storage_dir
name_pattern = self.latest_good_name_pattern if is_good else self.latest_name_pattern
command = util.Transform(
fill_list,
'ln',
'-sfvn',
util.Interpolate('../builds/%(kw:buildid)s', buildid=buildid),
util.Interpolate(name_pattern, st=self.storage_dir, name=ref_name),
)
super().__init__(
name='Mark build as latest-good' if is_good else 'Mark build as latest',
command=command,
logEnviron=False,
doStepIf=on_success if is_good else on_always,
**kwargs
)
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_and_job_map(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, job_name_to_index_map)
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)
def get_cur_repo_config(self):
return self.lbc.repos[self.getProperty('lilybuild_repo_id')]
@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
(steps, job_map) = self.get_steps_and_job_map(self.observer.getStdout())
self.setProperty('lilybuild_job_map', job_map, self.__class__.__name__)
self.build.addStepsAfterCurrentStep(steps)
latest_branch_map = self.get_cur_repo_config()['artifact_latest_branch_map']
ref, _ref_type = self.get_ref_and_type()
if ref in latest_branch_map:
latest_name = latest_branch_map[ref]
self.build.addStepsAfterLastStep([
EnsureLatestDirs(lbc=self.lbc, storage_dir=self.storage_dir),
MarkLatest(
lbc=self.lbc,
storage_dir=self.storage_dir,
buildid=self.build.buildid,
ref_name=latest_name,
is_good=True,
),
MarkLatest(
lbc=self.lbc,
storage_dir=self.storage_dir,
buildid=self.build.buildid,
ref_name=latest_name,
is_good=False,
),
])
return result
diff --git a/lilybuild/lilybuild/ci_syntax/ci_file.py b/lilybuild/lilybuild/ci_syntax/ci_file.py
index 6417502..46c0f8a 100644
--- a/lilybuild/lilybuild/ci_syntax/ci_file.py
+++ b/lilybuild/lilybuild/ci_syntax/ci_file.py
@@ -1,241 +1,281 @@
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
+def get_ref_from_ref_cond(ref_cond):
+ # We don't support project name so ignore everything after @
+ pos = ref_cond.rfind('@')
+ if pos != -1:
+ ref_cond = ref_cond[:pos]
+ if ref_cond[0] == '/' and ref_cond[-1] == '/':
+ return '$CI_COMMIT_REF_NAME =~ ' + ref_cond
+ else:
+ return '$CI_COMMIT_REF_NAME == "' + ref_cond.replace('\\', '\\\\').replace('"', '\\"') + '"'
+
+def get_alt_rules_from_job_struct(job_struct):
+ # Convert when/only/except to rules
+ when = None
+ if job_struct.get('when'):
+ when = job_struct.get('when')
+ positive_cond = []
+ if job_struct.get('only'):
+ for ref_cond in job_struct.get('only'):
+ positive_cond.append(get_ref_from_ref_cond(ref_cond))
+ negative_cond = []
+ if job_struct.get('except'):
+ for ref_cond in job_struct.get('except'):
+ negative_cond.append(get_ref_from_ref_cond(ref_cond))
+ rule_if = ''
+ if positive_cond:
+ rule_if += '(' + ' || '.join(positive_cond) + ')'
+ if negative_cond:
+ rule_if += ' && '
+ if negative_cond:
+ rule_if += '!(' + ' || '.join(negative_cond) + ')'
+ rule = {}
+ if when:
+ rule['when'] = when
+ if positive_cond or negative_cond:
+ rule['if'] = rule_if
+ if rule:
+ return [rule]
+ else:
+ return []
+
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.rules = job_struct.get('rules') or get_alt_rules_from_job_struct(job_struct)
self.dependencies = job_struct.get('dependencies')
self.services = job_struct.get('services') or []
def get_predefined_ci_variables(self):
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/ci_syntax/rules.py b/lilybuild/lilybuild/ci_syntax/rules.py
index d50e431..afcc55d 100644
--- a/lilybuild/lilybuild/ci_syntax/rules.py
+++ b/lilybuild/lilybuild/ci_syntax/rules.py
@@ -1,122 +1,132 @@
import re
# XXX: Since we are stuck with Python 3.9, we can't disable backtrack yet.
# https://docs.python.org/3/library/re.html
patterns = {
'OP': re.compile(r'\s*(==|\|\||&&|=~|\!=|\!~|\!)'),
'PAREN_LEFT': re.compile(r'\s*\('),
'PAREN_RIGHT': re.compile(r'\s*\)'),
'STR_DOUBLE': re.compile(r'''\s*"((?:[^\\"]|\\.)*)"'''),
'STR_SINGLE': re.compile(r"""\s*'((?:[^\\']|\\.)*)'"""),
+ 'REGEX': re.compile(r'''\s*/((?:[^\\/]|\\.)*)/'''),
'VAR': re.compile(r'\s*\$([A-Za-z_][A-Za-z0-9_]*)'),
'NULL': re.compile(r'\s*null'),
'END': re.compile(r'\s*$'),
}
ops = {
'==': (2, lambda a, b: a == b),
'!=': (2, lambda a, b: a != b),
- '=~': (2, lambda a, b: False),
- '!~': (2, lambda a, b: False),
+ '=~': (2, lambda a, b: not not re.search(b, a)),
+ '!~': (2, lambda a, b: not re.search(b, a)),
'!': (1, lambda a: not a),
'&&': (2, lambda a, b: a and b),
'||': (2, lambda a, b: a or b),
}
TERM_PRECEDENCE = 9
PAREN_PRECEDENCE = 8
def is_term(token):
- return token[0] in ['STR_DOUBLE', 'STR_SINGLE', 'VAR', 'NULL']
+ return token[0] in ['STR_DOUBLE', 'STR_SINGLE', 'REGEX', 'VAR', 'NULL']
def get_precedence(token):
if is_term(token):
return TERM_PRECEDENCE
elif token[0] in ['PAREN_LEFT', 'PAREN_RIGHT']:
return PAREN_PRECEDENCE
elif token[0] == 'OP' and (token[1][0] in ['==', '!=', '=~', '!~']):
return 6
elif token[0] == 'OP' and (token[1][0] in ['!']):
return 5
elif token[0] == 'OP' and (token[1][0] in ['&&']):
return 4
elif token[0] == 'OP' and (token[1][0] in ['||']):
return 3
raise SyntaxError('Unknown token')
def tokenize_rule(rule_str):
pos = 0
tokenized = []
while not (len(tokenized) and tokenized[-1][0] == 'END'):
match = None
for t in patterns:
regex = patterns[t]
match = regex.match(rule_str, pos)
if match:
tokenized.append((t, match.groups()))
pos = match.end()
break
if not match:
raise SyntaxError(f'Bad rule "{rule_str}", at pos {pos}')
return tokenized[:-1]
def make_tree(tokenized):
# https://en.wikipedia.org/wiki/Shunting_yard_algorithm
stack = []
res = []
for t in tokenized:
if is_term(t):
res.append(t)
elif t[0] == 'OP':
if len(stack) and stack[-1][0] == 'OP' and get_precedence(stack[-1]) >= get_precedence(t):
res.append(stack.pop())
stack.append(t)
elif t[0] == 'PAREN_LEFT':
stack.append(t)
elif t[0] == 'PAREN_RIGHT':
while len(stack) and stack[-1][0] != 'PAREN_LEFT':
res.append(stack.pop())
if not len(stack):
raise SyntaxError('Mismatched parentheses')
stack.pop()
while len(stack):
t = stack.pop()
if t[0] == 'PAREN_LEFT':
raise SyntaxError('Mismatched parentheses')
res.append(t)
stack = []
for t in res:
if t[0] == 'OP':
opname = t[1][0]
arity = ops[opname][0]
if len(stack) < arity:
raise SyntaxError('Missing operands')
operands = tuple(reversed([stack.pop() for i in range(arity)]))
stack.append((t[0], (opname,) + operands))
else:
stack.append(t)
if len(stack) != 1:
raise SyntaxError('Too many operands')
return stack[0]
def parse_rule(rule_str):
return make_tree(tokenize_rule(rule_str))
backslash_re = re.compile(r'\\(.)')
def replace_backslash(match):
c = match.groups()[0]
return c
+def replace_backslash_in_regex(match):
+ c = match.groups()[0]
+ if c == '/':
+ return c
+ # Treat everything except \/ as is
+ return '\\' + c
+
def evaluate_rule(expr, variables):
if expr[0] == 'VAR':
return variables.get(expr[1][0])
elif expr[0] == 'STR_DOUBLE' or expr[0] == 'STR_SINGLE':
return backslash_re.sub(replace_backslash, expr[1][0])
+ elif expr[0] == 'REGEX':
+ return backslash_re.sub(replace_backslash_in_regex, expr[1][0])
elif expr[0] == 'NULL':
return None
elif expr[0] == 'OP':
opname = expr[1][0]
operands = [evaluate_rule(o, variables) for o in expr[1][1:]]
return ops[opname][1](*operands)
raise SyntaxError(f'Cannot evaluate expression {expr}')
diff --git a/lilybuild/lilybuild/tests/ci_syntax/ci_file_test.py b/lilybuild/lilybuild/tests/ci_syntax/ci_file_test.py
index bffd8c6..504aa2f 100644
--- a/lilybuild/lilybuild/tests/ci_syntax/ci_file_test.py
+++ b/lilybuild/lilybuild/tests/ci_syntax/ci_file_test.py
@@ -1,315 +1,333 @@
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')
+ def test_when_only_except(self):
+ r = CIFile(get_res('when-only-except'))
+ expected_if = r'''($CI_COMMIT_REF_NAME =~ /a/ || $CI_COMMIT_REF_NAME =~ /bb$/ || $CI_COMMIT_REF_NAME == "ccccccc" || $CI_COMMIT_REF_NAME == "ddd" || $CI_COMMIT_REF_NAME == "ff\\\\\"f" || $CI_COMMIT_REF_NAME == "ff\\'f" || $CI_COMMIT_REF_NAME == "ff\\\"") && !($CI_COMMIT_REF_NAME == "bbb" || $CI_COMMIT_REF_NAME =~ /^ba/ || $CI_COMMIT_REF_NAME =~ /ff\w/ || $CI_COMMIT_REF_NAME =~ /@@@/ || $CI_COMMIT_REF_NAME == "@@@")'''
+ self.assertEqual(r.jobs['a'].rules, [{
+ 'when': 'manual',
+ 'if': expected_if
+ }])
+ self.assertEqual(r.jobs['b'].rules, [{
+ 'if': '($CI_COMMIT_REF_NAME == "servant")'
+ }])
+ self.assertEqual(r.jobs['c'].rules, [{
+ 'if': '!($CI_COMMIT_REF_NAME == "servant")'
+ }])
+ self.assertEqual(r.jobs['d'].rules, [])
+ self.assertEqual(r.jobs['e'].rules, [{
+ 'when': 'always'
+ }])
+
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/when-only-except.yaml b/lilybuild/lilybuild/tests/ci_syntax/res/when-only-except.yaml
new file mode 100644
index 0000000..0a1c655
--- /dev/null
+++ b/lilybuild/lilybuild/tests/ci_syntax/res/when-only-except.yaml
@@ -0,0 +1,31 @@
+
+a:
+ when: manual
+ only:
+ - /a/
+ - /bb$/@abcabc/defdef
+ - ccccccc@abcabc/defdef
+ - ddd
+ - 'ff\\"f'
+ - "ff\\'f"
+ - 'ff\"'
+ except:
+ - bbb
+ - /^ba/
+ - /ff\w/@abcabc/defdef
+ - /@@@/@abcabc/defdef
+ - '@@@@abcabc/defdef'
+
+b:
+ only:
+ - servant
+
+c:
+ except:
+ - servant
+
+d:
+ image: alpine
+
+e:
+ when: always
diff --git a/lilybuild/lilybuild/tests/ci_syntax/rules_test.py b/lilybuild/lilybuild/tests/ci_syntax/rules_test.py
index f6d58f1..9cf31bb 100644
--- a/lilybuild/lilybuild/tests/ci_syntax/rules_test.py
+++ b/lilybuild/lilybuild/tests/ci_syntax/rules_test.py
@@ -1,118 +1,143 @@
import unittest
from lilybuild.ci_syntax.rules import tokenize_rule, make_tree, parse_rule, evaluate_rule
class RulesTest(unittest.TestCase):
def test_tokenize(self):
self.assertEqual(tokenize_rule("$VAR_FOO == ''"), [
('VAR', ('VAR_FOO',)),
('OP', ('==',)),
('STR_SINGLE', ('',)),
])
+ self.assertEqual(tokenize_rule("$VAR_FOO =~ /abc/"), [
+ ('VAR', ('VAR_FOO',)),
+ ('OP', ('=~',)),
+ ('REGEX', ('abc',)),
+ ])
+ self.assertEqual(tokenize_rule(r"$VAR_FOO =~ /ab\s\/c/"), [
+ ('VAR', ('VAR_FOO',)),
+ ('OP', ('=~',)),
+ ('REGEX', ('ab\\s\\/c',)),
+ ])
self.assertEqual(tokenize_rule(r'$VAR_FOO == "xxx\""'), [
('VAR', ('VAR_FOO',)),
('OP', ('==',)),
('STR_DOUBLE', ('xxx\\"',)),
])
self.assertEqual(tokenize_rule(r'!($VAR_FOO == "xxx\"")'), [
('OP', ('!',)),
('PAREN_LEFT', ()),
('VAR', ('VAR_FOO',)),
('OP', ('==',)),
('STR_DOUBLE', ('xxx\\"',)),
('PAREN_RIGHT', ()),
])
with self.assertRaises(SyntaxError) as m:
tokenize_rule(r'$VAR_FOO =/= "foo"')
with self.assertRaises(SyntaxError) as m:
tokenize_rule(r'$VAR_FOO > "foo"')
# bareword
with self.assertRaises(SyntaxError) as m:
tokenize_rule(r'$VAR_FOO == foo')
# unterminated strings
with self.assertRaises(SyntaxError) as m:
tokenize_rule(r'$VAR_FOO == "foo\"')
with self.assertRaises(SyntaxError) as m:
tokenize_rule(r'$VAR_FOO == "foo')
with self.assertRaises(SyntaxError) as m:
tokenize_rule(r'$VAR_FOO == "foo\\\"')
+ # bareword after regex
+ with self.assertRaises(SyntaxError) as m:
+ tokenize_rule(r"$VAR_FOO =~ /ab\s\/c/d")
def test_make_tree(self):
self.assertEqual(make_tree(tokenize_rule(r'($FOO)')), ('VAR', ('FOO',)))
self.assertEqual(make_tree(tokenize_rule(r'($FOO=="foo(") || "xxx"')), (
'OP', (
'||',
('OP', ('==', ('VAR', ('FOO',)), ('STR_DOUBLE', ('foo(',)))),
('STR_DOUBLE', ('xxx',)),
)
))
self.assertEqual(make_tree(tokenize_rule(r'$FOO=="foo("|| "xxx"')), (
'OP', (
'||',
('OP', ('==', ('VAR', ('FOO',)), ('STR_DOUBLE', ('foo(',)))),
('STR_DOUBLE', ('xxx',)),
)
))
self.assertEqual(make_tree(tokenize_rule(r'$FOO==("foo("|| "xxx")')), (
'OP', (
'==',
('VAR', ('FOO',)),
('OP', ('||', ('STR_DOUBLE', ('foo(',)), ('STR_DOUBLE', ('xxx',)))),
)
))
self.assertEqual(make_tree(tokenize_rule(r'!$FOO==("foo("|| "xxx")')), (
'OP', (
'!',
('OP', (
'==',
('VAR', ('FOO',)),
('OP', ('||', ('STR_DOUBLE', ('foo(',)), ('STR_DOUBLE', ('xxx',)))),
))
)
))
with self.assertRaises(SyntaxError) as m:
make_tree(tokenize_rule(r'())'))
self.assertEqual(str(m.exception), 'Mismatched parentheses')
with self.assertRaises(SyntaxError) as m:
make_tree(tokenize_rule(r'('))
self.assertEqual(str(m.exception), 'Mismatched parentheses')
with self.assertRaises(SyntaxError) as m:
make_tree(tokenize_rule(r'("("))'))
self.assertEqual(str(m.exception), 'Mismatched parentheses')
with self.assertRaises(SyntaxError) as m:
make_tree(tokenize_rule(r'$FOO== != "0"'))
self.assertEqual(str(m.exception), 'Missing operands')
with self.assertRaises(SyntaxError) as m:
make_tree(tokenize_rule(r'( == "1" && "0" || "0")'))
self.assertEqual(str(m.exception), 'Missing operands')
with self.assertRaises(SyntaxError) as m:
make_tree(tokenize_rule(r'$VAR( $FOO == "0")'))
self.assertEqual(str(m.exception), 'Too many operands')
def test_eval(self):
self.assertEqual(evaluate_rule(parse_rule("$VAR_FOO == ''"), {}), False)
self.assertEqual(evaluate_rule(parse_rule("$VAR_FOO == null"), {}), True)
self.assertEqual(evaluate_rule(parse_rule("$VAR_FOO == 'xx'"), {}), False)
self.assertEqual(evaluate_rule(parse_rule("$VAR_FOO == 'xx'"), { 'VAR_FOO': 'xx' }), True)
self.assertEqual(
evaluate_rule(parse_rule("$VAR_FOO == 'xx' && $BAR"), {
'VAR_FOO': 'xx',
'BAR': 'yyy',
}), 'yyy')
self.assertEqual(
evaluate_rule(parse_rule("$VAR_FOO == 'xx' && !$BAR"), {
'VAR_FOO': 'xx',
'BAR': 'yyy',
}), False)
self.assertEqual(
evaluate_rule(parse_rule("$VAR_FOO == 'xx' && $Z"), {
'VAR_FOO': 'xx',
'BAR': 'yyy',
}), None)
self.assertEqual(
evaluate_rule(parse_rule(r"$VAR_FOO == 'x\a\\\'x'"), {
'VAR_FOO': r"xa\'x",
}), True)
+ self.assertEqual(
+ evaluate_rule(parse_rule(r"$VAR_FOO =~ /ab\s\/c/"), {
+ 'VAR_FOO': 'XXab /c/def',
+ }), True)
+ self.assertEqual(
+ evaluate_rule(parse_rule(r"$VAR_FOO =~ /ab\s\/c/"), {
+ 'VAR_FOO': 'ab \\/c',
+ }), False)
+ self.assertEqual(
+ evaluate_rule(parse_rule(r"$VAR_FOO !~ /ab\s\/c/"), {
+ 'VAR_FOO': 'ab \\/c',
+ }), True)
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Fri, Oct 9, 1:00 AM (1 d, 37 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1784372
Default Alt Text
(56 KB)
Attached To
Mode
rB lilybuild
Attached
Detach File
Event Timeline
Log In to Comment