Add history metadata for container builds

This commit adds the history section in contianerconfig. With it
'author', 'created_by' and 'comment' can be customized. In addition
'created' is always included with the image creation date time.
'created_by' entry is set to 'KIWI __version__' by default if nothing
is provided.

Fixes #852
This commit is contained in:
David Cassany 2018-11-06 16:31:37 +01:00
parent 2fa147dfb9
commit f0612486dd
No known key found for this signature in database
GPG Key ID: D91C0AAD9018D486
12 changed files with 333 additions and 59 deletions

View File

@ -52,7 +52,12 @@ class ContainerImageOCI(object):
'expose_ports': ['80', '42'],
'volumes': ['/var/log', '/tmp'],
'environment': {'PATH': '/bin'},
'labels': {'name': 'value'}
'labels': {'name': 'value'},
'history': {
'created_by': 'some explanation here',
'comment': 'some comment here',
'author': 'tux'
}
}
"""
def __init__(self, root_dir, custom_args=None):
@ -95,6 +100,12 @@ class ContainerImageOCI(object):
self.oci_config['entry_subcommand'] = \
Defaults.get_default_container_subcommand()
if 'history' not in self.oci_config:
self.oci_config['history'] = {}
if 'created_by' not in self.oci_config['history']:
self.oci_config['history']['created_by'] = \
Defaults.get_default_container_created_by()
self.oci = OCI(self.oci_config['container_tag'])
def create(self, filename, base_image):
@ -117,7 +128,7 @@ class ContainerImageOCI(object):
image_tar = ArchiveTar(base_image)
image_tar.extract(self.oci.container_dir)
self.oci.init_layout(base_image)
self.oci.init_layout(True if base_image else False)
self.oci.unpack(self.oci_root_dir)
oci_root = DataSync(
@ -133,7 +144,7 @@ class ContainerImageOCI(object):
for tag in self.oci_config['additional_tags']:
self.oci.add_tag(tag)
self.oci.set_config(self.oci_config)
self.oci.set_config(self.oci_config, True if base_image else False)
self.oci.garbage_collect()

View File

@ -25,7 +25,10 @@ from pkg_resources import resource_filename
# project
from .path import Path
from .version import __githash__
from .version import (
__githash__,
__version__
)
class Defaults(object):
@ -1173,6 +1176,17 @@ class Defaults(object):
"""
return ['/bin/bash']
@classmethod
def get_default_container_created_by(self):
"""
Provides the default 'created by' history entry for containers.
:return: the specific kiwi version used for the build
:rtype: str
"""
return 'KIWI {0}'.format(__version__)
@classmethod
def set_python_default_encoding_to_utf8(self):
"""

View File

@ -17,6 +17,7 @@
#
import os
from tempfile import mkdtemp
from datetime import datetime
# project
from kiwi.path import Path
@ -51,6 +52,9 @@ class OCIBase(object):
self.container_name = ':'.join(
[self.container_dir, self.container_tag]
)
self.creation_date = datetime.utcnow().strftime(
'%Y-%m-%dT%H:%M:%S+00:00'
)
def init_layout(self, base_image=False):
"""
@ -81,7 +85,6 @@ class OCIBase(object):
Implementation in specialized tool class
:param string oci_root_dir: root data directory
:param string container_name: custom container_dir:tag specifier
"""
raise NotImplementedError
@ -96,7 +99,7 @@ class OCIBase(object):
"""
raise NotImplementedError
def set_config(self, oci_config):
def set_config(self, oci_config, base_image=False):
"""
Set list of meta data information such as entry_point,
maintainer, etc... to the container. The validation of

View File

@ -15,7 +15,6 @@
# You should have received a copy of the GNU General Public License
# along with kiwi. If not, see <http://www.gnu.org/licenses/>
#
from datetime import datetime
# project
from kiwi.oci_tools.base import OCIBase
@ -36,16 +35,10 @@ class OCIUmoci(OCIBase):
The import and unpack of the base image is not a
responsibility of this class and done beforehead
:param string base_image: True|False
:param bool base_image: True|False
"""
if base_image:
Command.run(
[
'umoci', 'config', '--image',
'{0}:base_layer'.format(self.container_dir),
'--tag', self.container_tag
]
)
self.container_name = '{0}:base_layer'.format(self.container_dir)
else:
Command.run(
['umoci', 'init', '--layout', self.container_dir]
@ -87,24 +80,30 @@ class OCIUmoci(OCIBase):
]
)
def set_config(self, oci_config):
def set_config(self, oci_config, base_image=False):
"""
Set list of meta data information such as entry_point,
maintainer, etc... to the container.
:param list oci_config: meta data list
:param bool base_image: True|False
"""
config_args = self._process_oci_config_to_arguments(oci_config)
Command.run(
[
'umoci', 'config'
] + config_args + [
'--history.created', self.creation_date,
'--image', self.container_name,
'--created', datetime.utcnow().strftime(
'%Y-%m-%dT%H:%M:%S+00:00'
)
'--tag', self.container_tag,
'--created', self.creation_date
]
)
if base_image:
Command.run(['umoci', 'rm', '--image', self.container_name])
self.container_name = self.container_name = ':'.join(
[self.container_dir, self.container_tag]
)
@classmethod # noqa:C091
def _process_oci_config_to_arguments(self, oci_config):
@ -168,8 +167,27 @@ class OCIUmoci(OCIBase):
name, oci_config['labels'][name]
))
arguments.extend(self._process_oci_history_to_arguments(oci_config))
return arguments
@classmethod
def _process_oci_history_to_arguments(self, oci_config):
history_args = []
if 'history' in oci_config:
if 'comment' in oci_config['history']:
history_args.append('--history.comment={0}'.format(
oci_config['history']['comment']
))
if 'created_by' in oci_config['history']:
history_args.append('--history.created_by={0}'.format(
oci_config['history']['created_by']
))
if 'author' in oci_config['history']:
history_args.append('--history.author={0}'.format(
oci_config['history']['author']
))
return history_args
def garbage_collect(self):
"""
Cleanup unused data from operations

View File

@ -2207,7 +2207,8 @@ div {
k.expose? &
k.volumes? &
k.environment? &
k.labels?
k.labels? &
k.history?
}
}
@ -2400,6 +2401,32 @@ div {
}
}
#==========================================
# main block: <history>
#
div {
k.history.created_by.attribute =
## Specifies the 'created by' history record. By default set to 'KIWI'
attribute created_by { text }
k.history.author.attribute =
## Specifies the 'author' history record.
attribute author { text }
k.history.attlist =
k.history.created_by.attribute? &
k.history.author.attribute?
k.history =
## Provides details about the container history. Includes the
## 'created by', 'author' as attributes and its content represents
## the 'comment' entry.
element history {
k.history.attlist,
text
}
}
#==========================================
# main block: <oemconfig>
#

View File

@ -3427,6 +3427,9 @@ section provides globally useful container information.</a:documentation>
<optional>
<ref name="k.labels"/>
</optional>
<optional>
<ref name="k.history"/>
</optional>
</interleave>
</element>
</define>
@ -3699,6 +3702,42 @@ At least one label must be configured</a:documentation>
</element>
</define>
</div>
<!--
==========================================
main block: <history>
-->
<div>
<define name="k.history.created_by.attribute">
<attribute name="created_by">
<a:documentation>Specifies the 'created by' history record. By default set to 'KIWI'</a:documentation>
</attribute>
</define>
<define name="k.history.author.attribute">
<attribute name="author">
<a:documentation>Specifies the 'author' history record.</a:documentation>
</attribute>
</define>
<define name="k.history.attlist">
<interleave>
<optional>
<ref name="k.history.created_by.attribute"/>
</optional>
<optional>
<ref name="k.history.author.attribute"/>
</optional>
</interleave>
</define>
<define name="k.history">
<element name="history">
<a:documentation>Provides details about the container history. Includes the
'created by', 'author' as attributes and its content represents
the 'comment' entry.</a:documentation>
<ref name="k.history.attlist"/>
<text/>
</element>
</define>
</div>
<!--
==========================================
main block: <oemconfig>

View File

@ -16,7 +16,7 @@
# kiwi/schema/kiwi_for_generateDS.xsd
#
# Command line:
# /home/ms/Project/kiwi/.tox/3.6/bin/generateDS.py -f --external-encoding="utf-8" --no-dates --no-warnings -o "kiwi/xml_parse.py" kiwi/schema/kiwi_for_generateDS.xsd
# /home/david/work/kiwi/.env3/bin/generateDS.py -f --external-encoding="utf-8" --no-dates --no-warnings -o "kiwi/xml_parse.py" kiwi/schema/kiwi_for_generateDS.xsd
#
# Current working directory (os.getcwd()):
# kiwi
@ -4865,7 +4865,7 @@ class containerconfig(GeneratedsSuper):
useful container information."""
subclass = None
superclass = None
def __init__(self, name=None, tag=None, additionaltags=None, maintainer=None, user=None, workingdir=None, entrypoint=None, subcommand=None, expose=None, volumes=None, environment=None, labels=None):
def __init__(self, name=None, tag=None, additionaltags=None, maintainer=None, user=None, workingdir=None, entrypoint=None, subcommand=None, expose=None, volumes=None, environment=None, labels=None, history=None):
self.original_tagname_ = None
self.name = _cast(None, name)
self.tag = _cast(None, tag)
@ -4897,6 +4897,10 @@ class containerconfig(GeneratedsSuper):
self.labels = []
else:
self.labels = labels
if history is None:
self.history = []
else:
self.history = history
def factory(*args_, **kwargs_):
if CurrentSubclassModule_ is not None:
subclass = getSubclassFromModule_(
@ -4938,6 +4942,11 @@ class containerconfig(GeneratedsSuper):
def add_labels(self, value): self.labels.append(value)
def insert_labels_at(self, index, value): self.labels.insert(index, value)
def replace_labels_at(self, index, value): self.labels[index] = value
def get_history(self): return self.history
def set_history(self, history): self.history = history
def add_history(self, value): self.history.append(value)
def insert_history_at(self, index, value): self.history.insert(index, value)
def replace_history_at(self, index, value): self.history[index] = value
def get_name(self): return self.name
def set_name(self, name): self.name = name
def get_tag(self): return self.tag
@ -4957,7 +4966,8 @@ class containerconfig(GeneratedsSuper):
self.expose or
self.volumes or
self.environment or
self.labels
self.labels or
self.history
):
return True
else:
@ -5019,6 +5029,8 @@ class containerconfig(GeneratedsSuper):
environment_.export(outfile, level, namespace_, name_='environment', pretty_print=pretty_print)
for labels_ in self.labels:
labels_.export(outfile, level, namespace_, name_='labels', pretty_print=pretty_print)
for history_ in self.history:
history_.export(outfile, level, namespace_, name_='history', pretty_print=pretty_print)
def build(self, node):
already_processed = set()
self.buildAttributes(node, node.attrib, already_processed)
@ -5082,6 +5094,11 @@ class containerconfig(GeneratedsSuper):
obj_.build(child_)
self.labels.append(obj_)
obj_.original_tagname_ = 'labels'
elif nodeName_ == 'history':
obj_ = history.factory()
obj_.build(child_)
self.history.append(obj_)
obj_.original_tagname_ = 'history'
# end class containerconfig
@ -5933,6 +5950,107 @@ class label(GeneratedsSuper):
# end class label
class history(GeneratedsSuper):
"""Provides details about the container history. Includes the 'created
by', 'author' as attributes and its content represents the
'comment' entry."""
subclass = None
superclass = None
def __init__(self, created_by=None, author=None, valueOf_=None, mixedclass_=None, content_=None):
self.original_tagname_ = None
self.created_by = _cast(None, created_by)
self.author = _cast(None, author)
self.valueOf_ = valueOf_
if mixedclass_ is None:
self.mixedclass_ = MixedContainer
else:
self.mixedclass_ = mixedclass_
if content_ is None:
self.content_ = []
else:
self.content_ = content_
self.valueOf_ = valueOf_
def factory(*args_, **kwargs_):
if CurrentSubclassModule_ is not None:
subclass = getSubclassFromModule_(
CurrentSubclassModule_, history)
if subclass is not None:
return subclass(*args_, **kwargs_)
if history.subclass:
return history.subclass(*args_, **kwargs_)
else:
return history(*args_, **kwargs_)
factory = staticmethod(factory)
def get_created_by(self): return self.created_by
def set_created_by(self, created_by): self.created_by = created_by
def get_author(self): return self.author
def set_author(self, author): self.author = author
def get_valueOf_(self): return self.valueOf_
def set_valueOf_(self, valueOf_): self.valueOf_ = valueOf_
def hasContent_(self):
if (
(1 if type(self.valueOf_) in [int,float] else self.valueOf_)
):
return True
else:
return False
def export(self, outfile, level, namespace_='', name_='history', namespacedef_='', pretty_print=True):
imported_ns_def_ = GenerateDSNamespaceDefs_.get('history')
if imported_ns_def_ is not None:
namespacedef_ = imported_ns_def_
if pretty_print:
eol_ = '\n'
else:
eol_ = ''
if self.original_tagname_ is not None:
name_ = self.original_tagname_
showIndent(outfile, level, pretty_print)
outfile.write('<%s%s%s' % (namespace_, name_, namespacedef_ and ' ' + namespacedef_ or '', ))
already_processed = set()
self.exportAttributes(outfile, level, already_processed, namespace_, name_='history')
outfile.write('>')
self.exportChildren(outfile, level + 1, namespace_, name_, pretty_print=pretty_print)
outfile.write(self.convert_unicode(self.valueOf_))
outfile.write('</%s%s>%s' % (namespace_, name_, eol_))
def exportAttributes(self, outfile, level, already_processed, namespace_='', name_='history'):
if self.created_by is not None and 'created_by' not in already_processed:
already_processed.add('created_by')
outfile.write(' created_by=%s' % (self.gds_encode(self.gds_format_string(quote_attrib(self.created_by), input_name='created_by')), ))
if self.author is not None and 'author' not in already_processed:
already_processed.add('author')
outfile.write(' author=%s' % (self.gds_encode(self.gds_format_string(quote_attrib(self.author), input_name='author')), ))
def exportChildren(self, outfile, level, namespace_='', name_='history', fromsubclass_=False, pretty_print=True):
pass
def build(self, node):
already_processed = set()
self.buildAttributes(node, node.attrib, already_processed)
self.valueOf_ = get_all_text_(node)
if node.text is not None:
obj_ = self.mixedclass_(MixedContainer.CategoryText,
MixedContainer.TypeNone, '', node.text)
self.content_.append(obj_)
for child in node:
nodeName_ = Tag_pattern_.match(child.tag).groups()[-1]
self.buildChildren(child, node, nodeName_)
return self
def buildAttributes(self, node, attrs, already_processed):
value = find_attr_value_('created_by', node)
if value is not None and 'created_by' not in already_processed:
already_processed.add('created_by')
self.created_by = value
value = find_attr_value_('author', node)
if value is not None and 'author' not in already_processed:
already_processed.add('author')
self.author = value
def buildChildren(self, child_, node, nodeName_, fromsubclass_=False):
if not fromsubclass_ and child_.tail is not None:
obj_ = self.mixedclass_(MixedContainer.CategoryText,
MixedContainer.TypeNone, '', child_.tail)
self.content_.append(obj_)
pass
# end class history
class oemconfig(GeneratedsSuper):
"""The oemconfig element specifies the OEM image configuration options
which are used to repartition and setup the system disk."""
@ -7688,6 +7806,7 @@ __all__ = [
"expose",
"extension",
"file",
"history",
"ignore",
"image",
"k_source",

View File

@ -891,6 +891,9 @@ class XMLState(object):
container_config.update(
self._match_docker_labels()
)
container_config.update(
self._match_docker_history()
)
return container_config
def set_container_config_tag(self, tag):
@ -1812,6 +1815,23 @@ class XMLState(object):
label.get_value()
return container_labels
def _match_docker_history(self):
container_config_section = self.get_build_type_containerconfig_section()
container_history = {}
if container_config_section:
history = container_config_section.get_history()
if history:
container_history['history'] = {}
if history[0].get_created_by():
container_history['history']['created_by'] = \
history[0].get_created_by()
if history[0].get_author():
container_history['history']['author'] = \
history[0].get_author()
container_history['history']['comment'] = \
history[0].get_valueOf_()
return container_history
def _solve_profile_dependencies(
self, profile, available_profiles, current_profiles
):

View File

@ -128,6 +128,7 @@
<label name="somelabel" value="labelvalue"/>
<label name="someotherlabel" value="anotherlabelvalue"/>
</labels>
<history author="history author" created_by="created by text">This is a comment</history>
</containerconfig>
</type>
<type image="iso" mediacheck="true"/>

View File

@ -6,6 +6,7 @@ import mock
from .test_helper import patch_open
from kiwi.container.oci import ContainerImageOCI
from kiwi.version import __version__
class TestContainerImageOCI(object):
@ -47,7 +48,8 @@ class TestContainerImageOCI(object):
assert container.oci_config == {
'container_name': 'kiwi-container',
'container_tag': 'latest',
'entry_subcommand': ['/bin/bash']
'entry_subcommand': ['/bin/bash'],
'history': {'created_by': 'KIWI {0}'.format(__version__)}
}
@patch('kiwi.defaults.Defaults.is_buildservice_worker')
@ -106,15 +108,16 @@ class TestContainerImageOCI(object):
self.oci.create('result.tar', None)
self.oci.oci.init_layout.assert_called_once_with(None)
self.oci.oci.init_layout.assert_called_once_with(False)
self.oci.oci.unpack.assert_called_once_with('kiwi_oci_root_dir')
self.oci.oci.repack.assert_called_once_with('kiwi_oci_root_dir')
self.oci.oci.set_config.assert_called_once_with({
'container_name': 'foo/bar',
'additional_tags': ['current', 'foobar'],
'container_tag': 'latest',
'entry_subcommand': ['/bin/bash']
})
'entry_subcommand': ['/bin/bash'],
'history': {'created_by': 'KIWI {0}'.format(__version__)}
}, False)
assert self.oci.oci.add_tag.call_args_list == [
call('current'), call('foobar')
]
@ -164,17 +167,16 @@ class TestContainerImageOCI(object):
mock_create.assert_called_once_with('kiwi_oci_dir.XXXX/oci_layout')
self.oci.oci.init_layout.assert_called_once_with(
'root_dir/image/image_file'
)
self.oci.oci.init_layout.assert_called_once_with(True)
self.oci.oci.unpack.assert_called_once_with('kiwi_oci_root_dir')
self.oci.oci.repack.assert_called_once_with('kiwi_oci_root_dir')
self.oci.oci.set_config.assert_called_once_with({
'container_name': 'foo/bar',
'additional_tags': ['current', 'foobar'],
'container_tag': 'latest',
'entry_subcommand': ['/bin/bash']
})
'entry_subcommand': ['/bin/bash'],
'history': {'created_by': 'KIWI {0}'.format(__version__)}
}, True)
assert self.oci.oci.add_tag.call_args_list == [
call('current'), call('foobar')
]

View File

@ -6,9 +6,15 @@ from kiwi.oci_tools.umoci import OCIUmoci
class TestOCIBase(object):
@patch('kiwi.oci_tools.base.datetime')
@patch('kiwi.oci_tools.base.mkdtemp')
def setup(self, mock_mkdtemp):
def setup(self, mock_mkdtemp, mock_datetime):
mock_mkdtemp.return_value = 'tmpdir'
strftime = Mock()
strftime.strftime = Mock(return_value='current_date')
mock_datetime.utcnow = Mock(
return_value=strftime
)
self.oci = OCIUmoci('tag')
@patch('kiwi.oci_tools.umoci.Command.run')
@ -22,12 +28,7 @@ class TestOCIBase(object):
@patch('kiwi.oci_tools.umoci.Command.run')
def test_init_layout_base_image(self, mock_Command_run):
self.oci.init_layout(True)
mock_Command_run.assert_called_once_with(
[
'umoci', 'config', '--image',
'tmpdir/oci_layout:base_layer', '--tag', 'tag'
]
)
assert self.oci.container_name == 'tmpdir/oci_layout:base_layer'
@patch('kiwi.oci_tools.umoci.Command.run')
def test_unpack(self, mock_Command_run):
@ -54,13 +55,7 @@ class TestOCIBase(object):
)
@patch('kiwi.oci_tools.umoci.Command.run')
@patch('kiwi.oci_tools.umoci.datetime')
def test_set_config(self, mock_datetime, mock_Command_run):
strftime = Mock()
strftime.strftime = Mock(return_value='current_date')
mock_datetime.utcnow = Mock(
return_value=strftime
)
def test_set_config(self, mock_Command_run):
oci_config = {
'entry_command': ['/bin/bash', '-x'],
'entry_subcommand': ['ls', '-l'],
@ -70,7 +65,12 @@ class TestOCIBase(object):
'expose_ports': ['80', '42'],
'volumes': ['/var/log', '/tmp'],
'environment': {'FOO': 'bar', 'PATH': '/bin'},
'labels': {'a': 'value', 'b': 'value'}
'labels': {'a': 'value', 'b': 'value'},
'history': {
'author': 'history author',
'comment': 'This is a comment',
'created_by': 'created by text'
}
}
self.oci.set_config(oci_config)
mock_Command_run.assert_called_once_with(
@ -82,20 +82,34 @@ class TestOCIBase(object):
'--config.exposedports=80', '--config.exposedports=42',
'--config.env=FOO=bar', '--config.env=PATH=/bin',
'--config.label=a=value', '--config.label=b=value',
'--image', 'tmpdir/oci_layout:tag', '--created', 'current_date'
'--history.comment=This is a comment',
'--history.created_by=created by text',
'--history.author=history author',
'--history.created', 'current_date',
'--image', 'tmpdir/oci_layout:tag', '--tag', 'tag',
'--created', 'current_date'
]
)
@patch('kiwi.oci_tools.umoci.Command.run')
@patch('kiwi.oci_tools.umoci.datetime')
def test_set_config_dervied_image(self, mock_Command_run):
self.oci.init_layout(True)
self.oci.set_config({}, True)
assert mock_Command_run.call_args_list == [
call([
'umoci', 'config',
'--history.created', 'current_date',
'--image', 'tmpdir/oci_layout:base_layer', '--tag', 'tag',
'--created', 'current_date'
]),
call(['umoci', 'rm', '--image', 'tmpdir/oci_layout:base_layer'])
]
self.oci.container_name == 'tmpdir/oci_layout:tag'
@patch('kiwi.oci_tools.umoci.Command.run')
def test_set_config_clear_inherited_commands(
self, mock_datetime, mock_Command_run
self, mock_Command_run
):
strftime = Mock()
strftime.strftime = Mock(return_value='current_date')
mock_datetime.utcnow = Mock(
return_value=strftime
)
oci_config = {
'entry_command': [],
'entry_subcommand': []
@ -104,7 +118,8 @@ class TestOCIBase(object):
mock_Command_run.assert_called_once_with(
[
'umoci', 'config', '--clear=config.entrypoint',
'--clear=config.cmd', '--image', 'tmpdir/oci_layout:tag',
'--clear=config.cmd', '--history.created', 'current_date',
'--image', 'tmpdir/oci_layout:tag', '--tag', 'tag',
'--created', 'current_date'
]
)

View File

@ -620,7 +620,7 @@ class TestXMLState(object):
}
@patch('kiwi.logger.log.warning')
def test_add_container_label_no_contianer_image_type(self, mock_log_warn):
def test_add_container_label_no_container_image_type(self, mock_log_warn):
xml_data = self.description.load()
state = XMLState(xml_data, ['vmxFlavour'], 'vmx')
state.add_container_config_label('somelabel', 'newlabelvalue')
@ -652,7 +652,12 @@ class TestXMLState(object):
'user': 'root',
'volumes': ['/tmp', '/var/log'],
'entry_command': ['/bin/bash', '-x'],
'expose_ports': ['80', '8080']
'expose_ports': ['80', '8080'],
'history': {
'author': 'history author',
'comment': 'This is a comment',
'created_by': 'created by text'
}
}
xml_data = self.description.load()
state = XMLState(xml_data, ['vmxFlavour'], 'docker')
@ -666,7 +671,7 @@ class TestXMLState(object):
'container_tag': 'container_tag',
'workingdir': '/root',
'user': 'root',
'entry_command': []
'entry_command': [],
}
xml_data = self.description.load()
state = XMLState(xml_data, ['derivedContainer'], 'docker')