diff --git a/e2e/nomostest/helm/helm.go b/e2e/nomostest/helm/helm.go index 8b1f868d86..52a6ce9545 100644 --- a/e2e/nomostest/helm/helm.go +++ b/e2e/nomostest/helm/helm.go @@ -18,36 +18,34 @@ import ( "fmt" "os" "path/filepath" - "strconv" "strings" - "time" + "github.com/ettle/strcase" + "github.com/google/uuid" "kpt.dev/configsync/e2e" "kpt.dev/configsync/e2e/nomostest" + "kpt.dev/configsync/e2e/nomostest/testlogger" "kpt.dev/configsync/e2e/nomostest/testshell" "sigs.k8s.io/kustomize/kyaml/copyutil" + "sigs.k8s.io/yaml" ) -// PrivateARHelmRegistry is the registry URL to the private AR used for testing. -// Cannot be assigned as a global variable due to GCPProject pointer, which gets -// evaluated after initialization. -func PrivateARHelmRegistry() string { - return fmt.Sprintf("oci://us-docker.pkg.dev/%s/config-sync-test-ar-helm", *e2e.GCPProject) -} - -// PrivateARHelmHost is the host name of the private AR used for testing -var PrivateARHelmHost = "https://us-docker.pkg.dev" - // RemoteHelmChart represents a remote OCI-based helm chart type RemoteHelmChart struct { // Shell is a helper utility to execute shell commands in a test. Shell *testshell.TestShell - // Host is the host URL, e.g. https://us-docker.pkg.dev - Host string + // Logger to write logs to + Logger *testlogger.TestLogger + + // Project in which to store the chart image + Project string - // Registry is the registry URL, e.g. oci://us-docker.pkg.dev/oss-prow-build-kpt-config-sync/config-sync-test-ar-helm - Registry string + // Location to store the chart image + Location string + + // RepositoryName in which to store the chart imageS + RepositoryName string // ChartName is the name of the helm chart ChartName string @@ -55,59 +53,146 @@ type RemoteHelmChart struct { // ChartVersion is the version of the helm chart ChartVersion string - // Dir is a local directory from which RemoteHelmChart will read, package, and push the chart from - Dir string + // LocalChartPath is a local directory from which RemoteHelmChart will read, package, and push the chart from + LocalChartPath string } -// NewRemoteHelmChart creates a RemoteHelmChart -func NewRemoteHelmChart(shell *testshell.TestShell, host, registry, dir, chartName, version string) *RemoteHelmChart { - return &RemoteHelmChart{ - Shell: shell, - Host: host, - Registry: registry, - Dir: dir, - ChartName: chartName, - ChartVersion: version, +// CreateRepository uses gcloud to create the repository, if it doesn't exist. +func (r *RemoteHelmChart) CreateRepository() error { + out, err := r.Shell.ExecWithDebug("gcloud", "artifacts", "repositories", + "describe", r.RepositoryName, + "--location", r.Location) + if err != nil { + if !strings.Contains(string(out), "NOT_FOUND") { + return fmt.Errorf("failed to describe image repository: %w", err) + } + // repository does not exist, continue with creation + } else { + // repository already exists, skip creation + return nil + } + + r.Logger.Info("Creating image repository") + _, err = r.Shell.ExecWithDebug("gcloud", "artifacts", "repositories", + "create", r.RepositoryName, + "--repository-format", "docker", + "--location", r.Location) + if err != nil { + return fmt.Errorf("failed to create image repository: %w", err) } + return nil } // RegistryLogin will log into the registry host specified by r.Host using local gcloud credentials func (r *RemoteHelmChart) RegistryLogin() error { var err error authCmd := r.Shell.Command("gcloud", "auth", "print-access-token") - loginCmd := r.Shell.Command("helm", "registry", "login", "-uoauth2accesstoken", "--password-stdin", r.Host) + loginCmd := r.Shell.Command("helm", "registry", "login", + "-uoauth2accesstoken", "--password-stdin", + fmt.Sprintf("https://%s", r.RegistryHost())) loginCmd.Stdin, err = authCmd.StdoutPipe() if err != nil { - return fmt.Errorf("failed to setup command pipe: %v", err) + return fmt.Errorf("creating STDOUT pipe: %w", err) } if err := loginCmd.Start(); err != nil { - return fmt.Errorf("failed to start login command: %v", err) + return fmt.Errorf("starting login command: %w", err) } if err := authCmd.Run(); err != nil { - return fmt.Errorf("failed to run auth command: %v", err) + return fmt.Errorf("running print-access-token command: %w", err) } if err := loginCmd.Wait(); err != nil { - return fmt.Errorf("failed to wait for login command: %v", err) + return fmt.Errorf("waiting for login command: %w", err) + } + return nil +} + +// ConfigureAuthHelper configures the local docker client to use gcloud for +// image registry authorization. Helm uses the docker config. +func (r *RemoteHelmChart) ConfigureAuthHelper() error { + r.Logger.Info("Updating Docker config to use gcloud for auth") + if _, err := r.Shell.ExecWithDebug("gcloud", "auth", "configure-docker", r.RegistryHost()); err != nil { + return fmt.Errorf("failed to configure docker auth: %w", err) } return nil } +// RegistryHost returns the domain of the artifact registry +func (r *RemoteHelmChart) RegistryHost() string { + return fmt.Sprintf("%s-docker.pkg.dev", r.Location) +} + +// RepositoryAddress returns the domain and path to the chart repository +func (r *RemoteHelmChart) RepositoryAddress() string { + return fmt.Sprintf("%s/%s/%s", r.RegistryHost(), r.Project, r.RepositoryName) +} + +// RepositoryOCI returns the repository address with the oci:// scheme prefix. +func (r *RemoteHelmChart) RepositoryOCI() string { + return fmt.Sprintf("oci://%s", r.RepositoryAddress()) +} + +// ChartAddress returns the domain and path to the chart image +func (r *RemoteHelmChart) ChartAddress() string { + return fmt.Sprintf("%s/%s", r.RepositoryAddress(), r.ChartName) +} + // CopyChartFromLocal accepts a local path to a helm chart and recursively copies it to r.Dir, modifying // the name of the copied chart from its original name to r.ChartName -func (r *RemoteHelmChart) CopyChartFromLocal(chartPath, originalChartName string) error { - if err := copyutil.CopyDir(chartPath, r.Dir); err != nil { - return fmt.Errorf("failed to copy helm chart: %v", err) +func (r *RemoteHelmChart) CopyChartFromLocal(chartPath string) error { + r.Logger.Infof("Copying helm chart from test artifacts: %s", chartPath) + if err := os.MkdirAll(r.LocalChartPath, os.ModePerm); err != nil { + return fmt.Errorf("creating helm chart directory: %v", err) + } + if err := copyutil.CopyDir(chartPath, r.LocalChartPath); err != nil { + return fmt.Errorf("copying helm chart: %v", err) } - if err := findAndReplaceInFile(filepath.Join(r.Dir, "Chart.yaml"), fmt.Sprintf("name: %s", originalChartName), fmt.Sprintf("name: %s", r.ChartName)); err != nil { - return fmt.Errorf("failed to rename helm chart: %v", err) + r.Logger.Infof("Updating helm chart name & version: %s:%s", r.ChartName, r.ChartVersion) + chartFilePath := filepath.Join(r.LocalChartPath, "Chart.yaml") + err := updateYAMLFile(chartFilePath, func(chartMap map[string]interface{}) error { + chartMap["name"] = r.ChartName + chartMap["version"] = r.ChartVersion + return nil + }) + if err != nil { + return fmt.Errorf("updating Chart.yaml: %v", err) } return nil } -// UpdateVersion updates the local version of the helm chart to version +func updateYAMLFile(name string, updateFn func(map[string]interface{}) error) error { + chartBytes, err := os.ReadFile(name) + if err != nil { + return fmt.Errorf("reading file: %s: %w", name, err) + } + var chartManifest map[string]interface{} + if err := yaml.Unmarshal(chartBytes, &chartManifest); err != nil { + return fmt.Errorf("parsing yaml file: %s: %w", name, err) + } + if err := updateFn(chartManifest); err != nil { + return fmt.Errorf("updating yaml map for %s: %w", name, err) + } + chartBytes, err = yaml.Marshal(chartManifest) + if err != nil { + return fmt.Errorf("formatting yaml for %s: %w", name, err) + } + if err := os.WriteFile(name, chartBytes, os.ModePerm); err != nil { + return fmt.Errorf("writing file: %s: %w", name, err) + } + return nil +} + +// UpdateVersion updates the local version of the helm chart to the specified +// version with a timestamp suffix func (r *RemoteHelmChart) UpdateVersion(version string) error { - if err := findAndReplaceInFile(filepath.Join(r.Dir, "Chart.yaml"), fmt.Sprintf("version: %s", r.ChartVersion), fmt.Sprintf("version: %s", version)); err != nil { - return fmt.Errorf("failed to update helm chart version: %v", err) + version = generateChartVersion(version) + r.Logger.Infof("Updating helm chart version to %q", version) + chartFilePath := filepath.Join(r.LocalChartPath, "Chart.yaml") + err := updateYAMLFile(chartFilePath, func(chartMap map[string]interface{}) error { + chartMap["version"] = version + return nil + }) + if err != nil { + return fmt.Errorf("updating Chart.yaml: %v", err) } r.ChartVersion = version return nil @@ -115,77 +200,102 @@ func (r *RemoteHelmChart) UpdateVersion(version string) error { // Push will package and push the helm chart located at r.Dir to the remote registry. func (r *RemoteHelmChart) Push() error { - if _, err := r.Shell.Helm("package", r.Dir, "--destination", r.Dir); err != nil { - return fmt.Errorf("failed to package helm chart: %v", err) + r.Logger.Infof("Packaging helm chart: %s:%s", r.ChartName, r.ChartVersion) + parentPath := filepath.Dir(r.LocalChartPath) + if _, err := r.Shell.Helm("package", r.LocalChartPath, "--destination", parentPath+string(filepath.Separator)); err != nil { + return fmt.Errorf("packaging helm chart: %w", err) } - chartFile := filepath.Join(r.Dir, fmt.Sprintf("%s-%s.tgz", r.ChartName, r.ChartVersion)) - if out, err := r.Shell.Helm("push", chartFile, r.Registry); err != nil { - return fmt.Errorf("failed to run `helm push`: %s; %v", string(out), err) + r.Logger.Infof("Pushing helm chart: %s:%s", r.ChartName, r.ChartVersion) + chartFile := filepath.Join(parentPath, fmt.Sprintf("%s-%s.tgz", r.ChartName, r.ChartVersion)) + if _, err := r.Shell.Helm("push", chartFile, r.RepositoryOCI()); err != nil { + return fmt.Errorf("pushing helm chart: %w", err) + } + if err := os.Remove(chartFile); err != nil { + return fmt.Errorf("deleting local helm chart package: %w", err) } return nil } -// PushHelmChart pushes a new helm chart for use during an e2e test. Returns a reference to the RemoteHelmChart object -// and any errors that are encountered. -func PushHelmChart(nt *nomostest.NT, helmchart, version string) (*RemoteHelmChart, error) { - nt.T.Log("Push helm chart to the artifact registry") +// Delete the package from the remote registry, including all versions and tags. +func (r *RemoteHelmChart) Delete() error { + r.Logger.Infof("Deleting helm chart: %s", r.ChartName) + if _, err := r.Shell.ExecWithDebug("gcloud", "artifacts", "docker", "images", "delete", r.ChartAddress(), "--delete-tags"); err != nil { + return fmt.Errorf("deleting helm chart image from registry: %w", err) + } + return nil +} - chartName := generateChartName(helmchart, nt.ClusterName) +// PushHelmChart pushes a new helm chart for use during an e2e test. +// Returns a reference to the RemoteHelmChart object and any errors. +func PushHelmChart(nt *nomostest.NT, chartName, chartVersion string) (*RemoteHelmChart, error) { + if chartName == "" { + return nil, fmt.Errorf("chart name must not be empty") + } + if chartVersion == "" { + return nil, fmt.Errorf("chart version must not be empty") + } + chart := &RemoteHelmChart{ + Shell: nt.Shell, + Logger: nt.Logger, + Project: *e2e.GCPProject, + Location: "us", // store redundantly across regions in the US. + // Use cluster name to avoid overlap between images in parallel test runs. + RepositoryName: fmt.Sprintf("config-sync-e2e-test--%s", nt.ClusterName), + // Use chart name to avoid overlap between multiple charts in the same test. + LocalChartPath: filepath.Join(nt.TmpDir, chartName), + // Use test name and timestamp to avoid overlap between sequential test runs. + ChartName: generateChartName(chartName, strcase.ToKebab(nt.T.Name())), + ChartVersion: generateChartVersion(chartVersion), + } nt.T.Cleanup(func() { - if err := cleanHelmImages(nt, chartName); err != nil { + if err := chart.Delete(); err != nil { nt.T.Errorf(err.Error()) } }) - - remoteHelmChart := NewRemoteHelmChart(nt.Shell, PrivateARHelmHost, PrivateARHelmRegistry(), nt.TmpDir, chartName, version) - err := remoteHelmChart.CopyChartFromLocal(fmt.Sprintf("../testdata/helm-charts/%s", helmchart), helmchart) - if err != nil { + artifactPath := fmt.Sprintf("../testdata/helm-charts/%s", chartName) + if err := chart.CopyChartFromLocal(artifactPath); err != nil { return nil, err } - if err := remoteHelmChart.RegistryLogin(); err != nil { - return nil, fmt.Errorf("failed to login to the helm registry: %v", err) + if err := chart.CreateRepository(); err != nil { + return nil, err } - if err := remoteHelmChart.Push(); err != nil { + // TODO: Figure out why gcloud auth doesn't always work with helm push (401 Unauthorized) on new repositories + // if err := chart.ConfigureAuthHelper(); err != nil { + // return nil, err + // } + if err := chart.RegistryLogin(); err != nil { return nil, err } - - return remoteHelmChart, nil + if err := chart.Push(); err != nil { + return nil, err + } + return chart, nil } -// creates a chart name from current project id, cluster name, and timestamp -func generateChartName(helmchart, clusterName string) string { - chartName := fmt.Sprintf("%s-%s-%s", helmchart, clusterName, timestampAsString()) - if len(chartName) > 50 { - // the chartName + releaseName is used as the metadata.name of resources in the coredns helm chart, so we must trim this down - // to keep it under the k8s length limit - chartName = chartName[len(chartName)-50:] +// creates a chart name from the chart name, test name, and timestamp. +// Result will be no more than 40 characters and can function as a k8s metadata.name. +// Chart name and version must be less than 63 characters combined. +func generateChartName(chartName, testName string) string { + if len(chartName) > 20 { + chartName = chartName[:20] chartName = strings.Trim(chartName, "-") } - return chartName -} - -// removes helm charts created during e2e testing -func cleanHelmImages(nt *nomostest.NT, chartName string) error { - if out, err := nt.Shell.Command("gcloud", "artifacts", "docker", "images", "delete", fmt.Sprintf("us-docker.pkg.dev/%s/config-sync-test-ar-helm/%s", *e2e.GCPProject, chartName), "--delete-tags").CombinedOutput(); err != nil { - return fmt.Errorf("failed to cleanup helm chart image from registry: %s; %v", string(out), err) + chartName = fmt.Sprintf("%s-%s", chartName, testName) + if len(chartName) > 40 { + chartName = chartName[:40] + chartName = strings.Trim(chartName, "-") } - return nil + return chartName } -// finds and replaces particular text string in a file -func findAndReplaceInFile(path, old, new string) error { - oldFile, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("could not read file: %v", err) - } - err = os.WriteFile(path, []byte(strings.ReplaceAll(string(oldFile), old, new)), 0644) - if err != nil { - return fmt.Errorf("could not write to file: %v", err) +// generateChartVersion returns the version with a random 8 character suffix. +// Result will be no more than 20 characters and can function as a k8s metadata.name. +// Chart name and version must be less than 63 characters combined. +func generateChartVersion(chartVersion string) string { + if len(chartVersion) > 12 { + chartVersion = chartVersion[:12] + chartVersion = strings.Trim(strings.Trim(chartVersion, "-"), ".") } - return nil -} - -// returns the current unix timestamp as a string -func timestampAsString() string { - return strconv.FormatInt(time.Now().Unix(), 10) + return fmt.Sprintf("%s-%s", chartVersion, + strings.ReplaceAll(uuid.NewString(), "-", "")[:7]) } diff --git a/e2e/nomostest/name.go b/e2e/nomostest/name.go index c5dd7ed995..fb528f15ea 100644 --- a/e2e/nomostest/name.go +++ b/e2e/nomostest/name.go @@ -18,17 +18,13 @@ import ( "crypto/sha1" "encoding/hex" "fmt" - "regexp" "strings" + "github.com/ettle/strcase" "k8s.io/apimachinery/pkg/util/validation" "kpt.dev/configsync/e2e/nomostest/testing" ) -// re splits strings at word boundaries. Test names always begin with "Test", -// so we know we aren't missing any of the text. -var re = regexp.MustCompile(`[A-Z][^A-Z]*`) - // TestClusterName returns the name of the test cluster. func TestClusterName(t testing.NTB) string { t.Helper() @@ -58,14 +54,9 @@ func testDirName(t testing.NTB) string { t.Helper() n := t.Name() - // Capital letters are forbidden in Kind cluster names, so convert to - // kebab-case. - words := re.FindAllString(n, -1) - for i, w := range words { - words[i] = strings.ToLower(w) - } - - n = strings.Join(words, "-") + // Capital letters are forbidden in Kind cluster names + n = strcase.ToKebab(n) + // Slashes are forbidden in file paths. n = strings.ReplaceAll(n, "/", "--") return n } diff --git a/e2e/testcases/csr_auth_test.go b/e2e/testcases/csr_auth_test.go index bd3e20000f..6863594e9a 100644 --- a/e2e/testcases/csr_auth_test.go +++ b/e2e/testcases/csr_auth_test.go @@ -314,13 +314,15 @@ func testWorkloadIdentity(t *testing.T, testSpec workloadIdentityTestSpec) { // For helm charts, we need to push the chart to the AR before configuring the RootSync if testSpec.sourceType == v1beta1.HelmSource { - remoteHelmChart, err := helm.PushHelmChart(nt, privateCoreDNSHelmChart, privateCoreDNSHelmChartVersion) + chart, err := helm.PushHelmChart(nt, testSpec.sourceChart, testSpec.sourceVersion) if err != nil { nt.T.Fatalf("failed to push helm chart: %v", err) } - testSpec.sourceChart = remoteHelmChart.ChartName - testSpec.rootCommitFn = helmChartVersion(testSpec.sourceVersion) + testSpec.sourceRepo = chart.RepositoryOCI() + testSpec.sourceChart = chart.ChartName + testSpec.sourceVersion = chart.ChartVersion + testSpec.rootCommitFn = helmChartVersion(chart.ChartVersion) } // Reuse the RootSync instead of creating a new one so that testing resources can be cleaned up after the test. diff --git a/e2e/testcases/helm_sync_test.go b/e2e/testcases/helm_sync_test.go index 00b7d8de7d..3a823b1680 100644 --- a/e2e/testcases/helm_sync_test.go +++ b/e2e/testcases/helm_sync_test.go @@ -23,6 +23,7 @@ import ( "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/pointer" + "kpt.dev/configsync/e2e" "kpt.dev/configsync/e2e/nomostest" "kpt.dev/configsync/e2e/nomostest/gitproviders" "kpt.dev/configsync/e2e/nomostest/helm" @@ -397,16 +398,16 @@ func TestHelmDefaultNamespace(t *testing.T) { rs := fake.RootSyncObjectV1Beta1(configsync.RootSyncName) - remoteHelmChart, err := helm.PushHelmChart(nt, privateSimpleHelmChart, privateSimpleHelmChartVersion) + chart, err := helm.PushHelmChart(nt, privateSimpleHelmChart, privateSimpleHelmChartVersion) if err != nil { nt.T.Fatalf("failed to push helm chart: %v", err) } nt.T.Log("Update RootSync to sync from a private Artifact Registry") nt.MustMergePatch(rs, fmt.Sprintf(`{"spec": {"sourceType": "%s", "git": null, "helm": {"repo": "%s", "chart": "%s", "version": "%s", "auth": "gcpserviceaccount", "gcpServiceAccountEmail": "%s", "namespace": "", "deployNamespace": ""}}}`, - v1beta1.HelmSource, helm.PrivateARHelmRegistry(), remoteHelmChart.ChartName, privateSimpleHelmChartVersion, gsaARReaderEmail())) - err = nt.WatchForAllSyncs(nomostest.WithRootSha1Func(helmChartVersion(privateSimpleHelmChartVersion)), - nomostest.WithSyncDirectoryMap(map[types.NamespacedName]string{nomostest.DefaultRootRepoNamespacedName: remoteHelmChart.ChartName})) + v1beta1.HelmSource, chart.RepositoryOCI(), chart.ChartName, chart.ChartVersion, gsaARReaderEmail())) + err = nt.WatchForAllSyncs(nomostest.WithRootSha1Func(helmChartVersion(chart.ChartVersion)), + nomostest.WithSyncDirectoryMap(map[types.NamespacedName]string{nomostest.DefaultRootRepoNamespacedName: chart.ChartName})) if err != nil { nt.T.Fatal(err) } @@ -442,41 +443,41 @@ func TestHelmLatestVersion(t *testing.T) { ) rs := fake.RootSyncObjectV1Beta1(configsync.RootSyncName) - remoteHelmChart, err := helm.PushHelmChart(nt, privateSimpleHelmChart, privateSimpleHelmChartVersion) + chart, err := helm.PushHelmChart(nt, privateSimpleHelmChart, privateSimpleHelmChartVersion) if err != nil { nt.T.Fatalf("failed to push helm chart: %v", err) } nt.T.Log("Update RootSync to sync from a private Artifact Registry") nt.MustMergePatch(rs, fmt.Sprintf(`{"spec": {"sourceType": "%s", "helm": {"chart": "%s", "repo": "%s", "version": "", "period": "5s", "auth": "gcpserviceaccount", "gcpServiceAccountEmail": "%s", "deployNamespace": "simple"}, "git": null}}`, - v1beta1.HelmSource, remoteHelmChart.ChartName, helm.PrivateARHelmRegistry(), gsaARReaderEmail())) + v1beta1.HelmSource, chart.ChartName, chart.RepositoryOCI(), gsaARReaderEmail())) if err = nt.Watcher.WatchObject(kinds.Deployment(), "deploy-default", "simple", - []testpredicates.Predicate{testpredicates.HasLabel("version", privateSimpleHelmChartVersion)}); err != nil { + []testpredicates.Predicate{testpredicates.HasLabel("version", chart.ChartVersion)}); err != nil { nt.T.Error(err) } // helm-sync automatically detects and updates to the new helm chart version newVersion := "2.5.9" - if err := remoteHelmChart.UpdateVersion(newVersion); err != nil { + if err := chart.UpdateVersion(newVersion); err != nil { nt.T.Fatal(err) } - if err := remoteHelmChart.Push(); err != nil { + if err := chart.Push(); err != nil { nt.T.Fatal("failed to push helm chart update: %v", err) } if err = nt.Watcher.WatchObject(kinds.Deployment(), "deploy-default", "simple", - []testpredicates.Predicate{testpredicates.HasLabel("version", newVersion)}); err != nil { + []testpredicates.Predicate{testpredicates.HasLabel("version", chart.ChartVersion)}); err != nil { nt.T.Error(err) } newVersion = "3.0.0" - if err := remoteHelmChart.UpdateVersion(newVersion); err != nil { + if err := chart.UpdateVersion(newVersion); err != nil { nt.T.Fatal(err) } - if err := remoteHelmChart.Push(); err != nil { + if err := chart.Push(); err != nil { nt.T.Fatal("failed to push helm chart update: %v", err) } if err = nt.Watcher.WatchObject(kinds.Deployment(), "deploy-default", "simple", - []testpredicates.Predicate{testpredicates.HasLabel("version", newVersion)}); err != nil { + []testpredicates.Predicate{testpredicates.HasLabel("version", chart.ChartVersion)}); err != nil { nt.T.Error(err) } } @@ -523,27 +524,27 @@ func TestHelmNamespaceRepo(t *testing.T) { nt.Must(nt.RootRepos[configsync.RootSyncName].CommitAndPush("Update RepoSync to sync from a public Helm Chart with cluster-scoped type")) nt.WaitForRepoSyncSourceError(repoSyncNN.Namespace, repoSyncNN.Name, nonhierarchical.BadScopeErrCode, "must be Namespace-scoped type") - remoteHelmChart, err := helm.PushHelmChart(nt, privateNSHelmChart, privateNSHelmChartVersion) + chart, err := helm.PushHelmChart(nt, privateNSHelmChart, privateNSHelmChartVersion) if err != nil { nt.T.Fatalf("failed to push helm chart: %v", err) } nt.T.Log("Update RepoSync to sync from a private Artifact Registry") rs.Spec.Helm = &v1beta1.HelmRepoSync{HelmBase: v1beta1.HelmBase{ - Repo: helm.PrivateARHelmRegistry(), - Chart: remoteHelmChart.ChartName, + Repo: chart.RepositoryOCI(), + Chart: chart.ChartName, Auth: configsync.AuthGCPServiceAccount, GCPServiceAccountEmail: gsaARReaderEmail(), - Version: privateNSHelmChartVersion, + Version: chart.ChartVersion, ReleaseName: "test", }} nt.Must(nt.RootRepos[configsync.RootSyncName].Add(nomostest.StructuredNSPath(repoSyncNN.Namespace, repoSyncNN.Name), rs)) nt.Must(nt.RootRepos[configsync.RootSyncName].CommitAndPush("Update RepoSync to sync from a private Helm Chart without cluster scoped resources")) - err = nt.WatchForAllSyncs(nomostest.WithRepoSha1Func(helmChartVersion(privateNSHelmChartVersion)), nomostest.WithSyncDirectoryMap(map[types.NamespacedName]string{repoSyncNN: remoteHelmChart.ChartName})) + err = nt.WatchForAllSyncs(nomostest.WithRepoSha1Func(helmChartVersion(chart.ChartVersion)), nomostest.WithSyncDirectoryMap(map[types.NamespacedName]string{repoSyncNN: chart.ChartName})) if err != nil { nt.T.Fatal(err) } - if err := nt.Validate(rs.Spec.Helm.ReleaseName+"-"+remoteHelmChart.ChartName, testNs, &appsv1.Deployment{}); err != nil { + if err := nt.Validate(rs.Spec.Helm.ReleaseName+"-"+chart.ChartName, testNs, &appsv1.Deployment{}); err != nil { nt.T.Error(err) } } @@ -561,7 +562,7 @@ func TestHelmConfigMapNamespaceRepo(t *testing.T) { rs := nomostest.RepoSyncObjectV1Beta1FromNonRootRepo(nt, repoSyncNN) cmName := "helm-cm-ns-repo-1" - remoteHelmChart, err := helm.PushHelmChart(nt, privateNSHelmChart, privateNSHelmChartVersion) + chart, err := helm.PushHelmChart(nt, privateNSHelmChart, privateNSHelmChartVersion) if err != nil { nt.T.Fatalf("failed to push helm chart: %v", err) } @@ -569,11 +570,11 @@ func TestHelmConfigMapNamespaceRepo(t *testing.T) { nt.T.Log("Update RepoSync to sync from a private Artifact Registry") rs.Spec.SourceType = string(v1beta1.HelmSource) rs.Spec.Helm = &v1beta1.HelmRepoSync{HelmBase: v1beta1.HelmBase{ - Repo: helm.PrivateARHelmRegistry(), - Chart: remoteHelmChart.ChartName, + Repo: chart.RepositoryOCI(), + Chart: chart.ChartName, Auth: configsync.AuthGCPServiceAccount, GCPServiceAccountEmail: gsaARReaderEmail(), - Version: privateNSHelmChartVersion, + Version: chart.ChartVersion, ReleaseName: "test", ValuesFileRefs: []v1beta1.ValuesFileRef{{Name: cmName, DataKey: "foo.yaml"}}, }} @@ -643,11 +644,13 @@ func TestHelmConfigMapNamespaceRepo(t *testing.T) { nt.Must(nt.RootRepos[configsync.RootSyncName].Add(nomostest.StructuredNSPath(repoSyncNN.Namespace, repoSyncNN.Name), rs)) nt.Must(nt.RootRepos[configsync.RootSyncName].CommitAndPush("Update RepoSync to reference new ConfigMap")) - err = nt.WatchForAllSyncs(nomostest.WithRepoSha1Func(helmChartVersion(privateNSHelmChartVersion)), nomostest.WithSyncDirectoryMap(map[types.NamespacedName]string{repoSyncNN: remoteHelmChart.ChartName})) + err = nt.WatchForAllSyncs( + nomostest.WithRepoSha1Func(helmChartVersion(chart.ChartVersion)), + nomostest.WithSyncDirectoryMap(map[types.NamespacedName]string{repoSyncNN: chart.ChartName})) if err != nil { nt.T.Fatal(err) } - if err := nt.Validate(rs.Spec.Helm.ReleaseName+"-"+remoteHelmChart.ChartName, testNs, &appsv1.Deployment{}, + if err := nt.Validate(rs.Spec.Helm.ReleaseName+"-"+chart.ChartName, testNs, &appsv1.Deployment{}, testpredicates.HasLabel("labelsTest", "foo")); err != nil { nt.T.Fatal(err) } @@ -671,7 +674,6 @@ func TestHelmARFleetWISameProject(t *testing.T) { testWorkloadIdentity(t, workloadIdentityTestSpec{ fleetWITest: true, crossProject: false, - sourceRepo: helm.PrivateARHelmRegistry(), sourceVersion: privateCoreDNSHelmChartVersion, sourceChart: privateCoreDNSHelmChart, sourceType: v1beta1.HelmSource, @@ -699,7 +701,6 @@ func TestHelmARFleetWIDifferentProject(t *testing.T) { testWorkloadIdentity(t, workloadIdentityTestSpec{ fleetWITest: true, crossProject: true, - sourceRepo: helm.PrivateARHelmRegistry(), sourceVersion: privateCoreDNSHelmChartVersion, sourceChart: privateCoreDNSHelmChart, sourceType: v1beta1.HelmSource, @@ -726,7 +727,6 @@ func TestHelmARGKEWorkloadIdentity(t *testing.T) { testWorkloadIdentity(t, workloadIdentityTestSpec{ fleetWITest: false, crossProject: false, - sourceRepo: helm.PrivateARHelmRegistry(), sourceVersion: privateCoreDNSHelmChartVersion, sourceChart: privateCoreDNSHelmChart, sourceType: v1beta1.HelmSource, @@ -744,7 +744,7 @@ func TestHelmGCENode(t *testing.T) { nt := nomostest.New(t, nomostesting.SyncSource, ntopts.Unstructured, ntopts.RequireGKE(t), ntopts.GCENodeTest) - remoteHelmChart, err := helm.PushHelmChart(nt, privateCoreDNSHelmChart, privateCoreDNSHelmChartVersion) + chart, err := helm.PushHelmChart(nt, privateCoreDNSHelmChart, privateCoreDNSHelmChartVersion) if err != nil { nt.T.Fatalf("failed to push helm chart: %v", err) } @@ -752,13 +752,14 @@ func TestHelmGCENode(t *testing.T) { rs := fake.RootSyncObjectV1Beta1(configsync.RootSyncName) nt.T.Log("Update RootSync to sync from a private Artifact Registry") nt.MustMergePatch(rs, fmt.Sprintf(`{"spec": {"sourceType": "%s", "helm": {"repo": "%s", "chart": "%s", "auth": "gcenode", "version": "%s", "releaseName": "my-coredns", "namespace": "coredns"}, "git": null}}`, - v1beta1.HelmSource, helm.PrivateARHelmRegistry(), remoteHelmChart.ChartName, privateCoreDNSHelmChartVersion)) - err = nt.WatchForAllSyncs(nomostest.WithRootSha1Func(helmChartVersion(privateCoreDNSHelmChartVersion)), - nomostest.WithSyncDirectoryMap(map[types.NamespacedName]string{nomostest.DefaultRootRepoNamespacedName: remoteHelmChart.ChartName})) + v1beta1.HelmSource, chart.RepositoryOCI(), chart.ChartName, chart.ChartVersion)) + err = nt.WatchForAllSyncs( + nomostest.WithRootSha1Func(helmChartVersion(chart.ChartVersion)), + nomostest.WithSyncDirectoryMap(map[types.NamespacedName]string{nomostest.DefaultRootRepoNamespacedName: chart.ChartName})) if err != nil { nt.T.Fatal(err) } - if err := nt.Validate(fmt.Sprintf("my-coredns-%s", remoteHelmChart.ChartName), "coredns", &appsv1.Deployment{}, + if err := nt.Validate(fmt.Sprintf("my-coredns-%s", chart.ChartName), "coredns", &appsv1.Deployment{}, testpredicates.DeploymentContainerPullPolicyEquals("coredns", "IfNotPresent")); err != nil { nt.T.Error(err) } @@ -791,24 +792,96 @@ func TestHelmARTokenAuth(t *testing.T) { nt.MustKubectl("delete", "secret", "foo", "-n", v1.NSConfigManagementSystem, "--ignore-not-found") }) - remoteHelmChart, err := helm.PushHelmChart(nt, privateCoreDNSHelmChart, privateCoreDNSHelmChartVersion) + chart, err := helm.PushHelmChart(nt, privateCoreDNSHelmChart, privateCoreDNSHelmChartVersion) if err != nil { nt.T.Fatalf("failed to push helm chart: %v", err) } nt.T.Log("Update RootSync to sync from a private Artifact Registry") nt.MustMergePatch(rs, fmt.Sprintf(`{"spec": {"sourceType": "%s", "git": null, "helm": {"repo": "%s", "chart": "%s", "auth": "token", "version": "%s", "releaseName": "my-coredns", "namespace": "coredns", "secretRef": {"name" : "foo"}}}}`, - v1beta1.HelmSource, helm.PrivateARHelmRegistry(), remoteHelmChart.ChartName, privateCoreDNSHelmChartVersion)) - err = nt.WatchForAllSyncs(nomostest.WithRootSha1Func(helmChartVersion(privateCoreDNSHelmChartVersion)), - nomostest.WithSyncDirectoryMap(map[types.NamespacedName]string{nomostest.DefaultRootRepoNamespacedName: remoteHelmChart.ChartName})) + v1beta1.HelmSource, chart.RepositoryOCI(), chart.ChartName, chart.ChartVersion)) + err = nt.WatchForAllSyncs( + nomostest.WithRootSha1Func(helmChartVersion(chart.ChartVersion)), + nomostest.WithSyncDirectoryMap(map[types.NamespacedName]string{nomostest.DefaultRootRepoNamespacedName: chart.ChartName})) if err != nil { nt.T.Fatal(err) } - if err := nt.Validate(fmt.Sprintf("my-coredns-%s", remoteHelmChart.ChartName), "coredns", &appsv1.Deployment{}); err != nil { + if err := nt.Validate(fmt.Sprintf("my-coredns-%s", chart.ChartName), "coredns", &appsv1.Deployment{}); err != nil { nt.T.Error(err) } } +// TestHelmEmptyChart verifies Config Sync can apply an empty Helm chart. +// +// Requirements: +// 1. Helm auth https://cloud.google.com/artifact-registry/docs/helm/authentication +// 2. gcloud auth login OR gcloud auth activate-service-account ACCOUNT --key-file=KEY-FILE +// 3. Artifact Registry repo: us-docker.pkg.dev/${GCP_PROJECT}/config-sync-test-private +// 4. GKE cluster with Workload Identity +// 5. Google Service Account: e2e-test-ar-reader@${GCP_PROJECT}.iam.gserviceaccount.com +// 6. IAM for the GSA to read from the Artifact Registry repo +// 7. IAM for the test runner to write to Artifact Registry repo +// 8. gcloud & helm +func TestHelmEmptyChart(t *testing.T) { + nt := nomostest.New(t, + nomostesting.SyncSource, + ntopts.Unstructured, + ntopts.RequireGKE(t), + ) + + // Verify workload identity is enabled on the cluster + expectedPool := fmt.Sprintf("%s.svc.id.goog", *e2e.GCPProject) + if workloadPool, err := getWorkloadPool(nt); err != nil { + nt.T.Fatal(err) + } else if workloadPool != expectedPool { + nt.T.Fatalf("expected workloadPool %s but got %s", expectedPool, workloadPool) + } + + // Validate that the GCP Service Account exists. + // GSA is used for CS to read the Helm Chart from Artifact Registry. + // The local test user will also need to be able to push to Artifact Registry. + gsaEmail := gsaARReaderEmail() + if _, err := describeGCPServiceAccount(nt, gsaEmail, *e2e.GCPProject); err != nil { + nt.T.Fatalf("failed to get service account for workload identity: %v", err) + } + + chart, err := helm.PushHelmChart(nt, "empty", "v1.0.0") + if err != nil { + nt.T.Fatalf("failed to push helm chart: %v", err) + } + + nt.T.Logf("Updating RootSync to sync from the Helm chart: %s:%s", chart.ChartName, chart.ChartVersion) + rs := fake.RootSyncObjectV1Beta1(configsync.RootSyncName) + nt.MustMergePatch(rs, fmt.Sprintf(`{ + "spec": { + "sourceType": %q, + "helm": { + "repo": %q, + "chart": %q, + "version": %q, + "auth": "gcpserviceaccount", + "gcpServiceAccountEmail": %q + }, + "git": null + } + }`, + v1beta1.HelmSource, + chart.RepositoryOCI(), + chart.ChartName, + chart.ChartVersion, + gsaEmail)) + + // Validate that the chart syncs without error + err = nt.WatchForAllSyncs( + nomostest.WithRootSha1Func(helmChartVersion(chart.ChartVersion)), + nomostest.WithSyncDirectoryMap(map[types.NamespacedName]string{ + nomostest.DefaultRootRepoNamespacedName: chart.ChartName, + })) + if err != nil { + nt.T.Fatal(err) + } +} + func helmChartVersion(chartVersion string) nomostest.Sha1Func { return func(*nomostest.NT, types.NamespacedName) (string, error) { return chartVersion, nil diff --git a/e2e/testdata/helm-charts/empty/Chart.yaml b/e2e/testdata/helm-charts/empty/Chart.yaml new file mode 100644 index 0000000000..be117b90dc --- /dev/null +++ b/e2e/testdata/helm-charts/empty/Chart.yaml @@ -0,0 +1,19 @@ +# Copyright 2023 Google LLC +# +# 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. + +apiVersion: v1 +appVersion: "1.0" +description: An empty chart with no resources +name: empty +version: 1.0.0 diff --git a/go.mod b/go.mod index 54c7b1f377..3058a7d6dd 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/Masterminds/semver v1.5.0 github.com/Masterminds/semver/v3 v3.2.1 github.com/davecgh/go-spew v1.1.1 + github.com/ettle/strcase v0.1.1 github.com/evanphx/json-patch v4.12.0+incompatible github.com/go-logr/logr v1.2.3 github.com/golang/protobuf v1.5.3 diff --git a/go.sum b/go.sum index b9d08aa124..a306b96aa2 100644 --- a/go.sum +++ b/go.sum @@ -113,6 +113,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/ettle/strcase v0.1.1 h1:htFueZyVeE1XNnMEfbqp5r67qAN/4r6ya1ysq8Q+Zcw= +github.com/ettle/strcase v0.1.1/go.mod h1:hzDLsPC7/lwKyBOywSHEP89nt2pDgdy+No1NBA9o9VY= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go index bd970c6df1..8eeb0135d1 100644 --- a/pkg/helm/helm.go +++ b/pkg/helm/helm.go @@ -182,7 +182,7 @@ func (h *Hydrator) getChartVersion(ctx context.Context) error { if err := os.RemoveAll(helmCacheHome); err != nil { // we don't necessarily need to exit on error here, as it is possible that the later rendering // step could still succeed, so we just log the error and continue - klog.Infof("failed to clear helm cache: %w\n", err) + klog.Infof("failed to clear helm cache: %v", err) } klog.Infoln("using chart version: ", h.Version) return nil @@ -304,6 +304,11 @@ func (h *Hydrator) HelmTemplate(ctx context.Context) error { return fmt.Errorf("failed to render the helm chart: %w, stdout: %s", err, string(out)) } + // Create the repo/chart directory, in case the chart is empty. + if err := os.MkdirAll(filepath.Join(destDir, h.Chart), os.ModePerm); err != nil { + return fmt.Errorf("failed to create chart directory: %w", err) + } + if err := h.setDeployNamespace(destDir); err != nil { return fmt.Errorf("failed to set the deploy namespace: %w", err) } diff --git a/vendor/github.com/ettle/strcase/.gitignore b/vendor/github.com/ettle/strcase/.gitignore new file mode 100644 index 0000000000..54bc1fbff4 --- /dev/null +++ b/vendor/github.com/ettle/strcase/.gitignore @@ -0,0 +1,18 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# CPU and memory profiles +*.prof + +# Dependency directories +vendor/ diff --git a/vendor/github.com/ettle/strcase/.golangci.yml b/vendor/github.com/ettle/strcase/.golangci.yml new file mode 100644 index 0000000000..4d31fcc5b4 --- /dev/null +++ b/vendor/github.com/ettle/strcase/.golangci.yml @@ -0,0 +1,88 @@ +linters-settings: + dupl: + threshold: 100 + gocyclo: + min-complexity: 15 + gocritic: + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + disabled-checks: + - ifElseChain + - whyNoLint + - wrapperFunc + golint: + min-confidence: 0.5 + govet: + check-shadowing: true + lll: + line-length: 140 + maligned: + suggest-new: true + misspell: + locale: US + nolintlint: + allow-leading-space: false + allow-unused: false + require-specific: true + + require-explanation: true + allow-no-explanation: + - gocyclo + +linters: + disable-all: true + enable: + - bodyclose + - deadcode + - depguard + - dogsled + - dupl + - errcheck + - gochecknoinits + - gocritic + - gocyclo + - gofmt + - goimports + - golint + - goprintffuncname + - gosec + - gosimple + - govet + - ineffassign + - interfacer + - lll + - misspell + - nakedret + - nolintlint + - rowserrcheck + - staticcheck + - structcheck + - stylecheck + - typecheck + - unconvert + - unparam + - unused + - varcheck + - whitespace + + # don't enable: + # - asciicheck + # - gochecknoglobals + # - gocognit + # - godot + # - godox + # - goerr113 + # - maligned + # - nestif + # - prealloc + # - testpackage + # - wsl + +issues: + exclude-use-default: false + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/vendor/github.com/ettle/strcase/.readme.tmpl b/vendor/github.com/ettle/strcase/.readme.tmpl new file mode 100644 index 0000000000..135765c40a --- /dev/null +++ b/vendor/github.com/ettle/strcase/.readme.tmpl @@ -0,0 +1,80 @@ +{{with .PDoc}} +# Go Strcase + +[![Go Report Card](https://goreportcard.com/badge/github.com/ettle/strcase)](https://goreportcard.com/report/github.com/ettle/strcase) +[![Coverage](http://gocover.io/_badge/github.com/ettle/strcase?0)](http://gocover.io/github.com/ettle/strcase) +[![GoDoc](https://godoc.org/github.com/ettle/strcase?status.svg)](https://pkg.go.dev/github.com/ettle/strcase) + +Convert strings to `snake_case`, `camelCase`, `PascalCase`, `kebab-case` and more! Supports Go initialisms, customization, and Unicode. + +`import "{{.ImportPath}}"` + +## Overview +{{comment_md .Doc}} +{{example_html $ ""}} + +## Index{{if .Consts}} +* [Constants](#pkg-constants){{end}}{{if .Vars}} +* [Variables](#pkg-variables){{end}}{{- range .Funcs -}}{{$name_html := html .Name}} +* [{{node_html $ .Decl false | sanitize}}](#{{$name_html}}){{- end}}{{- range .Types}}{{$tname_html := html .Name}} +* [type {{$tname_html}}](#{{$tname_html}}){{- range .Funcs}}{{$name_html := html .Name}} + * [{{node_html $ .Decl false | sanitize}}](#{{$name_html}}){{- end}}{{- range .Methods}}{{$name_html := html .Name}} + * [{{node_html $ .Decl false | sanitize}}](#{{$tname_html}}.{{$name_html}}){{- end}}{{- end}}{{- if $.Notes}}{{- range $marker, $item := $.Notes}} +* [{{noteTitle $marker | html}}s](#pkg-note-{{$marker}}){{end}}{{end}} +{{if $.Examples}} +#### Examples{{- range $.Examples}} +* [{{example_name .Name}}](#example_{{.Name}}){{- end}}{{- end}} + +{{with .Consts}}## Constants +{{range .}}{{node $ .Decl | pre}} +{{comment_md .Doc}}{{end}}{{end}} +{{with .Vars}}## Variables +{{range .}}{{node $ .Decl | pre}} +{{comment_md .Doc}}{{end}}{{end}} + +{{range .Funcs}}{{$name_html := html .Name}}## func [{{$name_html}}]({{gh_url $ .Decl}}) +{{node $ .Decl | pre}} +{{comment_md .Doc}} +{{example_html $ .Name}} +{{callgraph_html $ "" .Name}}{{end}} +{{range .Types}}{{$tname := .Name}}{{$tname_html := html .Name}}## type [{{$tname_html}}]({{gh_url $ .Decl}}) +{{node $ .Decl | pre}} +{{comment_md .Doc}}{{range .Consts}} +{{node $ .Decl | pre }} +{{comment_md .Doc}}{{end}}{{range .Vars}} +{{node $ .Decl | pre }} +{{comment_md .Doc}}{{end}} + +{{example_html $ $tname}} +{{implements_html $ $tname}} +{{methodset_html $ $tname}} + +{{range .Funcs}}{{$name_html := html .Name}}### func [{{$name_html}}]({{gh_url $ .Decl}}) +{{node $ .Decl | pre}} +{{comment_md .Doc}} +{{example_html $ .Name}}{{end}} +{{callgraph_html $ "" .Name}} + +{{range .Methods}}{{$name_html := html .Name}}### func ({{md .Recv}}) [{{$name_html}}]({{gh_url $ .Decl}}) +{{node $ .Decl | pre}} +{{comment_md .Doc}} +{{$name := printf "%s_%s" $tname .Name}}{{example_html $ $name}} +{{callgraph_html $ .Recv .Name}} +{{end}}{{end}}{{end}} + +{{with $.Notes}} +{{range $marker, $content := .}} +## {{noteTitle $marker | html}}s + +{{end}} +{{end}} +{{if .Dirs}} +## Subdirectories +{{range $.Dirs.List}} +{{indent .Depth}}* [{{.Name | html}}]({{print "./" .Path}}){{if .Synopsis}} {{ .Synopsis}}{{end -}} +{{end}} +{{end}} diff --git a/vendor/github.com/ettle/strcase/LICENSE b/vendor/github.com/ettle/strcase/LICENSE new file mode 100644 index 0000000000..4f0116be2e --- /dev/null +++ b/vendor/github.com/ettle/strcase/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Liyan David Chang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/ettle/strcase/Makefile b/vendor/github.com/ettle/strcase/Makefile new file mode 100644 index 0000000000..462f8b473a --- /dev/null +++ b/vendor/github.com/ettle/strcase/Makefile @@ -0,0 +1,16 @@ +.PHONY: benchmark docs lint test + +docs: + which godoc2ghmd || ( go get github.com/DevotedHealth/godoc2ghmd && go mod tidy ) + godoc2ghmd -template .readme.tmpl github.com/ettle/strcase > README.md + +test: + go test -cover ./... + +lint: + which golangci-lint || ( go get github.com/golangci/golangci-lint/cmd/golangci-lint@v1.27.0 && go mod tidy ) + golangci-lint run + golangci-lint run benchmark/*.go + +benchmark: + cd benchmark && go test -bench=. -test.benchmem && go mod tidy diff --git a/vendor/github.com/ettle/strcase/README.md b/vendor/github.com/ettle/strcase/README.md new file mode 100644 index 0000000000..ee165e3e5e --- /dev/null +++ b/vendor/github.com/ettle/strcase/README.md @@ -0,0 +1,542 @@ + +# Go Strcase + +[![Go Report Card](https://goreportcard.com/badge/github.com/ettle/strcase)](https://goreportcard.com/report/github.com/ettle/strcase) +[![Coverage](http://gocover.io/_badge/github.com/ettle/strcase?0)](http://gocover.io/github.com/ettle/strcase) +[![GoDoc](https://godoc.org/github.com/ettle/strcase?status.svg)](https://pkg.go.dev/github.com/ettle/strcase) + +Convert strings to `snake_case`, `camelCase`, `PascalCase`, `kebab-case` and more! Supports Go initialisms, customization, and Unicode. + +`import "github.com/ettle/strcase"` + +## Overview +Package strcase is a package for converting strings into various word cases +(e.g. snake_case, camelCase) + + + go get -u github.com/ettle/strcase + +Example usage + + + strcase.ToSnake("Hello World") // hello_world + strcase.ToSNAKE("Hello World") // HELLO_WORLD + + strcase.ToKebab("helloWorld") // hello-world + strcase.ToKEBAB("helloWorld") // HELLO-WORLD + + strcase.ToPascal("hello-world") // HelloWorld + strcase.ToCamel("hello-world") // helloWorld + + // Handle odd cases + strcase.ToSnake("FOOBar") // foo_bar + + // Support Go initialisms + strcase.ToGoCamel("http_response") // HTTPResponse + + // Specify case and delimiter + strcase.ToCase("HelloWorld", strcase.UpperCase, '.') // HELLO.WORLD + +### Why this package +String strcase is pretty straight forward and there are a number of methods to +do it. This package is fully featured, more customizable, better tested, and +faster* than other packages and what you would probably whip up yourself. + +### Unicode support +We work for with unicode strings and pay very little performance penalty for it +as we optimized for the common use case of ASCII only strings. + +### Customization +You can create a custom caser that changes the behavior to what you want. This +customization also reduces the pressure for us to change the default behavior +which means that things are more stable for everyone involved. The goal is to +make the common path easy and fast, while making the uncommon path possible. + + + c := NewCaser( + // Use Go's default initialisms e.g. ID, HTML + true, + // Override initialisms (e.g. don't initialize HTML but initialize SSL + map[string]bool{"SSL": true, "HTML": false}, + // Write your own custom SplitFn + // + NewSplitFn( + []rune{'*', '.', ','}, + SplitCase, + SplitAcronym, + PreserveNumberFormatting, + SplitBeforeNumber, + SplitAfterNumber, + )) + assert.Equal(t, "http_200", c.ToSnake("http200")) + +### Initialism support +By default, we use the golint intialisms list. You can customize and override +the initialisms if you wish to add additional ones, such as "SSL" or "CMS" or +domain specific ones to your industry. + + + ToGoCamel("http_response") // HTTPResponse + ToGoSnake("http_response") // HTTP_response + +### Test coverage +We have a wide ranging test suite to make sure that we understand our behavior. +Test coverage isn't everything, but we aim for 100% coverage. + +### Fast +Optimized to reduce memory allocations with Builder. Benchmarked and optimized +around common cases. + +We're on par with the fastest packages (that have less features) and much +faster than others. We also benchmarked against code snippets. Using string +builders to reduce memory allocation and reordering boolean checks for the +common cases have a large performance impact. + +Hopefully I was fair to each library and happy to rerun benchmarks differently +or reword my commentary based on suggestions or updates. + + + // This package + // Go intialisms and custom casers are slower + BenchmarkToTitle-4 992491 1559 ns/op 32 B/op 1 allocs/op + BenchmarkToSnake-4 1000000 1475 ns/op 32 B/op 1 allocs/op + BenchmarkToSNAKE-4 1000000 1609 ns/op 32 B/op 1 allocs/op + BenchmarkToGoSnake-4 275010 3697 ns/op 44 B/op 4 allocs/op + BenchmarkToCustomCaser-4 342704 4191 ns/op 56 B/op 4 allocs/op + + // Segment has very fast snake case and camel case libraries + // No features or customization, but very very fast + BenchmarkSegment-4 1303809 938 ns/op 16 B/op 1 allocs/op + + // Stdlib strings.Title for comparison, even though it only splits on spaces + BenchmarkToTitleStrings-4 1213467 1164 ns/op 16 B/op 1 allocs/op + + // Other libraries or code snippets + // - Most are slower, by up to an order of magnitude + // - None support initialisms or customization + // - Some generate only camelCase or snake_case + // - Many lack unicode support + BenchmarkToSnakeStoewer-4 973200 2075 ns/op 64 B/op 2 allocs/op + // Copying small rune arrays is slow + BenchmarkToSnakeSiongui-4 264315 4229 ns/op 48 B/op 10 allocs/op + BenchmarkGoValidator-4 206811 5152 ns/op 184 B/op 9 allocs/op + // String alloction is slow + BenchmarkToSnakeFatih-4 82675 12280 ns/op 392 B/op 26 allocs/op + BenchmarkToSnakeIanColeman-4 83276 13903 ns/op 145 B/op 13 allocs/op + // Regexp is slow + BenchmarkToSnakeGolangPrograms-4 74448 18586 ns/op 176 B/op 11 allocs/op + + // These results aren't a surprise - my initial version of this library was + // painfully slow. I think most of us, without spending some time with + // profilers and benchmarks, would write also something on the slower side. + +### Why not this package +If every nanosecond matters and this is used in a tight loop, use segment.io's +libraries (https://github.com/segmentio/go-snakecase and +https://github.com/segmentio/go-camelcase). They lack features, but make up for +it by being blazing fast. Alternatively, if you need your code to work slightly +differently, fork them and tailor it for your use case. + +If you don't like having external imports, I get it. This package only imports +packages for testing, otherwise it only uses the standard library. If that's +not enough, you can use this repo as the foundation for your own. MIT Licensed. + +This package is still relatively new and while I've used it for a while +personally, it doesn't have the miles that other packages do. I've tested this +code agains't their test cases to make sure that there aren't any surprises. + +### Migrating from other packages +If you are migrating from from another package, you may find slight differences +in output. To reduce the delta, you may find it helpful to use the following +custom casers to mimic the behavior of the other package. + + + // From https://github.com/iancoleman/strcase + var c = NewCaser(false, nil, NewSplitFn([]rune{'_', '-', '.'}, SplitCase, SplitAcronym, SplitBeforeNumber)) + + // From https://github.com/stoewer/go-strcase + var c = NewCaser(false, nil, NewSplitFn([]rune{'_', '-'}, SplitCase), SplitAcronym) + + + + +## Index +* [func ToCamel(s string) string](#ToCamel) +* [func ToCase(s string, wordCase WordCase, delimiter rune) string](#ToCase) +* [func ToGoCamel(s string) string](#ToGoCamel) +* [func ToGoCase(s string, wordCase WordCase, delimiter rune) string](#ToGoCase) +* [func ToGoKebab(s string) string](#ToGoKebab) +* [func ToGoPascal(s string) string](#ToGoPascal) +* [func ToGoSnake(s string) string](#ToGoSnake) +* [func ToKEBAB(s string) string](#ToKEBAB) +* [func ToKebab(s string) string](#ToKebab) +* [func ToPascal(s string) string](#ToPascal) +* [func ToSNAKE(s string) string](#ToSNAKE) +* [func ToSnake(s string) string](#ToSnake) +* [type Caser](#Caser) + * [func NewCaser(goInitialisms bool, initialismOverrides map[string]bool, splitFn SplitFn) *Caser](#NewCaser) + * [func (c *Caser) ToCamel(s string) string](#Caser.ToCamel) + * [func (c *Caser) ToCase(s string, wordCase WordCase, delimiter rune) string](#Caser.ToCase) + * [func (c *Caser) ToKEBAB(s string) string](#Caser.ToKEBAB) + * [func (c *Caser) ToKebab(s string) string](#Caser.ToKebab) + * [func (c *Caser) ToPascal(s string) string](#Caser.ToPascal) + * [func (c *Caser) ToSNAKE(s string) string](#Caser.ToSNAKE) + * [func (c *Caser) ToSnake(s string) string](#Caser.ToSnake) +* [type SplitAction](#SplitAction) +* [type SplitFn](#SplitFn) + * [func NewSplitFn(delimiters []rune, splitOptions ...SplitOption) SplitFn](#NewSplitFn) +* [type SplitOption](#SplitOption) +* [type WordCase](#WordCase) + + + + + +## func [ToCamel](./strcase.go#L57) +``` go +func ToCamel(s string) string +``` +ToCamel returns words in camelCase (capitalized words concatenated together, with first word lower case). +Also known as lowerCamelCase or mixedCase. + + + +## func [ToCase](./strcase.go#L70) +``` go +func ToCase(s string, wordCase WordCase, delimiter rune) string +``` +ToCase returns words in given case and delimiter. + + + +## func [ToGoCamel](./strcase.go#L65) +``` go +func ToGoCamel(s string) string +``` +ToGoCamel returns words in camelCase (capitalized words concatenated together, with first word lower case). +Also known as lowerCamelCase or mixedCase. + +Respects Go's common initialisms (e.g. httpResponse -> HTTPResponse). + + + +## func [ToGoCase](./strcase.go#L77) +``` go +func ToGoCase(s string, wordCase WordCase, delimiter rune) string +``` +ToGoCase returns words in given case and delimiter. + +Respects Go's common initialisms (e.g. httpResponse -> HTTPResponse). + + + +## func [ToGoKebab](./strcase.go#L31) +``` go +func ToGoKebab(s string) string +``` +ToGoKebab returns words in kebab-case (lower case words with dashes). +Also known as dash-case. + +Respects Go's common initialisms (e.g. http-response -> HTTP-response). + + + +## func [ToGoPascal](./strcase.go#L51) +``` go +func ToGoPascal(s string) string +``` +ToGoPascal returns words in PascalCase (capitalized words concatenated together). +Also known as UpperPascalCase. + +Respects Go's common initialisms (e.g. HttpResponse -> HTTPResponse). + + + +## func [ToGoSnake](./strcase.go#L11) +``` go +func ToGoSnake(s string) string +``` +ToGoSnake returns words in snake_case (lower case words with underscores). + +Respects Go's common initialisms (e.g. http_response -> HTTP_response). + + + +## func [ToKEBAB](./strcase.go#L37) +``` go +func ToKEBAB(s string) string +``` +ToKEBAB returns words in KEBAB-CASE (upper case words with dashes). +Also known as SCREAMING-KEBAB-CASE or SCREAMING-DASH-CASE. + + + +## func [ToKebab](./strcase.go#L23) +``` go +func ToKebab(s string) string +``` +ToKebab returns words in kebab-case (lower case words with dashes). +Also known as dash-case. + + + +## func [ToPascal](./strcase.go#L43) +``` go +func ToPascal(s string) string +``` +ToPascal returns words in PascalCase (capitalized words concatenated together). +Also known as UpperPascalCase. + + + +## func [ToSNAKE](./strcase.go#L17) +``` go +func ToSNAKE(s string) string +``` +ToSNAKE returns words in SNAKE_CASE (upper case words with underscores). +Also known as SCREAMING_SNAKE_CASE or UPPER_CASE. + + + +## func [ToSnake](./strcase.go#L4) +``` go +func ToSnake(s string) string +``` +ToSnake returns words in snake_case (lower case words with underscores). + + + + +## type [Caser](./caser.go#L4-L7) +``` go +type Caser struct { + // contains filtered or unexported fields +} + +``` +Caser allows for customization of parsing and intialisms + + + + + + + +### func [NewCaser](./caser.go#L24) +``` go +func NewCaser(goInitialisms bool, initialismOverrides map[string]bool, splitFn SplitFn) *Caser +``` +NewCaser returns a configured Caser. + +A Caser should be created when you want fine grained control over how the words are split. + + + Notes on function arguments + + goInitialisms: Whether to use Golint's intialisms + + initialismOverrides: A mapping of extra initialisms + Keys must be in ALL CAPS. Merged with Golint's if goInitialisms is set. + Setting a key to false will override Golint's. + + splitFn: How to separate words + Override the default split function. Consider using NewSplitFn to + configure one instead of writing your own. + + + + + +### func (\*Caser) [ToCamel](./caser.go#L80) +``` go +func (c *Caser) ToCamel(s string) string +``` +ToCamel returns words in camelCase (capitalized words concatenated together, with first word lower case). +Also known as lowerCamelCase or mixedCase. + + + + +### func (\*Caser) [ToCase](./caser.go#L85) +``` go +func (c *Caser) ToCase(s string, wordCase WordCase, delimiter rune) string +``` +ToCase returns words with a given case and delimiter. + + + + +### func (\*Caser) [ToKEBAB](./caser.go#L68) +``` go +func (c *Caser) ToKEBAB(s string) string +``` +ToKEBAB returns words in KEBAB-CASE (upper case words with dashes). +Also known as SCREAMING-KEBAB-CASE or SCREAMING-DASH-CASE. + + + + +### func (\*Caser) [ToKebab](./caser.go#L62) +``` go +func (c *Caser) ToKebab(s string) string +``` +ToKebab returns words in kebab-case (lower case words with dashes). +Also known as dash-case. + + + + +### func (\*Caser) [ToPascal](./caser.go#L74) +``` go +func (c *Caser) ToPascal(s string) string +``` +ToPascal returns words in PascalCase (capitalized words concatenated together). +Also known as UpperPascalCase. + + + + +### func (\*Caser) [ToSNAKE](./caser.go#L56) +``` go +func (c *Caser) ToSNAKE(s string) string +``` +ToSNAKE returns words in SNAKE_CASE (upper case words with underscores). +Also known as SCREAMING_SNAKE_CASE or UPPER_CASE. + + + + +### func (\*Caser) [ToSnake](./caser.go#L50) +``` go +func (c *Caser) ToSnake(s string) string +``` +ToSnake returns words in snake_case (lower case words with underscores). + + + + +## type [SplitAction](./split.go#L110) +``` go +type SplitAction int +``` +SplitAction defines if and how to split a string + + +``` go +const ( + // Noop - Continue to next character + Noop SplitAction = iota + // Split - Split between words + // e.g. to split between wordsWithoutDelimiters + Split + // SkipSplit - Split the word and drop the character + // e.g. to split words with delimiters + SkipSplit + // Skip - Remove the character completely + Skip +) +``` + + + + + + + + + +## type [SplitFn](./split.go#L6) +``` go +type SplitFn func(prev, curr, next rune) SplitAction +``` +SplitFn defines how to split a string into words + + + + + + + +### func [NewSplitFn](./split.go#L14-L17) +``` go +func NewSplitFn( + delimiters []rune, + splitOptions ...SplitOption, +) SplitFn +``` +NewSplitFn returns a SplitFn based on the options provided. + +NewSplitFn covers the majority of common options that other strcase +libraries provide and should allow you to simply create a custom caser. +For more complicated use cases, feel free to write your own SplitFn +nolint:gocyclo + + + + + +## type [SplitOption](./split.go#L93) +``` go +type SplitOption int +``` +SplitOption are options that allow for configuring NewSplitFn + + +``` go +const ( + // SplitCase - FooBar -> Foo_Bar + SplitCase SplitOption = iota + // SplitAcronym - FOOBar -> Foo_Bar + // It won't preserve FOO's case. If you want, you can set the Caser's initialisms so FOO will be in all caps + SplitAcronym + // SplitBeforeNumber - port80 -> port_80 + SplitBeforeNumber + // SplitAfterNumber - 200status -> 200_status + SplitAfterNumber + // PreserveNumberFormatting - a.b.2,000.3.c -> a_b_2,000.3_c + PreserveNumberFormatting +) +``` + + + + + + + + + +## type [WordCase](./convert.go#L6) +``` go +type WordCase int +``` +WordCase is an enumeration of the ways to format a word. + + +``` go +const ( + // Original - Preserve the original input strcase + Original WordCase = iota + // LowerCase - All letters lower cased (example) + LowerCase + // UpperCase - All letters upper cased (EXAMPLE) + UpperCase + // TitleCase - Only first letter upper cased (Example) + TitleCase + // CamelCase - TitleCase except lower case first word (exampleText) + CamelCase +) +``` + + + + + + + + + + + + + diff --git a/vendor/github.com/ettle/strcase/caser.go b/vendor/github.com/ettle/strcase/caser.go new file mode 100644 index 0000000000..891a671897 --- /dev/null +++ b/vendor/github.com/ettle/strcase/caser.go @@ -0,0 +1,87 @@ +package strcase + +// Caser allows for customization of parsing and intialisms +type Caser struct { + initialisms map[string]bool + splitFn SplitFn +} + +// NewCaser returns a configured Caser. +// +// A Caser should be created when you want fine grained control over how the words are split. +// +// Notes on function arguments +// +// goInitialisms: Whether to use Golint's intialisms +// +// initialismOverrides: A mapping of extra initialisms +// Keys must be in ALL CAPS. Merged with Golint's if goInitialisms is set. +// Setting a key to false will override Golint's. +// +// splitFn: How to separate words +// Override the default split function. Consider using NewSplitFn to +// configure one instead of writing your own. +func NewCaser(goInitialisms bool, initialismOverrides map[string]bool, splitFn SplitFn) *Caser { + c := &Caser{ + initialisms: golintInitialisms, + splitFn: splitFn, + } + + if c.splitFn == nil { + c.splitFn = defaultSplitFn + } + + if goInitialisms && initialismOverrides != nil { + c.initialisms = map[string]bool{} + for k, v := range golintInitialisms { + c.initialisms[k] = v + } + for k, v := range initialismOverrides { + c.initialisms[k] = v + } + } else if !goInitialisms { + c.initialisms = initialismOverrides + } + + return c +} + +// ToSnake returns words in snake_case (lower case words with underscores). +func (c *Caser) ToSnake(s string) string { + return convert(s, c.splitFn, '_', LowerCase, c.initialisms) +} + +// ToSNAKE returns words in SNAKE_CASE (upper case words with underscores). +// Also known as SCREAMING_SNAKE_CASE or UPPER_CASE. +func (c *Caser) ToSNAKE(s string) string { + return convert(s, c.splitFn, '_', UpperCase, c.initialisms) +} + +// ToKebab returns words in kebab-case (lower case words with dashes). +// Also known as dash-case. +func (c *Caser) ToKebab(s string) string { + return convert(s, c.splitFn, '-', LowerCase, c.initialisms) +} + +// ToKEBAB returns words in KEBAB-CASE (upper case words with dashes). +// Also known as SCREAMING-KEBAB-CASE or SCREAMING-DASH-CASE. +func (c *Caser) ToKEBAB(s string) string { + return convert(s, c.splitFn, '-', UpperCase, c.initialisms) +} + +// ToPascal returns words in PascalCase (capitalized words concatenated together). +// Also known as UpperPascalCase. +func (c *Caser) ToPascal(s string) string { + return convert(s, c.splitFn, '\x00', TitleCase, c.initialisms) +} + +// ToCamel returns words in camelCase (capitalized words concatenated together, with first word lower case). +// Also known as lowerCamelCase or mixedCase. +func (c *Caser) ToCamel(s string) string { + return convert(s, c.splitFn, '\x00', CamelCase, c.initialisms) +} + +// ToCase returns words with a given case and delimiter. +func (c *Caser) ToCase(s string, wordCase WordCase, delimiter rune) string { + return convert(s, c.splitFn, delimiter, wordCase, c.initialisms) +} diff --git a/vendor/github.com/ettle/strcase/convert.go b/vendor/github.com/ettle/strcase/convert.go new file mode 100644 index 0000000000..70fedb1449 --- /dev/null +++ b/vendor/github.com/ettle/strcase/convert.go @@ -0,0 +1,297 @@ +package strcase + +import "strings" + +// WordCase is an enumeration of the ways to format a word. +type WordCase int + +const ( + // Original - Preserve the original input strcase + Original WordCase = iota + // LowerCase - All letters lower cased (example) + LowerCase + // UpperCase - All letters upper cased (EXAMPLE) + UpperCase + // TitleCase - Only first letter upper cased (Example) + TitleCase + // CamelCase - TitleCase except lower case first word (exampleText) + // Notably, even if the first word is an initialism, it will be lower + // cased. This is important for code generators where capital letters + // mean exported functions. i.e. jsonString(), not JSONString() + CamelCase +) + +// We have 3 convert functions for performance reasons +// The general convert could handle everything, but is not optimized +// +// The other two functions are optimized for the general use cases - that is the non-custom caser functions +// Case 1: Any Case and supports Go Initialisms +// Case 2: UpperCase words, which don't need to support initialisms since everything is in upper case + +// convertWithoutInitialims only works for to UpperCase and LowerCase +//nolint:gocyclo +func convertWithoutInitialisms(input string, delimiter rune, wordCase WordCase) string { + input = strings.TrimSpace(input) + runes := []rune(input) + if len(runes) == 0 { + return "" + } + + var b strings.Builder + b.Grow(len(input) * 2) // In case we need to write delimiters where they weren't before + + var prev, curr rune + next := runes[0] // 0 length will have already returned so safe to index + inWord := false + firstWord := true + for i := 0; i < len(runes); i++ { + prev = curr + curr = next + if i+1 == len(runes) { + next = 0 + } else { + next = runes[i+1] + } + + switch defaultSplitFn(prev, curr, next) { + case SkipSplit: + if inWord && delimiter != 0 { + b.WriteRune(delimiter) + } + inWord = false + continue + case Split: + if inWord && delimiter != 0 { + b.WriteRune(delimiter) + } + inWord = false + } + switch wordCase { + case UpperCase: + b.WriteRune(toUpper(curr)) + case LowerCase: + b.WriteRune(toLower(curr)) + case TitleCase: + if inWord { + b.WriteRune(toLower(curr)) + } else { + b.WriteRune(toUpper(curr)) + } + case CamelCase: + if inWord { + b.WriteRune(toLower(curr)) + } else if firstWord { + b.WriteRune(toLower(curr)) + firstWord = false + } else { + b.WriteRune(toUpper(curr)) + } + default: + // Must be original case + b.WriteRune(curr) + } + inWord = inWord || true + } + return b.String() +} + +// convertWithGoInitialisms changes a input string to a certain case with a +// delimiter, respecting go initialisms but not skip runes +//nolint:gocyclo +func convertWithGoInitialisms(input string, delimiter rune, wordCase WordCase) string { + input = strings.TrimSpace(input) + runes := []rune(input) + if len(runes) == 0 { + return "" + } + + var b strings.Builder + b.Grow(len(input) * 2) // In case we need to write delimiters where they weren't before + + firstWord := true + + addWord := func(start, end int) { + if start == end { + return + } + + if !firstWord && delimiter != 0 { + b.WriteRune(delimiter) + } + + // Don't bother with initialisms if the word is longer than 5 + // A quick proxy to avoid the extra memory allocations + if end-start <= 5 { + key := strings.ToUpper(string(runes[start:end])) + if golintInitialisms[key] { + if !firstWord || wordCase != CamelCase { + b.WriteString(key) + firstWord = false + return + } + } + } + + for i := start; i < end; i++ { + r := runes[i] + switch wordCase { + case UpperCase: + panic("use convertWithoutInitialisms instead") + case LowerCase: + b.WriteRune(toLower(r)) + case TitleCase: + if i == start { + b.WriteRune(toUpper(r)) + } else { + b.WriteRune(toLower(r)) + } + case CamelCase: + if !firstWord && i == start { + b.WriteRune(toUpper(r)) + } else { + b.WriteRune(toLower(r)) + } + default: + b.WriteRune(r) + } + } + firstWord = false + } + + var prev, curr rune + next := runes[0] // 0 length will have already returned so safe to index + wordStart := 0 + for i := 0; i < len(runes); i++ { + prev = curr + curr = next + if i+1 == len(runes) { + next = 0 + } else { + next = runes[i+1] + } + + switch defaultSplitFn(prev, curr, next) { + case Split: + addWord(wordStart, i) + wordStart = i + case SkipSplit: + addWord(wordStart, i) + wordStart = i + 1 + } + } + + if wordStart != len(runes) { + addWord(wordStart, len(runes)) + } + return b.String() +} + +// convert changes a input string to a certain case with a delimiter, +// respecting arbitrary initialisms and skip characters +//nolint:gocyclo +func convert(input string, fn SplitFn, delimiter rune, wordCase WordCase, + initialisms map[string]bool) string { + input = strings.TrimSpace(input) + runes := []rune(input) + if len(runes) == 0 { + return "" + } + + var b strings.Builder + b.Grow(len(input) * 2) // In case we need to write delimiters where they weren't before + + firstWord := true + var skipIndexes []int + + addWord := func(start, end int) { + // If you have nothing good to say, say nothing at all + if start == end || len(skipIndexes) == end-start { + skipIndexes = nil + return + } + + // If you have something to say, start with a delimiter + if !firstWord && delimiter != 0 { + b.WriteRune(delimiter) + } + + // Check if you're an initialism + // Note - we don't check skip characters here since initialisms + // will probably never have junk characters in between + // I'm open to it if there is a use case + if initialisms != nil { + var word strings.Builder + for i := start; i < end; i++ { + word.WriteRune(toUpper(runes[i])) + } + key := word.String() + if initialisms[key] { + if !firstWord || wordCase != CamelCase { + b.WriteString(key) + firstWord = false + return + } + } + } + + skipIdx := 0 + for i := start; i < end; i++ { + if len(skipIndexes) > 0 && skipIdx < len(skipIndexes) && i == skipIndexes[skipIdx] { + skipIdx++ + continue + } + r := runes[i] + switch wordCase { + case UpperCase: + b.WriteRune(toUpper(r)) + case LowerCase: + b.WriteRune(toLower(r)) + case TitleCase: + if i == start { + b.WriteRune(toUpper(r)) + } else { + b.WriteRune(toLower(r)) + } + case CamelCase: + if !firstWord && i == start { + b.WriteRune(toUpper(r)) + } else { + b.WriteRune(toLower(r)) + } + default: + b.WriteRune(r) + } + } + firstWord = false + skipIndexes = nil + } + + var prev, curr rune + next := runes[0] // 0 length will have already returned so safe to index + wordStart := 0 + for i := 0; i < len(runes); i++ { + prev = curr + curr = next + if i+1 == len(runes) { + next = 0 + } else { + next = runes[i+1] + } + + switch fn(prev, curr, next) { + case Skip: + skipIndexes = append(skipIndexes, i) + case Split: + addWord(wordStart, i) + wordStart = i + case SkipSplit: + addWord(wordStart, i) + wordStart = i + 1 + } + } + + if wordStart != len(runes) { + addWord(wordStart, len(runes)) + } + return b.String() +} diff --git a/vendor/github.com/ettle/strcase/doc.go b/vendor/github.com/ettle/strcase/doc.go new file mode 100644 index 0000000000..b898a4e45f --- /dev/null +++ b/vendor/github.com/ettle/strcase/doc.go @@ -0,0 +1,155 @@ +/* +Package strcase is a package for converting strings into various word cases +(e.g. snake_case, camelCase) + + go get -u github.com/ettle/strcase + +Example usage + + strcase.ToSnake("Hello World") // hello_world + strcase.ToSNAKE("Hello World") // HELLO_WORLD + + strcase.ToKebab("helloWorld") // hello-world + strcase.ToKEBAB("helloWorld") // HELLO-WORLD + + strcase.ToPascal("hello-world") // HelloWorld + strcase.ToCamel("hello-world") // helloWorld + + // Handle odd cases + strcase.ToSnake("FOOBar") // foo_bar + + // Support Go initialisms + strcase.ToGoPascal("http_response") // HTTPResponse + + // Specify case and delimiter + strcase.ToCase("HelloWorld", strcase.UpperCase, '.') // HELLO.WORLD + +Why this package + +String strcase is pretty straight forward and there are a number of methods to +do it. This package is fully featured, more customizable, better tested, and +faster* than other packages and what you would probably whip up yourself. + +Unicode support + +We work for with unicode strings and pay very little performance penalty for it +as we optimized for the common use case of ASCII only strings. + +Customization + +You can create a custom caser that changes the behavior to what you want. This +customization also reduces the pressure for us to change the default behavior +which means that things are more stable for everyone involved. The goal is to +make the common path easy and fast, while making the uncommon path possible. + + c := NewCaser( + // Use Go's default initialisms e.g. ID, HTML + true, + // Override initialisms (e.g. don't initialize HTML but initialize SSL + map[string]bool{"SSL": true, "HTML": false}, + // Write your own custom SplitFn + // + NewSplitFn( + []rune{'*', '.', ','}, + SplitCase, + SplitAcronym, + PreserveNumberFormatting, + SplitBeforeNumber, + SplitAfterNumber, + )) + assert.Equal(t, "http_200", c.ToSnake("http200")) + +Initialism support + +By default, we use the golint intialisms list. You can customize and override +the initialisms if you wish to add additional ones, such as "SSL" or "CMS" or +domain specific ones to your industry. + + ToGoPascal("http_response") // HTTPResponse + ToGoSnake("http_response") // HTTP_response + +Test coverage + +We have a wide ranging test suite to make sure that we understand our behavior. +Test coverage isn't everything, but we aim for 100% coverage. + +Fast + +Optimized to reduce memory allocations with Builder. Benchmarked and optimized +around common cases. + +We're on par with the fastest packages (that have less features) and much +faster than others. We also benchmarked against code snippets. Using string +builders to reduce memory allocation and reordering boolean checks for the +common cases have a large performance impact. + +Hopefully I was fair to each library and happy to rerun benchmarks differently +or reword my commentary based on suggestions or updates. + + // This package - faster then almost all libraries + // Initialisms are more complicated and slightly slower, but still faster then other libraries that do less + BenchmarkToTitle-4 7821166 221 ns/op 32 B/op 1 allocs/op + BenchmarkToSnake-4 9378589 202 ns/op 32 B/op 1 allocs/op + BenchmarkToSNAKE-4 6174453 223 ns/op 32 B/op 1 allocs/op + BenchmarkToGoSnake-4 3114266 434 ns/op 44 B/op 4 allocs/op + BenchmarkToCustomCaser-4 2973855 448 ns/op 56 B/op 4 allocs/op + + // Segment has very fast snake case and camel case libraries + // No features or customization, but very very fast + BenchmarkSegment-4 24003495 64.9 ns/op 16 B/op 1 allocs/op + + // Stdlib strings.Title for comparison, even though it only splits on spaces + BenchmarkToTitleStrings-4 11259376 161 ns/op 16 B/op 1 allocs/op + + // Other libraries or code snippets + // - Most are slower, by up to an order of magnitude + // - None support initialisms or customization + // - Some generate only camelCase or snake_case + // - Many lack unicode support + BenchmarkToSnakeStoewer-4 7103268 297 ns/op 64 B/op 2 allocs/op + // Copying small rune arrays is slow + BenchmarkToSnakeSiongui-4 3710768 413 ns/op 48 B/op 10 allocs/op + BenchmarkGoValidator-4 2416479 1049 ns/op 184 B/op 9 allocs/op + // String alloction is slow + BenchmarkToSnakeFatih-4 1000000 2407 ns/op 624 B/op 26 allocs/op + BenchmarkToSnakeIanColeman-4 1005766 1426 ns/op 160 B/op 13 allocs/op + // Regexp is slow + BenchmarkToSnakeGolangPrograms-4 614689 2237 ns/op 225 B/op 11 allocs/op + + + + // These results aren't a surprise - my initial version of this library was + // painfully slow. I think most of us, without spending some time with + // profilers and benchmarks, would write also something on the slower side. + + +Why not this package + +If every nanosecond matters and this is used in a tight loop, use segment.io's +libraries (https://github.com/segmentio/go-snakecase and +https://github.com/segmentio/go-camelcase). They lack features, but make up for +it by being blazing fast. Alternatively, if you need your code to work slightly +differently, fork them and tailor it for your use case. + +If you don't like having external imports, I get it. This package only imports +packages for testing, otherwise it only uses the standard library. If that's +not enough, you can use this repo as the foundation for your own. MIT Licensed. + +This package is still relatively new and while I've used it for a while +personally, it doesn't have the miles that other packages do. I've tested this +code agains't their test cases to make sure that there aren't any surprises. + +Migrating from other packages + +If you are migrating from from another package, you may find slight differences +in output. To reduce the delta, you may find it helpful to use the following +custom casers to mimic the behavior of the other package. + + // From https://github.com/iancoleman/strcase + var c = NewCaser(false, nil, NewSplitFn([]rune{'_', '-', '.'}, SplitCase, SplitAcronym, SplitBeforeNumber)) + + // From https://github.com/stoewer/go-strcase + var c = NewCaser(false, nil, NewSplitFn([]rune{'_', '-'}, SplitCase), SplitAcronym) + +*/ +package strcase diff --git a/vendor/github.com/ettle/strcase/initialism.go b/vendor/github.com/ettle/strcase/initialism.go new file mode 100644 index 0000000000..3c313d3e9a --- /dev/null +++ b/vendor/github.com/ettle/strcase/initialism.go @@ -0,0 +1,43 @@ +package strcase + +// golintInitialisms are the golint initialisms +var golintInitialisms = map[string]bool{ + "ACL": true, + "API": true, + "ASCII": true, + "CPU": true, + "CSS": true, + "DNS": true, + "EOF": true, + "GUID": true, + "HTML": true, + "HTTP": true, + "HTTPS": true, + "ID": true, + "IP": true, + "JSON": true, + "LHS": true, + "QPS": true, + "RAM": true, + "RHS": true, + "RPC": true, + "SLA": true, + "SMTP": true, + "SQL": true, + "SSH": true, + "TCP": true, + "TLS": true, + "TTL": true, + "UDP": true, + "UI": true, + "UID": true, + "UUID": true, + "URI": true, + "URL": true, + "UTF8": true, + "VM": true, + "XML": true, + "XMPP": true, + "XSRF": true, + "XSS": true, +} diff --git a/vendor/github.com/ettle/strcase/split.go b/vendor/github.com/ettle/strcase/split.go new file mode 100644 index 0000000000..84381106bc --- /dev/null +++ b/vendor/github.com/ettle/strcase/split.go @@ -0,0 +1,164 @@ +package strcase + +import "unicode" + +// SplitFn defines how to split a string into words +type SplitFn func(prev, curr, next rune) SplitAction + +// NewSplitFn returns a SplitFn based on the options provided. +// +// NewSplitFn covers the majority of common options that other strcase +// libraries provide and should allow you to simply create a custom caser. +// For more complicated use cases, feel free to write your own SplitFn +//nolint:gocyclo +func NewSplitFn( + delimiters []rune, + splitOptions ...SplitOption, +) SplitFn { + var splitCase, splitAcronym, splitBeforeNumber, splitAfterNumber, preserveNumberFormatting bool + + for _, option := range splitOptions { + switch option { + case SplitCase: + splitCase = true + case SplitAcronym: + splitAcronym = true + case SplitBeforeNumber: + splitBeforeNumber = true + case SplitAfterNumber: + splitAfterNumber = true + case PreserveNumberFormatting: + preserveNumberFormatting = true + } + } + + return func(prev, curr, next rune) SplitAction { + // The most common case will be that it's just a letter + // There are safe cases to process + if isLower(curr) && !isNumber(prev) { + return Noop + } + if isUpper(prev) && isUpper(curr) && isUpper(next) { + return Noop + } + + if preserveNumberFormatting { + if (curr == '.' || curr == ',') && + isNumber(prev) && isNumber(next) { + return Noop + } + } + + if unicode.IsSpace(curr) { + return SkipSplit + } + for _, d := range delimiters { + if curr == d { + return SkipSplit + } + } + + if splitBeforeNumber { + if isNumber(curr) && !isNumber(prev) { + if preserveNumberFormatting && (prev == '.' || prev == ',') { + return Noop + } + return Split + } + } + + if splitAfterNumber { + if isNumber(prev) && !isNumber(curr) { + return Split + } + } + + if splitCase { + if !isUpper(prev) && isUpper(curr) { + return Split + } + } + + if splitAcronym { + if isUpper(prev) && isUpper(curr) && isLower(next) { + return Split + } + } + + return Noop + } +} + +// SplitOption are options that allow for configuring NewSplitFn +type SplitOption int + +const ( + // SplitCase - FooBar -> Foo_Bar + SplitCase SplitOption = iota + // SplitAcronym - FOOBar -> Foo_Bar + // It won't preserve FOO's case. If you want, you can set the Caser's initialisms so FOO will be in all caps + SplitAcronym + // SplitBeforeNumber - port80 -> port_80 + SplitBeforeNumber + // SplitAfterNumber - 200status -> 200_status + SplitAfterNumber + // PreserveNumberFormatting - a.b.2,000.3.c -> a_b_2,000.3_c + PreserveNumberFormatting +) + +// SplitAction defines if and how to split a string +type SplitAction int + +const ( + // Noop - Continue to next character + Noop SplitAction = iota + // Split - Split between words + // e.g. to split between wordsWithoutDelimiters + Split + // SkipSplit - Split the word and drop the character + // e.g. to split words with delimiters + SkipSplit + // Skip - Remove the character completely + Skip +) + +//nolint:gocyclo +func defaultSplitFn(prev, curr, next rune) SplitAction { + // The most common case will be that it's just a letter so let lowercase letters return early since we know what they should do + if isLower(curr) { + return Noop + } + // Delimiters are _, -, ., and unicode spaces + // Handle . lower down as it needs to happen after number exceptions + if curr == '_' || curr == '-' || isSpace(curr) { + return SkipSplit + } + + if isUpper(curr) { + if isLower(prev) { + // fooBar + return Split + } else if isUpper(prev) && isLower(next) { + // FOOBar + return Split + } + } + + // Do numeric exceptions last to avoid perf penalty + if unicode.IsNumber(prev) { + // v4.3 is not split + if (curr == '.' || curr == ',') && unicode.IsNumber(next) { + return Noop + } + if !unicode.IsNumber(curr) && curr != '.' { + return Split + } + } + // While period is a default delimiter, keep it down here to avoid + // penalty for other delimiters + if curr == '.' { + return SkipSplit + } + + return Noop +} diff --git a/vendor/github.com/ettle/strcase/strcase.go b/vendor/github.com/ettle/strcase/strcase.go new file mode 100644 index 0000000000..46b4f7a684 --- /dev/null +++ b/vendor/github.com/ettle/strcase/strcase.go @@ -0,0 +1,81 @@ +package strcase + +// ToSnake returns words in snake_case (lower case words with underscores). +func ToSnake(s string) string { + return convertWithoutInitialisms(s, '_', LowerCase) +} + +// ToGoSnake returns words in snake_case (lower case words with underscores). +// +// Respects Go's common initialisms (e.g. http_response -> HTTP_response). +func ToGoSnake(s string) string { + return convertWithGoInitialisms(s, '_', LowerCase) +} + +// ToSNAKE returns words in SNAKE_CASE (upper case words with underscores). +// Also known as SCREAMING_SNAKE_CASE or UPPER_CASE. +func ToSNAKE(s string) string { + return convertWithoutInitialisms(s, '_', UpperCase) +} + +// ToKebab returns words in kebab-case (lower case words with dashes). +// Also known as dash-case. +func ToKebab(s string) string { + return convertWithoutInitialisms(s, '-', LowerCase) +} + +// ToGoKebab returns words in kebab-case (lower case words with dashes). +// Also known as dash-case. +// +// Respects Go's common initialisms (e.g. http-response -> HTTP-response). +func ToGoKebab(s string) string { + return convertWithGoInitialisms(s, '-', LowerCase) +} + +// ToKEBAB returns words in KEBAB-CASE (upper case words with dashes). +// Also known as SCREAMING-KEBAB-CASE or SCREAMING-DASH-CASE. +func ToKEBAB(s string) string { + return convertWithoutInitialisms(s, '-', UpperCase) +} + +// ToPascal returns words in PascalCase (capitalized words concatenated together). +// Also known as UpperPascalCase. +func ToPascal(s string) string { + return convertWithoutInitialisms(s, 0, TitleCase) +} + +// ToGoPascal returns words in PascalCase (capitalized words concatenated together). +// Also known as UpperPascalCase. +// +// Respects Go's common initialisms (e.g. HttpResponse -> HTTPResponse). +func ToGoPascal(s string) string { + return convertWithGoInitialisms(s, 0, TitleCase) +} + +// ToCamel returns words in camelCase (capitalized words concatenated together, with first word lower case). +// Also known as lowerCamelCase or mixedCase. +func ToCamel(s string) string { + return convertWithoutInitialisms(s, 0, CamelCase) +} + +// ToGoCamel returns words in camelCase (capitalized words concatenated together, with first word lower case). +// Also known as lowerCamelCase or mixedCase. +// +// Respects Go's common initialisms, but first word remains lowercased which is +// important for code generator use cases (e.g. toJson -> toJSON, httpResponse +// -> httpResponse). +func ToGoCamel(s string) string { + return convertWithGoInitialisms(s, 0, CamelCase) +} + +// ToCase returns words in given case and delimiter. +func ToCase(s string, wordCase WordCase, delimiter rune) string { + return convertWithoutInitialisms(s, delimiter, wordCase) +} + +// ToGoCase returns words in given case and delimiter. +// +// Respects Go's common initialisms (e.g. httpResponse -> HTTPResponse). +func ToGoCase(s string, wordCase WordCase, delimiter rune) string { + return convertWithGoInitialisms(s, delimiter, wordCase) +} diff --git a/vendor/github.com/ettle/strcase/unicode.go b/vendor/github.com/ettle/strcase/unicode.go new file mode 100644 index 0000000000..b75e25a512 --- /dev/null +++ b/vendor/github.com/ettle/strcase/unicode.go @@ -0,0 +1,48 @@ +package strcase + +import "unicode" + +// Unicode functions, optimized for the common case of ascii +// No performance lost by wrapping since these functions get inlined by the compiler + +func isUpper(r rune) bool { + return unicode.IsUpper(r) +} + +func isLower(r rune) bool { + return unicode.IsLower(r) +} + +func isNumber(r rune) bool { + if r >= '0' && r <= '9' { + return true + } + return unicode.IsNumber(r) +} + +func isSpace(r rune) bool { + if r == ' ' || r == '\t' || r == '\n' || r == '\r' { + return true + } else if r < 128 { + return false + } + return unicode.IsSpace(r) +} + +func toUpper(r rune) rune { + if r >= 'a' && r <= 'z' { + return r - 32 + } else if r < 128 { + return r + } + return unicode.ToUpper(r) +} + +func toLower(r rune) rune { + if r >= 'A' && r <= 'Z' { + return r + 32 + } else if r < 128 { + return r + } + return unicode.ToLower(r) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index abe40dee96..6e64145e68 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -108,6 +108,9 @@ github.com/docker/docker-credential-helpers/credentials ## explicit; go 1.13 github.com/emicklei/go-restful/v3 github.com/emicklei/go-restful/v3/log +# github.com/ettle/strcase v0.1.1 +## explicit; go 1.12 +github.com/ettle/strcase # github.com/evanphx/json-patch v4.12.0+incompatible ## explicit github.com/evanphx/json-patch