diff --git a/internal/shared/util/helm/chart.go b/internal/shared/util/helm/chart.go new file mode 100644 index 000000000..1bb5be48a --- /dev/null +++ b/internal/shared/util/helm/chart.go @@ -0,0 +1,115 @@ +package helm + +import ( + "context" + "fmt" + "net/http" + "net/url" + "regexp" + "slices" + "strings" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/registry" +) + +type HelmCheckResponse struct { + // Chart returns true if helm chart + Chart bool + // Oci returns true if resource is stored + // in an OCI registry + Oci bool +} + +func IsChart(ctx context.Context, chartURI string) (HelmCheckResponse, error) { + addr, err := url.Parse(chartURI) + if err != nil { + return HelmCheckResponse{}, err + } + + if addr.Scheme != "" { + if !strings.HasPrefix(addr.Scheme, "http") { + return HelmCheckResponse{}, fmt.Errorf("unexpected scheme; %s", addr.Scheme) + } + + helmchart, err := validateHelmChart(addr.String()) + if err != nil { + return HelmCheckResponse{}, err + } + + if helmchart != nil && + helmchart.Metadata != nil && + helmchart.Metadata.Name != "" { + return HelmCheckResponse{ + Chart: true, + Oci: false, + }, err + } + } + + ociRe := regexp.MustCompile("^(?P[a-zA-Z0-9-_.:]+)([/]?)(?P[a-zA-Z0-9-_/]+)?([/](?P[a-zA-Z0-9-_.:@]+))$") + if !ociRe.MatchString(chartURI) { + return HelmCheckResponse{ + Chart: false, + Oci: false, + }, fmt.Errorf("does not conform to OCI url format") + } + + ociCheck, err := helmOciCheck(ctx, chartURI) + if err != nil { + return HelmCheckResponse{ + Chart: false, + Oci: true, + }, err + } + + return HelmCheckResponse{ + Chart: ociCheck, + Oci: true, + }, nil +} + +// helmOciCheck() pull a helm chart using the provided chartURI from an +// OCI registiry and inspects its media type to determine if a Helm chart +func helmOciCheck(_ context.Context, chartURI string) (bool, error) { + helmclient, err := registry.NewClient() + if err != nil { + return false, err + } + + summary, err := helmclient.Pull(chartURI, + registry.PullOptWithProv(false), + registry.PullOptWithChart(true), + registry.PullOptIgnoreMissingProv(true), + ) + if err != nil { + return false, err + } + + return summary != nil && summary.Ref != "", nil +} + +func validateHelmChart(chartURI string) (*chart.Chart, error) { + // Download helm chart from HTTP + req, err := http.NewRequest(http.MethodGet, chartURI, nil) + if err != nil { + return nil, fmt.Errorf("creating request failed; %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("loading URL failed; %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected response; %w", err) + } + + if !slices.Contains(resp.Header["Content-Type"], "application/octet-stream") { + return nil, fmt.Errorf("unknown contype-type") + } + + return loader.LoadArchive(resp.Body) +} diff --git a/internal/shared/util/helm/chart_test.go b/internal/shared/util/helm/chart_test.go new file mode 100644 index 000000000..2e6501557 --- /dev/null +++ b/internal/shared/util/helm/chart_test.go @@ -0,0 +1,97 @@ +package helm_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + helmutils "github.com/operator-framework/operator-controller/internal/shared/util/helm" +) + +func TestIsChart(t *testing.T) { + type response struct { + Oci bool + Chart bool + } + + tt := []struct { + name string + url string + want response + wantErr bool + }{ + { + name: "pull helm chart using image tag", + url: "quay.io/eochieng/metrics-server:3.12.0", + want: response{ + Oci: true, + Chart: true, + }, + wantErr: false, + }, + { + name: "pull helm chart using image digest", + url: "quay.io/eochieng/metrics-server@sha256:dd56f2ccc6e29ba7a2c5492e12c8210fb7367771eca93380a8dd64a6c9c985cb", + want: response{ + Oci: true, + Chart: true, + }, + wantErr: false, + }, + { + name: "pull helm chart from HTTP repository", + url: "https://github.com/kubernetes-sigs/metrics-server/releases/download/metrics-server-helm-chart-3.12.0/metrics-server-3.12.0.tgz", + want: response{ + Oci: false, + Chart: true, + }, + wantErr: false, + }, + { + name: "pull helm chart with oci scheme", + url: "oci://quay.io/eochieng/metrics-server@sha256:dd56f2ccc6e29ba7a2c5492e12c8210fb7367771eca93380a8dd64a6c9c985cb", + want: response{ + Oci: false, + Chart: false, + }, + wantErr: true, + }, + { + name: "pull kubernetes web page", + url: "https://kubernetes.io", + want: response{ + Oci: false, + Chart: false, + }, + wantErr: true, + }, + { + name: "pull busybox image from OCI registry", + url: "quay.io/opdev/busybox:latest", + want: response{ + Oci: true, + Chart: false, + }, + wantErr: true, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + response, err := helmutils.IsChart(context.Background(), tc.url) + assert.Equal(t, response.Oci, tc.want.Oci) + assert.Equal(t, response.Chart, tc.want.Chart) + + if testing.Verbose() { + t.Logf("IsChart() is checking if %s is a helm chart.\n", tc.url) + } + + if !tc.wantErr { + assert.NoError(t, err) + } else { + assert.Error(t, err, "helm chart not found") + } + }) + } +}