From 2e280f27172277c004fad5a54f029356eba0fd7f Mon Sep 17 00:00:00 2001 From: Adam Martin Date: Sun, 2 Aug 2026 23:41:23 -0400 Subject: [PATCH] address helm chart verification, auth, and tls options (#601) Signed-off-by: Adam Martin --- .github/workflows/tests.yaml | 8 +-- cmd/hauler/cli/store/sync.go | 38 ++++++++++- cmd/hauler/cli/store/sync_test.go | 90 +++++++++++++++++++++++++++ internal/flags/add.go | 1 + pkg/apis/hauler.cattle.io/v1/chart.go | 17 +++++ pkg/content/chart/chart.go | 14 +++++ pkg/content/chart/chart_test.go | 17 +++++ 7 files changed, 179 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d53a61a..5f08c48 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -140,18 +140,18 @@ jobs: # verify via helm repository hauler store add chart rancher --repo https://releases.rancher.com/server-charts/stable hauler store add chart rancher --repo https://releases.rancher.com/server-charts/stable --version 2.8.4 - hauler store add chart rancher --repo https://releases.rancher.com/server-charts/stable --version 2.8.3 --verify # verify via oci helm repository hauler store add chart hauler-helm --repo oci://ghcr.io/hauler-dev hauler store add chart hauler-helm --repo oci://ghcr.io/hauler-dev --version 1.0.6 - hauler store add chart hauler-helm --repo oci://ghcr.io/hauler-dev --version 1.0.4 --verify # verify via local helm repository curl -sfOL https://github.com/rancherfederal/rancher-cluster-templates/releases/download/rancher-cluster-templates-0.5.2/rancher-cluster-templates-0.5.2.tgz hauler store add chart rancher-cluster-templates-0.5.2.tgz --repo . curl -sfOL https://github.com/rancherfederal/rancher-cluster-templates/releases/download/rancher-cluster-templates-0.5.1/rancher-cluster-templates-0.5.1.tgz hauler store add chart rancher-cluster-templates-0.5.1.tgz --repo . --version 0.5.1 - curl -sfOL https://github.com/rancherfederal/rancher-cluster-templates/releases/download/rancher-cluster-templates-0.5.0/rancher-cluster-templates-0.5.0.tgz - hauler store add chart rancher-cluster-templates-0.5.0.tgz --repo . --version 0.5.0 --verify + curl -sfLO https://raw.githubusercontent.com/helm/helm/v3.18.6/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz + curl -sfLO https://raw.githubusercontent.com/helm/helm/v3.18.6/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz.prov + curl -sfLO https://raw.githubusercontent.com/helm/helm/v3.18.6/cmd/helm/testdata/helm-test-key.pub + hauler store add chart signtest-0.1.0.tgz --repo . --version 0.1.0 --verify --keyring helm-test-key.pub # verify via the hauler store contents hauler store info diff --git a/cmd/hauler/cli/store/sync.go b/cmd/hauler/cli/store/sync.go index e83b27e..beefb0c 100644 --- a/cmd/hauler/cli/store/sync.go +++ b/cmd/hauler/cli/store/sync.go @@ -509,11 +509,26 @@ func processContent(ctx context.Context, fi *os.File, o *flags.SyncOpts, s *stor platform = ch.Platform } + chartUsername, chartPassword, err := resolveChartCreds(ch) + if err != nil { + return err + } + if err := storeChart(ctx, s, ch, &flags.AddChartOpts{ ChartOpts: &action.ChartPathOptions{ - RepoURL: ch.RepoURL, - Version: ch.Version, + RepoURL: ch.RepoURL, + Version: ch.Version, + Verify: ch.Verify, + Keyring: ch.Keyring, + Username: chartUsername, + Password: chartPassword, + PassCredentialsAll: ch.PassCredentialsAll, + CertFile: ch.CertFile, + KeyFile: ch.KeyFile, + CaFile: ch.CaFile, + InsecureSkipTLSVerify: ch.InsecureSkipTLSVerify, + PlainHTTP: ch.PlainHTTP, }, AddImages: ch.AddImages, AddDependencies: ch.AddDependencies, @@ -540,6 +555,25 @@ func processContent(ctx context.Context, fi *os.File, o *flags.SyncOpts, s *stor return nil } +// resolveChartCreds reads credentials for a Chart entry from the env vars +// named by UsernameEnv and PasswordEnv. Both fields must be set or both must +// be empty; a mix is a configuration error. If both are set, the env vars +// must be non-empty at runtime. +func resolveChartCreds(ch v1.Chart) (username, password string, err error) { + if ch.UsernameEnv == "" && ch.PasswordEnv == "" { + return "", "", nil + } + if ch.UsernameEnv == "" || ch.PasswordEnv == "" { + return "", "", fmt.Errorf("chart %q: usernameEnv and passwordEnv must both be set or both be empty", ch.Name) + } + username = os.Getenv(ch.UsernameEnv) + password = os.Getenv(ch.PasswordEnv) + if username == "" || password == "" { + return "", "", fmt.Errorf("chart %q: env vars %q and %q must both be set and non-empty", ch.Name, ch.UsernameEnv, ch.PasswordEnv) + } + return username, password, nil +} + func processImageTxt(ctx context.Context, fi *os.File, o *flags.SyncOpts, s *store.Layout, rso *flags.StoreRootOpts, ro *flags.CliRootOpts) error { l := log.FromContext(ctx) l.Infof("syncing images from [%s] to store", filepath.Base(fi.Name())) diff --git a/cmd/hauler/cli/store/sync_test.go b/cmd/hauler/cli/store/sync_test.go index 18a35d2..11d3507 100644 --- a/cmd/hauler/cli/store/sync_test.go +++ b/cmd/hauler/cli/store/sync_test.go @@ -20,6 +20,7 @@ import ( "github.com/rs/zerolog" "hauler.dev/go/hauler/v2/internal/flags" + v1 "hauler.dev/go/hauler/v2/pkg/apis/hauler.cattle.io/v1" "hauler.dev/go/hauler/v2/pkg/consts" ) @@ -49,6 +50,95 @@ func newSyncOpts(storeDir string) *flags.SyncOpts { } } +// -------------------------------------------------------------------------- +// resolveChartCreds tests +// -------------------------------------------------------------------------- + +func TestResolveChartCreds_BothEmpty(t *testing.T) { + ch := v1.Chart{Name: "mychart", RepoURL: "https://charts.example.com"} + u, p, err := resolveChartCreds(ch) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u != "" || p != "" { + t.Errorf("expected empty creds, got username=%q password=%q", u, p) + } +} + +func TestResolveChartCreds_BothSetAndEnvPopulated(t *testing.T) { + t.Setenv("CHART_TEST_USER", "alice") + t.Setenv("CHART_TEST_PASS", "s3cr3t") + + ch := v1.Chart{ + Name: "mychart", + RepoURL: "https://charts.example.com", + UsernameEnv: "CHART_TEST_USER", + PasswordEnv: "CHART_TEST_PASS", + } + u, p, err := resolveChartCreds(ch) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u != "alice" { + t.Errorf("username: got %q, want %q", u, "alice") + } + if p != "s3cr3t" { + t.Errorf("password: got %q, want %q", p, "s3cr3t") + } +} + +func TestResolveChartCreds_OnlyUsernameEnvSet_ReturnsError(t *testing.T) { + ch := v1.Chart{ + Name: "mychart", + RepoURL: "https://charts.example.com", + UsernameEnv: "CHART_TEST_USER_ONLY", + // PasswordEnv intentionally omitted + } + _, _, err := resolveChartCreds(ch) + if err == nil { + t.Fatal("expected error when only usernameEnv is set, got nil") + } + if !strings.Contains(err.Error(), "usernameEnv and passwordEnv must both be set") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestResolveChartCreds_OnlyPasswordEnvSet_ReturnsError(t *testing.T) { + ch := v1.Chart{ + Name: "mychart", + RepoURL: "https://charts.example.com", + // UsernameEnv intentionally omitted + PasswordEnv: "CHART_TEST_PASS_ONLY", + } + _, _, err := resolveChartCreds(ch) + if err == nil { + t.Fatal("expected error when only passwordEnv is set, got nil") + } + if !strings.Contains(err.Error(), "usernameEnv and passwordEnv must both be set") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestResolveChartCreds_EnvVarUnset_ReturnsError(t *testing.T) { + // Ensure the env vars are definitely absent. + t.Setenv("CHART_UNSET_USER", "") + t.Setenv("CHART_UNSET_PASS", "") + + ch := v1.Chart{ + Name: "mychart", + RepoURL: "https://charts.example.com", + UsernameEnv: "CHART_UNSET_USER", + PasswordEnv: "CHART_UNSET_PASS", + } + _, _, err := resolveChartCreds(ch) + if err == nil { + t.Fatal("expected error when env vars are empty, got nil") + } + if !strings.Contains(err.Error(), "must both be set and non-empty") { + t.Errorf("unexpected error message: %v", err) + } +} + // -------------------------------------------------------------------------- // processContent tests // -------------------------------------------------------------------------- diff --git a/internal/flags/add.go b/internal/flags/add.go index 06c92a6..ae77f51 100644 --- a/internal/flags/add.go +++ b/internal/flags/add.go @@ -66,6 +66,7 @@ func (o *AddChartOpts) AddFlags(cmd *cobra.Command) { f.StringVar(&o.ChartOpts.RepoURL, "repo", "", "Location of the chart (https:// | http:// | oci://)") f.StringVar(&o.ChartOpts.Version, "version", "", "(Optional) Specify the version of the chart (v1.0.0 | 2.0.0 | ^2.0.0)") f.BoolVar(&o.ChartOpts.Verify, "verify", false, "(Optional) Verify the chart before fetching it") + f.StringVar(&o.ChartOpts.Keyring, "keyring", "", "(Optional) Location of public keyring used by --verify (default: $HOME/.gnupg/pubring.gpg)") f.StringVar(&o.ChartOpts.Username, "username", "", "(Optional) Username to use for authentication") f.StringVar(&o.ChartOpts.Password, "password", "", "(Optional) Password to use for authentication") f.StringVar(&o.ChartOpts.CertFile, "cert-file", "", "(Optional) Location of the TLS Certificate to use for client authentication") diff --git a/pkg/apis/hauler.cattle.io/v1/chart.go b/pkg/apis/hauler.cattle.io/v1/chart.go index 4309a3d..cbd73a0 100644 --- a/pkg/apis/hauler.cattle.io/v1/chart.go +++ b/pkg/apis/hauler.cattle.io/v1/chart.go @@ -26,4 +26,21 @@ type Chart struct { AddImages bool `json:"add-images,omitempty"` AddDependencies bool `json:"add-dependencies,omitempty"` ExcludeExtras bool `json:"exclude-extras,omitempty"` + + // Verification + Verify bool `json:"verify,omitempty"` + Keyring string `json:"keyring,omitempty"` + + // Auth (HTTP repos only — for OCI registries use `hauler login`) + // Credentials are referenced by env-var name; raw values must NOT appear in manifests. + UsernameEnv string `json:"usernameEnv,omitempty"` + PasswordEnv string `json:"passwordEnv,omitempty"` + PassCredentialsAll bool `json:"passCredentialsAll,omitempty"` + + // TLS + CertFile string `json:"certFile,omitempty"` + KeyFile string `json:"keyFile,omitempty"` + CaFile string `json:"caFile,omitempty"` + InsecureSkipTLSVerify bool `json:"insecureSkipTLSVerify,omitempty"` + PlainHTTP bool `json:"plainHTTP,omitempty"` } diff --git a/pkg/content/chart/chart.go b/pkg/content/chart/chart.go index 1ab2712..c2d9124 100644 --- a/pkg/content/chart/chart.go +++ b/pkg/content/chart/chart.go @@ -50,7 +50,21 @@ func NewChart(name string, opts *action.ChartPathOptions) (*Chart, error) { } client := action.NewInstall(actionConfig) + + // Propagate auth, TLS, and verification options from the caller. + // RepoURL is intentionally NOT copied here — it is set conditionally below + // based on URL scheme (OCI vs HTTP vs bare). client.ChartPathOptions.Version = opts.Version + client.ChartPathOptions.Verify = opts.Verify + client.ChartPathOptions.Keyring = opts.Keyring + client.ChartPathOptions.Username = opts.Username + client.ChartPathOptions.Password = opts.Password + client.ChartPathOptions.PassCredentialsAll = opts.PassCredentialsAll + client.ChartPathOptions.CertFile = opts.CertFile + client.ChartPathOptions.KeyFile = opts.KeyFile + client.ChartPathOptions.CaFile = opts.CaFile + client.ChartPathOptions.InsecureSkipTLSVerify = opts.InsecureSkipTLSVerify + client.ChartPathOptions.PlainHTTP = opts.PlainHTTP registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, client.InsecureSkipTLSVerify, client.PlainHTTP) diff --git a/pkg/content/chart/chart_test.go b/pkg/content/chart/chart_test.go index eff9304..f1bf08a 100644 --- a/pkg/content/chart/chart_test.go +++ b/pkg/content/chart/chart_test.go @@ -3,6 +3,7 @@ package chart_test import ( "os" "reflect" + "strings" "testing" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -129,3 +130,19 @@ func TestNewChart(t *testing.T) { }) } } + +func TestNewChart_VerifyOnUnsignedChartFails(t *testing.T) { + _, err := chart.NewChart( + "rancher-cluster-templates-0.5.2.tgz", + &action.ChartPathOptions{ + RepoURL: "../../../testdata", + Verify: true, + }, + ) + if err == nil { + t.Fatalf("expected verify failure on unsigned chart, got nil error") + } + if !strings.Contains(err.Error(), "provenance") { + t.Fatalf("expected provenance error, got: %v", err) + } +}