Skip to content
Snippets Groups Projects
  • Jonas Höppner's avatar
    CI: deploy_gitlab_ci: Rework the deployment of gitlab-ci into the child projects · 31d6d7da
    Jonas Höppner authored
    The deploy_gitlab_ci now creates the integration commit and branch in
    each passed subproject and create an integration commit in the manifest
    containing all these new revisions.
    A build is then triggered on this commit to test the functionality.
    
    Split the update_submodule functions to reuse them in different ways.
    Remove some previously used files.
    
    BCS 746-000740
    31d6d7da
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
update_submodule.py 12.24 KiB
#!/usr/bin/env python3
import common

import argparse
import logging
import os
import sys
import tempfile
from configparser import ConfigParser
from furl import furl
from git import GitCommandError, Repo
from gitlab import Gitlab
from gitlab.v4.objects import Project
from ruamel.yaml import YAML


def get_submodule_project_path_and_revision(project: Project, submodule, branch=None):

    gitmodules = common.get_repository_file_raw(project, ".gitmodules", ref=branch)
    if gitmodules is None:
        logging.error("Submodule %s not found in %s.", submodule, project.name)
        return None, None

    logging.debug("Gitmodules: %s", gitmodules)

    cfgparse = ConfigParser()
    cfgparse.read_string(gitmodules)
    try:
        section = cfgparse['submodule "{}"'.format(submodule)]
    except KeyError:
        logging.error("Submodule %s not found in %s.", submodule, project.name)
        return None, None

    submodule_url = section["url"]
    # absolut path to a relative submodule
    # Check for relative path
    if not submodule_url.startswith(".."):
        logging.error("absolute submodule paths are not supported (%s)", submodule_url)
        return None, None

    # Get absolute project path
    # This cannot be done with gitpython directly due to issue:
    # https://github.com/gitpython-developers/GitPython/issues/730
    relative_path = os.path.splitext(submodule_url)[0]  # remove .git
    project_path = project.path_with_namespace
    while relative_path.startswith(".."):
        relative_path = relative_path[3:]  # strip off '../'
        project_path, _ = os.path.split(project_path)  # remove last part
    submodule_project_path = os.path.join(project_path, relative_path)

    # Get current revision
    gitmodule_rev = common.get_repository_file_obj(project, submodule, ref=branch)

    return submodule_project_path, gitmodule_rev["id"]


def get_submodule_integration_branch_suffix(submodule_project: Project, revision):
    # Find out if top commit is part of a merge request
    # If so, use source branch of this MR as integration branch name
    # Else use commit sha instead
    integration_branch_suffix = revision
    for mr in submodule_project.commits.get(revision).merge_requests():
        if mr["target_branch"] == submodule_project.default_branch:
            integration_branch_suffix = mr["source_branch"]
            break
    return integration_branch_suffix


def clone_project_and_submodule(project: Project, submodule_name, branch=None):
    """Creates a clone of the given project including the submodule
    return:
    """
    gitlab = project.manager.gitlab

    # If no branch is given, use project's default branch
    if branch is None:
        branch = project.default_branch

    project_dir = tempfile.TemporaryDirectory()

    # Construct clone url containing access token
    clone_url = furl(project.http_url_to_repo)
    clone_url.username = "gitlab-ci"
    clone_url.password = gitlab.private_token

    # Checkout project
    try:
        repo = Repo.clone_from(clone_url.url, project_dir, branch=branch, depth=1)
    except GitCommandError as e:
        sys.exit("ERROR: could not clone repository\n" + str(e))
    except IndexError:
        sys.exit("ERROR: branch '%s' not found" % branch)

    # Find submodule
    submodule = common.get_submodule(repo, submodule_name)

    # Check for relative path
    if not submodule.url.startswith(".."):
        sys.exit(
            "ERROR: absolute submodule paths are not supported (%s)" % submodule.url
        )

    # Get absolute project path
    # This cannot be done with gitpython directly due to issue:
    # https://github.com/gitpython-developers/GitPython/issues/730
    relative_path = os.path.splitext(submodule.url)[0]  # remove .git
    project_path = project.path_with_namespace
    while relative_path.startswith(".."):
        relative_path = relative_path[3:]  # strip off '../'
        project_path, _ = os.path.split(project_path)  # remove last part
    submodule_project_path = os.path.join(project_path, relative_path)

    # Get submodule project
    submodule_project = common.get_project(gitlab, submodule_project_path)

    # Initialize submodule
    # Hack due to issue above: change to absolute path and switch back afterwards
    submodule_clone_url = furl(submodule_project.http_url_to_repo)
    submodule_clone_url.username = "gitlab-ci"
    submodule_clone_url.password = gitlab.private_token
    submodule_relative_url = submodule.url
    with submodule.config_writer() as writer:
        writer.set("url", submodule_clone_url.url)
    submodule.update(init=True)
    with submodule.config_writer() as writer:
        writer.set("url", submodule_relative_url)

    return repo, submodule_project


def update_submodule_in_repo(repo: Repo, submodule_project: Project, new_revision):
    """Updates the given submodule to the given revision and adds it to the
    staging of repo
    """

    # Update submodule
    try:
        submodule_project.module().git.checkout(new_revision)
    except GitCommandError as e:
        sys.exit("ERROR: could not checkout commit\n" + str(e))
    repo.git.add(submodule_project.path)


def update_gitlab_ci_include(content, include_project, new_revision):

    # Remove the SECO-Northern-Europe part from the project filter
    # as it is normally specified by $CI_PROJECT_ROOT_NAMESPACE
    include_project = include_project.split("/", 1)[1]

    yaml = YAML()
    data = yaml.load(content)
    logging.debug("Yaml: %s", data)
    try:
        includes = data["include"]
    except KeyError:
        logging.debug("No include statement found")
        return None
    current_revision = None
    for entry in includes:
        try:
            if include_project in entry["project"]:
                current_revision = entry["ref"]
                break
        except KeyError:
            logging.debug("Failed to parse include statement")
            return None
    if current_revision is None:
        logging.debug("Failed to find %s in include statement", include_project)
        return None

    # Use plain replacement to keep the content of the file
    # Yes, this may fail if the 'current_revision' is used multiple
    # time is this fail. But probably this will not ever happen
    logging.debug("Replace %s with %s", current_revision, new_revision)
    return content.replace(current_revision, new_revision)


def update_submodule_and_include_ref(
    project,
    submodule_name,
    new_revision,
    branch=None,
    commit_and_push=True,
):
    """Update the submodule and include refs to the submodule in the given project.
        Create mergerequest if needed.

    Parameters:
        project ( gitlab project): The project which's submodule should be updated
        submodule_name (string): The name of the submodule to pull
        new_revision (hex string): The sha hash of the commit to update the submodule to
        branch (string): branch to update, if None, the projects default branch is used
        commit_and_push: Set to false if no commit should be created. Changes are left in staging.

    Returns: tuple of:
        project_repo (Repo): GitPython repo with the cloned project
        integration_branch (string): Name of the newly created integration branch
        integration_commit (hexsha): Hash of the newly created commit
        message: Commit message based on the integrated changes.
    """

    if branch is None:
        branch = project.default_branch
    logging.debug("Branch: %s", branch)

    _, submodule_current_rev = get_submodule_project_path_and_revision(
        project, submodule_name, branch
    )

    # Check if revisions are different
    if submodule_current_rev == new_revision:
        print("Submodule is already at %s" % new_revision)
        return (None, None, None, None)

    project_repo, submodule_project = clone_project_and_submodule(
        project, submodule_name, branch
    )

    # Get commits between current and new revision
    revision_range = submodule_current_rev + ".." + new_revision
    commits = submodule_project.commits.list(
        ref_name=revision_range, retry_transient_errors=True
    )
    if not commits:
        sys.exit("ERROR: no commits found in range %s" % revision_range)
    logging.debug("New commits: %s", commits)

    # Find out if top commit is part of a merge request
    # If so, use source branch of this MR as integration branch name
    # Else use commit sha instead
    integration_branch_suffix = new_revision
    for mr in commits[0].merge_requests():
        if mr["target_branch"] == submodule_project.default_branch:
            integration_branch_suffix = mr["source_branch"]
            break
    logging.debug("Integration branch suffix: %s", integration_branch_suffix)

    # Setup integration branch
    integration_branch_name = common.integration_branch_name(
        submodule_project.name, integration_branch_suffix
    )
    # Create integration branch
    print("Creating/replacing integration branch %s" % integration_branch_name)
    project_repo.head.set_reference(project_repo.create_head(integration_branch_name))

    # Update submodule to new revision
    submodule_repo = common.get_submodule(project_repo, submodule_name)
    update_submodule_in_repo(project_repo, submodule_repo, new_revision)

    # Update the gitlab-ci.yml file to the new revision
    # Now also update the project '.gitlab-ci.yml' file
    gitlab_ci_yml_filename = os.path.join(
        project_repo.working_tree_dir, ".gitlab-ci.yml"
    )
    with open(gitlab_ci_yml_filename, "r", encoding="utf8") as fp:
        gitlab_ci_yml = fp.read()
    logging.debug(gitlab_ci_yml)

    new_gitlab_ci_yml = update_gitlab_ci_include(
        gitlab_ci_yml,
        submodule_project.web_url.split("//")[1].split("/", 1)[1],
        new_revision,
    )
    if new_gitlab_ci_yml is None:
        print("Failed to update the include revision in '.gitlab-ci.yml'")
    else:
        logging.debug(new_gitlab_ci_yml)
        with open(gitlab_ci_yml_filename, "w", encoding="utf8") as fp:
            fp.write(new_gitlab_ci_yml)
        project_repo.git.add(os.path.basename(gitlab_ci_yml_filename))

    # Construct commit message and commit the change
    message = "Integrate %s/%s%s\n%s" % (
        submodule_project.name,
        integration_branch_suffix,
        " and %d more" % (len(commits) - 1) if len(commits) > 1 else "",
        common.list_commits(commits),
    )
    # Commit the changes
    if commit_and_push:

        # Make an API request to create the gitlab.user object
        gitlab = project.manager.gitlab
        gitlab.auth()
        integration_commit = common.commit_and_push(
            project,
            project_repo,
            integration_branch_name,
            message,
            gitlab.user.username,
            gitlab.user.email,
            less_verbose=True,
        )
    else:
        integration_commit = None

    return project_repo, integration_branch_name, integration_commit, message


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--gitlab-url",
        help="""URL to the GitLab instance""",
        dest="gitlab_url",
        required=True,
    )
    parser.add_argument(
        "--token",
        help="""GitLab REST API private access token""",
        dest="token",
        required=True,
    )
    parser.add_argument(
        "--project",
        help="""name of the GitLab project""",
        dest="project",
        required=True,
    )
    parser.add_argument(
        "--submodule",
        help="""submodule to update""",
        dest="submodule",
        required=True,
    )
    parser.add_argument(
        "--revision",
        help="""new revision for submodule""",
        dest="revision",
        required=True,
    )
    parser.add_argument(
        "--branch",
        help="""project branch (if not default branch)""",
        dest="branch",
        required=False,
        default=None,
    )
    parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="""Increase verbosity.""",
    )

    args, _ = parser.parse_known_args()
    if args.verbose:
        logging.basicConfig(level=logging.DEBUG)

    gitlab = Gitlab(args.gitlab_url, private_token=args.token)
    project = common.get_project(gitlab, args.project)

    update_submodule_and_include_ref(
        project, args.submodule, args.revision, args.branch
    )


if __name__ == "__main__":
    main()