diff --git a/cmd/flux/create_source_chart.go b/cmd/flux/create_source_chart.go new file mode 100644 index 0000000000..aab0219687 --- /dev/null +++ b/cmd/flux/create_source_chart.go @@ -0,0 +1,217 @@ +/* +Copyright 2024 The Flux authors + +Licensed 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. +*/ + +package main + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + + "github.com/fluxcd/flux2/v2/internal/flags" + "github.com/fluxcd/flux2/v2/internal/utils" +) + +var createSourceChartCmd = &cobra.Command{ + Use: "chart [name]", + Short: "Create or update a HelmChart source", + Long: `The create source chart command generates a HelmChart resource and waits for the chart to be available.`, + Example: ` # Create a source for a chart residing in a HelmRepository + flux create source chart podinfo \ + --source=HelmRepository/podinfo \ + --chart=podinfo \ + --chart-version=6.x + + # Create a source for a chart residing in a Git repository + flux create source chart podinfo \ + --source=GitRepository/podinfo \ + --chart=./charts/podinfo + + # Create a source for a chart residing in a S3 Bucket + flux create source chart podinfo \ + --source=Bucket/podinfo \ + --chart=./charts/podinfo + + # Create a source for a chart from OCI and verify its signature + flux create source chart podinfo \ + --source HelmRepository/podinfo \ + --chart podinfo \ + --chart-version=6.6.2 \ + --verify-provider=cosign \ + --verify-issuer=https://token.actions.githubusercontent.com \ + --verify-subject=https://github.com/stefanprodan/podinfo/.github/workflows/release.yml@refs/tags/6.6.2`, + RunE: createSourceChartCmdRun, +} + +type sourceChartFlags struct { + chart string + chartVersion string + source flags.LocalHelmChartSource + reconcileStrategy string + verifyProvider flags.SourceOCIVerifyProvider + verifySecretRef string + verifyOIDCIssuer string + verifySubject string +} + +var sourceChartArgs sourceChartFlags + +func init() { + createSourceChartCmd.Flags().StringVar(&sourceChartArgs.chart, "chart", "", "Helm chart name or path") + createSourceChartCmd.Flags().StringVar(&sourceChartArgs.chartVersion, "chart-version", "", "Helm chart version, accepts a semver range (ignored for charts from GitRepository sources)") + createSourceChartCmd.Flags().Var(&sourceChartArgs.source, "source", sourceChartArgs.source.Description()) + createSourceChartCmd.Flags().StringVar(&sourceChartArgs.reconcileStrategy, "reconcile-strategy", "ChartVersion", "the reconcile strategy for helm chart (accepted values: Revision and ChartRevision)") + createSourceChartCmd.Flags().Var(&sourceChartArgs.verifyProvider, "verify-provider", sourceOCIRepositoryArgs.verifyProvider.Description()) + createSourceChartCmd.Flags().StringVar(&sourceChartArgs.verifySecretRef, "verify-secret-ref", "", "the name of a secret to use for signature verification") + createSourceChartCmd.Flags().StringVar(&sourceChartArgs.verifySubject, "verify-subject", "", "regular expression to use for the OIDC subject during signature verification") + createSourceChartCmd.Flags().StringVar(&sourceChartArgs.verifyOIDCIssuer, "verify-issuer", "", "regular expression to use for the OIDC issuer during signature verification") + + createSourceCmd.AddCommand(createSourceChartCmd) +} + +func createSourceChartCmdRun(cmd *cobra.Command, args []string) error { + name := args[0] + + if sourceChartArgs.source.Kind == "" || sourceChartArgs.source.Name == "" { + return fmt.Errorf("chart source is required") + } + + if sourceChartArgs.chart == "" { + return fmt.Errorf("chart name or path is required") + } + + logger.Generatef("generating HelmChart source") + + sourceLabels, err := parseLabels() + if err != nil { + return err + } + + helmChart := &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: *kubeconfigArgs.Namespace, + Labels: sourceLabels, + }, + Spec: sourcev1.HelmChartSpec{ + Chart: sourceChartArgs.chart, + Version: sourceChartArgs.chartVersion, + Interval: metav1.Duration{ + Duration: createArgs.interval, + }, + ReconcileStrategy: sourceChartArgs.reconcileStrategy, + SourceRef: sourcev1.LocalHelmChartSourceReference{ + Kind: sourceChartArgs.source.Kind, + Name: sourceChartArgs.source.Name, + }, + }, + } + + if provider := sourceChartArgs.verifyProvider.String(); provider != "" { + helmChart.Spec.Verify = &sourcev1.OCIRepositoryVerification{ + Provider: provider, + } + if secretName := sourceChartArgs.verifySecretRef; secretName != "" { + helmChart.Spec.Verify.SecretRef = &meta.LocalObjectReference{ + Name: secretName, + } + } + verifyIssuer := sourceChartArgs.verifyOIDCIssuer + verifySubject := sourceChartArgs.verifySubject + if verifyIssuer != "" || verifySubject != "" { + helmChart.Spec.Verify.MatchOIDCIdentity = []sourcev1.OIDCIdentityMatch{{ + Issuer: verifyIssuer, + Subject: verifySubject, + }} + } + } else if sourceChartArgs.verifySecretRef != "" { + return fmt.Errorf("a verification provider must be specified when a secret is specified") + } else if sourceChartArgs.verifyOIDCIssuer != "" || sourceOCIRepositoryArgs.verifySubject != "" { + return fmt.Errorf("a verification provider must be specified when OIDC issuer/subject is specified") + } + + if createArgs.export { + return printExport(exportHelmChart(helmChart)) + } + + logger.Actionf("applying HelmChart source") + + ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout) + defer cancel() + + kubeClient, err := utils.KubeClient(kubeconfigArgs, kubeclientOptions) + if err != nil { + return err + } + + namespacedName, err := upsertHelmChart(ctx, kubeClient, helmChart) + if err != nil { + return err + } + + logger.Waitingf("waiting for HelmChart source reconciliation") + readyConditionFunc := isObjectReadyConditionFunc(kubeClient, namespacedName, helmChart) + if err := wait.PollUntilContextTimeout(ctx, rootArgs.pollInterval, rootArgs.timeout, true, readyConditionFunc); err != nil { + return err + } + logger.Successf("HelmChart source reconciliation completed") + + if helmChart.Status.Artifact == nil { + return fmt.Errorf("HelmChart source reconciliation completed but no artifact was found") + } + logger.Successf("fetched revision: %s", helmChart.Status.Artifact.Revision) + return nil +} + +func upsertHelmChart(ctx context.Context, kubeClient client.Client, + helmChart *sourcev1.HelmChart) (types.NamespacedName, error) { + namespacedName := types.NamespacedName{ + Namespace: helmChart.GetNamespace(), + Name: helmChart.GetName(), + } + + var existing sourcev1.HelmChart + err := kubeClient.Get(ctx, namespacedName, &existing) + if err != nil { + if errors.IsNotFound(err) { + if err := kubeClient.Create(ctx, helmChart); err != nil { + return namespacedName, err + } else { + logger.Successf("source created") + return namespacedName, nil + } + } + return namespacedName, err + } + + existing.Labels = helmChart.Labels + existing.Spec = helmChart.Spec + if err := kubeClient.Update(ctx, &existing); err != nil { + return namespacedName, err + } + helmChart = &existing + logger.Successf("source updated") + return namespacedName, nil +} diff --git a/cmd/flux/create_source_chart_test.go b/cmd/flux/create_source_chart_test.go new file mode 100644 index 0000000000..9566708636 --- /dev/null +++ b/cmd/flux/create_source_chart_test.go @@ -0,0 +1,91 @@ +//go:build unit +// +build unit + +/* +Copyright 2024 The Flux authors + +Licensed 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. +*/ + +package main + +import "testing" + +func TestCreateSourceChart(t *testing.T) { + tmpl := map[string]string{ + "fluxns": allocateNamespace("flux-system"), + } + setupSourceChart(t, tmpl) + + tests := []struct { + name string + args string + assert assertFunc + }{ + { + name: "missing name", + args: "create source chart --export", + assert: assertError("name is required"), + }, + { + name: "missing source reference", + args: "create source chart podinfo --export ", + assert: assertError("chart source is required"), + }, + { + name: "missing chart name", + args: "create source chart podinfo --source helmrepository/podinfo --export", + assert: assertError("chart name or path is required"), + }, + { + name: "unknown source kind", + args: "create source chart podinfo --source foobar/podinfo --export", + assert: assertError(`invalid argument "foobar/podinfo" for "--source" flag: source kind 'foobar' is not supported, must be one of: HelmRepository, GitRepository, Bucket`), + }, + { + name: "basic chart", + args: "create source chart podinfo --source helmrepository/podinfo --chart podinfo --export", + assert: assertGoldenTemplateFile("testdata/create_source_chart/basic.yaml", tmpl), + }, + { + name: "chart with basic signature verification", + args: "create source chart podinfo --source helmrepository/podinfo --chart podinfo --verify-provider cosign --export", + assert: assertGoldenTemplateFile("testdata/create_source_chart/verify_basic.yaml", tmpl), + }, + { + name: "unknown signature verification provider", + args: "create source chart podinfo --source helmrepository/podinfo --chart podinfo --verify-provider foobar --export", + assert: assertError(`invalid argument "foobar" for "--verify-provider" flag: source OCI verify provider 'foobar' is not supported, must be one of: cosign`), + }, + { + name: "chart with complete signature verification", + args: "create source chart podinfo --source helmrepository/podinfo --chart podinfo --verify-provider cosign --verify-issuer foo --verify-subject bar --export", + assert: assertGoldenTemplateFile("testdata/create_source_chart/verify_complete.yaml", tmpl), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := cmdTestCase{ + args: tt.args + " -n " + tmpl["fluxns"], + assert: tt.assert, + } + cmd.runTestCmd(t) + }) + } +} + +func setupSourceChart(t *testing.T, tmpl map[string]string) { + t.Helper() + testEnv.CreateObjectFile("./testdata/create_source_chart/setup-source.yaml", tmpl, t) +} diff --git a/cmd/flux/delete_source_chart.go b/cmd/flux/delete_source_chart.go new file mode 100644 index 0000000000..db8f20907f --- /dev/null +++ b/cmd/flux/delete_source_chart.go @@ -0,0 +1,40 @@ +/* +Copyright 2024 The Flux authors + +Licensed 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. +*/ + +package main + +import ( + "github.com/spf13/cobra" + + sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" +) + +var deleteSourceChartCmd = &cobra.Command{ + Use: "chart [name]", + Short: "Delete a HelmChart source", + Long: "The delete source chart command deletes the given HelmChart from the cluster.", + Example: ` # Delete a HelmChart + flux delete source chart podinfo`, + ValidArgsFunction: resourceNamesCompletionFunc(sourcev1.GroupVersion.WithKind(sourcev1.HelmChartKind)), + RunE: deleteCommand{ + apiType: helmChartType, + object: universalAdapter{&sourcev1.HelmChart{}}, + }.run, +} + +func init() { + deleteSourceCmd.AddCommand(deleteSourceChartCmd) +} diff --git a/cmd/flux/export_source_chart.go b/cmd/flux/export_source_chart.go new file mode 100644 index 0000000000..ea9b720773 --- /dev/null +++ b/cmd/flux/export_source_chart.go @@ -0,0 +1,67 @@ +/* +Copyright 2024 The Flux authors + +Licensed 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. +*/ + +package main + +import ( + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + sourcev1 "github.com/fluxcd/source-controller/api/v1" +) + +var exportSourceChartCmd = &cobra.Command{ + Use: "chart [name]", + Short: "Export HelmChart sources in YAML format", + Long: withPreviewNote("The export source chart command exports one or all HelmChart sources in YAML format."), + Example: ` # Export all chart sources + flux export source chart --all > sources.yaml`, + ValidArgsFunction: resourceNamesCompletionFunc(sourcev1.GroupVersion.WithKind(sourcev1.HelmChartKind)), + RunE: exportCommand{ + list: helmChartListAdapter{&sourcev1.HelmChartList{}}, + object: helmChartAdapter{&sourcev1.HelmChart{}}, + }.run, +} + +func init() { + exportSourceCmd.AddCommand(exportSourceChartCmd) +} + +func exportHelmChart(source *sourcev1.HelmChart) interface{} { + gvk := sourcev1.GroupVersion.WithKind(sourcev1.HelmChartKind) + export := sourcev1.HelmChart{ + TypeMeta: metav1.TypeMeta{ + Kind: gvk.Kind, + APIVersion: gvk.GroupVersion().String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: source.Name, + Namespace: source.Namespace, + Labels: source.Labels, + Annotations: source.Annotations, + }, + Spec: source.Spec, + } + return export +} + +func (ex helmChartAdapter) export() interface{} { + return exportHelmChart(ex.HelmChart) +} + +func (ex helmChartListAdapter) exportItem(i int) interface{} { + return exportHelmChart(&ex.HelmChartList.Items[i]) +} diff --git a/cmd/flux/export_test.go b/cmd/flux/export_test.go index 85ae174aae..5488305b18 100644 --- a/cmd/flux/export_test.go +++ b/cmd/flux/export_test.go @@ -58,6 +58,12 @@ func TestExport(t *testing.T) { "testdata/export/git-repo.yaml", tmpl, }, + { + "source chart", + "export source chart flux-system", + "testdata/export/helm-chart.yaml", + tmpl, + }, { "source helm", "export source helm flux-system", diff --git a/cmd/flux/testdata/create_source_chart/basic.yaml b/cmd/flux/testdata/create_source_chart/basic.yaml new file mode 100644 index 0000000000..fc7dc51da9 --- /dev/null +++ b/cmd/flux/testdata/create_source_chart/basic.yaml @@ -0,0 +1,14 @@ +✚ generating HelmChart source +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmChart +metadata: + name: podinfo + namespace: {{ .fluxns }} +spec: + chart: podinfo + interval: 0s + reconcileStrategy: ChartVersion + sourceRef: + kind: HelmRepository + name: podinfo diff --git a/cmd/flux/testdata/create_source_chart/setup-source.yaml b/cmd/flux/testdata/create_source_chart/setup-source.yaml new file mode 100644 index 0000000000..18787c728d --- /dev/null +++ b/cmd/flux/testdata/create_source_chart/setup-source.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .fluxns }} +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: podinfo + namespace: {{ .fluxns }} +spec: + interval: 1m0s + provider: generic + type: oci + url: oci://ghcr.io/stefanprodan/charts diff --git a/cmd/flux/testdata/create_source_chart/verify_basic.yaml b/cmd/flux/testdata/create_source_chart/verify_basic.yaml new file mode 100644 index 0000000000..46ce5dedd8 --- /dev/null +++ b/cmd/flux/testdata/create_source_chart/verify_basic.yaml @@ -0,0 +1,16 @@ +✚ generating HelmChart source +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmChart +metadata: + name: podinfo + namespace: {{ .fluxns }} +spec: + chart: podinfo + interval: 0s + reconcileStrategy: ChartVersion + sourceRef: + kind: HelmRepository + name: podinfo + verify: + provider: cosign diff --git a/cmd/flux/testdata/create_source_chart/verify_complete.yaml b/cmd/flux/testdata/create_source_chart/verify_complete.yaml new file mode 100644 index 0000000000..73b32b024b --- /dev/null +++ b/cmd/flux/testdata/create_source_chart/verify_complete.yaml @@ -0,0 +1,19 @@ +✚ generating HelmChart source +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmChart +metadata: + name: podinfo + namespace: {{ .fluxns }} +spec: + chart: podinfo + interval: 0s + reconcileStrategy: ChartVersion + sourceRef: + kind: HelmRepository + name: podinfo + verify: + matchOIDCIdentity: + - issuer: foo + subject: bar + provider: cosign diff --git a/cmd/flux/testdata/export/helm-chart.yaml b/cmd/flux/testdata/export/helm-chart.yaml new file mode 100644 index 0000000000..7300917c52 --- /dev/null +++ b/cmd/flux/testdata/export/helm-chart.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmChart +metadata: + name: flux-system + namespace: {{ .fluxns }} +spec: + chart: podinfo + interval: 1m0s + reconcileStrategy: ChartVersion + sourceRef: + kind: HelmRepository + name: podinfo + version: '*' diff --git a/cmd/flux/testdata/export/objects.yaml b/cmd/flux/testdata/export/objects.yaml index 40405d0bc6..40bac8d546 100644 --- a/cmd/flux/testdata/export/objects.yaml +++ b/cmd/flux/testdata/export/objects.yaml @@ -124,6 +124,20 @@ spec: timeout: 1m0s url: https://stefanprodan.github.io/podinfo --- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmChart +metadata: + name: flux-system + namespace: {{ .fluxns }} +spec: + chart: podinfo + interval: 1m0s + reconcileStrategy: ChartVersion + sourceRef: + kind: HelmRepository + name: podinfo + version: '*' +--- apiVersion: helm.toolkit.fluxcd.io/v2beta2 kind: HelmRelease metadata: diff --git a/internal/flags/local_helm_chart_source.go b/internal/flags/local_helm_chart_source.go new file mode 100644 index 0000000000..19d2e5305c --- /dev/null +++ b/internal/flags/local_helm_chart_source.go @@ -0,0 +1,70 @@ +/* +Copyright 2024 The Flux authors + +Licensed 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. +*/ + +package flags + +import ( + "fmt" + "strings" + + "github.com/fluxcd/flux2/v2/internal/utils" +) + +type LocalHelmChartSource struct { + Kind string + Name string +} + +func (s *LocalHelmChartSource) String() string { + if s.Name == "" { + return "" + } + return fmt.Sprintf("%s/%s", s.Kind, s.Name) +} + +func (s *LocalHelmChartSource) Set(str string) error { + if strings.TrimSpace(str) == "" { + return fmt.Errorf("no helm chart source given, please specify %s", + s.Description()) + } + + sourceKind, sourceName := utils.ParseObjectKindName(str) + if sourceKind == "" || sourceName == "" { + return fmt.Errorf("invalid helm chart source '%s', must be in format /", str) + } + cleanSourceKind, ok := utils.ContainsEqualFoldItemString(supportedHelmChartSourceKinds, sourceKind) + if !ok { + return fmt.Errorf("source kind '%s' is not supported, must be one of: %s", + sourceKind, strings.Join(supportedHelmChartSourceKinds, ", ")) + } + + s.Kind = cleanSourceKind + s.Name = sourceName + + return nil +} + +func (s *LocalHelmChartSource) Type() string { + return "helmChartSource" +} + +func (s *LocalHelmChartSource) Description() string { + return fmt.Sprintf( + "source that contains the chart in the format '/', "+ + "where kind must be one of: (%s)", + strings.Join(supportedHelmChartSourceKinds, ", "), + ) +}