Initial commit
[ta/rpmbuilder.git] / rpmbuilder / project.py
diff --git a/rpmbuilder/project.py b/rpmbuilder/project.py
new file mode 100644 (file)
index 0000000..e100d94
--- /dev/null
@@ -0,0 +1,561 @@
+# Copyright 2019 Nokia
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Project is a subsystem which contains one spec file which
+defines how it is build. Every project has one git
+repository from where it is cloned from.
+"""
+import glob
+import json
+import logging
+import os
+import shutil
+import subprocess
+
+import re
+
+import datetime
+from rpmbuilder.baseerror import RpmbuilderError
+from rpmbuilder.prettyprinter import Prettyprint
+from rpmbuilder.rpmtools import Repotool, Specworker, RepotoolError, SpecError
+from rpmbuilder.utils import find_files
+from rpmbuilder.version_control import VersionControlSystem, VcsError
+from rpmbuilder.get_sources import get_sources
+
+
+class Project(object):
+
+    """ Instance of a project """
+
+    def __init__(self, name, workspace, projects, builders, packagebuilder, chrootscrub=True, nosrpm=False):
+        self.name = name
+
+        self.logger = logging.getLogger(__name__ + "." + self.name)
+
+        self.project_rebuild_needed = False
+
+        self.project_workspace = os.path.join(workspace,
+                                              'projects',
+                                              self.name)
+
+        self.projects = projects
+        self.builders = builders
+        self.directory_of_specpatch = os.path.join(self.project_workspace,
+                                                   'rpmbuild',
+                                                   'spec')
+        self.directory_of_sourcepackage = os.path.join(self.project_workspace,
+                                                       'rpmbuild',
+                                                       'sources')
+        self.directory_of_srpms = os.path.join(self.project_workspace,
+                                               'rpmbuild',
+                                               'srpm')
+        self.directory_of_rpm = os.path.join(self.project_workspace,
+                                             'rpmbuild',
+                                             'rpm')
+        self.directory_of_commonrepo = os.path.join(workspace,
+                                                    'buildrepository')
+
+        self.directory_of_builder = self.builders.get_configdir()
+
+        self.__create_directories([self.directory_of_specpatch,
+                                   self.directory_of_srpms],
+                                  verify_empty=True)
+        self.__create_directories([self.directory_of_sourcepackage],
+                                  verify_empty=False)
+
+        self.packagebuilder = packagebuilder
+
+        self.chrootscrub = chrootscrub
+        self.built = {}
+        for mockroot in builders.roots:
+            self.built[mockroot] = False
+
+        self.project_changed = False
+        self.projconf = None
+        self.spec = None
+        self.useversion = None
+        self.directory_of_checkout = None
+        self.nosrpm = nosrpm
+        self.centos_style = False
+        self.buildrequires_downstream = set()
+        self.buildrequires_upstream = set()
+
+    def mark_for_rebuild(self):
+        """ Marking project for rebuild only if project has not changed """
+        if not self.project_changed:
+            self.logger.debug("Marking project %s for rebuild.", self.name)
+            self.project_rebuild_needed = True
+
+    def mark_downstream_for_rebuild(self, marked_for_build=None):
+        """
+        Recursively mark downstream projects for rebuilding.
+        Return set of projects marked for rebuild
+        """
+        if marked_for_build is None:
+            marked_for_build = set()
+        self.logger.debug("Marking downstream for rebuild in \"%s\"",
+            self.name)
+        for project in self.who_buildrequires_me():
+            self.logger.debug("BuildRequires to \"%s\" found in \"%s\"",
+                self.name, project)
+            if project in marked_for_build:
+                self.logger.debug("\"%s\" already marked for build", project)
+            elif self.projects[project].project_rebuild_needed:
+                self.logger.debug("\"%s\" already marked for rebuild", project)
+            else:
+                self.projects[project].mark_for_rebuild()
+                marked_for_build.add(project)
+                # Check if downstream has downstream projects
+                tmpset = self.projects[project].mark_downstream_for_rebuild(
+                    marked_for_build)
+                marked_for_build.update(tmpset)
+        return marked_for_build
+
+    def build_project(self, mockroot):
+        """ Do building of SRPM and RPM files """
+        time_start = datetime.datetime.now()
+        Prettyprint().print_heading("Build " + self.name, 60)
+        assert not self.built[mockroot], "Project already built"
+
+        # Produce spec file
+        if self.spec.version == '%{_version}':
+            self.logger.debug("patching spec file")
+            self.logger.debug("Version in spec is going to be %s", self.useversion)
+
+            rpm = Repotool()
+            userelease = rpm.next_release_of_package(
+                os.path.join(self.directory_of_commonrepo,
+                             self.builders.roots[0],
+                             "rpm"),
+                self.spec.name,
+                self.useversion,
+                self.spec.release)
+            self.logger.debug("Release in spec is going to be %s", userelease)
+
+            specfile = self.packagebuilder.patch_specfile(self.spec.specfilefullpath,
+                                                          self.directory_of_specpatch,
+                                                          self.useversion,
+                                                          userelease)
+        else:
+            self.logger.debug("Skipping spec patching")
+            specfile = self.spec.specfilefullpath
+
+        # Start mocking
+        self.logger.debug("Starting building in root \"%s\"", mockroot)
+        if self.centos_style:
+            shutil.rmtree(self.directory_of_sourcepackage)
+            ignore_git = shutil.ignore_patterns('.git')
+            shutil.copytree(self.directory_of_checkout, self.directory_of_sourcepackage, ignore=ignore_git)
+            sources_key = 'CENTOS_SOURCES'
+            if sources_key not in os.environ:
+                raise RpmbuilderError('Cannot build CentOS style RPM, %s not defined in the environment' % sources_key)
+            get_sources(self.directory_of_sourcepackage, os.environ[sources_key].split(','), self.logger)
+            self.create_rpm_from_filesystem(self.directory_of_sourcepackage, mockroot)
+        elif self.nosrpm:
+            list_of_source_packages = self.get_source_package()
+            self.create_rpm_from_archive(list_of_source_packages, mockroot)
+        else:
+            self.get_source_package()
+            # Create source RPM file
+            sourcerpm = self.get_source_rpm(self.directory_of_sourcepackage, specfile, mockroot)
+
+            # Create final RPM file(s)
+            self.create_rpm_from_srpm(sourcerpm, mockroot)
+
+        # Mark build completed
+        self.built[mockroot] = True
+        time_delta = datetime.datetime.now() - time_start
+        self.logger.info('Building success: %s (took %s [%s sec])', self.name, time_delta, time_delta.seconds)
+
+        # We wipe buildroot of previously built rpm, source etc. packages
+        # This is custom cleaning which does not remove chroot
+        self.packagebuilder.mock_wipe_buildroot(self.project_workspace, self.directory_of_builder, mockroot)
+
+    def pull_source_packages(self, target_dir):
+        cmd = ['/usr/bin/spectool', '-d', 'KVERSION a.b', '-g', '--directory', target_dir, self.spec.specfilefullpath]
+        self.logger.info('Pulling source packages: %s', cmd)
+        try:
+            subprocess.check_call(cmd, shell=False)
+            self.logger.info('Pulling source packages ok')
+        except OSError as err:
+            self.logger.info('Pulling source packages nok %s', err.strerror)
+            raise RepotoolError("Calling of command spectool caused: \"%s\"" % err.strerror)
+        except:
+            self.logger.info('Pulling source packages nok ??', err.strerror)
+            raise RepotoolError("There was error pulling source content")
+
+    def get_source_package(self):
+        # Produce source package
+        source_package_list = []
+        for source_file_hit in self.spec.source_files:
+            self.logger.info("Acquiring source file \"%s\"", source_file_hit)
+            if re.match(r'^(http[s]*|ftp)://', source_file_hit):
+                self.logger.info("PULL %s", self.directory_of_sourcepackage)
+                self.pull_source_packages(self.directory_of_sourcepackage)
+                source_package_list.append(self.directory_of_sourcepackage + '/' + source_file_hit.split('/')[-1])
+                continue
+            for subdir in ["", "SOURCES"]:
+                if os.path.isfile(os.path.join(self.directory_of_checkout, subdir, source_file_hit)):
+                    shutil.copy(os.path.join(self.directory_of_checkout, subdir, source_file_hit), self.directory_of_sourcepackage)
+                    source_package_list.append(os.path.join(self.directory_of_sourcepackage, source_file_hit))
+                    break
+            else:
+                tarname = self.spec.name + '-' + self.useversion
+                source_package_list.append(self.packagebuilder.create_source_archive(tarname,
+                                                                                     self.directory_of_checkout,
+                                                                                     self.directory_of_sourcepackage,
+                                                                                     self.project_changed,
+                                                                                     self.spec.source_file_extension))
+
+        for patch_file_hit in self.spec.patch_files:
+            self.logger.info("Copying %s to directory %s", patch_file_hit, self.directory_of_sourcepackage)
+            for subdir in ["", "SOURCES"]:
+                if os.path.isfile(os.path.join(self.directory_of_checkout, subdir, patch_file_hit)):
+                    shutil.copy(os.path.join(self.directory_of_checkout, subdir, patch_file_hit), self.directory_of_sourcepackage)
+                    break
+            else:
+                raise ProjectError("Spec file lists patch \"%s\" but no file found" % patch_file_hit)
+        return source_package_list
+
+
+    def get_source_rpm(self, hostsourcedir, specfile, mockroot):
+        return self.packagebuilder.mock_source_rpm(hostsourcedir,
+                                                   specfile,
+                                                   self.directory_of_srpms,
+                                                   self.directory_of_builder,
+                                                   mockroot)
+
+    def create_rpm_from_srpm(self, sourcerpm, mockroot):
+        directory_of_rpm = os.path.join(self.directory_of_rpm, mockroot)
+        self.packagebuilder.mock_rpm(sourcerpm,
+                                     directory_of_rpm,
+                                     self.directory_of_builder,
+                                     mockroot)
+        # Delete duplicated src.rpm which is returned by rpm creation
+        os.remove(os.path.join(directory_of_rpm, os.path.basename(sourcerpm)))
+
+    def create_rpm_from_archive(self, source_tar_packages, mockroot):
+        directory_of_rpm = os.path.join(self.directory_of_rpm, mockroot)
+        self.packagebuilder.mock_rpm_from_archive(source_tar_packages, directory_of_rpm, self.directory_of_builder, mockroot)
+
+    def create_rpm_from_filesystem(self, path, mockroot):
+        directory_of_rpm = os.path.join(self.directory_of_rpm, mockroot)
+        self.packagebuilder.mock_rpm_from_filesystem(path,
+                                                     self.spec.specfilename,
+                                                     directory_of_rpm,
+                                                     self.directory_of_builder,
+                                                     mockroot,
+                                                     self.directory_of_srpms)
+
+    def list_buildproducts_for_mockroot(self, mockroot):
+        """ List both source and final rpm packages """
+        srpmlist = []
+        rpmlist = []
+        for occurence in os.listdir(os.path.join(self.directory_of_rpm, mockroot)):
+            if occurence.endswith(".rpm"):
+                rpmlist.append(occurence)
+        for occurence in os.listdir(self.directory_of_srpms):
+            if occurence.endswith(".src.rpm"):
+                srpmlist.append(occurence)
+        return rpmlist, srpmlist
+
+    def resolve_dependencies(self, mockroot):
+        return self.packagebuilder.run_builddep(self.spec.specfilefullpath,
+                                                self.directory_of_srpms,
+                                                self.directory_of_builder,
+                                                mockroot)
+
+    def store_build_products(self, commonrepo):
+        """ Save build products under common yum repository """
+        self.__create_directories([commonrepo])
+        for mockroot in self.builders.roots:
+            srpmtargetdir = os.path.join(commonrepo, mockroot, 'srpm')
+            rpmtargetdir = os.path.join(commonrepo, mockroot, 'rpm')
+            self.__create_directories([srpmtargetdir, rpmtargetdir])
+            (rpmlist, srpmlist) = self.list_buildproducts_for_mockroot(mockroot)
+            build_product_dir = os.path.join(self.directory_of_rpm, mockroot)
+            self.logger.debug("Hard linking %s rpm packages to %s", self.name, rpmtargetdir)
+            for rpm_file in rpmlist:
+                self.logger.info("Hard linking %s", rpm_file)
+                try:
+                    os.link(os.path.join(build_product_dir, rpm_file),
+                            os.path.join(rpmtargetdir, os.path.basename(rpm_file)))
+                except OSError:
+                    pass
+            self.logger.debug("Hard linking %s srpm packages to %s", self.name, srpmtargetdir)
+            for srpm_file in srpmlist:
+                self.logger.info("Hard linking %s", srpm_file)
+                try:
+                    os.link(os.path.join(self.directory_of_srpms, srpm_file),
+                            os.path.join(srpmtargetdir, srpm_file))
+                except OSError:
+                    pass
+
+        # Store info of latest build
+        self.store_project_status()
+
+
+    def who_buildrequires_me(self):
+        """
+        Return a list of projects which directly buildrequires this project (non-recursive)
+        """
+        downstream_projects = set()
+        # Loop through my packages
+        for package in self.spec.packages:
+            # Loop other projects and check if they need me
+            # To need me, they have my package in buildrequires
+            for project in self.projects:
+                if package in self.projects[project].spec.buildrequires:
+                    self.logger.debug("Found dependency in {}: my package {} is required by project {}".format(self.name, package, project))
+                    self.projects[project].buildrequires_upstream.add(self.name)
+                    self.projects[self.name].buildrequires_downstream.add(project)
+                    downstream_projects.add(project)
+        return downstream_projects
+
+
+    def who_requires_me(self, recursive=False, depth=0):
+        """
+        Return a list of projects which have requirement to this project
+        """
+        if depth > 10:
+            self.logger.warn("Hit infinite recursion limiter in {}".format(self.name))
+            recursive = False
+        # Loop through my packages
+        downstream_projects = set()
+        for package in self.spec.packages:
+            # Loop other projects and check if they need me
+            # To need me, they have my package in buildrequires or requires
+            for project in self.projects:
+                if package in self.projects[project].spec.buildrequires \
+                or package in self.projects[project].spec.requires:
+                    downstream_projects.add(project)
+                    if recursive:
+                        downstream_projects.update(
+                            self.projects[project].who_requires_me(True, depth+1))
+        self.logger.debug("Returning who_requires_me for %s: %s",
+            self.name, ', '.join(downstream_projects))
+        return downstream_projects
+
+    def get_project_changed(self):
+        raise NotImplementedError
+
+    def store_project_status(self):
+        raise NotImplementedError
+
+    def __create_directories(self, directories, verify_empty=False):
+        """ Directory creation """
+        for directory in directories:
+            if os.path.isdir(directory):
+                if verify_empty and os.listdir(directory) != []:
+                    self.logger.debug("Cleaning directory %s", directory)
+                    globstring = directory + "/*"
+                    files = glob.glob(globstring)
+                    for foundfile in files:
+                        self.logger.debug("Removing file %s", foundfile)
+                        os.remove(foundfile)
+            else:
+                self.logger.debug("Creating directory %s", directory)
+                try:
+                    os.makedirs(directory)
+                except OSError:
+                    raise
+        return True
+
+class LocalMountProject(Project):
+    """ Projects coming from local disk mount """
+    def __init__(self, name, directory, workspace, projects, builders, packagebuilder, masterargs, spec_path):
+        chrootscrub = masterargs.scrub
+        nosrpm = masterargs.nosrpm
+        forcebuild = masterargs.forcerebuild
+
+        Prettyprint().print_heading("Initializing %s from disk" % name, 60)
+        super(LocalMountProject, self).__init__(name, workspace, projects, builders, packagebuilder)
+
+        if not os.path.isdir(directory):
+            raise ProjectError("No directory %s found", directory)
+
+        self.vcs = VersionControlSystem(directory)
+        self.directory_of_checkout = directory
+
+        # Values from build configuration file
+        self.projconf = {}
+        # Read spec
+        if len(list(find_files(directory, r'\..+\.metadata$'))) > 0 and \
+                os.path.isdir(os.path.join(directory, 'SOURCES')) and \
+                os.path.isdir(os.path.join(directory, 'SPECS')):
+            self.centos_style = True
+            self.logger.debug('CentOS stype RPM detected')
+        self.spec = Specworker(os.path.dirname(spec_path), os.path.basename(spec_path))
+
+        self.gitversioned = False
+        try:
+            citag = self.vcs.get_citag()
+            self.gitversioned = True
+        except VcsError:
+            self.logger.debug("Project does not come from Git")
+        except:
+            raise
+
+        if self.spec.version == '%{_version}':
+            if self.gitversioned:
+                self.logger.debug("Using Git describe for package version")
+                self.useversion = citag
+            else:
+                self.logger.debug("Project not from Git. Using a.b package version")
+                self.useversion = 'a.b'
+        else:
+            self.logger.debug("Using spec definition for package version")
+            self.useversion = self.spec.version
+
+        self.packageversion = self.useversion
+        self.project_changed = self.get_project_changed()
+        self.nosrpm = nosrpm
+
+        if forcebuild:
+            self.mark_for_rebuild()
+
+        self.chrootscrub = chrootscrub
+
+    def get_project_changed(self):
+        """
+        Project status is read from status.txt file. Dirty git clones always require rebuild.
+        """
+        statusfile = os.path.join(self.project_workspace, 'status.txt')
+
+        if os.path.isfile(statusfile):
+            with open(statusfile, 'r') as filep:
+                previousprojectstatus = json.load(filep)
+            # Compare old values against new values
+            if not self.gitversioned:
+                self.logger.warning("Project %s is not git versioned. Forcing rebuild.", self.name)
+                return True
+            elif self.vcs.is_dirty():
+                self.logger.warning("Project %s contains unversioned changes and is \"dirty\". Forcing rebuild.", self.name)
+                return True
+            elif previousprojectstatus['sha'] != self.vcs.commitsha:
+                self.logger.info("Project %s log has new hash. Rebuild needed.", self.name)
+                return True
+            else:
+                self.logger.info("Project %s has NO new changes.", self.name)
+            return False
+        else:
+            # No configuration means that project has not been compiled
+            self.logger.warning("No previous build found for %s. Building initial version.", self.name)
+        return True
+
+    def store_project_status(self):
+        """ Write information of project version to status.txt
+        This can only be done for git versioned projects """
+        if self.gitversioned:
+            # Save information of the last compilation
+            statusfile = os.path.join(self.project_workspace, 'status.txt')
+            self.logger.debug("Updating status file %s", statusfile)
+
+            projectstatus = {"packageversion": self.packageversion,
+                             "sha": self.vcs.commitsha,
+                             "project": self.name}
+
+            with open(statusfile, 'w') as outfile:
+                json.dump(projectstatus, outfile)
+
+class GitProject(Project):
+    """ Projects cloned from Git version control system """
+    def __init__(self, name, workspace, conf, projects, builders, packagebuilder, masterargs):
+        forcebuild = masterargs.forcerebuild
+        chrootscrub = masterargs.scrub
+
+        Prettyprint().print_heading("Initializing %s from Git" % name, 60)
+        super(GitProject, self).__init__(name, workspace, projects, builders, packagebuilder)
+
+        # Values from build configuration file
+        self.projconf = {'url': conf.get_string(name, "url", mandatory=True),
+                         'ref': conf.get_string(name, "ref", mandatory=True),
+                         'spec': conf.get_string(name, "spec", mandatory=False, defaultvalue=None)}
+
+        # Do version control updates
+        self.directory_of_checkout = os.path.join(self.project_workspace,
+                                                  'checkout')
+        self.vcs = VersionControlSystem(self.directory_of_checkout)
+        self.vcs.update_git_project(self.projconf["url"], self.projconf["ref"])
+        self.useversion = self.vcs.get_citag()
+
+        # Read spec
+        try:
+            self.spec = Specworker(self.directory_of_checkout,
+                                   self.projconf["spec"])
+        except SpecError:
+            self.spec = Specworker(os.path.join(self.directory_of_checkout, "SPEC"), None)
+            self.centos_style = True
+
+        # Define what version shall be used in spec file
+        if self.spec.version == '%{_version}':
+            self.packageversion = self.vcs.get_citag()
+            self.logger.debug("Taking package version from VCS")
+        else:
+            self.packageversion = self.spec.version
+            self.logger.debug("Taking package version from spec")
+        self.logger.debug("Package version: %s", self.packageversion)
+
+        self.project_changed = self.get_project_changed()
+        if forcebuild:
+            self.mark_for_rebuild()
+
+        self.chrootscrub = chrootscrub
+
+    def get_project_changed(self):
+        """
+        Check if there has been changes in the project
+        if project has not been compiled -> return = True
+        if project has GIT/VCS changes   -> return = True
+        if project has not changed       -> return = False
+        """
+        statusfile = os.path.join(self.project_workspace, 'status.txt')
+
+        if os.path.isfile(statusfile):
+            with open(statusfile, 'r') as filep:
+                previousprojectstatus = json.load(filep)
+            # Compare old values against new values
+            if previousprojectstatus['url'] != self.projconf["url"] \
+                or previousprojectstatus['ref'] != self.projconf["ref"] \
+                or previousprojectstatus['sha'] != self.vcs.commitsha:
+                self.logger.debug("Returning info that changes found")
+                return True
+            else:
+                self.logger.debug("Returning info of NO changes")
+            return False
+        else:
+            # No configuration means that project has not been compiled
+            self.logger.debug("Doing first build of this project")
+        return True
+
+    def store_project_status(self):
+        """ Save information of the last compilation """
+        statusfile = os.path.join(self.project_workspace, 'status.txt')
+        self.logger.debug("Updating status file %s", statusfile)
+
+        projectstatus = {"url": self.projconf["url"],
+                         "ref": self.projconf["ref"],
+                         "spec": self.projconf["spec"],
+                         "packageversion": self.packageversion,
+                         "sha": self.vcs.commitsha,
+                         "project": self.name}
+
+        with open(statusfile, 'w') as outfile:
+            json.dump(projectstatus, outfile)
+
+class ProjectError(RpmbuilderError):
+
+    """ Exceptions originating from Project """
+    pass