hgctl/pkg/helm/render.go (547 lines of code) (raw):

// Copyright (c) 2022 Alibaba Group Holding Ltd. // // 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 helm import ( "encoding/json" "errors" "fmt" "io" "io/fs" "net/url" "os" "path" "path/filepath" "sort" "strings" "github.com/alibaba/higress/hgctl/pkg/manifests" "github.com/alibaba/higress/hgctl/pkg/util" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/chart/loader" "helm.sh/helm/v3/pkg/chartutil" "helm.sh/helm/v3/pkg/cli" "helm.sh/helm/v3/pkg/downloader" "helm.sh/helm/v3/pkg/engine" "helm.sh/helm/v3/pkg/getter" "helm.sh/helm/v3/pkg/repo" "k8s.io/client-go/rest" "sigs.k8s.io/yaml" ) const ( // DefaultProfileName is the name of the default profile for installation. DefaultProfileName = "local-k8s" // DefaultProfileFilename is the name of the default profile yaml file for installation. DefaultProfileFilename = "local-k8s.yaml" // DefaultUninstallProfileName is the name of the default profile yaml file for uninstallation. DefaultUninstallProfileName = "local-k8s" // ChartsSubdirName = "charts" profilesRoot = "profiles" RepoLatestVersion = "latest" RepoChartIndexYamlHigressIndex = "higress" YAMLSeparator = "\n---\n" NotesFileNameSuffix = ".txt" ) func LoadValues(profileName string, chartsDir string) (string, error) { path := strings.Join([]string{profilesRoot, builtinProfileToFilename(profileName)}, "/") by, err := fs.ReadFile(manifests.BuiltinOrDir(chartsDir), path) if err != nil { return "", err } return string(by), nil } func readProfiles(chartsDir string) (map[string]bool, error) { profiles := map[string]bool{} f := manifests.BuiltinOrDir(chartsDir) dir, err := fs.ReadDir(f, profilesRoot) if err != nil { return nil, err } for _, f := range dir { if f.Name() == "_all.yaml" { continue } trimmedString := strings.TrimSuffix(f.Name(), ".yaml") if f.Name() != trimmedString { profiles[trimmedString] = true } } return profiles, nil } func builtinProfileToFilename(name string) string { if name == "" { return DefaultProfileFilename } return name + ".yaml" } // stripPrefix removes the given prefix from prefix. func stripPrefix(path, prefix string) string { pl := len(strings.Split(prefix, "/")) pv := strings.Split(path, "/") return strings.Join(pv[pl:], "/") } // ListProfiles list all the profiles. func ListProfiles(charts string) ([]string, error) { profiles, err := readProfiles(charts) if err != nil { return nil, err } return util.StringBoolMapToSlice(profiles), nil } var DefaultFilters = []util.FilterFunc{ util.LicenseFilter, util.FormatterFilter, util.SpaceFilter, } // Renderer is responsible for rendering helm chart with new values. type Renderer interface { Init() error RenderManifest(valsYaml string) (string, error) SetVersion(version string) } type RendererOptions struct { Name string Namespace string // fields for LocalChartRenderer and LocalFileRenderer FS fs.FS Dir string // fields for RemoteRenderer Version string RepoURL string // Capabilities Capabilities *chartutil.Capabilities // rest config restConfig *rest.Config } type RendererOption func(*RendererOptions) func WithName(name string) RendererOption { return func(opts *RendererOptions) { opts.Name = name } } func WithNamespace(ns string) RendererOption { return func(opts *RendererOptions) { opts.Namespace = ns } } func WithFS(f fs.FS) RendererOption { return func(opts *RendererOptions) { opts.FS = f } } func WithDir(dir string) RendererOption { return func(opts *RendererOptions) { opts.Dir = dir } } func WithVersion(version string) RendererOption { return func(opts *RendererOptions) { opts.Version = version } } func WithRepoURL(repo string) RendererOption { return func(opts *RendererOptions) { opts.RepoURL = repo } } func WithCapabilities(capabilities *chartutil.Capabilities) RendererOption { return func(opts *RendererOptions) { opts.Capabilities = capabilities } } func WithRestConfig(config *rest.Config) RendererOption { return func(opts *RendererOptions) { opts.restConfig = config } } // LocalFileRenderer load yaml files from local file system type LocalFileRenderer struct { Opts *RendererOptions filesMap map[string]string Started bool } func NewLocalFileRenderer(opts ...RendererOption) (Renderer, error) { newOpts := &RendererOptions{} for _, opt := range opts { opt(newOpts) } return &LocalFileRenderer{ Opts: newOpts, filesMap: make(map[string]string), }, nil } func (l *LocalFileRenderer) Init() error { fileNames, err := getFileNames(l.Opts.FS, l.Opts.Dir) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("chart of component %s doesn't exist", l.Opts.Name) } return fmt.Errorf("getFileNames err: %s", err) } for _, fileName := range fileNames { data, err := fs.ReadFile(l.Opts.FS, fileName) if err != nil { return fmt.Errorf("ReadFile %s err: %s", fileName, err) } l.filesMap[fileName] = string(data) } l.Started = true return nil } func (l *LocalFileRenderer) RenderManifest(valsYaml string) (string, error) { if !l.Started { return "", errors.New("LocalFileRenderer has not been init") } keys := make([]string, 0, len(l.filesMap)) for key := range l.filesMap { keys = append(keys, key) } // to ensure that every manifest rendered by same values are the same sort.Strings(keys) var builder strings.Builder for i := 0; i < len(keys); i++ { file := l.filesMap[keys[i]] file = util.ApplyFilters(file, DefaultFilters...) // ignore empty manifest if file == "" { continue } if !strings.HasSuffix(file, YAMLSeparator) { file += YAMLSeparator } builder.WriteString(file) } return builder.String(), nil } func (l *LocalFileRenderer) SetVersion(version string) { l.Opts.Version = version } // LocalChartRenderer load chart from local file system type LocalChartRenderer struct { Opts *RendererOptions Chart *chart.Chart Started bool } func (lr *LocalChartRenderer) Init() error { fileNames, err := getFileNames(lr.Opts.FS, lr.Opts.Dir) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("chart of component %s doesn't exist", lr.Opts.Name) } return fmt.Errorf("getFileNames err: %s", err) } var files []*loader.BufferedFile for _, fileName := range fileNames { data, err := fs.ReadFile(lr.Opts.FS, fileName) if err != nil { return fmt.Errorf("ReadFile %s err: %s", fileName, err) } // todo:// explain why we need to do this name := util.StripPrefix(fileName, lr.Opts.Dir) file := &loader.BufferedFile{ Name: name, Data: data, } files = append(files, file) } newChart, err := loader.LoadFiles(files) if err != nil { return fmt.Errorf("load chart of component %s err: %s", lr.Opts.Name, err) } lr.Chart = newChart lr.Started = true return nil } func (lr *LocalChartRenderer) RenderManifest(valsYaml string) (string, error) { if !lr.Started { return "", errors.New("LocalChartRenderer has not been init") } return renderManifest(valsYaml, lr.Chart, true, lr.Opts, DefaultFilters...) } func (lr *LocalChartRenderer) SetVersion(version string) { lr.Opts.Version = version } func NewLocalChartRenderer(opts ...RendererOption) (Renderer, error) { newOpts := &RendererOptions{} for _, opt := range opts { opt(newOpts) } if err := verifyRendererOptions(newOpts); err != nil { return nil, fmt.Errorf("verify err: %s", err) } return &LocalChartRenderer{ Opts: newOpts, }, nil } type RemoteRenderer struct { Opts *RendererOptions Chart *chart.Chart Started bool } func (rr *RemoteRenderer) initChartPathOptions() *action.ChartPathOptions { return &action.ChartPathOptions{ RepoURL: rr.Opts.RepoURL, Version: rr.Opts.Version, } } func (rr *RemoteRenderer) Init() error { cpOpts := rr.initChartPathOptions() settings := cli.New() // using release name as chart name by default cp, err := locateChart(cpOpts, rr.Opts.Name, settings) if err != nil { return err } // Check chart dependencies to make sure all are present in /charts chartRequested, err := loader.Load(cp) if err != nil { return err } if err := verifyInstallable(chartRequested); err != nil { return err } rr.Chart = chartRequested rr.Started = true return nil } func (rr *RemoteRenderer) SetVersion(version string) { rr.Opts.Version = version } func (rr *RemoteRenderer) RenderManifest(valsYaml string) (string, error) { if !rr.Started { return "", errors.New("RemoteRenderer has not been init") } return renderManifest(valsYaml, rr.Chart, false, rr.Opts, DefaultFilters...) } func NewRemoteRenderer(opts ...RendererOption) (Renderer, error) { newOpts := &RendererOptions{} for _, opt := range opts { opt(newOpts) } return &RemoteRenderer{ Opts: newOpts, }, nil } func verifyRendererOptions(opts *RendererOptions) error { if opts.Name == "" { return errors.New("missing component name for Renderer") } if opts.Namespace == "" { return errors.New("missing component namespace for Renderer") } if opts.FS == nil { return errors.New("missing chart FS for Renderer") } if opts.Dir == "" { return errors.New("missing chart dir for Renderer") } return nil } // read all files recursively under root path from a certain local file system func getFileNames(f fs.FS, root string) ([]string, error) { var fileNames []string if err := fs.WalkDir(f, root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } fileNames = append(fileNames, path) return nil }); err != nil { return nil, err } return fileNames, nil } func verifyInstallable(cht *chart.Chart) error { typ := cht.Metadata.Type if typ == "" || typ == "application" { return nil } return fmt.Errorf("%s chart %s is not installable", typ, cht.Name()) } func renderManifest(valsYaml string, cht *chart.Chart, builtIn bool, opts *RendererOptions, filters ...util.FilterFunc) (string, error) { valsMap := make(map[string]any) if err := yaml.Unmarshal([]byte(valsYaml), &valsMap); err != nil { return "", fmt.Errorf("unmarshal failed err: %s", err) } RelOpts := chartutil.ReleaseOptions{ Name: opts.Name, Namespace: opts.Namespace, } var caps *chartutil.Capabilities caps = opts.Capabilities if caps == nil { caps = chartutil.DefaultCapabilities } // maybe we need a configuration to change this caps resVals, err := chartutil.ToRenderValues(cht, valsMap, RelOpts, caps) if err != nil { return "", fmt.Errorf("ToRenderValues failed err: %s", err) } if builtIn { resVals["Values"].(chartutil.Values)["enabled"] = true } filesMap, err := engine.RenderWithClient(cht, resVals, opts.restConfig) if err != nil { return "", fmt.Errorf("Render chart failed err: %s", err) } keys := make([]string, 0, len(filesMap)) for key := range filesMap { // remove notation files such as Notes.txt if strings.HasSuffix(key, NotesFileNameSuffix) { continue } keys = append(keys, key) } // to ensure that every manifest rendered by same values are the same sort.Strings(keys) var builder strings.Builder for i := 0; i < len(keys); i++ { file := filesMap[keys[i]] file = util.ApplyFilters(file, filters...) // ignore empty manifest if file == "" { continue } if !strings.HasSuffix(file, YAMLSeparator) { file += YAMLSeparator } builder.WriteString(file) } // render CRD crdFiles := cht.CRDObjects() // Sort crd files by name to ensure stable manifest output sort.Slice(crdFiles, func(i, j int) bool { return crdFiles[i].Name < crdFiles[j].Name }) for _, crdFile := range crdFiles { f := string(crdFile.File.Data) // add yaml separator if the rendered file doesn't have one at the end f = strings.TrimSpace(f) + "\n" if !strings.HasSuffix(f, YAMLSeparator) { f += YAMLSeparator } builder.WriteString(f) } return builder.String(), nil } // locateChart locate the target chart path by sequential orders: // 1. find local helm repository using "name-version.tgz" format // 2. using downloader to pull remote chart func locateChart(cpOpts *action.ChartPathOptions, name string, settings *cli.EnvSettings) (string, error) { name = strings.TrimSpace(name) version := strings.TrimSpace(cpOpts.Version) // check if it's in Helm's chart cache // cacheName is hardcoded as format of helm. eg: grafana-6.31.1.tgz cacheName := name + "-" + cpOpts.Version + ".tgz" cachePath := path.Join(settings.RepositoryCache, cacheName) if _, err := os.Stat(cachePath); err == nil { abs, err := filepath.Abs(cachePath) if err != nil { return abs, err } if cpOpts.Verify { if _, err := downloader.VerifyChart(abs, cpOpts.Keyring); err != nil { return "", err } } return abs, nil } dl := downloader.ChartDownloader{ Out: os.Stdout, Keyring: cpOpts.Keyring, Getters: getter.All(settings), Options: []getter.Option{ getter.WithPassCredentialsAll(cpOpts.PassCredentialsAll), getter.WithTLSClientConfig(cpOpts.CertFile, cpOpts.KeyFile, cpOpts.CaFile), getter.WithInsecureSkipVerifyTLS(cpOpts.InsecureSkipTLSverify), }, RepositoryConfig: settings.RepositoryConfig, RepositoryCache: settings.RepositoryCache, } if cpOpts.Verify { dl.Verify = downloader.VerifyAlways } if cpOpts.RepoURL != "" { chartURL, err := repo.FindChartInAuthAndTLSAndPassRepoURL(cpOpts.RepoURL, cpOpts.Username, cpOpts.Password, name, version, cpOpts.CertFile, cpOpts.KeyFile, cpOpts.CaFile, cpOpts.InsecureSkipTLSverify, cpOpts.PassCredentialsAll, getter.All(settings)) if err != nil { return "", err } name = chartURL // Only pass the user/pass on when the user has said to or when the // location of the chart repo and the chart are the same domain. u1, err := url.Parse(cpOpts.RepoURL) if err != nil { return "", err } u2, err := url.Parse(chartURL) if err != nil { return "", err } // Host on URL (returned from url.Parse) contains the port if present. // This check ensures credentials are not passed between different // services on different ports. if cpOpts.PassCredentialsAll || (u1.Scheme == u2.Scheme && u1.Host == u2.Host) { dl.Options = append(dl.Options, getter.WithBasicAuth(cpOpts.Username, cpOpts.Password)) } else { dl.Options = append(dl.Options, getter.WithBasicAuth("", "")) } } else { dl.Options = append(dl.Options, getter.WithBasicAuth(cpOpts.Username, cpOpts.Password)) } // if RepositoryCache doesn't exist, create it if err := os.MkdirAll(settings.RepositoryCache, 0o755); err != nil { return "", err } filename, _, err := dl.DownloadTo(name, version, settings.RepositoryCache) if err != nil { return "", err } fileAbsPath, err := filepath.Abs(filename) if err != nil { return filename, err } return fileAbsPath, nil } func ParseLatestVersion(repoUrl string, version string, devel bool) (string, error) { cpOpts := &action.ChartPathOptions{ RepoURL: repoUrl, Version: version, } settings := cli.New() indexURL, err := repo.ResolveReferenceURL(repoUrl, "index.yaml") if err != nil { return "", err } u, err := url.Parse(repoUrl) if err != nil { return "", fmt.Errorf("invalid chart URL format: %s", repoUrl) } client, err := getter.All(settings).ByScheme(u.Scheme) if err != nil { return "", fmt.Errorf("could not find protocol handler for: %s", u.Scheme) } resp, err := client.Get(indexURL, getter.WithURL(cpOpts.RepoURL), getter.WithInsecureSkipVerifyTLS(cpOpts.InsecureSkipTLSverify), getter.WithTLSClientConfig(cpOpts.CertFile, cpOpts.KeyFile, cpOpts.CaFile), getter.WithBasicAuth(cpOpts.Username, cpOpts.Password), getter.WithPassCredentialsAll(cpOpts.PassCredentialsAll), ) if err != nil { return "", err } index, err := io.ReadAll(resp) if err != nil { return "", err } indexFile, err := loadIndex(index) if err != nil { return "", err } // get higress helm chart latest version if entries, ok := indexFile.Entries[RepoChartIndexYamlHigressIndex]; ok { if devel { return entries[0].AppVersion, nil } if chatVersion, err := indexFile.Get(RepoChartIndexYamlHigressIndex, ""); err != nil { return "", errors.New("can't find higress latest version") } else { return chatVersion.Version, nil } } return "", errors.New("can't find higress latest version") } // loadIndex loads an index file and does minimal validity checking. // // The source parameter is only used for logging. // This will fail if API Version is not set (ErrNoAPIVersion) or if the unmarshal fails. func loadIndex(data []byte) (*repo.IndexFile, error) { i := &repo.IndexFile{} if len(data) == 0 { return i, errors.New("empty index.yaml file") } if err := jsonOrYamlUnmarshal(data, i); err != nil { return i, err } for _, cvs := range i.Entries { for idx := len(cvs) - 1; idx >= 0; idx-- { if cvs[idx] == nil { continue } if cvs[idx].APIVersion == "" { cvs[idx].APIVersion = chart.APIVersionV1 } if err := cvs[idx].Validate(); err != nil { cvs = append(cvs[:idx], cvs[idx+1:]...) } } } i.SortEntries() if i.APIVersion == "" { return i, errors.New("no API version specified") } return i, nil } // jsonOrYamlUnmarshal unmarshals the given byte slice containing JSON or YAML // into the provided interface. // // It automatically detects whether the data is in JSON or YAML format by // checking its validity as JSON. If the data is valid JSON, it will use the // `encoding/json` package to unmarshal it. Otherwise, it will use the // `sigs.k8s.io/yaml` package to unmarshal the YAML data. func jsonOrYamlUnmarshal(b []byte, i interface{}) error { if json.Valid(b) { return json.Unmarshal(b, i) } return yaml.UnmarshalStrict(b, i) }