tools/build-release.py (438 lines of code) (raw):
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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 distutils.dir_util
import getopt
import hashlib
import json
import os
import re
import shutil
import subprocess
import tarfile
from tempfile import mkstemp
import git
import sys
# fail the execution
def fail(message):
print(message)
sys.exit(1)
# Main build routine
def build_release(email_address):
tools_dir = os.path.dirname(os.path.realpath(__file__))
# load configs
config_file = os.path.join(tools_dir, "release-configs.json")
with open(config_file) as configs:
try:
data = json.load(configs)
except json.JSONDecodeError:
fail("load config: unexpected json decode failure")
if "release" not in data:
fail("load config: release data not found")
release_meta = data["release"]
if "version" not in release_meta:
fail("load config: version data not found in release")
version = release_meta["version"]
release_package_name = "apache-yunikorn-{0}-src".format(version)
if "repositories" not in data:
fail("load config: repository list not found")
repo_list = data["repositories"]
print("release meta info:")
print(" - main version: %s" % version)
print(" - release package name: %s" % release_package_name)
staging_dir = os.path.join(os.path.dirname(tools_dir), "staging")
release_base = os.path.join(staging_dir, release_package_name)
release_top_path = os.path.join(os.path.dirname(tools_dir), "release-top-level-artifacts")
helm_chart_path = os.path.join(os.path.dirname(tools_dir), "helm-charts")
# setup artifacts in the release base dir
setup_base_dir(release_top_path, helm_chart_path, release_base, version)
# download source code from github repo
sha = dict()
for repo_meta in repo_list:
if "name" not in repo_meta:
fail("repository name missing in repo list")
name = repo_meta["name"]
if "alias" not in repo_meta:
fail("repository alias missing in repo list")
alias = repo_meta["alias"]
sha[name] = download_sourcecode(release_base, repo_meta)
update_make_version(name, os.path.join(release_base, alias), version)
# update required Golang, NodeJS and Angular versions
update_required_versions(release_base, repo_list)
# update the sha for all repos in the build scripts
# must be run after all repos have been checked out
update_sha(release_base, repo_list, sha)
# merge licenses for anything that was added not part of Apache
merge_licenses(release_base, repo_list)
# build the helm package
call_helm(staging_dir, release_base, version, email_address)
# Ensure release dirs are clean (and generate build.date files)
clean_release(release_base)
# generate staging source code tarball
tarball_name = release_package_name + "-staging.tar.gz"
tarball_path = os.path.join(staging_dir, tarball_name)
print("creating tarball %s" % tarball_path)
with tarfile.open(tarball_path, "w:gz") as tar:
tar.add(release_base, arcname=release_package_name, filter=exclude_files)
# generate yunikorn-web reproducible binaries
web_hashes_amd64 = build_web_and_generate_hashes(staging_dir, release_package_name, "x86_64")
web_hashes_arm64 = build_web_and_generate_hashes(staging_dir, release_package_name, "aarch64")
# generate yunikorn-k8shim reproducible binaries
shim_hashes_amd64 = build_shim_and_generate_hashes(staging_dir, release_package_name, "x86_64")
shim_hashes_arm64 = build_shim_and_generate_hashes(staging_dir, release_package_name, "aarch64")
# merge hashes
hashes_amd64 = "\n".join([shim_hashes_amd64, web_hashes_amd64])
hashes_arm64 = "\n".join([shim_hashes_arm64, web_hashes_arm64])
# remove staging tarball
os.remove(tarball_path)
# update reproducible build information in README
go_version = get_go_version()
update_reproducible_build_info(release_base, go_version, hashes_amd64, hashes_arm64)
# generate final source code tarball
tarball_name = release_package_name + ".tar.gz"
tarball_path = os.path.join(staging_dir, tarball_name)
print("creating tarball %s" % tarball_path)
with tarfile.open(tarball_path, "w:gz") as tar:
tar.add(release_base, arcname=release_package_name, filter=exclude_files)
write_checksum(tarball_path, tarball_name)
if email_address:
call_gpg(tarball_path, email_address)
# Function passed in as a filter to the tar command to keep tar as clean as possible
def exclude_files(tarinfo):
file_name = os.path.basename(tarinfo.name)
exclude = [".DS_Store", ".git", ".github", ".gitignore", ".asf.yaml"]
if file_name in exclude:
print("exclude file from tarball %s" % tarinfo.name)
return None
return tarinfo
# Setup base for the source code tar ball with release repo files
def setup_base_dir(release_top_path, helm_path, base_path, version):
print("setting up base dir for release artifacts, path: %s" % base_path)
if os.path.exists(base_path):
print("\nstaging dir already exist:\n%s\nplease remove it and retry\n" % base_path)
sys.exit(1)
# setup base dir
os.makedirs(base_path)
# copy top level artifacts
for file in os.listdir(release_top_path):
org = os.path.join(release_top_path, file)
dest = os.path.join(base_path, file)
print("copying files: %s ===> %s" % (org, dest))
shutil.copy2(org, dest)
# set the base Makefile version info
replace(os.path.join(base_path, "Makefile"), 'latest', version)
# set the base validate_cluster version info
replace(os.path.join(base_path, "validate_cluster.sh"), 'latest', version)
# update the version tags in the README
replace(os.path.join(base_path, "README.md"), '-latest', '-' + version)
# copy the helm charts
copy_helm_charts(helm_path, base_path, version)
# copy the helm charts into the base path and replace the version to the one defined in config
def copy_helm_charts(helm_path, base_path, version):
print("helm patch: %s, base path: %s" % (helm_path, base_path))
release_helm_path = os.path.join(base_path, "helm-charts")
distutils.dir_util.copy_tree(helm_path, release_helm_path)
# rename the version in the helm charts to the actual version
yunikorn_chart_path = os.path.join(release_helm_path, "yunikorn")
replace(os.path.join(yunikorn_chart_path, "values.yaml"), '(tag: .*-)(latest)', '\\g<1>' + version)
replace(os.path.join(yunikorn_chart_path, "Chart.yaml"), 'version: .*', 'version: ' + version)
replace(os.path.join(yunikorn_chart_path, "Chart.yaml"), 'appVersion: .*', 'appVersion: \"' + version + '\"')
# replaces the string that match to pattern to subst from the file_path
def replace(file_path, pattern, subst):
# Create temp file
fh, abs_path = mkstemp()
with os.fdopen(fh, 'w') as new_file:
with open(file_path) as old_file:
for line in old_file:
new_line = re.sub(pattern, subst, line)
new_file.write(new_line)
# Copy the file permissions from the old file to the new file
shutil.copymode(file_path, abs_path)
# Remove original file
os.remove(file_path)
# Move new file
shutil.move(abs_path, file_path)
def download_sourcecode(base_path, repo_meta):
# these two have been checked before we get here
alias = repo_meta["alias"]
name = repo_meta["name"]
# make sure the rest is OK
if "tag" not in repo_meta:
fail("repository tag missing in repo list")
tag = repo_meta["tag"]
if "repository" not in repo_meta:
fail("repository url missing in repo list")
url = repo_meta["repository"]
description = ""
if "description" in repo_meta:
description = repo_meta["description"]
print("downloading source code")
print("repository info:")
print(" - repository: %s" % url)
print(" - description: %s" % description)
print(" - tag: %s" % tag)
repo = git.Repo.clone_from(url=url, to_path=os.path.join(base_path, alias))
repo.git.checkout(tag)
tags = repo.tag("refs/tags/" + tag)
sha = tags.commit.hexsha
print(" - tag sha: %s" % sha)
# avoid pulling dependencies from github,
# add replace to go mod files to make sure it builds locally
update_dep_ref(name, os.path.join(base_path, alias))
return sha
# Run distclean on the source code path
def clean_release(local_repo_path):
print("ensuring local source repo is clean")
path = os.getcwd()
os.chdir(local_repo_path)
retcode = subprocess.call(['make', 'distclean'])
if retcode:
fail("failed to clean staging repo")
os.chdir(path)
# Unpack tarball into tmp dir
def unpack_staging_tarball(staging_dir, dest_dir, release_name):
path = os.getcwd()
os.chdir(staging_dir)
retcode = subprocess.call(['rm', '-rf', dest_dir])
if retcode:
fail("failed to clean dest dir")
retcode = subprocess.call(['mkdir', dest_dir])
if retcode:
fail("failed to create dest dir")
os.chdir(dest_dir)
retcode = subprocess.call(['tar', 'xf', os.path.join(staging_dir, "%s-staging.tar.gz" % release_name)])
if retcode:
fail("failed to unpack tarball")
os.chdir(path)
# Generate binaries for yunikorn-web and compute checksums
def build_web_and_generate_hashes(staging_dir, release_name, arch):
print("generating reproducible build artifacts for yunikorn-web (%s)" % arch)
path = os.getcwd()
tmp_dir = os.path.join(staging_dir, "tmp")
release_dir = os.path.join(tmp_dir, release_name)
unpack_staging_tarball(staging_dir, tmp_dir, release_name)
web_dir = os.path.join(release_dir, "web")
os.chdir(web_dir)
retcode = subprocess.call(['make', 'REPRODUCIBLE_BUILDS=1', 'HOST_ARCH=' + arch, 'build_server_prod'])
if retcode:
fail("failed to build yunikorn-web (%s)" % arch)
hash = get_checksum("build/prod/yunikorn-web", "yunikorn-web")
os.chdir(staging_dir)
retcode = subprocess.call(['rm', '-rf', 'tmp'])
if retcode:
fail("failed to clean temp dir")
os.chdir(path)
return hash
# Generate binaries for yunikorn-k8shim and compute checksums
def build_shim_and_generate_hashes(staging_dir, release_name, arch):
print("generating reproducible build artifacts for yunikorn-k8shim (%s)" % arch)
path = os.getcwd()
tmp_dir = os.path.join(staging_dir, "tmp")
release_dir = os.path.join(tmp_dir, release_name)
unpack_staging_tarball(staging_dir, tmp_dir, release_name)
shim_dir = os.path.join(release_dir, "k8shim")
os.chdir(shim_dir)
retcode = subprocess.call(['make', 'REPRODUCIBLE_BUILDS=1', 'HOST_ARCH=' + arch, 'scheduler', 'plugin', 'admission'])
if retcode:
fail("failed to build yunikorn-k8shim (%s)" % arch)
adm_hash = get_checksum("build/bin/yunikorn-admission-controller", "yunikorn-admission-controller")
scheduler_hash = get_checksum("build/bin/yunikorn-scheduler", "yunikorn-scheduler")
plugin_hash = get_checksum("build/bin/yunikorn-scheduler-plugin", "yunikorn-scheduler-plugin")
hash = "\n".join([adm_hash, scheduler_hash, plugin_hash])
os.chdir(staging_dir)
retcode = subprocess.call(['rm', '-rf', 'tmp'])
if retcode:
fail("failed to clean temp dir")
os.chdir(path)
return hash
# K8shim depends on yunikorn-core and scheduler-interface
def update_dep_ref_k8shim(local_repo_path):
print("updating dependency for k8shim")
mod_file = os.path.join(local_repo_path, "go.mod")
if not os.path.isfile(mod_file):
fail("k8shim go.mod does not exist")
path = os.getcwd()
os.chdir(local_repo_path)
command = ['go', 'mod', 'edit']
command.extend(['-replace', 'github.com/apache/yunikorn-core=../core'])
command.extend(['-replace', 'github.com/apache/yunikorn-scheduler-interface=../scheduler-interface'])
retcode = subprocess.call(command)
if retcode:
fail("failed to update k8shim go.mod references")
os.chdir(path)
# core depends on scheduler-interface
def update_dep_ref_core(local_repo_path):
print("updating dependency for core")
mod_file = os.path.join(local_repo_path, "go.mod")
if not os.path.isfile(mod_file):
fail("core go.mod does not exist")
path = os.getcwd()
os.chdir(local_repo_path)
command = ['go', 'mod', 'edit']
command.extend(['-replace', 'github.com/apache/yunikorn-scheduler-interface=../scheduler-interface'])
retcode = subprocess.call(command)
if retcode:
fail("failed to update core go.mod references")
os.chdir(path)
# update go mod in the repos
def update_dep_ref(repo_name, local_repo_path):
switcher = {
"yunikorn-k8shim": update_dep_ref_k8shim,
"yunikorn-core": update_dep_ref_core,
}
if switcher.get(repo_name) is not None:
switcher.get(repo_name)(local_repo_path)
# replace the default version to release version in the Makefile(s)
def update_make_version(repo_name, local_repo_path, version):
switcher = {
"yunikorn-k8shim": "update",
"yunikorn-web": "update",
}
if switcher.get(repo_name) is not None:
replace(os.path.join(local_repo_path, "Makefile"), 'latest', version)
# k8shim uses its own, yunikorn-core and scheduler-interface revisions
def update_sha_shim(repo_name, local_repo_path, sha):
print("updating sha for k8shim")
make_file = os.path.join(local_repo_path, "Makefile")
if not os.path.isfile(make_file):
fail("k8shim repo Makefile does not exist")
replace(make_file, "(CORE_SHA=)(.*)", "\\g<1>" + sha["yunikorn-core"])
replace(make_file, "(SI_SHA=)(.*)", "\\g<1>" + sha["yunikorn-scheduler-interface"])
replace(make_file, "(SHIM_SHA=)(.*)", "\\g<1>" + sha[repo_name])
# web only uses its own revision
def update_sha_web(repo_name, local_repo_path, sha):
print("updating sha for web")
make_file = os.path.join(local_repo_path, "Makefile")
if not os.path.isfile(make_file):
fail("web repo Makefile does not exist")
replace(make_file, "(WEB_SHA=)(.*)", "\\g<1>" + sha[repo_name])
# update git revision in the makefiles
def update_sha(release_base, repo_list, sha):
for repo_meta in repo_list:
repo_name = repo_meta["name"]
switcher = {
"yunikorn-k8shim": update_sha_shim,
"yunikorn-web": update_sha_web,
}
if switcher.get(repo_name) is not None:
switcher.get(repo_name)(repo_name, os.path.join(release_base, repo_meta["alias"]), sha)
# update required Golang version in the README.md
def update_required_go_version(base_path, local_repo_path):
print("updating required go version")
go_version_file = os.path.join(local_repo_path, ".go_version")
if not os.path.isfile(go_version_file):
fail("k8shim repo .go_version does not exist")
with open(go_version_file) as f:
go_version = f.readline().strip()
if not go_version:
fail("k8shim repo .go_version is empty")
print(f" - go version: {go_version}")
replace(os.path.join(base_path, "README.md"), 'Go 1.16', 'Go ' + go_version)
# update reproducible build information in README
def update_reproducible_build_info(base_path, go_version, hashes_amd64, hashes_arm64):
print("recording go compiler used for reproducible builds")
replace(os.path.join(base_path, "README.md"), '@GO_VERSION@', go_version)
print("recording build artifact hashes (amd64)")
replace(os.path.join(base_path, "README.md"), '@AMD64_BINARIES@', hashes_amd64)
print("recording build artifact hashes (arm64)")
replace(os.path.join(base_path, "README.md"), '@ARM64_BINARIES@', hashes_arm64)
# update required Node.js and angular versions in the README.md
def update_required_node_and_angular_versions(base_path, local_repo_path):
print("updating required Node.js version")
nvmrc_file = os.path.join(local_repo_path, ".nvmrc")
if not os.path.isfile(nvmrc_file):
fail("web repo .nvmrc does not exist")
with open(nvmrc_file) as f:
node_version = f.readline().strip()
if not node_version:
fail("web repo .nvmrc is empty")
print(f" - node version: {node_version}")
replace(os.path.join(base_path, "README.md"), 'Node.js 16.14.2', 'Node ' + node_version)
print("updating required Angular version")
package_json_file = os.path.join(local_repo_path, "package.json")
if not os.path.isfile(package_json_file):
fail("web repo package.json does not exist")
with open(package_json_file) as f:
try:
data = json.load(f)
except json.JSONDecodeError:
fail("load web package.json: unexpected json decode failure")
angular_version_match = re.search("\d+\.\d+\.\d+", data.get("dependencies", {}).get("@angular/core", ""))
if not angular_version_match:
fail("web repo package.json: unexpected @angular/core version")
angular_version = angular_version_match.group()
print(f" - angular version: {angular_version}")
replace(os.path.join(base_path, "README.md"), 'Angular CLI 13.3.0', 'Angular CLI ' + angular_version)
# update required versions in the README.md
def update_required_versions(release_base, repo_list):
switcher = {
"yunikorn-k8shim": update_required_go_version,
"yunikorn-web": update_required_node_and_angular_versions,
}
for repo_meta in repo_list:
repo_name = repo_meta["name"]
if switcher.get(repo_name) is not None:
switcher.get(repo_name)(release_base, os.path.join(release_base, repo_meta["alias"]))
# Write the checksum for the source code tarball to file
def write_checksum(tarball_file, tarball_name):
print("generating sha512 checksum file for tar")
h = hashlib.sha512()
# read the file and generate the sha
with open(tarball_file, 'rb') as file:
while True:
data = file.read(65536)
if not data:
break
h.update(data)
sha = h.hexdigest()
# write out the checksum
sha_file = open(tarball_file + ".sha512", "w")
sha_file.write(sha)
sha_file.write(" " + tarball_name)
sha_file.write("\n")
sha_file.close()
print("sha512 checksum: %s" % sha)
# Generate a checksum for a file
def get_checksum(file_path, file_name):
print("generating sha512 checksum for %s" % file_name)
h = hashlib.sha512()
# read the file and generate the sha
with open(file_path, 'rb') as file:
while True:
data = file.read(65536)
if not data:
break
h.update(data)
sha = h.hexdigest()
return "%s %s" % (sha, file_name)
# Sign the source archive if an email is provided
def call_gpg(tarball_file, email_address):
cmd = shutil.which("gpg")
if not cmd:
print("gpg not found on the path, not signing package")
return
print("Signing source code file using email: %s" % email_address)
command = [cmd, '--armor', '--detach-sig']
command.extend(['--local-user', email_address])
command.extend(['--output', tarball_file + ".asc", tarball_file])
retcode = subprocess.call(command)
if retcode:
fail("failed to create gpg signature")
# Determine the specific go compiler in use
def get_go_version():
command = ['go', 'env', 'GOVERSION']
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode:
fail("failed to get go version")
output = re.sub(r'^go', r'', result.stdout.strip())
return output
# Package the helm chart and sign if an email is provided
def call_helm(staging_dir, base_path, version, email_address):
cmd = shutil.which("helm")
if not cmd:
print("helm not found on the path, not creating package")
return
release_helm_path = os.path.join(base_path, "helm-charts/yunikorn")
command = [cmd, 'package']
if email_address:
secring = os.path.expanduser('~/.gnupg/secring.gpg')
if os.path.isfile(secring):
print("Packaging helm chart, signed with: %s" % email_address)
command.extend(['--sign', '--key', email_address, '--keyring', secring])
else:
print("Packing helm chart (unsigned)\nFile with pre gpg2 keys not found, expecting: %s" % secring)
email_address = None
else:
print("Packaging helm chart (unsigned)")
command.extend([release_helm_path, '--destination', staging_dir])
retcode = subprocess.call(command)
if retcode:
fail("helm chart creation failed")
if not email_address:
helm_package = "yunikorn-" + version + ".tgz"
helm_pack_path = os.path.join(staging_dir, helm_package)
h = hashlib.sha256()
# read the file and generate the sha
with open(helm_pack_path, 'rb') as file:
while True:
data = file.read(65536)
if not data:
break
h.update(data)
print("Helm package digest: %s %s\n" % (h.hexdigest(), helm_package))
# Merge the added lines from the license files
def merge_licenses(base_dir, repo_list):
start = 202 # Apache License is 202 lines
lic = os.path.join(base_dir, "LICENSE")
if not os.path.isfile(lic):
fail("license does not exist at top level staging directory")
with open(lic, 'a') as lp:
for repo_meta in repo_list:
alias = repo_meta["alias"]
lic_repo = os.path.join(os.path.join(base_dir, alias), "LICENSE")
if not os.path.isfile(lic_repo):
fail("license does not exist in '%s' repository" % alias)
with open(lic_repo, 'r') as fp:
lines = fp.readlines()
if len(lines) <= start:
continue
print("copying license details from: %s\n" % alias)
i = start
while i < len(lines):
lp.write(lines[i])
i += 1
# Print the usage info
def usage(script):
print("%s [--sign=<email>]" % script)
sys.exit(2)
def main(argv):
script = sys.argv[0]
email_address = ''
try:
opts, args = getopt.getopt(argv[1:], "", ["sign="])
except getopt.GetoptError:
usage(script)
if args:
usage(script)
for opt, arg in opts:
if opt == "--sign":
if not arg:
usage(script)
email_address = arg
build_release(email_address)
if __name__ == "__main__":
main(sys.argv)