diff --git a/chartpress.py b/chartpress.py index fbcb43a..99d075f 100644 --- a/chartpress.py +++ b/chartpress.py @@ -4,6 +4,7 @@ This is used as part of the JupyterHub and Binder projects. """ + import argparse import os import pipes @@ -277,13 +278,13 @@ def _get_current_branchname(**kwargs): def _get_image_build_args(image_options, ns): """ - Render buildArgs from chartpress.yaml that could be templates, using + Render buildArgs from the config file that could be templates, using provided namespace that contains keys with dynamic values such as LAST_COMMIT or TAG. Args: image_options (dict): - The dictionary for a given image from chartpress.yaml. + The dictionary for a given image from the config file. Fields in `image_options['buildArgs']` will be rendered and returned, if defined. ns (dict): the namespace used when rendering templated arguments @@ -296,13 +297,13 @@ def _get_image_build_args(image_options, ns): def _get_image_extra_build_command_options(image_options, ns): """ - Render extraBuildCommandOptions from chartpress.yaml that could be + Render extraBuildCommandOptions from the config file that could be templates, using the provided namespace that contains keys with dynamic values such as LAST_COMMIT or TAG. Args: image_options (dict): - The dictionary for a given image from chartpress.yaml. + The dictionary for a given image from the config file. Strings in `image_options['extraBuildCommandOptions']` will be rendered and returned. ns (dict): the namespace used when rendering templated arguments @@ -333,7 +334,7 @@ def _get_image_dockerfile_path(name, options): return os.path.join(_get_image_build_context_path(name, options), "Dockerfile") -def _get_all_image_paths(name, options): +def _get_all_image_paths(name, options, config_path): """ Returns the unique paths that when changed should trigger a rebuild of a chart's image. This includes the Dockerfile itself and the context of the @@ -343,7 +344,7 @@ def _get_all_image_paths(name, options): Dockerfile path, and the optional others for extra paths. """ paths = [] - paths.append("chartpress.yaml") + paths.append(config_path) if options.get("rebuildOnContextPathChanges", True): paths.append(_get_image_build_context_path(name, options)) paths.append(_get_image_dockerfile_path(name, options)) @@ -351,18 +352,18 @@ def _get_all_image_paths(name, options): return list(set(paths)) -def _get_all_chart_paths(options): +def _get_all_chart_paths(options, config_path): """ Returns the unique paths that when changed should trigger a version update of the chart. These paths includes all the chart's images' paths as well. """ paths = [] - paths.append("chartpress.yaml") + paths.append(config_path) paths.append(options["chartPath"]) paths.extend(options.get("paths", [])) if "images" in options: for image_name, image_config in options["images"].items(): - paths.extend(_get_all_image_paths(image_name, image_config)) + paths.extend(_get_all_image_paths(image_name, image_config, config_path)) return list(set(paths)) @@ -393,7 +394,7 @@ def build_image( directory during the build process of the Dockerfile. This is typically the same folder as the Dockerfile resides in. dockerfile_path (str, optional): - Path to Dockerfile relative to chartpress.yaml's directory if not + Path to Dockerfile relative to the config file's directory if not "/Dockerfile". build_args (dict, optional): Dictionary of docker build arguments. @@ -603,12 +604,13 @@ def build_images( builder=Builder.DOCKER_BUILD, platforms=None, base_version=None, + config_path="chartpress.yaml", ): """Build a collection of docker images Args: prefix (str): the prefix to add to image names - images (dict): dict of image-specs from chartpress.yaml + images (dict): dict of image-specs from config file. tag (str): Specific tag to use instead of the last modified commit. If unspecified the tag for each image will be the hash of the last commit @@ -643,6 +645,8 @@ def build_images( base_version (str): The base version string (before '.git'), used when useChartVersion is True instead of the tag found via `git describe`. + config_path (str): + Path to the chartpress config file (default: "chartpress.yaml"). """ if platforms: # for later use of set operations like .difference() @@ -650,9 +654,9 @@ def build_images( values_file_modifications = {} for name, options in images.items(): - # include chartpress.yaml in the image paths to inspect as - # chartpress.yaml can contain build args influencing the image - all_image_paths = _get_all_image_paths(name, options) + # include config file in the image paths to inspect as + # it can contain build args influencing the image + all_image_paths = _get_all_image_paths(name, options, config_path) if tag is None: image_tag = _get_identifier_from_paths( @@ -1056,7 +1060,7 @@ def _version_number(groups): # check ordering with latest tag # do not check on a tagged commit if tag and count: - sort_error = f"baseVersion {base_version} is not greater than latest tag {tag}. Please update baseVersion config in chartpress.yaml." + sort_error = f"baseVersion {base_version} is not greater than latest tag {tag}. Please update baseVersion in config." if tag_match: base_version_number = _version_number(base_version_groups) if base_version_number < tag_version_number: @@ -1143,7 +1147,7 @@ def main(argv=None): argparser.add_argument( "--reset", action="store_true", - help="Skip image build step and reset Chart.yaml's version field and values.yaml's image tags. What it resets to can be configured in chartpress.yaml with the resetTag and resetVersion configurations.", + help="Skip image build step and reset Chart.yaml's version field and values.yaml's image tags. What it resets to can be configured in your config file with the resetTag and resetVersion configurations.", ) skip_or_force_build_group = argparser.add_mutually_exclusive_group() skip_or_force_build_group.add_argument( @@ -1182,6 +1186,14 @@ def main(argv=None): action="store_true", help="print list of images to stdout. Images will not be built.", ) + + argparser.add_argument( + "--config", + type=str, + default="chartpress.yaml", + help="Path to the configuration file", + ) + argparser.add_argument( "--version", action="version", @@ -1192,16 +1204,21 @@ def main(argv=None): if args.builder == Builder.DOCKER_BUILD and args.platform: argparser.error(f"--platform is not supported with {Builder.DOCKER_BUILD}") + if args.config: + # check that config exists and is readable + with open(args.config): + pass + if args.reset: - # reset conflicts with everything + # reset conflicts with everything except the configuration file # this could probably be clearer by using subparsers argv = list(argv or sys.argv[1:]) + argv.remove("--reset") + argv = _remove_config_arg(argv) if len(argv) > 1: - argv = list(argv) - argv.remove("--reset") - extra_args = " ".join(shlex.quote(arg) for arg in argv if arg != "--reset") + extra_args = " ".join(shlex.quote(arg) for arg in argv) argparser.error( - f"`chartpress --reset` takes no additional arguments: {extra_args}" + f"`chartpress --reset` can only be used with `--config` and no additional arguments: {extra_args}" ) # allow simple checks for whether publish will happen @@ -1212,11 +1229,11 @@ def main(argv=None): args.no_build = True args.publish_chart = False - with open("chartpress.yaml") as f: + with open(args.config) as f: config = yaml.load(f) # main logic - # - loop through each chart listed in chartpress.yaml + # - loop through each chart listed in the config file # - build chart.yaml (--reset) # - build images (--skip-build | --reset) # - push images (--push) @@ -1248,7 +1265,7 @@ def main(argv=None): # update Chart.yaml with a version chart_version = build_chart( chart["chartPath"], - paths=_get_all_chart_paths(chart), + paths=_get_all_chart_paths(chart, args.config), version=forced_version, base_version=base_version, long=args.long, @@ -1307,5 +1324,23 @@ def main(argv=None): ) +def _remove_config_arg(argv): + argv = [*argv] + + # get the index for --config, --config=something, or None + config_idx = next( + (i for i, arg in enumerate(argv) if arg.startswith("--config")), + None, + ) + if config_idx is not None: + # remove the --config argument (and its value if passed with =) + argv.pop(config_idx) + if not argv[config_idx].startswith("--") and config_idx < len(argv): + # remove the value of the --config argument if it was passed separately + argv.pop(config_idx) + + return argv + + if __name__ == "__main__": main() diff --git a/tests/test_repo_interactions.py b/tests/test_repo_interactions.py index fa3d82a..d4128dd 100644 --- a/tests/test_repo_interactions.py +++ b/tests/test_repo_interactions.py @@ -1,6 +1,8 @@ +import contextlib import os import subprocess import sys +import tempfile import pytest from conftest import cache_clear @@ -29,17 +31,22 @@ def test_git_repo_fixture(git_repo): assert os.path.isfile("index.yaml") +@pytest.mark.parametrize("config_name", ["chartpress.yaml", "chartpress.alt.yaml"]) @pytest.mark.parametrize("base_version", [None, "0.0.1-0.dev"]) -def test_chartpress_run(git_repo, capfd, base_version): +def test_chartpress_run(git_repo, capfd, base_version, config_name): """Run chartpress and inspect the output.""" + using_default_config = config_name == "chartpress.yaml" + args = [] if using_default_config else ["--config", config_name] + if not using_default_config: + os.rename("chartpress.yaml", config_name) - with open("chartpress.yaml") as f: + with open(config_name) as f: chartpress_config = yaml.load(f) chart = chartpress_config["charts"][0] if base_version: chart["baseVersion"] = base_version - with open("chartpress.yaml", "w") as f: + with open(config_name, "w") as f: yaml.dump(chartpress_config, f) # summarize information from git_repo @@ -48,8 +55,8 @@ def test_chartpress_run(git_repo, capfd, base_version): branch = "main" check_version(tag) - # run chartpress - out = _capture_output([], capfd) + # run chartpress, with parameterized config file name + out = _capture_output(args, capfd) print(out) # verify image was built # verify the fallback tag of "0.0.1" when a tag is missing @@ -74,30 +81,30 @@ def test_chartpress_run(git_repo, capfd, base_version): # verify usage of chartpress.yaml's resetVersion and resetTag reset_version = chart["resetVersion"] reset_tag = chart["resetTag"] - out = _capture_output(["--reset"], capfd) + out = _capture_output([*args, "--reset"], capfd) assert f"Updating testchart/Chart.yaml: version: {reset_version}" in out assert ( f"Updating testchart/values.yaml: image: testchart/testimage:{reset_tag}" in out ) # verify that we don't need to rebuild the image - out = _capture_output([], capfd) + out = _capture_output(args, capfd) assert "Skipping build" in out # verify usage of --force-build - out = _capture_output(["--force-build"], capfd) + out = _capture_output([*args, "--force-build"], capfd) assert "Successfully tagged" in out or "naming to" in out # verify usage --skip-build and --tag tag = "1.2.3-test.tag" - out = _capture_output(["--skip-build", "--tag", tag], capfd) + out = _capture_output([*args, "--skip-build", "--tag", tag], capfd) assert "Successfully tagged" not in out or "naming to" in out assert f"Updating testchart/Chart.yaml: version: {tag}" in out assert f"Updating testchart/values.yaml: image: testchart/testimage:{tag}" in out # verify a real git tag is detected git_repo.create_tag(tag, message=tag) - out = _capture_output(["--skip-build"], capfd) + out = _capture_output([*args, "--skip-build"], capfd) # This assertion verifies chartpress has considered the git tag by the fact # that no values required to be updated. No values should be updated as the @@ -108,12 +115,12 @@ def test_chartpress_run(git_repo, capfd, base_version): # Run again, but from a clean repo (versions in git don't match tag) # Should produce the same result git_repo.git.checkout(tag, "--", "testchart") - out = _capture_output(["--skip-build"], capfd) + out = _capture_output([*args, "--skip-build"], capfd) assert f"Updating testchart/Chart.yaml: version: {tag}\n" in out assert f"Updating testchart/values.yaml: image: testchart/testimage:{tag}\n" in out # verify usage of --long - out = _capture_output(["--skip-build", "--long"], capfd) + out = _capture_output([*args, "--skip-build", "--long"], capfd) assert f"Updating testchart/Chart.yaml: version: {tag}.git.1.h{sha}" in out assert ( f"Updating testchart/values.yaml: image: testchart/testimage:{tag}.git.1.h{sha}" @@ -121,13 +128,16 @@ def test_chartpress_run(git_repo, capfd, base_version): ) # verify usage of --image-prefix - out = _capture_output(["--skip-build", "--image-prefix", "test-prefix/"], capfd) + out = _capture_output( + [*args, "--skip-build", "--image-prefix", "test-prefix/"], capfd + ) assert f"Updating testchart/Chart.yaml: version: {tag}" in out assert f"Updating testchart/values.yaml: image: test-prefix/testimage:{tag}" in out # verify usage of --publish-chart and --extra-message out = _capture_output( [ + *args, "--skip-build", "--publish-chart", "--extra-message", @@ -165,6 +175,7 @@ def test_chartpress_run(git_repo, capfd, base_version): # repo already out = _capture_output( [ + *args, "--skip-build", "--publish-chart", ], @@ -177,6 +188,7 @@ def test_chartpress_run(git_repo, capfd, base_version): # chart repo already out = _capture_output( [ + *args, "--skip-build", "--force-publish-chart", ], @@ -197,6 +209,7 @@ def test_chartpress_run(git_repo, capfd, base_version): with pytest.raises(ValueError): out = _capture_output( [ + *args, "--skip-build", ], capfd, @@ -204,19 +217,21 @@ def test_chartpress_run(git_repo, capfd, base_version): with pytest.raises(ValueError): out = _capture_output( [ + *args, "--reset", ], capfd, ) # update baseVersion chart["baseVersion"] = next_tag = "1.2.4-0.dev" - with open("chartpress.yaml", "w") as f: + with open(config_name, "w") as f: yaml.dump(chartpress_config, f) else: next_tag = tag out = _capture_output( [ + *args, "--skip-build", "--publish-chart", ], @@ -549,3 +564,15 @@ def test_reset_exclusive(git_repo, capfd): chartpress.main(["--reset", "--tag", "1.2.3"]) out, err = capfd.readouterr() assert "no additional arguments" in err + + with pytest.raises(SystemExit): + chartpress.main(["--reset", "--config=chartpress.yaml", "--tag", "1.2.3"]) + out, err = capfd.readouterr() + assert "no additional arguments" in err + assert "chartpress.yaml" not in err + + with pytest.raises(SystemExit): + chartpress.main(["--reset", "--config", "chartpress.yaml", "--tag", "1.2.3"]) + out, err = capfd.readouterr() + assert "no additional arguments" in err + assert "chartpress.yaml" not in err