diff --git a/manifest-pipeline.yml b/manifest-pipeline.yml index befbf9782754acc251cffd0c0083cd825c640872..21481347484aea10a96f0a92d8a54b8f362c31e6 100644 --- a/manifest-pipeline.yml +++ b/manifest-pipeline.yml @@ -10,6 +10,7 @@ stages: - trigger - retrigger - build + - artifacts workflow: rules: @@ -132,3 +133,22 @@ build:merge_request: --project=${CI_PROJECT_PATH} --commit=${CI_COMMIT_SHA} --ref=${MASTER_BRANCH} + +# -------------------------------------------------------------------------------------- +# Keep latest build artifacts (runs on master after merging a merge request) +# -------------------------------------------------------------------------------------- + +handle_artifacts: + extends: + - .infrastructure + - .short_master_pipeline + stage: artifacts + needs: ["build:merge_request"] + timeout: 1h + script: + - cd ${CI_PROJECT_DIR} + - .gitlab-ci/scripts/handle_artifacts.py + --gitlab-url="${CI_SERVER_URL}" + --token="${GITBOT_TOKEN}" + --manifest-project="${CI_PROJECT_PATH}" + --manifest-branch="${MASTER_BRANCH}" diff --git a/scripts/handle_artifacts.py b/scripts/handle_artifacts.py new file mode 100755 index 0000000000000000000000000000000000000000..29cf1fc64368cdac3315de15ce2e58a70a70ac57 --- /dev/null +++ b/scripts/handle_artifacts.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +import argparse +import fnmatch +import logging +import sys +import time + +from gitlab import Gitlab +from gitlab.v4.objects import Project +from gitlab.v4.objects.pipelines import ProjectPipeline, ProjectPipelineJob + +import common + + +class FullBuildPipeline: + def __init__(self, project: Project, commit_sha: str): + self.project = project + self.commit_sha = commit_sha + self.upstream_pipeline = self.__get_upstream_pipeline() + self.build_pipelines = self.__get_build_pipelines() + + def __get_upstream_pipeline(self) -> ProjectPipeline: + """ + Get upstream (main) pipeline for the specified commit in the repository. + + Returns: + A ProjectPipeline object if succeed, None otherwise. + """ + + pipelines_for_commit = self.project.pipelines.list( + all=False, sha=self.commit_sha, order_by="id", sort="desc" + ) + + if not pipelines_for_commit: + return {} + + # For the main branch we have two types of pipelines: short and full. + # The short one just retriggers the full pipeline and does not contain any artifacts. + # The source of the short pipeline is "push". So skip it here. + # This can be done earlier when calling project.pipelines.list(). + # However, the currently installed version of python-gitlab does not support the "source" filter parameter. + # TODO: use self.project.pipelines.list(…, source="push") insted + build_pipeline = None + for p in pipelines_for_commit: + if p.source != "push": + build_pipeline = p + + if not build_pipeline: + return None + + return build_pipeline + + def __get_build_pipelines(self) -> dict[str, tuple[ProjectPipelineJob]]: + """ + Get the latest pipeline for the specified commit in the repository. + Then extract the downstream build pipelines with their jobs and return + them as a dictionary. + + Returns: + A dictionary where the key is the build pipeline name and + the value is a tuple of downstream jobs. + """ + + timeout = 3000 # 50 min + check_interval = 30 + + not_rdy_status = ["created", "pending", "running"] + if self.upstream_pipeline.status in not_rdy_status: + print( + f"The build pipeline ({self.upstream_pipeline.web_url}) is not ready." + ) + print("Wait for it to complete", end="", flush=True) + + while self.upstream_pipeline.status in not_rdy_status: + print(".", end="", flush=True) + time.sleep(check_interval) + timeout -= check_interval + if timeout < 0: + sys.exit("timeout") + + ret = {} + for bridge in self.upstream_pipeline.bridges.list(): + if not bridge.downstream_pipeline: + continue + downstream_pipeline = self.project.pipelines.get( + bridge.downstream_pipeline["id"] + ) + ret[bridge.name] = tuple(downstream_pipeline.jobs.list(all=True)) + return ret + + def get_jobs( + self, pipeline_name: str = "*", job_filter: str = "*" + ) -> tuple[ProjectPipelineJob]: + """ + Get build jobs for the specified pipeline. + The result can also be filtered by name. + + Args: + pipeline_name: str — name of build pipeline (e.g. "fngsystem-pipeline", "sdk-pipeline"). + job_filter: str — fnmatch pattern to select jobs by name. + + Returns: + A tuple of pipeline jobs. + """ + + ret = [] + + if pipeline_name == "*": + jobs = [] + for v in self.build_pipelines.values(): + jobs.extend(list(v)) + else: + try: + jobs = self.build_pipelines[pipeline_name] + except KeyError: + return None + + for job in jobs: + if fnmatch.fnmatch(job.name, job_filter): + ret.append(job) + return tuple(ret) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--gitlab-url", + help="""URL to the GitLab instance""", + dest="gitlab_url", + default=common.GITLAB_URL, + ) + parser.add_argument( + "--token", + help="""GitLab REST API private access token""", + dest="token", + required=True, + ) + parser.add_argument( + "--manifest-project", + help="""ID or name of the manifest project""", + dest="manifest_project", + required=True, + ) + parser.add_argument( + "--manifest-branch", + help="""manifest integration branch""", + dest="manifest_branch", + required=True, + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="""Increase verbosity.""", + ) + + args, _ = parser.parse_known_args() + if args.verbose: + logging.basicConfig(level=logging.DEBUG) + + logging.debug(args) + gitlab = Gitlab(args.gitlab_url, private_token=args.token) + + manifest_project = common.get_project(gitlab, args.manifest_project) + + manifest_commits = manifest_project.commits.list( + all=False, ref_name=args.manifest_branch, order_by="id", sort="desc" + ) + if not manifest_commits: + sys.exit("Failed to get the latest commit ID") + latest_build_sha = manifest_commits[0].id + + latest_successful_build_sha = None + for commit in manifest_commits: + if ( + FullBuildPipeline(manifest_project, commit.id).upstream_pipeline.status + != "success" + ): + continue + else: + latest_successful_build_sha = commit.id + break + + if not latest_successful_build_sha: + sys.exit("Failed to find the latest successful pipeline.") + + tags = manifest_project.tags.list() + tagged_commits_sha = [tag.commit["id"] for tag in tags] + + # List of commits IDs for which keep build artifacts forever + keep_artifacts_sha = [] + # Always keep artifacts for the latest build + keep_artifacts_sha.append(latest_build_sha) + # Just in case, keep artifacts for the latest successful build + keep_artifacts_sha.append(latest_successful_build_sha) + # Always keep artifacts for tagged commits (e.g. "fngsystem/47.0", "kirkstone/20.0") + keep_artifacts_sha.extend(tagged_commits_sha) + # Remove duplicates + keep_artifacts_sha = list(set(keep_artifacts_sha)) + + for commit in manifest_commits: + full_build_pipeline = FullBuildPipeline(manifest_project, commit.id) + print(f"Full pipeline: {full_build_pipeline.upstream_pipeline}") + for pipelinejob in full_build_pipeline.get_jobs(): + + if pipelinejob.status != "success": + continue + + if not pipelinejob.artifacts: + continue + + # There are no "real" artifacts in "build-version" job but only artifacts:reports:dotenv + # https://docs.gitlab.com/ee/ci/yaml/artifacts_reports.html#artifactsreportsdotenv + if pipelinejob.name == "build-version": + continue + + # Job methods (keep_artifacts, delete_artifacts) are not available on ProjectPipelineJob objects. + # To use these methods create a ProjectJob object: + # pipeline_job = pipeline.jobs.list()[0] + # job = project.jobs.get(pipeline_job.id, lazy=True) + # job.keep_artifacts() + job = manifest_project.jobs.get(pipelinejob.id, lazy=True) + + if commit.id in keep_artifacts_sha: + print(f"keep_artifacts() for {pipelinejob.web_url}") + job.keep_artifacts() + else: + print(f"delete_artifacts() for {pipelinejob.web_url}") + job.delete_artifacts() + + print("—" * 80) + + +if __name__ == "__main__": + main()