better systemd service support

This commit is contained in:
Emmanuel Garette 2021-05-13 22:30:58 +02:00
parent 20f329d433
commit 9c1589ca53
45 changed files with 496 additions and 120 deletions

View file

@ -28,15 +28,13 @@ from os.path import basename
from typing import Tuple
from rougail.i18n import _
from rougail.utils import normalize_family
from rougail.utils import normalize_family, valid_variable_family_name
from rougail.error import DictConsistencyError
# a object's attribute has some annotations
# that shall not be present in the exported (flatened) XML
ERASED_ATTRIBUTES = ('redefine', 'exists', 'optional', 'remove_check', 'namespace',
'remove_condition', 'path', 'instance_mode', 'index',
'level', 'remove_fill', 'xmlfiles', 'type', 'reflector_name',
'reflector_object',)
ALLOW_ATTRIBUT_NOT_MANAGE = ['file']
ERASED_ATTRIBUTES = ('redefine', 'namespace', 'xmlfiles', 'disabled', 'name', 'manage')
ERASED_ATTRIBUTES2 = ('redefine', 'namespace', 'xmlfiles')
ALLOW_ATTRIBUT_NOT_MANAGE = ['file', 'engine', 'target']
class Annotator:
@ -72,6 +70,7 @@ class Annotator:
self.objectspace.space.services.doc = 'services'
self.objectspace.space.services.path = 'services'
for service_name, service in self.objectspace.space.services.service.items():
valid_variable_family_name(service_name, service.xmlfiles)
activate_obj = self._generate_element('boolean',
None,
None,
@ -88,11 +87,12 @@ class Annotator:
values,
[]).append(activate_obj)
continue
if not isinstance(values, (dict, list)) or elttype in ERASED_ATTRIBUTES:
if elttype in ERASED_ATTRIBUTES:
continue
if not service.manage and elttype not in ALLOW_ATTRIBUT_NOT_MANAGE:
msg = _(f'unmanage service cannot have "{elttype}"')
raise DictConsistencyError(msg, 66, service.xmlfiles)
if isinstance(values, (dict, list)):
if elttype != 'ip':
eltname = elttype + 's'
else:
@ -111,6 +111,10 @@ class Annotator:
path,
)
setattr(service, elttype, family)
else:
if not hasattr(service, 'information'):
service.information = self.objectspace.information(service.xmlfiles)
setattr(service.information, elttype, values)
manage = self._generate_element('boolean',
None,
None,
@ -157,7 +161,7 @@ class Annotator:
'.'.join([subpath, 'activate']),
)
for key in dir(elt):
if key.startswith('_') or key.endswith('_type') or key in ERASED_ATTRIBUTES:
if key.startswith('_') or key.endswith('_type') or key in ERASED_ATTRIBUTES2:
continue
value = getattr(elt, key)
if key == listname:

View file

@ -51,6 +51,9 @@
<!ATTLIST service manage (True|False) "True">
<!ATTLIST service servicelist CDATA #IMPLIED>
<!ATTLIST service disabled (True|False) "False">
<!ATTLIST service engine (none|creole|jinja2) #IMPLIED>
<!ATTLIST service target CDATA #IMPLIED>
<!ATTLIST service type (service|mount) "service">
<!ELEMENT ip (#PCDATA)>
<!ATTLIST ip iplist CDATA #IMPLIED>

View file

@ -255,6 +255,7 @@ class RougailBaseTemplate:
filevar: Dict,
type_: str,
service_name: str,
service_type: str,
) -> None:
"""Run templatisation on one file
"""
@ -275,10 +276,11 @@ class RougailBaseTemplate:
var = variable[idx]
else:
var = None
func = f'_instance_{type_}'
func = f'get_data_{type_}'
data = getattr(self, func)(filevar,
filename,
service_name,
service_type,
variable,
idx,
)
@ -319,10 +321,26 @@ class RougailBaseTemplate:
for included in (True, False):
for service_obj in await self.config.option('services').list('all'):
service_name = await service_obj.option.name()
service_type = await service_obj.information.get('type', 'service')
if await service_obj.option('activate').value.get() is False:
if included is False:
self.desactive_service(service_name)
self.desactive_service(service_name, service_type)
continue
if not included:
engine = await service_obj.information.get('engine', None)
if engine:
self.instance_file({'engine': engine},
'service',
service_name,
service_type,
)
target_name = await service_obj.information.get('target', None)
if target_name:
self.target_service(service_name,
target_name,
service_type,
engine is None,
)
for fills in await service_obj.list('optiondescription'):
type_ = await fills.option.name()
for fill_obj in await fills.list('all'):
@ -335,10 +353,14 @@ class RougailBaseTemplate:
elif included is True:
continue
if fill['activate']:
self.instance_file(fill, type_, service_name)
self.instance_file(fill,
type_,
service_name,
service_type,
)
else:
self.log.debug(_("Instantiation of file '{filename}' disabled"))
self.post_instance_service(service_name)
self.post_instance_service(service_name, service_type)
self.post_instance()
chdir(ori_dir)
@ -356,27 +378,40 @@ class RougailBaseTemplate:
dico[key] = await obj.information.get(key, default_value)
def desactive_service(self,
service_name: str,
*args,
):
raise NotImplementedError(_('cannot desactivate a service'))
def post_instance_service(self, service_name): # pragma: no cover
def target_service(self,
service_name: str,
*args,
):
raise NotImplementedError(_('cannot use target for the service {service_name}'))
def post_instance_service(self,
*args,
): # pragma: no cover
pass
def post_instance(self): # pragma: no cover
pass
def _instance_ip(self,
def get_data_ip(self,
*args,
) -> None: # pragma: no cover
raise NotImplementedError(_('cannot instanciate this service type ip'))
def _instance_files(self,
def get_data_files(self,
*args,
) -> None: # pragma: no cover
raise NotImplementedError(_('cannot instanciate this service type file'))
def _instance_overrides(self,
def get_data_service(self,
*args,
) -> None: # pragma: no cover
raise NotImplementedError(_('cannot instanciate this service'))
def get_data_overrides(self,
*args,
) -> None: # pragma: no cover
raise NotImplementedError(_('cannot instanciate this service type override'))

View file

@ -38,9 +38,13 @@ IPAddressDeny=any
"""
ROUGAIL_TMPL_TEMPLATE = """%def display(%%file, %%filename)
ROUGAIL_DEST = '/usr/local/lib'
ROUGAIL_GLOBAL_SYSTEMD_FILE = '/usr/lib/systemd/system'
ROUGAIL_TMPL_TEMPLATE = f"""%def display(%%file, %%filename)
%if %%filename.startswith('/etc/') or %%filename.startswith('/var/') or %%filename.startswith('/srv/')
C %%filename %%file.mode %%file.owner %%file.group - /usr/local/lib%%filename
C %%filename %%file.mode %%file.owner %%file.group - {ROUGAIL_DEST}%%filename
z %%filename - - - - -
%end if
%end def
@ -70,10 +74,11 @@ class RougailSystemdTemplate(RougailBaseTemplate):
self.ip_per_service = None
super().__init__(config, rougailconfig)
def _instance_files(self,
def get_data_files(self,
filevar: Dict,
destfile: str,
service_name: str,
service_type: str,
variable,
idx: int,
) -> tuple:
@ -88,10 +93,11 @@ class RougailSystemdTemplate(RougailBaseTemplate):
var = None
return tmp_file, None, destfile, var
def _instance_overrides(self,
def get_data_overrides(self,
filevar: Dict,
destfile,
service_name: str,
service_type: str,
*args,
) -> tuple:
source = filevar['source']
@ -99,12 +105,14 @@ class RougailSystemdTemplate(RougailBaseTemplate):
raise FileNotFound(_(f"File {source} does not exist."))
tmp_file = join(self.tmp_dir, source)
service_name = filevar['name']
return tmp_file, None, f'/systemd/system/{service_name}.service.d/rougail.conf', None
destfile = f'/systemd/system/{service_name}.{service_type}.d/rougail.conf'
return tmp_file, None, destfile, None
def _instance_ip(self,
def get_data_ip(self,
filevar: Dict,
ip,
service_name: str,
service_type: str,
var: Any,
idx: int,
*args,
@ -120,19 +128,49 @@ class RougailSystemdTemplate(RougailBaseTemplate):
elif ip:
self.ip_per_service.append(ip)
def get_data_service(self,
servicevar: Dict,
info,
service_name: str,
service_type: str,
*args,
):
filename = f'{service_name}.{service_type}'
tmp_file = join(self.tmp_dir, filename)
var = None
destfile = f'/systemd/system/{filename}'
return tmp_file, None, destfile, var
def desactive_service(self,
service_name: str,
service_type: str,
):
filename = f'{self.destinations_dir}/systemd/system/{service_name}.service'
filename = f'{self.destinations_dir}/systemd/system/{service_name}.{service_type}'
makedirs(dirname(filename), exist_ok=True)
symlink('/dev/null', filename)
def target_service(self,
service_name: str,
target_name: str,
service_type: str,
global_service: str,
):
filename = f'{self.destinations_dir}/systemd/system/{target_name}.target.wants/{service_name}.{service_type}'
makedirs(dirname(filename), exist_ok=True)
if global_service:
source_filename = f'{ROUGAIL_GLOBAL_SYSTEMD_FILE}/{service_name}.{service_type}'
else:
source_filename = f'{ROUGAIL_DEST}/systemd/system/{service_name}.{service_type}'
symlink(source_filename, filename)
def post_instance_service(self,
service_name: str,
service_type: str,
) -> None: # pragma: no cover
if self.ip_per_service is None:
return
destfile = f'/systemd/system/{service_name}.service.d/rougail_ip.conf'
destfile = f'/systemd/system/{service_name}.{service_type}.d/rougail_ip.conf'
destfilename = join(self.destinations_dir, destfile[1:])
makedirs(dirname(destfilename), exist_ok=True)
self.log.info(_(f"creole processing: '{destfilename}'"))

View file

@ -144,6 +144,63 @@ class RougailUpgrade:
value.text = choices[0]
variable.attrib['mandatory'] = 'True'
# convert group to leadership
groups = []
if constraints is not None:
for constraint in constraints:
if constraint.tag == 'group':
constraints.remove(constraint)
groups.append(constraint)
for group in groups:
if group.attrib['leader'] in paths:
leader_obj = paths[group.attrib['leader']]
#FIXME name peut avoir "." il faut le virer
#FIXME si extra c'est un follower !
if 'name' in group.attrib:
grpname = group.attrib['name']
if 'description' in group.attrib:
description = group.attrib['description']
else:
description = grpname
else:
grpname = leader_obj['variable'].attrib['name']
if '.' in grpname:
grpname = grpname.rsplit('.', 1)[-1]
if 'description' in group.attrib:
description = group.attrib['description']
elif 'description' in leader_obj['variable'].attrib:
description = leader_obj['variable'].attrib['description']
else:
description = grpname
family = SubElement(leader_obj['parent'], 'family', name=grpname, description=description, leadership="True")
leader_obj['parent'].remove(leader_obj['variable'])
family.append(leader_obj['variable'])
else:
# append in group
follower = next(iter(group))
leader_name = group.attrib['leader']
if '.' in leader_name:
leader_path = leader_name.rsplit('.', 1)[0]
follower_path = leader_path + '.' + follower.text
else:
follower_path = follower.text
obj = paths[follower_path]
family = SubElement(obj['parent'], 'family', name=leader_name, leadership="True")
grpname = leader_name
for follower in group:
leader_name = group.attrib['leader']
if '.' in leader_name:
leader_path = leader_name.rsplit('.', 1)[0]
follower_path = leader_path + '.' + follower.text
else:
follower_path = follower.text
follower_obj = paths[follower_path]
follower_obj['parent'].remove(follower_obj['variable'])
family.append(follower_obj['variable'])
if '.' in follower_path:
new_path = follower_path.rsplit('.', 1)[0] + '.' + grpname + '.' + follower_path.rsplit('.', 1)[1]
paths[new_path] = paths[follower_path]
# convert choice option
valid_enums = []
if constraints is not None:
@ -207,58 +264,6 @@ class RougailUpgrade:
for target in targets:
if 'remove_choice' not in target.attrib or target.attrib['remove_choice'] != 'True':
target.attrib['type'] = 'choice'
# convert group to leadership
groups = []
if constraints is not None:
for constraint in constraints:
if constraint.tag == 'group':
constraints.remove(constraint)
groups.append(constraint)
for group in groups:
if group.attrib['leader'] in paths:
leader_obj = paths[group.attrib['leader']]
#FIXME name peut avoir "." il faut le virer
#FIXME si extra c'est un follower !
if 'name' in group.attrib:
name = group.attrib['name']
if 'description' in group.attrib:
description = group.attrib['description']
else:
description = name
else:
name = leader_obj['variable'].attrib['name']
if '.' in name:
name = name.rsplit('.', 1)[-1]
if 'description' in group.attrib:
description = group.attrib['description']
elif 'description' in leader_obj['variable'].attrib:
description = leader_obj['variable'].attrib['description']
else:
description = name
family = SubElement(leader_obj['parent'], 'family', name=name, description=description, leadership="True")
leader_obj['parent'].remove(leader_obj['variable'])
family.append(leader_obj['variable'])
else:
# append in group
follower = next(iter(group))
leader_name = group.attrib['leader']
if '.' in leader_name:
leader_path = leader_name.rsplit('.', 1)[0]
follower_path = leader_path + '.' + follower.text
else:
follower_path = follower.text
obj = paths[follower_path]
family = SubElement(obj['parent'], 'family', name=leader_name, leadership="True")
for follower in group:
leader_name = group.attrib['leader']
if '.' in leader_name:
leader_path = leader_name.rsplit('.', 1)[0]
follower_path = leader_path + '.' + follower.text
else:
follower_path = follower.text
follower_obj = paths[follower_path]
follower_obj['parent'].remove(follower_obj['variable'])
family.append(follower_obj['variable'])
return root
def _get_path_variables(self, variables, is_variable_namespace, path, dico=None):

View file

@ -0,0 +1,14 @@
<?xml version='1.0' encoding='UTF-8'?>
<rougail version="0.10">
<services>
<service name="testsrv" engine="creole">
</service>
</services>
<variables>
<family name="general" description="général">
<variable name="mode_conteneur_actif" type="string" description="No change" hidden="True">
<value>oui</value>
</variable>
</family>
</variables>
</rougail>

View file

@ -0,0 +1,14 @@
{
"rougail.general.mode_conteneur_actif": {
"owner": "default",
"value": "oui"
},
"services.testsrv.activate": {
"owner": "default",
"value": true
},
"services.testsrv.manage": {
"owner": "default",
"value": true
}
}

View file

@ -0,0 +1,5 @@
{
"rougail.general.mode_conteneur_actif": "oui",
"services.testsrv.activate": true,
"services.testsrv.manage": true
}

View file

@ -0,0 +1,14 @@
{
"rougail.general.mode_conteneur_actif": {
"owner": "default",
"value": "oui"
},
"services.testsrv.activate": {
"owner": "default",
"value": true
},
"services.testsrv.manage": {
"owner": "default",
"value": true
}
}

View file

@ -0,0 +1,22 @@
from importlib.machinery import SourceFileLoader
from importlib.util import spec_from_loader, module_from_spec
loader = SourceFileLoader('func', 'tests/dictionaries/../eosfunc/test.py')
spec = spec_from_loader(loader.name, loader)
func = module_from_spec(spec)
loader.exec_module(func)
for key, value in dict(locals()).items():
if key != ['SourceFileLoader', 'func']:
setattr(func, key, value)
try:
from tiramisu3 import *
except:
from tiramisu import *
option_3 = StrOption(name="mode_conteneur_actif", doc="No change", default="oui", properties=frozenset({"force_default_on_freeze", "frozen", "hidden", "mandatory", "normal"}))
option_2 = OptionDescription(name="general", doc="général", children=[option_3], properties=frozenset({"normal"}))
option_1 = OptionDescription(name="rougail", doc="rougail", children=[option_2])
option_6 = BoolOption(name="activate", doc="activate", default=True)
option_7 = BoolOption(name="manage", doc="manage", default=True)
option_5 = OptionDescription(name="testsrv", doc="testsrv", children=[option_6, option_7])
option_5.impl_set_information('engine', "creole")
option_4 = OptionDescription(name="services", doc="services", children=[option_5], properties=frozenset({"hidden"}))
option_0 = OptionDescription(name="baseoption", doc="baseoption", children=[option_1, option_4])

View file

@ -0,0 +1 @@
%%mode_conteneur_actif

View file

@ -0,0 +1,13 @@
<?xml version='1.0' encoding='UTF-8'?>
<rougail version="0.10">
<services>
<service name="testsrv" type="mount" engine="creole"/>
</services>
<variables>
<family name="general" description="général">
<variable name="mode_conteneur_actif" type="string" description="No change" hidden="True">
<value>oui</value>
</variable>
</family>
</variables>
</rougail>

View file

@ -0,0 +1,14 @@
{
"rougail.general.mode_conteneur_actif": {
"owner": "default",
"value": "oui"
},
"services.testsrv.activate": {
"owner": "default",
"value": true
},
"services.testsrv.manage": {
"owner": "default",
"value": true
}
}

View file

@ -0,0 +1,5 @@
{
"rougail.general.mode_conteneur_actif": "oui",
"services.testsrv.activate": true,
"services.testsrv.manage": true
}

View file

@ -0,0 +1,14 @@
{
"rougail.general.mode_conteneur_actif": {
"owner": "default",
"value": "oui"
},
"services.testsrv.activate": {
"owner": "default",
"value": true
},
"services.testsrv.manage": {
"owner": "default",
"value": true
}
}

View file

@ -0,0 +1,23 @@
from importlib.machinery import SourceFileLoader
from importlib.util import spec_from_loader, module_from_spec
loader = SourceFileLoader('func', 'tests/dictionaries/../eosfunc/test.py')
spec = spec_from_loader(loader.name, loader)
func = module_from_spec(spec)
loader.exec_module(func)
for key, value in dict(locals()).items():
if key != ['SourceFileLoader', 'func']:
setattr(func, key, value)
try:
from tiramisu3 import *
except:
from tiramisu import *
option_3 = StrOption(name="mode_conteneur_actif", doc="No change", default="oui", properties=frozenset({"force_default_on_freeze", "frozen", "hidden", "mandatory", "normal"}))
option_2 = OptionDescription(name="general", doc="général", children=[option_3], properties=frozenset({"normal"}))
option_1 = OptionDescription(name="rougail", doc="rougail", children=[option_2])
option_6 = BoolOption(name="activate", doc="activate", default=True)
option_7 = BoolOption(name="manage", doc="manage", default=True)
option_5 = OptionDescription(name="testsrv", doc="testsrv", children=[option_6, option_7])
option_5.impl_set_information('type', "mount")
option_5.impl_set_information('engine', "creole")
option_4 = OptionDescription(name="services", doc="services", children=[option_5], properties=frozenset({"hidden"}))
option_0 = OptionDescription(name="baseoption", doc="baseoption", children=[option_1, option_4])

View file

@ -0,0 +1 @@
%%mode_conteneur_actif

View file

@ -0,0 +1,13 @@
<?xml version='1.0' encoding='UTF-8'?>
<rougail version="0.10">
<services>
<service name="testsrv" target="test"/>
</services>
<variables>
<family name="general" description="général">
<variable name="mode_conteneur_actif" type="string" description="No change" hidden="True">
<value>oui</value>
</variable>
</family>
</variables>
</rougail>

View file

@ -0,0 +1,14 @@
{
"rougail.general.mode_conteneur_actif": {
"owner": "default",
"value": "oui"
},
"services.testsrv.activate": {
"owner": "default",
"value": true
},
"services.testsrv.manage": {
"owner": "default",
"value": true
}
}

View file

@ -0,0 +1,5 @@
{
"rougail.general.mode_conteneur_actif": "oui",
"services.testsrv.activate": true,
"services.testsrv.manage": true
}

View file

@ -0,0 +1,14 @@
{
"rougail.general.mode_conteneur_actif": {
"owner": "default",
"value": "oui"
},
"services.testsrv.activate": {
"owner": "default",
"value": true
},
"services.testsrv.manage": {
"owner": "default",
"value": true
}
}

View file

@ -0,0 +1 @@
/usr/lib/systemd/system/testsrv.service

View file

@ -0,0 +1,22 @@
from importlib.machinery import SourceFileLoader
from importlib.util import spec_from_loader, module_from_spec
loader = SourceFileLoader('func', 'tests/dictionaries/../eosfunc/test.py')
spec = spec_from_loader(loader.name, loader)
func = module_from_spec(spec)
loader.exec_module(func)
for key, value in dict(locals()).items():
if key != ['SourceFileLoader', 'func']:
setattr(func, key, value)
try:
from tiramisu3 import *
except:
from tiramisu import *
option_3 = StrOption(name="mode_conteneur_actif", doc="No change", default="oui", properties=frozenset({"force_default_on_freeze", "frozen", "hidden", "mandatory", "normal"}))
option_2 = OptionDescription(name="general", doc="général", children=[option_3], properties=frozenset({"normal"}))
option_1 = OptionDescription(name="rougail", doc="rougail", children=[option_2])
option_6 = BoolOption(name="activate", doc="activate", default=True)
option_7 = BoolOption(name="manage", doc="manage", default=True)
option_5 = OptionDescription(name="testsrv", doc="testsrv", children=[option_6, option_7])
option_5.impl_set_information('target', "test")
option_4 = OptionDescription(name="services", doc="services", children=[option_5], properties=frozenset({"hidden"}))
option_0 = OptionDescription(name="baseoption", doc="baseoption", children=[option_1, option_4])

View file

@ -0,0 +1,13 @@
<?xml version='1.0' encoding='UTF-8'?>
<rougail version="0.10">
<services>
<service name="testsrv" target="test" engine="none"/>
</services>
<variables>
<family name="general" description="général">
<variable name="mode_conteneur_actif" type="string" description="No change" hidden="True">
<value>oui</value>
</variable>
</family>
</variables>
</rougail>

View file

@ -0,0 +1,14 @@
{
"rougail.general.mode_conteneur_actif": {
"owner": "default",
"value": "oui"
},
"services.testsrv.activate": {
"owner": "default",
"value": true
},
"services.testsrv.manage": {
"owner": "default",
"value": true
}
}

View file

@ -0,0 +1,5 @@
{
"rougail.general.mode_conteneur_actif": "oui",
"services.testsrv.activate": true,
"services.testsrv.manage": true
}

View file

@ -0,0 +1,14 @@
{
"rougail.general.mode_conteneur_actif": {
"owner": "default",
"value": "oui"
},
"services.testsrv.activate": {
"owner": "default",
"value": true
},
"services.testsrv.manage": {
"owner": "default",
"value": true
}
}

View file

@ -0,0 +1 @@
/usr/local/lib/systemd/system/testsrv.service

View file

@ -0,0 +1 @@
%%mode_conteneur_actif

View file

@ -0,0 +1,23 @@
from importlib.machinery import SourceFileLoader
from importlib.util import spec_from_loader, module_from_spec
loader = SourceFileLoader('func', 'tests/dictionaries/../eosfunc/test.py')
spec = spec_from_loader(loader.name, loader)
func = module_from_spec(spec)
loader.exec_module(func)
for key, value in dict(locals()).items():
if key != ['SourceFileLoader', 'func']:
setattr(func, key, value)
try:
from tiramisu3 import *
except:
from tiramisu import *
option_3 = StrOption(name="mode_conteneur_actif", doc="No change", default="oui", properties=frozenset({"force_default_on_freeze", "frozen", "hidden", "mandatory", "normal"}))
option_2 = OptionDescription(name="general", doc="général", children=[option_3], properties=frozenset({"normal"}))
option_1 = OptionDescription(name="rougail", doc="rougail", children=[option_2])
option_6 = BoolOption(name="activate", doc="activate", default=True)
option_7 = BoolOption(name="manage", doc="manage", default=True)
option_5 = OptionDescription(name="testsrv", doc="testsrv", children=[option_6, option_7])
option_5.impl_set_information('target', "test")
option_5.impl_set_information('engine', "none")
option_4 = OptionDescription(name="services", doc="services", children=[option_5], properties=frozenset({"hidden"}))
option_0 = OptionDescription(name="baseoption", doc="baseoption", children=[option_1, option_4])

View file

@ -0,0 +1 @@
%%mode_conteneur_actif

View file

@ -0,0 +1,6 @@
<?xml version='1.0' encoding='UTF-8'?>
<rougail version="0.10">
<services>
<service name="testsrv.mount"/>
</services>
</rougail>

View file

@ -1,4 +1,4 @@
from os import listdir, mkdir
from os import listdir, mkdir, readlink
from os.path import join, isdir, isfile, islink
from shutil import rmtree
from pytest import fixture, mark
@ -82,7 +82,9 @@ async def test_dictionary(test_dir):
assert list_templates == list_results
for result in list_results:
template_file = join(dest_dir, result)
if islink(template_file) and islink(join(test_dir, 'result', result)):
assert islink(template_file) == islink(join(test_dir, 'result', result))
if islink(template_file):
assert readlink(template_file) == readlink(join(test_dir, 'result', result))
continue
if not isfile(template_file):
raise Exception(f'{template_file} is not generated')