sync_repo.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. #
  2. # Copyright (c) Contributors to the Open 3D Engine Project.
  3. # For complete copyright and license terms please see the LICENSE at the root of this distribution.
  4. #
  5. # SPDX-License-Identifier: Apache-2.0 OR MIT
  6. #
  7. #
  8. import argparse
  9. import boto3
  10. import logging
  11. import os
  12. import subprocess
  13. import sys
  14. from botocore.exceptions import ClientError
  15. from urllib.parse import urlparse, urlunparse
  16. log = logging.getLogger(__name__)
  17. log.setLevel(logging.INFO)
  18. DEFAULT_BRANCH = "main"
  19. DEFAULT_WORKSPACE_ROOT = "."
  20. class MergeError(Exception):
  21. pass
  22. class SyncRepo:
  23. """A git repo with configured remotes to sync with GitHub.
  24. Used by the sync pipeline to push branches to GitHub and pull down latest from main. Changes flow from
  25. the upstream remote down to origin. Remotes can be swapped to pull changes in the other direction.
  26. Attributes:
  27. origin: URL for the origin repo. This is the target for the sync.
  28. upstream: URL for the upstream repo. This is the source with the latest changes.
  29. workspace_root: Path to the parent directory for the local workspace.
  30. parameter: Name of the parameter used to store GitHub credentials.
  31. """
  32. def __init__(self, origin, upstream, workspace_root, region=None, parameter=None):
  33. self.workspace_root = workspace_root
  34. self.parameter = parameter
  35. self.region = region
  36. if self.parameter and self.region:
  37. log.info(f"Adding credentials from {self.parameter} in {self.region}")
  38. self.origin = self._add_credentials(origin)
  39. self.upstream = self._add_credentials(upstream)
  40. else:
  41. self.origin = origin
  42. self.upstream = upstream
  43. self.origin_name = self.origin.split("/")[-1]
  44. self.upstream_name = self.upstream.split("/")[-1]
  45. self.workspace = os.path.join(workspace_root, self.origin_name)
  46. def _add_credentials(self, url):
  47. """Add credentials to a github repo URL from parameter store."""
  48. parsed_url = urlparse(url)
  49. if parsed_url.netloc == "github.com":
  50. try:
  51. ssm = boto3.client("ssm", self.region)
  52. credentials = ssm.get_parameter(
  53. Name=self.parameter,
  54. WithDecryption=True
  55. )["Parameter"]["Value"]
  56. url = urlunparse(parsed_url._replace(netloc=f"{credentials}@github.com"))
  57. except ClientError as e:
  58. log.error(f"Error retrieving credentials from parameter store: {e}")
  59. return url
  60. def clone(self):
  61. """Clones repo to the instance workspace. Refreshes remote configs for existing repos."""
  62. if not os.path.exists(self.workspace):
  63. os.mkdir(self.workspace)
  64. if subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], cwd=self.workspace).returncode != 0:
  65. log.info(f"Cloning repo {self.origin} to {self.workspace}.")
  66. subprocess.run(["git", "clone", self.origin, self.origin_name], cwd=self.workspace_root, check=True)
  67. subprocess.run(["git", "remote", "add", "upstream", self.upstream], cwd=self.workspace)
  68. else:
  69. log.info("Update remote config for existing repos.")
  70. subprocess.run(["git", "remote", "set-url", "origin", self.origin], cwd=self.workspace)
  71. subprocess.run(["git", "remote", "set-url", "upstream", self.upstream], cwd=self.workspace)
  72. def sync(self, branch):
  73. """Fetches latest from upstream and syncs changes to origin.
  74. Syncs are one-way and conflicts are not expected. Fast-forward merges are performed if possible. If a
  75. fast-forward merge is not possible, a merge will not be attempted and will raise an exception.
  76. The checkout command will create a new branch from upstream/<branch> if it does not exist in origin. The
  77. remote will be remapped to origin during the push.
  78. Args:
  79. branch: Name of the upstream branch to sync with origin.
  80. Raises:
  81. MergeError: An error occured when attempting to merge to the target branch.
  82. """
  83. subprocess.run(["git", "fetch", "origin"], cwd=self.workspace, check=True)
  84. subprocess.run(["git", "fetch", "upstream"], cwd=self.workspace, check=True)
  85. subprocess.run(["git", "checkout", branch], cwd=self.workspace, check=True)
  86. # If the branch exists in origin, merge from upstream. New branches do not require a merge.
  87. if subprocess.run(["git", "ls-remote", "--exit-code", "-h", "origin", branch], cwd=self.workspace).returncode == 0:
  88. subprocess.run(["git", "reset", "--hard", "HEAD"], cwd=self.workspace, check=True)
  89. subprocess.run(["git", "pull"], cwd=self.workspace, check=True)
  90. if subprocess.run(["git", "merge", "--ff-only", f"upstream/{branch}"], cwd=self.workspace).returncode != 0:
  91. raise MergeError(f"Unable to perform ff merge to target branch: {self.origin}/{branch} Intervention required.")
  92. subprocess.run(["git", "push", "-u", "origin", branch], cwd=self.workspace, check=True)
  93. def process_args():
  94. """Process arguements.
  95. Example:
  96. sync_repo.py <upstream> <origin> [Options]
  97. """
  98. parser = argparse.ArgumentParser()
  99. parser.add_argument("upstream")
  100. parser.add_argument("origin")
  101. parser.add_argument("-b", "--branch", default=DEFAULT_BRANCH)
  102. parser.add_argument("-w", "--workspace-root", default=DEFAULT_WORKSPACE_ROOT)
  103. parser.add_argument("-r", "--region", default=None)
  104. parser.add_argument("-p", "--parameter", default=None)
  105. return parser.parse_args()
  106. def main():
  107. args = process_args()
  108. repo = SyncRepo(args.origin, args.upstream, args.workspace_root, args.region, args.parameter)
  109. repo.clone()
  110. try:
  111. repo.sync(args.branch)
  112. except MergeError as e:
  113. log.error(e)
  114. if __name__ == "__main__":
  115. sys.exit(main())