diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 9bd7ebf..5633fca 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1875,7 +1875,7 @@ }, "//tests:test_extensions.bzl%helm_test": { "general": { - "bzlTransitiveDigest": "Il07v34bQC0F0TXD/zqX8dYPcnapTKN53tEWs9CT0ZU=", + "bzlTransitiveDigest": "/daJAFxR7sMlWp6zD7AgTn+TxYSzJhDCWX13TZSPZdo=", "accumulatedFileDigests": {}, "envVariables": {}, "generatedRepoSpecs": { diff --git a/README.md b/README.md index d8c50b2..ffece1f 100755 --- a/README.md +++ b/README.md @@ -191,20 +191,27 @@ Rules for creating Helm chart packages. ## helm_push
-helm_push(name, include_images, package, registry_url) +helm_push(name, env, include_images, login_url, package, registry_url)Produce an executable for performing a helm push to a registry. +Before performing `helm push` the executable produced will conditionally perform [`helm registry login`](https://helm.sh/docs/helm/helm_registry_login/) +if the following environment variables are defined: +- `HELM_REGISTRY_USERNAME`: The value of `--username`. +- `HELM_REGISTRY_PASSWORD`/`HELM_REGISTRY_PASSWORD_FILE`: The value of `--password` or a file containing the `--password` value. + **ATTRIBUTES** | Name | Description | Type | Mandatory | Default | | :------------- | :------------- | :------------- | :------------- | :------------- | | name | A unique name for this target. | Name | required | | +| env | Environment variables to set when running this target. | Dictionary: String -> String | optional | `{}` | | include_images | If True, images depended on by `package` will be pushed as well. | Boolean | optional | `False` | +| login_url | The URL of the registry to use for `helm login`. E.g. `my.registry.io` | String | optional | `""` | | package | The helm package to push to the registry. | Label | required | | -| registry_url | The URL of the registry. | String | required | | +| registry_url | The registry URL at which to push the helm chart to. E.g. `oci://my.registry.io/chart-name` | String | required | | @@ -212,7 +219,7 @@ Produce an executable for performing a helm push to a registry. ## helm_push_images
-helm_push_images(name, package) +helm_push_images(name, env, package)Produce an executable for pushing all oci images used by a helm chart. @@ -223,6 +230,7 @@ Produce an executable for pushing all oci images used by a helm chart. | Name | Description | Type | Mandatory | Default | | :------------- | :------------- | :------------- | :------------- | :------------- | | name | A unique name for this target. | Name | required | | +| env | Environment variables to set when running this target. | Dictionary: String -> String | optional | `{}` | | package | The helm package to upload images from. | Label | required | | @@ -231,20 +239,27 @@ Produce an executable for pushing all oci images used by a helm chart. ## helm_push_registry
-helm_push_registry(name, include_images, package, registry_url) +helm_push_registry(name, env, include_images, login_url, package, registry_url)Produce an executable for performing a helm push to a registry. +Before performing `helm push` the executable produced will conditionally perform [`helm registry login`](https://helm.sh/docs/helm/helm_registry_login/) +if the following environment variables are defined: +- `HELM_REGISTRY_USERNAME`: The value of `--username`. +- `HELM_REGISTRY_PASSWORD`/`HELM_REGISTRY_PASSWORD_FILE`: The value of `--password` or a file containing the `--password` value. + **ATTRIBUTES** | Name | Description | Type | Mandatory | Default | | :------------- | :------------- | :------------- | :------------- | :------------- | | name | A unique name for this target. | Name | required | | +| env | Environment variables to set when running this target. | Dictionary: String -> String | optional | `{}` | | include_images | If True, images depended on by `package` will be pushed as well. | Boolean | optional | `False` | +| login_url | The URL of the registry to use for `helm login`. E.g. `my.registry.io` | String | optional | `""` | | package | The helm package to push to the registry. | Label | required | | -| registry_url | The URL of the registry. | String | required | | +| registry_url | The registry URL at which to push the helm chart to. E.g. `oci://my.registry.io/chart-name` | String | required | | diff --git a/helm/private/BUILD.bazel b/helm/private/BUILD.bazel index 467d8d3..6e1c71f 100644 --- a/helm/private/BUILD.bazel +++ b/helm/private/BUILD.bazel @@ -7,7 +7,6 @@ filegroup( srcs = glob(["**/*.bzl"]) + [ "//helm/private/packager:bzl_srcs", "//helm/private/pusher:bzl_srcs", - "//helm/private/registrar:bzl_srcs", "//helm/private/runner:bzl_srcs", "//helm/private/stamp:bzl_srcs", ], diff --git a/helm/private/helm_registry.bzl b/helm/private/helm_registry.bzl index 0049be7..f313909 100644 --- a/helm/private/helm_registry.bzl +++ b/helm/private/helm_registry.bzl @@ -1,6 +1,7 @@ """Helm rules""" load("//helm:providers.bzl", "HelmPackageInfo") +load(":helm_utils.bzl", "rlocationpath") def _get_image_push_commands(ctx, pkg_info): image_pushers = [] @@ -9,48 +10,58 @@ def _get_image_push_commands(ctx, pkg_info): image_pushers.append(image[DefaultInfo].files_to_run.executable) image_runfiles.append(image[DefaultInfo].default_runfiles) - if image_pushers: - image_commands = "\n".join([pusher.short_path for pusher in image_pushers]) - else: - image_commands = "echo 'No OCI images to push for Helm chart.'" - runfiles = ctx.runfiles(files = image_pushers) for ir in image_runfiles: runfiles = runfiles.merge(ir) - return image_commands, runfiles + return image_pushers, runfiles def _helm_push_impl(ctx): toolchain = ctx.toolchains[Label("//helm:toolchain_type")] if toolchain.helm.basename.endswith(".exe"): - registrar = ctx.actions.declare_file(ctx.label.name + ".bat") + registrar = ctx.actions.declare_file(ctx.label.name + ".exe") else: - registrar = ctx.actions.declare_file(ctx.label.name + ".sh") + registrar = ctx.actions.declare_file(ctx.label.name) + + ctx.actions.symlink( + target_file = ctx.executable._registrar, + output = registrar, + is_executable = True, + ) - registry_url = ctx.attr.registry_url pkg_info = ctx.attr.package[HelmPackageInfo] - image_commands = "" + args = ctx.actions.args() + args.set_param_file_format("multiline") + args.add("-helm", rlocationpath(toolchain.helm, ctx.workspace_name)) + args.add("-chart", rlocationpath(pkg_info.chart, ctx.workspace_name)) + args.add("-registry_url", ctx.attr.registry_url) + + if ctx.attr.login_url: + args.add("-login_url", ctx.attr.login_url) + image_runfiles = ctx.runfiles() if ctx.attr.include_images: - image_commands, image_runfiles = _get_image_push_commands( + image_pushers, image_runfiles = _get_image_push_commands( ctx = ctx, pkg_info = pkg_info, ) - ctx.actions.expand_template( - template = ctx.file._registrar, - output = registrar, - substitutions = { - "{chart}": pkg_info.chart.short_path, - "{helm}": toolchain.helm.short_path, - "{image_pushers}": image_commands, - "{registry_url}": registry_url, - }, - is_executable = True, + if image_pushers: + args.add("-image_pusher", ",".join([rlocationpath(p, ctx.workspace_name) for p in image_pushers])) + + args_file = ctx.actions.declare_file("{}.args.txt".format(ctx.label.name)) + ctx.actions.write( + output = args_file, + content = args, ) - runfiles = ctx.runfiles([registrar, toolchain.helm, pkg_info.chart]).merge(image_runfiles) + runfiles = ctx.runfiles([ + registrar, + args_file, + toolchain.helm, + pkg_info.chart, + ]).merge(image_runfiles) return [ DefaultInfo( @@ -58,30 +69,49 @@ def _helm_push_impl(ctx): runfiles = runfiles, executable = registrar, ), + RunEnvironmentInfo( + environment = ctx.attr.env | { + "HELM_PUSH_ARGS_FILE": rlocationpath(args_file, ctx.workspace_name), + }, + ), ] helm_push = rule( - doc = "Produce an executable for performing a helm push to a registry.", + doc = """\ +Produce an executable for performing a helm push to a registry. + +Before performing `helm push` the executable produced will conditionally perform [`helm registry login`](https://helm.sh/docs/helm/helm_registry_login/) +if the following environment variables are defined: +- `HELM_REGISTRY_USERNAME`: The value of `--username`. +- `HELM_REGISTRY_PASSWORD`/`HELM_REGISTRY_PASSWORD_FILE`: The value of `--password` or a file containing the `--password` value. +""", implementation = _helm_push_impl, executable = True, attrs = { + "env": attr.string_dict( + doc = "Environment variables to set when running this target.", + ), "include_images": attr.bool( doc = "If True, images depended on by `package` will be pushed as well.", default = False, ), + "login_url": attr.string( + doc = "The URL of the registry to use for `helm login`. E.g. `my.registry.io`", + ), "package": attr.label( doc = "The helm package to push to the registry.", providers = [HelmPackageInfo], mandatory = True, ), "registry_url": attr.string( - doc = "The URL of the registry.", + doc = "The registry URL at which to push the helm chart to. E.g. `oci://my.registry.io/chart-name`", mandatory = True, ), "_registrar": attr.label( doc = "A process wrapper to use for performing `helm registry and helm push`.", - allow_single_file = True, - default = Label("//helm/private/registrar:template"), + executable = True, + cfg = "exec", + default = Label("//helm/private/registrar"), ), }, toolchains = [ @@ -99,11 +129,13 @@ def _helm_push_images_impl(ctx): pkg_info = ctx.attr.package[HelmPackageInfo] - image_commands, image_runfiles = _get_image_push_commands( + image_pushers, image_runfiles = _get_image_push_commands( ctx = ctx, pkg_info = pkg_info, ) + image_commands = "\n".join([file.short_path for file in image_pushers]) + ctx.actions.expand_template( template = ctx.file._pusher, output = pusher, @@ -121,6 +153,9 @@ def _helm_push_images_impl(ctx): runfiles = runfiles, executable = pusher, ), + RunEnvironmentInfo( + environment = ctx.attr.env, + ), ] helm_push_images = rule( @@ -128,6 +163,9 @@ helm_push_images = rule( implementation = _helm_push_images_impl, executable = True, attrs = { + "env": attr.string_dict( + doc = "Environment variables to set when running this target.", + ), "package": attr.label( doc = "The helm package to upload images from.", providers = [HelmPackageInfo], diff --git a/helm/private/registrar/BUILD.bazel b/helm/private/registrar/BUILD.bazel index 4b91f85..a52c668 100644 --- a/helm/private/registrar/BUILD.bazel +++ b/helm/private/registrar/BUILD.bazel @@ -1,23 +1,10 @@ -package(default_visibility = ["//visibility:public"]) +load("@io_bazel_rules_go//go:def.bzl", "go_binary") -exports_files([ - "helm_registry.bat.template", - "helm_registry.sh.template", -]) - -alias( - name = "template", - actual = select({ - "@platforms//os:windows": ":helm_registry.bat.template", - "//conditions:default": ":helm_registry.sh.template", - }), -) - -filegroup( - name = "bzl_srcs", - srcs = glob( - ["**/*.bzl"], - allow_empty = True, - ), - visibility = ["//:__subpackages__"], +go_binary( + name = "registrar", + srcs = ["registrar.go"], + visibility = ["//visibility:public"], + deps = [ + "@io_bazel_rules_go//go/runfiles", + ], ) diff --git a/helm/private/registrar/helm_registry.bat.template b/helm/private/registrar/helm_registry.bat.template deleted file mode 100644 index bf977d7..0000000 --- a/helm/private/registrar/helm_registry.bat.template +++ /dev/null @@ -1,4 +0,0 @@ -ECHO OFF - -{image_pushers} -{helm} push {chart} {registry_url} diff --git a/helm/private/registrar/helm_registry.sh.template b/helm/private/registrar/helm_registry.sh.template deleted file mode 100644 index 21bc1fd..0000000 --- a/helm/private/registrar/helm_registry.sh.template +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -{image_pushers} -eval exec {helm} push {chart} {registry_url} diff --git a/helm/private/registrar/registrar.go b/helm/private/registrar/registrar.go new file mode 100644 index 0000000..dc49b92 --- /dev/null +++ b/helm/private/registrar/registrar.go @@ -0,0 +1,185 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "log" + "net/url" + "os" + "os/exec" + "strings" + + "github.com/bazelbuild/rules_go/go/runfiles" +) + +// Chart represents the structure of Chart.yaml +type Chart struct { + Version string `yaml:"version"` +} + +func readArgsFromFile(filepath string) ([]string, error) { + file, err := os.Open(filepath) + if err != nil { + return nil, fmt.Errorf("failed to open arguments file: %w", err) + } + defer file.Close() + + var args []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + args = append(args, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read arguments file: %w", err) + } + return args, nil +} + +func getRunfile(runfile_path string) string { + + runfiles, err := runfiles.New() + if err != nil { + log.Fatalf("Failed to load runfiles: ", err) + } + + // Use the runfiles library to locate files + runfile, err := runfiles.Rlocation(runfile_path) + if err != nil { + log.Fatal("When reading file ", runfile_path, " got error ", err) + } + + // Check that the file actually exist + if _, err := os.Stat(runfile); err != nil { + log.Fatal("File found by runfile doesn't exist") + } + + return runfile +} + +func getHostFromURL(inputURL string) (string, error) { + // Replace "oci://" with "https://" so that net/url can parse it + parsedURL, err := url.Parse(strings.Replace(inputURL, "oci://", "https://", 1)) + if err != nil { + return "", fmt.Errorf("failed to parse URL: %w", err) + } + return parsedURL.Host, nil +} + +func main() { + // Get the file path for args + argsRlocation := os.Getenv("HELM_PUSH_ARGS_FILE") + if argsRlocation == "" { + log.Fatalf("HELM_PUSH_ARGS_FILE environment variable is not set") + } + + argsFilePath := getRunfile(argsRlocation) + + // Read args from the file + fileArgs, err := readArgsFromFile(argsFilePath) + if err != nil { + log.Fatalf("Error reading arguments file: %v", err) + } + + // Setup flags for helm, chart, registry_url, and image_pushers + rawHelmPath := flag.String("helm", "", "Path to helm binary") + rawChartPath := flag.String("chart", "", "Path to Helm .tgz file") + registryURL := flag.String("registry_url", "", "URL of registry to upload helm chart") + rawLoginURL := flag.String("login_url", "", "URL of registry to login to.") + rawImagePushers := flag.String("image_pushers", "", "Comma-separated list of image pusher executables") + + // Parse command line arguments + flag.CommandLine.Parse(fileArgs) + + // Check required arguments + if *rawHelmPath == "" || *rawChartPath == "" || *registryURL == "" { + log.Fatalf("Missing required arguments: helm, chart, registry_url") + } + + helmPath := getRunfile(*rawHelmPath) + chartPath := getRunfile(*rawChartPath) + + var imagePushers []string + if *rawImagePushers != "" { + for _, pusher := range strings.Split(*rawImagePushers, ",") { + imagePushers = append(imagePushers, getRunfile(pusher)) + } + } + + // Check for registry login credentials + helmUser := os.Getenv("HELM_REGISTRY_USERNAME") + var helmPassword string + + // Try to get the password from HELM_REGISTRY_PASSWORD or HELM_REGISTRY_PASSWORD_FILE + if pwFile := os.Getenv("HELM_REGISTRY_PASSWORD_FILE"); pwFile != "" { + // Read the first line from the specified password file + file, err := os.Open(pwFile) + if err != nil { + log.Fatalf("Failed to open password file: %v", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + if scanner.Scan() { + helmPassword = scanner.Text() + } + if err := scanner.Err(); err != nil { + log.Fatalf("Failed to read password file: %v", err) + } + } else { + // Use HELM_REGISTRY_PASSWORD if HELM_REGISTRY_PASSWORD_FILE is not set + helmPassword = os.Getenv("HELM_REGISTRY_PASSWORD") + } + + // Proceed with login if both username and password are available + if helmUser != "" && helmPassword != "" { + loginUrl := *rawLoginURL + + // If an explicit login url was not set, attempt to parse it from registryURL. + if loginUrl == "" { + host, err := getHostFromURL(*registryURL) + if err == nil { + loginUrl = host + } + } + + loginCmd := exec.Command(helmPath, "registry", "login", "--username", helmUser, "--password-stdin", loginUrl) + loginCmd.Stdout = os.Stdout + loginCmd.Stderr = os.Stderr + + // Provide the password to stdin of the login command + loginCmd.Stdin = strings.NewReader(helmPassword) + + log.Printf("Logging into Helm registry `%s`...\n", loginUrl) + if err := loginCmd.Run(); err != nil { + log.Fatalf("Failed to login to Helm registry: %v", err) + } + } else if helmUser != "" { + log.Printf("WARNING: A Helm registry username was set but no associated `HELM_REGISTRY_PASSWORD`/`HELM_REGISTRY_PASSWORD_FILE` var was found. Skipping `helm registry login`.") + } else if helmPassword != "" { + log.Printf("WARNING: A Helm registry password was set but no associated `HELM_REGISTRY_USERNAME` var was found. Skipping `helm registry login`.") + } + + // Subprocess image pushers + for _, pusher := range imagePushers { + cmd := exec.Command(pusher) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + log.Printf("Running image pusher: %s", pusher) + if err := cmd.Run(); err != nil { + log.Fatalf("Failed to run image pusher %s: %v", pusher, err) + } + } + + // Subprocess helm push + pushCmd := exec.Command(helmPath, "push", chartPath, *registryURL) + pushCmd.Stdout = os.Stdout + pushCmd.Stderr = os.Stderr + + log.Printf("Running helm push: %s", pushCmd.String()) + if err := pushCmd.Run(); err != nil { + log.Fatalf("Failed to push helm chart: %v", err) + } +} diff --git a/tests/simple/BUILD.bazel b/tests/simple/BUILD.bazel index 96aa763..3720235 100644 --- a/tests/simple/BUILD.bazel +++ b/tests/simple/BUILD.bazel @@ -3,6 +3,7 @@ load("//helm:defs.bzl", "helm_chart", "helm_lint_test") helm_chart( name = "simple", chart = "Chart.yaml", + registry_url = "oci://localhost/helm-registry", values = "values.yaml", )