Merge changes I3e95263c,Ibf4d4e9f,Id9b6a1ec * changes: Factor out Git commands into the GitRepo class Allow to specify multiple refSpec values Convert fetch-job.sh to fetch-job.py
diff --git a/container-images/fetch-job/Dockerfile b/container-images/fetch-job/Dockerfile index e089da3..e0d0c30 100644 --- a/container-images/fetch-job/Dockerfile +++ b/container-images/fetch-job/Dockerfile
@@ -1,8 +1,21 @@ ARG TAG=latest FROM base:${TAG} +USER root + +COPY dependencies/* /var/tools/ +COPY requirements.txt /var/tools/ +WORKDIR /var/tools + +ENV VIRTUAL_ENV=/opt/venv +ENV PATH="$VIRTUAL_ENV/bin:$PATH" RUN apk update && \ - apk add --no-cache yq + apk add --no-cache python3 && \ + python3 -m venv $VIRTUAL_ENV && \ + python3 -m ensurepip --upgrade && \ + rm -r /usr/lib/python*/ensurepip && \ + python3 -m pip install --require-hashes -r requirements.txt --no-cache --upgrade && \ + python3 -m pipenv install --python 3.12 --system COPY tools/* /var/tools/ @@ -10,4 +23,4 @@ VOLUME ["/var/gerrit/git"] -ENTRYPOINT ["/var/tools/fetch-job.sh"] +ENTRYPOINT ["python3", "/var/tools/fetch-job.py"]
diff --git a/container-images/fetch-job/README.md b/container-images/fetch-job/README.md index 9ddc620..9d217fc 100644 --- a/container-images/fetch-job/README.md +++ b/container-images/fetch-job/README.md
@@ -8,11 +8,11 @@ ## Content * base image -* `fetch-job.sh`: script executing the fetches +* `fetch-job.py`: script executing the fetches ## Start -* execution of the provided `fetch-job.sh` +* execution of the provided `fetch-job.py` ## Configuration @@ -28,6 +28,11 @@ - remoteRepo: project3 localRepo: local/project3 refSpec: "+refs/heads/*:refs/heads/remote/*" + - remoteRepo: project4 + localRepo: local/project4 + refSpec: + - +refs/heads/*:refs/heads/remote/* + - ^refs/heads/excluded-* ``` You will need to mount the credentials used to authenticate with remote servers
diff --git a/container-images/fetch-job/dependencies/Pipfile b/container-images/fetch-job/dependencies/Pipfile new file mode 100644 index 0000000..ab826bd --- /dev/null +++ b/container-images/fetch-job/dependencies/Pipfile
@@ -0,0 +1,12 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] + +[packages] +pyyaml = "~=6.0.3" + +[requires] +python_version = "3.12"
diff --git a/container-images/fetch-job/fetch-config.example.yaml b/container-images/fetch-job/fetch-config.example.yaml index 46a1fea..9fa6c77 100644 --- a/container-images/fetch-job/fetch-config.example.yaml +++ b/container-images/fetch-job/fetch-config.example.yaml
@@ -9,3 +9,8 @@ - remoteRepo: project3 localRepo: local/project3 refSpec: +refs/heads/*:refs/heads/remote/* + - remoteRepo: project4 + localRepo: local/project4 + refSpec: + - +refs/heads/*:refs/heads/remote/* + - ^refs/heads/excluded-*
diff --git a/container-images/fetch-job/requirements.in b/container-images/fetch-job/requirements.in new file mode 100644 index 0000000..a7f62a1 --- /dev/null +++ b/container-images/fetch-job/requirements.in
@@ -0,0 +1,3 @@ +setuptools +wheel +pipenv
diff --git a/container-images/fetch-job/requirements.txt b/container-images/fetch-job/requirements.txt new file mode 100644 index 0000000..079853f --- /dev/null +++ b/container-images/fetch-job/requirements.txt
@@ -0,0 +1,56 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --allow-unsafe --generate-hashes requirements.in +# +certifi==2026.4.22 \ + --hash=sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a \ + --hash=sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580 + # via pipenv +distlib==0.4.0 \ + --hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \ + --hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d + # via virtualenv +filelock==3.29.0 \ + --hash=sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90 \ + --hash=sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258 + # via + # python-discovery + # virtualenv +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 + # via + # pipenv + # wheel +pipenv==2025.0.4 \ + --hash=sha256:36fc2a7841ccdb2f58a9f787b296c2e15dea3b5b79b84d4071812f28b7e8d7a2 \ + --hash=sha256:e1fbe4cfd25ab179f123d1fbb1fa1cdc0b3ffcdb1f21c775dcaa12ccc356f2bb + # via -r requirements.in +platformdirs==4.4.0 \ + --hash=sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85 \ + --hash=sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf + # via + # python-discovery + # virtualenv +python-discovery==1.3.0 \ + --hash=sha256:441d9ced3dfce36e113beb35ca302c71c7ef06f3c0f9c227a0b9bb3bd49b9e9f \ + --hash=sha256:d098f1e86be5d45fe4d14bf1029294aabbd332f4321179dec85e76cddce834b0 + # via virtualenv +virtualenv==21.3.1 \ + --hash=sha256:c2305bc1fddeec40699b8370d13f8d431b0701f00ce895061ce493aeded4426b \ + --hash=sha256:d1a71cf58f2f9228fff23a1f6ec15d39785c6b32e03658d104974247145edd35 + # via pipenv +wheel==0.47.0 \ + --hash=sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced \ + --hash=sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3 + # via -r requirements.in + +# The following packages are considered to be unsafe in a requirements file: +setuptools==82.0.1 \ + --hash=sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9 \ + --hash=sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb + # via + # -r requirements.in + # pipenv
diff --git a/container-images/fetch-job/tools/fetch-job.py b/container-images/fetch-job/tools/fetch-job.py new file mode 100755 index 0000000..55267d5 --- /dev/null +++ b/container-images/fetch-job/tools/fetch-job.py
@@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 The Android Open Source Project +# +# 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. + +import argparse +import subprocess +import sys +import yaml + +GIT_HOME = "/var/gerrit/git" +DEFAULT_CONFIG_FILE = "/var/gerrit/etc/incoming-replication.config.yaml" +DEFAULT_REFSPEC_TEMPLATE = "+refs/heads/*:refs/heads/{remote_name}/*" + + +def parse_timeout(value): + """Convert a timeout string like '5m', '30s', or '120' to seconds (int).""" + if value is None or value == "null": + return None + value = str(value).strip() + if value.endswith("m"): + return int(value[:-1]) * 60 + if value.endswith("s"): + return int(value[:-1]) + return int(value) + + +class GitRepo: + def __init__(self, git_dir): + self.git_dir = git_dir + self.base = ["git", f"--git-dir={git_dir}"] + + def config(self, *args, check=True): + subprocess.run(self.base + ["config"] + list(args), check=check) + + def fetch(self, *args, timeout=None): + subprocess.run(self.base + ["fetch"] + list(args), check=True, timeout=timeout) + + def __str__(self): + return self.git_dir + + +def configure_remote(git_repo, remote_name, remote_url, refspec): + git_repo.config(f"remote.{remote_name}.url", remote_url) + # exit code 5 means the key did not exist — that is fine on first run + git_repo.config("unset", "--all", f"remote.{remote_name}.fetch", check=False) + refspecs = refspec if isinstance(refspec, list) else [refspec] + for spec in refspecs: + git_repo.config("--add", f"remote.{remote_name}.fetch", spec) + + +def process_remote(remote): + remote_name = remote["name"] + server_url = remote["url"].rstrip("/") + timeout_seconds = parse_timeout(remote.get("timeout")) + + for fetch in remote.get("fetch", []): + remote_repo = fetch["remoteRepo"] + local_repo = fetch.get("localRepo") or remote_repo + refspec = fetch.get("refSpec") or DEFAULT_REFSPEC_TEMPLATE.format( + remote_name=remote_name + ) + + git_repo = GitRepo(f"{GIT_HOME}/{local_repo}.git") + configure_remote(git_repo, remote_name, f"{server_url}/{remote_repo}", refspec) + + print(f"Fetching {remote_repo} from {remote_name} into {git_repo}") + try: + git_repo.fetch(remote_name, timeout=timeout_seconds) + except subprocess.TimeoutExpired: + print( + f"ERROR: fetch of {remote_repo} from {remote_name} timed out " + f"after {timeout_seconds}s", + file=sys.stderr, + ) + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser( + description="Fetch from remote Git repositories into local Gerrit repositories." + ) + parser.add_argument( + "--config", + default=DEFAULT_CONFIG_FILE, + metavar="CONFIG_FILE", + help=f"Path to the YAML config file (default: {DEFAULT_CONFIG_FILE})", + ) + args = parser.parse_args() + + try: + with open(args.config) as f: + config = yaml.safe_load(f) + except FileNotFoundError: + print(f"ERROR: config file not found: {args.config}", file=sys.stderr) + sys.exit(1) + + for remote in config.get("remotes", []): + process_remote(remote) + + +if __name__ == "__main__": + main()
diff --git a/container-images/fetch-job/tools/fetch-job.sh b/container-images/fetch-job/tools/fetch-job.sh deleted file mode 100755 index 44c3d23..0000000 --- a/container-images/fetch-job/tools/fetch-job.sh +++ /dev/null
@@ -1,75 +0,0 @@ -#!/bin/ash -# Copyright (C) 2011, 2024 SAP SE -# -# 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. - -usage() { - me=`basename "$0"` - echo >&2 "Usage: $me [--config CONFIG_FILE]" - exit 1 -} - -GIT_HOME=/var/gerrit/git -CONFIG_FILE=/var/gerrit/etc/incoming-replication.config.yaml - -while test $# -gt 0 ; do - case "$1" in - --help) - usage - ;; - - --config) - shift - CONFIG_FILE=$1 - shift - ;; - - *) - break - esac -done - -if ! test -f $CONFIG_FILE; then - echo "No config file has been found." - usage -fi - -NUM_REMOTES=$(yq e '.remotes | length' "$CONFIG_FILE") -for i in $(seq 0 $((NUM_REMOTES-1))); do - REMOTE=$(NUM_REMOTE=$i yq e '.remotes[env(NUM_REMOTE)]' "$CONFIG_FILE") - REMOTE_NAME="$(echo "$REMOTE" | yq e '.name' -)" - REMOTE_SERVER_URL="$(echo "$REMOTE" | yq e '.url' -)" - REMOTE_TIMEOUT="$(echo "$REMOTE" | yq e '.timeout' -)" - - NUM_FETCHES=$(echo "$REMOTE" | yq e '.fetch | length' -) - for j in $(seq 0 $((NUM_FETCHES-1))); do - FETCH=$(echo "$REMOTE" | NUM_FETCH=$j yq e '.fetch[env(NUM_FETCH)]' -) - REMOTE_REPO="$(echo "$FETCH" | yq e '.remoteRepo' -)" - - LOCAL_REPO="$(echo "$FETCH" | yq e '.localRepo' -)" - (test -z "$LOCAL_REPO" || [ "$LOCAL_REPO" = "null" ]) && LOCAL_REPO="$REMOTE_REPO" - - REFSPEC="$(echo "$FETCH" | yq e '.refSpec' -)" - (test -z "$REFSPEC" || [ "$REFSPEC" = "null" ]) && REFSPEC="+refs/heads/*:refs/heads/$REMOTE_NAME/*" - - REMOTE_URL="$REMOTE_SERVER_URL/$REMOTE_REPO" - - GIT_DIR="--git-dir=$GIT_HOME/$LOCAL_REPO.git" - git $GIT_DIR config remote.$REMOTE_NAME.url $REMOTE_URL - git $GIT_DIR config --unset-all remote.$REMOTE_NAME.fetch - git $GIT_DIR config remote.$REMOTE_NAME.fetch $REFSPEC - - echo "Fetching $REMOTE_REPO from $REMOTE_NAME" - timeout $REMOTE_TIMEOUT git $GIT_DIR fetch $REMOTE_NAME - done -done