Populate Capabilities.HelmVersion during install

Signed-off-by: Graham Reed <greed@7deadly.org>
pull/10849/head
Graham Reed 4 years ago
parent cba3b1eed4
commit bb343ca7b8

@ -17,32 +17,32 @@ limitations under the License.
package action package action
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"github.com/pkg/errors" "github.com/pkg/errors"
"k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/meta"
"k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/client-go/discovery" "k8s.io/client-go/discovery"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chartutil" "helm.sh/helm/v3/pkg/chartutil"
"helm.sh/helm/v3/pkg/engine" "helm.sh/helm/v3/pkg/engine"
"helm.sh/helm/v3/pkg/kube" "helm.sh/helm/v3/pkg/kube"
"helm.sh/helm/v3/pkg/postrender" "helm.sh/helm/v3/pkg/postrender"
"helm.sh/helm/v3/pkg/registry" "helm.sh/helm/v3/pkg/registry"
"helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/releaseutil" "helm.sh/helm/v3/pkg/releaseutil"
"helm.sh/helm/v3/pkg/storage" "helm.sh/helm/v3/pkg/storage"
"helm.sh/helm/v3/pkg/storage/driver" "helm.sh/helm/v3/pkg/storage/driver"
"helm.sh/helm/v3/pkg/time" "helm.sh/helm/v3/pkg/time"
) )
// Timestamper is a function capable of producing a timestamp.Timestamper. // Timestamper is a function capable of producing a timestamp.Timestamper.
@ -52,14 +52,14 @@ import (
var Timestamper = time.Now var Timestamper = time.Now
var ( var (
// errMissingChart indicates that a chart was not provided. // errMissingChart indicates that a chart was not provided.
errMissingChart = errors.New("no chart provided") errMissingChart = errors.New("no chart provided")
// errMissingRelease indicates that a release (name) was not provided. // errMissingRelease indicates that a release (name) was not provided.
errMissingRelease = errors.New("no release provided") errMissingRelease = errors.New("no release provided")
// errInvalidRevision indicates that an invalid release revision number was provided. // errInvalidRevision indicates that an invalid release revision number was provided.
errInvalidRevision = errors.New("invalid release revision") errInvalidRevision = errors.New("invalid release revision")
// errPending indicates that another instance of Helm is already applying an operation on a release. // errPending indicates that another instance of Helm is already applying an operation on a release.
errPending = errors.New("another operation (install/upgrade/rollback) is in progress") errPending = errors.New("another operation (install/upgrade/rollback) is in progress")
) )
// ValidName is a regular expression for resource names. // ValidName is a regular expression for resource names.
@ -79,22 +79,22 @@ var ValidName = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-
// Configuration injects the dependencies that all actions share. // Configuration injects the dependencies that all actions share.
type Configuration struct { type Configuration struct {
// RESTClientGetter is an interface that loads Kubernetes clients. // RESTClientGetter is an interface that loads Kubernetes clients.
RESTClientGetter RESTClientGetter RESTClientGetter RESTClientGetter
// Releases stores records of releases. // Releases stores records of releases.
Releases *storage.Storage Releases *storage.Storage
// KubeClient is a Kubernetes API client. // KubeClient is a Kubernetes API client.
KubeClient kube.Interface KubeClient kube.Interface
// RegistryClient is a client for working with registries // RegistryClient is a client for working with registries
RegistryClient *registry.Client RegistryClient *registry.Client
// Capabilities describes the capabilities of the Kubernetes cluster. // Capabilities describes the capabilities of the Kubernetes cluster.
Capabilities *chartutil.Capabilities Capabilities *chartutil.Capabilities
Log func(string, ...interface{}) Log func(string, ...interface{})
} }
// renderResources renders the templates in a chart // renderResources renders the templates in a chart
@ -103,133 +103,133 @@ type Configuration struct {
// TODO: As part of the refactor the duplicate code in cmd/helm/template.go should be removed // TODO: As part of the refactor the duplicate code in cmd/helm/template.go should be removed
// This code has to do with writing files to disk. // This code has to do with writing files to disk.
func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrender.PostRenderer, dryRun bool) ([]*release.Hook, *bytes.Buffer, string, error) { func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrender.PostRenderer, dryRun bool) ([]*release.Hook, *bytes.Buffer, string, error) {
hs := []*release.Hook{} hs := []*release.Hook{}
b := bytes.NewBuffer(nil) b := bytes.NewBuffer(nil)
caps, err := cfg.getCapabilities() caps, err := cfg.getCapabilities()
if err != nil { if err != nil {
return hs, b, "", err return hs, b, "", err
} }
if ch.Metadata.KubeVersion != "" { if ch.Metadata.KubeVersion != "" {
if !chartutil.IsCompatibleRange(ch.Metadata.KubeVersion, caps.KubeVersion.String()) { if !chartutil.IsCompatibleRange(ch.Metadata.KubeVersion, caps.KubeVersion.String()) {
return hs, b, "", errors.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", ch.Metadata.KubeVersion, caps.KubeVersion.String()) return hs, b, "", errors.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", ch.Metadata.KubeVersion, caps.KubeVersion.String())
} }
} }
var files map[string]string var files map[string]string
var err2 error var err2 error
// A `helm template` or `helm install --dry-run` should not talk to the remote cluster. // A `helm template` or `helm install --dry-run` should not talk to the remote cluster.
// It will break in interesting and exotic ways because other data (e.g. discovery) // It will break in interesting and exotic ways because other data (e.g. discovery)
// is mocked. It is not up to the template author to decide when the user wants to // is mocked. It is not up to the template author to decide when the user wants to
// connect to the cluster. So when the user says to dry run, respect the user's // connect to the cluster. So when the user says to dry run, respect the user's
// wishes and do not connect to the cluster. // wishes and do not connect to the cluster.
if !dryRun && cfg.RESTClientGetter != nil { if !dryRun && cfg.RESTClientGetter != nil {
restConfig, err := cfg.RESTClientGetter.ToRESTConfig() restConfig, err := cfg.RESTClientGetter.ToRESTConfig()
if err != nil { if err != nil {
return hs, b, "", err return hs, b, "", err
} }
files, err2 = engine.RenderWithClient(ch, values, restConfig) files, err2 = engine.RenderWithClient(ch, values, restConfig)
} else { } else {
files, err2 = engine.Render(ch, values) files, err2 = engine.Render(ch, values)
} }
if err2 != nil { if err2 != nil {
return hs, b, "", err2 return hs, b, "", err2
} }
// NOTES.txt gets rendered like all the other files, but because it's not a hook nor a resource, // NOTES.txt gets rendered like all the other files, but because it's not a hook nor a resource,
// pull it out of here into a separate file so that we can actually use the output of the rendered // pull it out of here into a separate file so that we can actually use the output of the rendered
// text file. We have to spin through this map because the file contains path information, so we // text file. We have to spin through this map because the file contains path information, so we
// look for terminating NOTES.txt. We also remove it from the files so that we don't have to skip // look for terminating NOTES.txt. We also remove it from the files so that we don't have to skip
// it in the sortHooks. // it in the sortHooks.
var notesBuffer bytes.Buffer var notesBuffer bytes.Buffer
for k, v := range files { for k, v := range files {
if strings.HasSuffix(k, notesFileSuffix) { if strings.HasSuffix(k, notesFileSuffix) {
if subNotes || (k == path.Join(ch.Name(), "templates", notesFileSuffix)) { if subNotes || (k == path.Join(ch.Name(), "templates", notesFileSuffix)) {
// If buffer contains data, add newline before adding more // If buffer contains data, add newline before adding more
if notesBuffer.Len() > 0 { if notesBuffer.Len() > 0 {
notesBuffer.WriteString("\n") notesBuffer.WriteString("\n")
} }
notesBuffer.WriteString(v) notesBuffer.WriteString(v)
} }
delete(files, k) delete(files, k)
} }
} }
notes := notesBuffer.String() notes := notesBuffer.String()
// Sort hooks, manifests, and partials. Only hooks and manifests are returned, // Sort hooks, manifests, and partials. Only hooks and manifests are returned,
// as partials are not used after renderer.Render. Empty manifests are also // as partials are not used after renderer.Render. Empty manifests are also
// removed here. // removed here.
hs, manifests, err := releaseutil.SortManifests(files, caps.APIVersions, releaseutil.InstallOrder) hs, manifests, err := releaseutil.SortManifests(files, caps.APIVersions, releaseutil.InstallOrder)
if err != nil { if err != nil {
// By catching parse errors here, we can prevent bogus releases from going // By catching parse errors here, we can prevent bogus releases from going
// to Kubernetes. // to Kubernetes.
// //
// We return the files as a big blob of data to help the user debug parser // We return the files as a big blob of data to help the user debug parser
// errors. // errors.
for name, content := range files { for name, content := range files {
if strings.TrimSpace(content) == "" { if strings.TrimSpace(content) == "" {
continue continue
} }
fmt.Fprintf(b, "---\n# Source: %s\n%s\n", name, content) fmt.Fprintf(b, "---\n# Source: %s\n%s\n", name, content)
} }
return hs, b, "", err return hs, b, "", err
} }
// Aggregate all valid manifests into one big doc. // Aggregate all valid manifests into one big doc.
fileWritten := make(map[string]bool) fileWritten := make(map[string]bool)
if includeCrds { if includeCrds {
for _, crd := range ch.CRDObjects() { for _, crd := range ch.CRDObjects() {
if outputDir == "" { if outputDir == "" {
fmt.Fprintf(b, "---\n# Source: %s\n%s\n", crd.Name, string(crd.File.Data[:])) fmt.Fprintf(b, "---\n# Source: %s\n%s\n", crd.Name, string(crd.File.Data[:]))
} else { } else {
err = writeToFile(outputDir, crd.Filename, string(crd.File.Data[:]), fileWritten[crd.Name]) err = writeToFile(outputDir, crd.Filename, string(crd.File.Data[:]), fileWritten[crd.Name])
if err != nil { if err != nil {
return hs, b, "", err return hs, b, "", err
} }
fileWritten[crd.Name] = true fileWritten[crd.Name] = true
} }
} }
} }
for _, m := range manifests { for _, m := range manifests {
if outputDir == "" { if outputDir == "" {
fmt.Fprintf(b, "---\n# Source: %s\n%s\n", m.Name, m.Content) fmt.Fprintf(b, "---\n# Source: %s\n%s\n", m.Name, m.Content)
} else { } else {
newDir := outputDir newDir := outputDir
if useReleaseName { if useReleaseName {
newDir = filepath.Join(outputDir, releaseName) newDir = filepath.Join(outputDir, releaseName)
} }
// NOTE: We do not have to worry about the post-renderer because // NOTE: We do not have to worry about the post-renderer because
// output dir is only used by `helm template`. In the next major // output dir is only used by `helm template`. In the next major
// release, we should move this logic to template only as it is not // release, we should move this logic to template only as it is not
// used by install or upgrade // used by install or upgrade
err = writeToFile(newDir, m.Name, m.Content, fileWritten[m.Name]) err = writeToFile(newDir, m.Name, m.Content, fileWritten[m.Name])
if err != nil { if err != nil {
return hs, b, "", err return hs, b, "", err
} }
fileWritten[m.Name] = true fileWritten[m.Name] = true
} }
} }
if pr != nil { if pr != nil {
b, err = pr.Run(b) b, err = pr.Run(b)
if err != nil { if err != nil {
return hs, b, notes, errors.Wrap(err, "error while running post render on files") return hs, b, notes, errors.Wrap(err, "error while running post render on files")
} }
} }
return hs, b, notes, nil return hs, b, notes, nil
} }
// RESTClientGetter gets the rest client // RESTClientGetter gets the rest client
type RESTClientGetter interface { type RESTClientGetter interface {
ToRESTConfig() (*rest.Config, error) ToRESTConfig() (*rest.Config, error)
ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error)
ToRESTMapper() (meta.RESTMapper, error) ToRESTMapper() (meta.RESTMapper, error)
} }
// DebugLog sets the logger that writes debug strings // DebugLog sets the logger that writes debug strings
@ -237,53 +237,54 @@ type DebugLog func(format string, v ...interface{})
// capabilities builds a Capabilities from discovery information. // capabilities builds a Capabilities from discovery information.
func (cfg *Configuration) getCapabilities() (*chartutil.Capabilities, error) { func (cfg *Configuration) getCapabilities() (*chartutil.Capabilities, error) {
if cfg.Capabilities != nil { if cfg.Capabilities != nil {
return cfg.Capabilities, nil return cfg.Capabilities, nil
} }
dc, err := cfg.RESTClientGetter.ToDiscoveryClient() dc, err := cfg.RESTClientGetter.ToDiscoveryClient()
if err != nil { if err != nil {
return nil, errors.Wrap(err, "could not get Kubernetes discovery client") return nil, errors.Wrap(err, "could not get Kubernetes discovery client")
} }
// force a discovery cache invalidation to always fetch the latest server version/capabilities. // force a discovery cache invalidation to always fetch the latest server version/capabilities.
dc.Invalidate() dc.Invalidate()
kubeVersion, err := dc.ServerVersion() kubeVersion, err := dc.ServerVersion()
if err != nil { if err != nil {
return nil, errors.Wrap(err, "could not get server version from Kubernetes") return nil, errors.Wrap(err, "could not get server version from Kubernetes")
} }
// Issue #6361: // Issue #6361:
// Client-Go emits an error when an API service is registered but unimplemented. // Client-Go emits an error when an API service is registered but unimplemented.
// We trap that error here and print a warning. But since the discovery client continues // We trap that error here and print a warning. But since the discovery client continues
// building the API object, it is correctly populated with all valid APIs. // building the API object, it is correctly populated with all valid APIs.
// See https://github.com/kubernetes/kubernetes/issues/72051#issuecomment-521157642 // See https://github.com/kubernetes/kubernetes/issues/72051#issuecomment-521157642
apiVersions, err := GetVersionSet(dc) apiVersions, err := GetVersionSet(dc)
if err != nil { if err != nil {
if discovery.IsGroupDiscoveryFailedError(err) { if discovery.IsGroupDiscoveryFailedError(err) {
cfg.Log("WARNING: The Kubernetes server has an orphaned API service. Server reports: %s", err) cfg.Log("WARNING: The Kubernetes server has an orphaned API service. Server reports: %s", err)
cfg.Log("WARNING: To fix this, kubectl delete apiservice <service-name>") cfg.Log("WARNING: To fix this, kubectl delete apiservice <service-name>")
} else { } else {
return nil, errors.Wrap(err, "could not get apiVersions from Kubernetes") return nil, errors.Wrap(err, "could not get apiVersions from Kubernetes")
} }
} }
cfg.Capabilities = &chartutil.Capabilities{ cfg.Capabilities = &chartutil.Capabilities{
APIVersions: apiVersions, APIVersions: apiVersions,
KubeVersion: chartutil.KubeVersion{ KubeVersion: chartutil.KubeVersion{
Version: kubeVersion.GitVersion, Version: kubeVersion.GitVersion,
Major: kubeVersion.Major, Major: kubeVersion.Major,
Minor: kubeVersion.Minor, Minor: kubeVersion.Minor,
}, },
} HelmVersion: chartutil.DefaultCapabilities.HelmVersion,
return cfg.Capabilities, nil }
return cfg.Capabilities, nil
} }
// KubernetesClientSet creates a new kubernetes ClientSet based on the configuration // KubernetesClientSet creates a new kubernetes ClientSet based on the configuration
func (cfg *Configuration) KubernetesClientSet() (kubernetes.Interface, error) { func (cfg *Configuration) KubernetesClientSet() (kubernetes.Interface, error) {
conf, err := cfg.RESTClientGetter.ToRESTConfig() conf, err := cfg.RESTClientGetter.ToRESTConfig()
if err != nil { if err != nil {
return nil, errors.Wrap(err, "unable to generate config for kubernetes client") return nil, errors.Wrap(err, "unable to generate config for kubernetes client")
} }
return kubernetes.NewForConfig(conf) return kubernetes.NewForConfig(conf)
} }
// Now generates a timestamp // Now generates a timestamp
@ -291,130 +292,130 @@ func (cfg *Configuration) KubernetesClientSet() (kubernetes.Interface, error) {
// If the configuration has a Timestamper on it, that will be used. // If the configuration has a Timestamper on it, that will be used.
// Otherwise, this will use time.Now(). // Otherwise, this will use time.Now().
func (cfg *Configuration) Now() time.Time { func (cfg *Configuration) Now() time.Time {
return Timestamper() return Timestamper()
} }
func (cfg *Configuration) releaseContent(name string, version int) (*release.Release, error) { func (cfg *Configuration) releaseContent(name string, version int) (*release.Release, error) {
if err := chartutil.ValidateReleaseName(name); err != nil { if err := chartutil.ValidateReleaseName(name); err != nil {
return nil, errors.Errorf("releaseContent: Release name is invalid: %s", name) return nil, errors.Errorf("releaseContent: Release name is invalid: %s", name)
} }
if version <= 0 { if version <= 0 {
return cfg.Releases.Last(name) return cfg.Releases.Last(name)
} }
return cfg.Releases.Get(name, version) return cfg.Releases.Get(name, version)
} }
// GetVersionSet retrieves a set of available k8s API versions // GetVersionSet retrieves a set of available k8s API versions
func GetVersionSet(client discovery.ServerResourcesInterface) (chartutil.VersionSet, error) { func GetVersionSet(client discovery.ServerResourcesInterface) (chartutil.VersionSet, error) {
groups, resources, err := client.ServerGroupsAndResources() groups, resources, err := client.ServerGroupsAndResources()
if err != nil && !discovery.IsGroupDiscoveryFailedError(err) { if err != nil && !discovery.IsGroupDiscoveryFailedError(err) {
return chartutil.DefaultVersionSet, errors.Wrap(err, "could not get apiVersions from Kubernetes") return chartutil.DefaultVersionSet, errors.Wrap(err, "could not get apiVersions from Kubernetes")
} }
// FIXME: The Kubernetes test fixture for cli appears to always return nil // FIXME: The Kubernetes test fixture for cli appears to always return nil
// for calls to Discovery().ServerGroupsAndResources(). So in this case, we // for calls to Discovery().ServerGroupsAndResources(). So in this case, we
// return the default API list. This is also a safe value to return in any // return the default API list. This is also a safe value to return in any
// other odd-ball case. // other odd-ball case.
if len(groups) == 0 && len(resources) == 0 { if len(groups) == 0 && len(resources) == 0 {
return chartutil.DefaultVersionSet, nil return chartutil.DefaultVersionSet, nil
} }
versionMap := make(map[string]interface{}) versionMap := make(map[string]interface{})
versions := []string{} versions := []string{}
// Extract the groups // Extract the groups
for _, g := range groups { for _, g := range groups {
for _, gv := range g.Versions { for _, gv := range g.Versions {
versionMap[gv.GroupVersion] = struct{}{} versionMap[gv.GroupVersion] = struct{}{}
} }
} }
// Extract the resources // Extract the resources
var id string var id string
var ok bool var ok bool
for _, r := range resources { for _, r := range resources {
for _, rl := range r.APIResources { for _, rl := range r.APIResources {
// A Kind at a GroupVersion can show up more than once. We only want // A Kind at a GroupVersion can show up more than once. We only want
// it displayed once in the final output. // it displayed once in the final output.
id = path.Join(r.GroupVersion, rl.Kind) id = path.Join(r.GroupVersion, rl.Kind)
if _, ok = versionMap[id]; !ok { if _, ok = versionMap[id]; !ok {
versionMap[id] = struct{}{} versionMap[id] = struct{}{}
} }
} }
} }
// Convert to a form that NewVersionSet can use // Convert to a form that NewVersionSet can use
for k := range versionMap { for k := range versionMap {
versions = append(versions, k) versions = append(versions, k)
} }
return chartutil.VersionSet(versions), nil return chartutil.VersionSet(versions), nil
} }
// recordRelease with an update operation in case reuse has been set. // recordRelease with an update operation in case reuse has been set.
func (cfg *Configuration) recordRelease(r *release.Release) { func (cfg *Configuration) recordRelease(r *release.Release) {
if err := cfg.Releases.Update(r); err != nil { if err := cfg.Releases.Update(r); err != nil {
cfg.Log("warning: Failed to update release %s: %s", r.Name, err) cfg.Log("warning: Failed to update release %s: %s", r.Name, err)
} }
} }
// Init initializes the action configuration // Init initializes the action configuration
func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namespace, helmDriver string, log DebugLog) error { func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namespace, helmDriver string, log DebugLog) error {
kc := kube.New(getter) kc := kube.New(getter)
kc.Log = log kc.Log = log
lazyClient := &lazyClient{ lazyClient := &lazyClient{
namespace: namespace, namespace: namespace,
clientFn: kc.Factory.KubernetesClientSet, clientFn: kc.Factory.KubernetesClientSet,
} }
var store *storage.Storage var store *storage.Storage
switch helmDriver { switch helmDriver {
case "secret", "secrets", "": case "secret", "secrets", "":
d := driver.NewSecrets(newSecretClient(lazyClient)) d := driver.NewSecrets(newSecretClient(lazyClient))
d.Log = log d.Log = log
store = storage.Init(d) store = storage.Init(d)
case "configmap", "configmaps": case "configmap", "configmaps":
d := driver.NewConfigMaps(newConfigMapClient(lazyClient)) d := driver.NewConfigMaps(newConfigMapClient(lazyClient))
d.Log = log d.Log = log
store = storage.Init(d) store = storage.Init(d)
case "memory": case "memory":
var d *driver.Memory var d *driver.Memory
if cfg.Releases != nil { if cfg.Releases != nil {
if mem, ok := cfg.Releases.Driver.(*driver.Memory); ok { if mem, ok := cfg.Releases.Driver.(*driver.Memory); ok {
// This function can be called more than once (e.g., helm list --all-namespaces). // This function can be called more than once (e.g., helm list --all-namespaces).
// If a memory driver was already initialized, re-use it but set the possibly new namespace. // If a memory driver was already initialized, re-use it but set the possibly new namespace.
// We re-use it in case some releases where already created in the existing memory driver. // We re-use it in case some releases where already created in the existing memory driver.
d = mem d = mem
} }
} }
if d == nil { if d == nil {
d = driver.NewMemory() d = driver.NewMemory()
} }
d.SetNamespace(namespace) d.SetNamespace(namespace)
store = storage.Init(d) store = storage.Init(d)
case "sql": case "sql":
d, err := driver.NewSQL( d, err := driver.NewSQL(
os.Getenv("HELM_DRIVER_SQL_CONNECTION_STRING"), os.Getenv("HELM_DRIVER_SQL_CONNECTION_STRING"),
log, log,
namespace, namespace,
) )
if err != nil { if err != nil {
panic(fmt.Sprintf("Unable to instantiate SQL driver: %v", err)) panic(fmt.Sprintf("Unable to instantiate SQL driver: %v", err))
} }
store = storage.Init(d) store = storage.Init(d)
default: default:
// Not sure what to do here. // Not sure what to do here.
panic("Unknown driver in HELM_DRIVER: " + helmDriver) panic("Unknown driver in HELM_DRIVER: " + helmDriver)
} }
cfg.RESTClientGetter = getter cfg.RESTClientGetter = getter
cfg.KubeClient = kc cfg.KubeClient = kc
cfg.Releases = store cfg.Releases = store
cfg.Log = log cfg.Log = log
return nil return nil
} }

Loading…
Cancel
Save