Files
helmfile/pkg/remote/remote.go
T
Yusuke Kuokaandyxxhero 6664f01596 Use goccy/go-yaml for v1 / Prep bringing back go-yaml v2 for v0.x (#604)
This is a successor to #596. We need a smooth migration path from `gopkg.in/yaml.v2`, and this pull request moves it forward with `goccy/go-yaml` instead of `gopkg.in/yaml.v3`. Merging this unblocks users stuck in Helmfile v0.146.x or earlier due to #435, so that they can upgrade to 0.147.x or greater without updating their helmfile configs.

We previously tried to upgrade to `yaml.v3` (https://github.com/helmfile/helmfile/issues/394) in Helmfile v0.x, presuming it won't break anything. Apparently, it broke use-cases where you want to layer release's `values` field over three or more release templates and releases (#435).

We then tried to bring back `yaml.v2` for Helmfile v0.x and keep `yaml.v3` for the upcoming Helmfile v1. However, it failed due to incompatibility in the Unmarshaller interface between `yaml.v2` and `yaml.v3` (https://github.com/helmfile/helmfile/pull/596).

`goccy/go-yaml` is, from my observation, a well-maintained alternative to `yaml.v2`. One of its premises is that it enables us to swap the implementation from `gopkg.in/yaml.v2` to `goccy/go-yaml` just by replacing the import directive. It seems to use the same `Unmarshaller` interface as yaml.v2 too.

Once this PR gets merged, I'd like to follow-up with adding a new build-time variable and an envvar to set the proper default for the yaml parser Helmfile uses and the ability to switch the parser at runtime. All in all, the next Helmfile release, v0.150.0 will get reverted to use `gopkg.in/yaml.v2` by default which resolves #435.

New users who started using Helmfile since any of v0.148.0, v0.148.1, and v0.149.0 might be already relying on the new behavior, They might need to specify a new envvar to enable `goccy/go-yaml`.

Signed-off-by: yxxhero <aiopsclub@163.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
2022-12-27 10:14:35 +09:00

305 lines
7.0 KiB
Go

package remote
import (
"context"
"encoding/json"
"fmt"
neturl "net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/hashicorp/go-getter"
"github.com/hashicorp/go-getter/helper/url"
"go.uber.org/multierr"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/envvar"
"github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/yaml"
)
var disableInsecureFeatures bool
func init() {
disableInsecureFeatures, _ = strconv.ParseBool(os.Getenv(envvar.DisableInsecureFeatures))
}
func CacheDir() string {
dir, err := os.UserCacheDir()
if err != nil {
// fall back to relative path with hidden directory
return ".helmfile"
}
return filepath.Join(dir, "helmfile")
}
type Remote struct {
Logger *zap.SugaredLogger
// Home is the directory in which remote downloads files. If empty, user cache directory is used
Home string
// Getter is the underlying implementation of getter used for fetching remote files
Getter Getter
// Filesystem abstraction
// Inject any implementation of your choice, like an im-memory impl for testing, os.ReadFile for the real-world use.
fs *filesystem.FileSystem
}
func (r *Remote) Unmarshal(src string, dst interface{}) error {
bytes, err := r.GetBytes(src)
if err != nil {
return err
}
strs := strings.Split(src, "/")
file := strs[len(strs)-1]
ext := filepath.Ext(file)
{
r.Logger.Debugf("remote> unmarshalling %s", string(bytes))
var err error
switch ext {
case "json":
err = json.Unmarshal(bytes, dst)
default:
err = yaml.Unmarshal(bytes, dst)
}
r.Logger.Debugf("remote> unmarshalled to %v", dst)
if err != nil {
return err
}
}
return nil
}
func (r *Remote) GetBytes(goGetterSrc string) ([]byte, error) {
f, err := r.Fetch(goGetterSrc)
if err != nil {
return nil, err
}
bytes, err := r.fs.ReadFile(f)
if err != nil {
return nil, fmt.Errorf("read file: %v", err)
}
return bytes, nil
}
// Locate takes an URL to a remote file or a path to a local file.
// If the argument was an URL, it fetches the remote directory contained within the URL,
// and returns the path to the file in the fetched directory
func (r *Remote) Locate(urlOrPath string) (string, error) {
if r.fs.FileExistsAt(urlOrPath) || r.fs.DirectoryExistsAt(urlOrPath) {
return urlOrPath, nil
}
fetched, err := r.Fetch(urlOrPath)
if err != nil {
if _, ok := err.(InvalidURLError); ok {
return urlOrPath, nil
}
return "", err
}
return fetched, nil
}
type InvalidURLError struct {
err string
}
func (e InvalidURLError) Error() string {
return e.err
}
type Source struct {
Getter, Scheme, User, Host, Dir, File, RawQuery string
}
func IsRemote(goGetterSrc string) bool {
if _, err := Parse(goGetterSrc); err != nil {
return false
}
return true
}
func Parse(goGetterSrc string) (*Source, error) {
items := strings.Split(goGetterSrc, "::")
var getter string
if len(items) == 2 {
getter = items[0]
goGetterSrc = items[1]
}
u, err := url.Parse(goGetterSrc)
if err != nil {
return nil, InvalidURLError{err: fmt.Sprintf("parse url: %v", err)}
}
if u.Scheme == "" {
return nil, InvalidURLError{err: fmt.Sprintf("parse url: missing scheme - probably this is a local file path? %s", goGetterSrc)}
}
pathComponents := strings.Split(u.Path, "@")
if len(pathComponents) != 2 {
return nil, fmt.Errorf("invalid src format: it must be `[<getter>::]<scheme>://<host>/<path/to/dir>@<path/to/file>?key1=val1&key2=val2: got %s", goGetterSrc)
}
return &Source{
Getter: getter,
User: u.User.String(),
Scheme: u.Scheme,
Host: u.Host,
Dir: pathComponents[0],
File: pathComponents[1],
RawQuery: u.RawQuery,
}, nil
}
func (r *Remote) Fetch(goGetterSrc string, cacheDirOpt ...string) (string, error) {
u, err := Parse(goGetterSrc)
if err != nil {
return "", err
}
srcDir := fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Dir)
file := u.File
r.Logger.Debugf("remote> getter: %s", u.Getter)
r.Logger.Debugf("remote> scheme: %s", u.Scheme)
r.Logger.Debugf("remote> user: %s", u.User)
r.Logger.Debugf("remote> host: %s", u.Host)
r.Logger.Debugf("remote> dir: %s", u.Dir)
r.Logger.Debugf("remote> file: %s", u.File)
// This should be shared across variant commands, so that they can share cache for the shared imports
cacheBaseDir := ""
if len(cacheDirOpt) == 1 {
cacheBaseDir = cacheDirOpt[0]
} else if len(cacheDirOpt) > 0 {
return "", fmt.Errorf("[bug] cacheDirOpt's length: want 0 or 1, got %d", len(cacheDirOpt))
}
query := u.RawQuery
var cacheKey string
replacer := strings.NewReplacer(":", "", "//", "_", "/", "_", ".", "_")
dirKey := replacer.Replace(srcDir)
if len(query) > 0 {
q, _ := neturl.ParseQuery(query)
if q.Has("sshkey") {
q.Set("sshkey", "redacted")
}
paramsKey := strings.ReplaceAll(q.Encode(), "&", "_")
cacheKey = fmt.Sprintf("%s.%s", dirKey, paramsKey)
} else {
cacheKey = dirKey
}
cached := false
// e.g. https_github_com_cloudposse_helmfiles_git.ref=0.xx.0
getterDst := filepath.Join(cacheBaseDir, cacheKey)
// e.g. os.CacheDir()/helmfile/https_github_com_cloudposse_helmfiles_git.ref=0.xx.0
cacheDirPath := filepath.Join(r.Home, getterDst)
r.Logger.Debugf("remote> home: %s", r.Home)
r.Logger.Debugf("remote> getter dest: %s", getterDst)
r.Logger.Debugf("remote> cached dir: %s", cacheDirPath)
{
if r.fs.FileExistsAt(cacheDirPath) {
return "", fmt.Errorf("%s is not directory. please remove it so that variant could use it for dependency caching", getterDst)
}
if r.fs.DirectoryExistsAt(cacheDirPath) {
cached = true
}
}
if !cached {
var getterSrc string
if u.User != "" {
getterSrc = fmt.Sprintf("%s://%s@%s%s", u.Scheme, u.User, u.Host, u.Dir)
} else {
getterSrc = fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Dir)
}
if len(query) > 0 {
getterSrc = strings.Join([]string{getterSrc, query}, "?")
}
if u.Getter != "" {
getterSrc = u.Getter + "::" + getterSrc
}
r.Logger.Debugf("remote> downloading %s to %s", getterSrc, getterDst)
if err := r.Getter.Get(r.Home, getterSrc, cacheDirPath); err != nil {
rmerr := os.RemoveAll(cacheDirPath)
if rmerr != nil {
return "", multierr.Append(err, rmerr)
}
return "", err
}
}
return filepath.Join(cacheDirPath, file), nil
}
type Getter interface {
Get(wd, src, dst string) error
}
type GoGetter struct {
Logger *zap.SugaredLogger
}
func (g *GoGetter) Get(wd, src, dst string) error {
ctx := context.Background()
get := &getter.Client{
Ctx: ctx,
Src: src,
Dst: dst,
Pwd: wd,
Mode: getter.ClientModeDir,
Options: []getter.ClientOption{},
}
g.Logger.Debugf("client: %+v", *get)
if err := get.Get(); err != nil {
return fmt.Errorf("get: %v", err)
}
return nil
}
func NewRemote(logger *zap.SugaredLogger, homeDir string, fs *filesystem.FileSystem) *Remote {
if disableInsecureFeatures {
panic("Remote sources are disabled due to 'DISABLE_INSECURE_FEATURES'")
}
remote := &Remote{
Logger: logger,
Home: homeDir,
Getter: &GoGetter{Logger: logger},
fs: fs,
}
if remote.Home == "" {
// Use for remote charts
remote.Home = CacheDir()
}
return remote
}