Skip to content

Commit

Permalink
Implement TemplateLocalChart with helm (#5294)
Browse files Browse the repository at this point in the history
* Implement TemplateLocalChart with helm

Signed-off-by: Shinnosuke Sawada-Dazai <[email protected]>

* Copy testdata for helm test

Signed-off-by: Shinnosuke Sawada-Dazai <[email protected]>

* Add helm test

Signed-off-by: Shinnosuke Sawada-Dazai <[email protected]>

---------

Signed-off-by: Shinnosuke Sawada-Dazai <[email protected]>
  • Loading branch information
Warashi authored Oct 28, 2024
1 parent 91000fe commit 6512b1f
Show file tree
Hide file tree
Showing 20 changed files with 757 additions and 0 deletions.
31 changes: 31 additions & 0 deletions pkg/app/pipedv1/plugin/kubernetes/config/helm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright 2024 The PipeCD Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package config

type InputHelmOptions struct {
// The release name of helm deployment.
// By default the release name is equal to the application name.
ReleaseName string `json:"releaseName,omitempty"`
// List of values.
SetValues map[string]string `json:"setValues,omitempty"`
// List of value files should be loaded.
ValueFiles []string `json:"valueFiles,omitempty"`
// List of file path for values.
SetFiles map[string]string `json:"setFiles,omitempty"`
// Set of supported Kubernetes API versions.
APIVersions []string `json:"apiVersions,omitempty"`
// Kubernetes version used for Capabilities.KubeVersion
KubeVersion string `json:"kubeVersion,omitempty"`
}
161 changes: 161 additions & 0 deletions pkg/app/pipedv1/plugin/kubernetes/provider/helm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Copyright 2024 The PipeCD Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package provider

import (
"bytes"
"context"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"

"go.uber.org/zap"

"github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/config"
)

var (
allowedURLSchemes = []string{"http", "https"}
)

type Helm struct {
execPath string
logger *zap.Logger
}

func NewHelm(path string, logger *zap.Logger) *Helm {
return &Helm{
execPath: path,
logger: logger,
}
}

func (h *Helm) TemplateLocalChart(ctx context.Context, appName, appDir, namespace, chartPath string, opts *config.InputHelmOptions) (string, error) {
releaseName := appName
if opts != nil && opts.ReleaseName != "" {
releaseName = opts.ReleaseName
}

args := []string{
"template",
"--no-hooks",
"--include-crds",
releaseName,
chartPath,
}

if namespace != "" {
args = append(args, fmt.Sprintf("--namespace=%s", namespace))
}

if opts != nil {
for k, v := range opts.SetValues {
args = append(args, "--set", fmt.Sprintf("%s=%s", k, v))
}
for _, v := range opts.ValueFiles {
if err := verifyHelmValueFilePath(appDir, v); err != nil {
h.logger.Error("failed to verify values file path", zap.Error(err))
return "", err
}
args = append(args, "-f", v)
}
for k, v := range opts.SetFiles {
args = append(args, "--set-file", fmt.Sprintf("%s=%s", k, v))
}
for _, v := range opts.APIVersions {
args = append(args, "--api-versions", v)
}
if opts.KubeVersion != "" {
args = append(args, "--kube-version", opts.KubeVersion)
}
}

var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, h.execPath, args...)
cmd.Dir = appDir
cmd.Stdout = &stdout
cmd.Stderr = &stderr

h.logger.Info(fmt.Sprintf("start templating a local chart (or cloned remote git chart) for application %s", appName),
zap.Any("args", args),
)

if err := cmd.Run(); err != nil {
return stdout.String(), fmt.Errorf("%w: %s", err, stderr.String())
}
return stdout.String(), nil
}

// verifyHelmValueFilePath verifies if the path of the values file references
// a remote URL or inside the path where the application configuration file (i.e. *.pipecd.yaml) is located.
func verifyHelmValueFilePath(appDir, valueFilePath string) error {
url, err := url.Parse(valueFilePath)
if err == nil && url.Scheme != "" {
for _, s := range allowedURLSchemes {
if strings.EqualFold(url.Scheme, s) {
return nil
}
}

return fmt.Errorf("scheme %s is not allowed to load values file", url.Scheme)
}

// valueFilePath is a path where non-default Helm values file is located.
if !filepath.IsAbs(valueFilePath) {
valueFilePath = filepath.Join(appDir, valueFilePath)
}

if isSymlink(valueFilePath) {
if valueFilePath, err = resolveSymlinkToAbsPath(valueFilePath, appDir); err != nil {
return err
}
}

// If a path outside of appDir is specified as the path for the values file,
// it may indicate that someone trying to illegally read a file as values file that
// exists in the environment where Piped is running.
if !strings.HasPrefix(valueFilePath, appDir) {
return fmt.Errorf("values file %s references outside the application configuration directory", valueFilePath)
}

return nil
}

// isSymlink returns the path is whether symbolic link or not.
func isSymlink(path string) bool {
lstat, err := os.Lstat(path)
if err != nil {
return false
}

return lstat.Mode()&os.ModeSymlink == os.ModeSymlink
}

// resolveSymlinkToAbsPath resolves symbolic link to an absolute path.
func resolveSymlinkToAbsPath(path, absParentDir string) (string, error) {
resolved, err := os.Readlink(path)
if err != nil {
return "", err
}

if !filepath.IsAbs(resolved) {
resolved = filepath.Join(absParentDir, resolved)
}

return resolved, nil
}
178 changes: 178 additions & 0 deletions pkg/app/pipedv1/plugin/kubernetes/provider/helm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// Copyright 2024 The PipeCD Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package provider

import (
"context"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"

"github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/toolregistry"
"github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/toolregistry/toolregistrytest"
)

func TestTemplateLocalChart(t *testing.T) {
t.Parallel()

var (
ctx = context.Background()
appName = "testapp"
appDir = "testdata"
chartPath = "testchart"
)

c, err := toolregistrytest.NewToolRegistry(t)
require.NoError(t, err)
t.Cleanup(func() { c.Close() })

r := toolregistry.NewRegistry(c)
helmPath, err := r.Helm(ctx, "3.16.1")
require.NoError(t, err)

helm := NewHelm(helmPath, zap.NewNop())
out, err := helm.TemplateLocalChart(ctx, appName, appDir, "", chartPath, nil)
require.NoError(t, err)

out = strings.TrimPrefix(out, "---")
manifests := strings.Split(out, "---")
assert.Equal(t, 3, len(manifests))
}

func TestTemplateLocalChart_WithNamespace(t *testing.T) {
t.Parallel()

var (
ctx = context.Background()
appName = "testapp"
appDir = "testdata"
chartPath = "testchart"
namespace = "testnamespace"
)

c, err := toolregistrytest.NewToolRegistry(t)
require.NoError(t, err)
t.Cleanup(func() { c.Close() })

r := toolregistry.NewRegistry(c)
helmPath, err := r.Helm(ctx, "3.16.1")
require.NoError(t, err)

helm := NewHelm(helmPath, zap.NewNop())
out, err := helm.TemplateLocalChart(ctx, appName, appDir, namespace, chartPath, nil)
require.NoError(t, err)

out = strings.TrimPrefix(out, "---")

manifests, _ := ParseManifests(out)
for _, manifest := range manifests {
metadata, _, err := unstructured.NestedMap(manifest.Body.Object, "metadata")
require.NoError(t, err)
require.Equal(t, namespace, metadata["namespace"])
}
}

func TestVerifyHelmValueFilePath(t *testing.T) {
t.Parallel()

testcases := []struct {
name string
appDir string
valueFilePath string
wantErr bool
}{
{
name: "Values file locates inside the app dir",
appDir: "testdata/testhelm/appconfdir",
valueFilePath: "values.yaml",
wantErr: false,
},
{
name: "Values file locates inside the app dir (with ..)",
appDir: "testdata/testhelm/appconfdir",
valueFilePath: "../../../testdata/testhelm/appconfdir/values.yaml",
wantErr: false,
},
{
name: "Values file locates under the app dir",
appDir: "testdata/testhelm/appconfdir",
valueFilePath: "dir/values.yaml",
wantErr: false,
},
{
name: "Values file locates under the app dir (with ..)",
appDir: "testdata/testhelm/appconfdir",
valueFilePath: "../../../testdata/testhelm/appconfdir/dir/values.yaml",
wantErr: false,
},
{
name: "arbitrary file locates outside the app dir",
appDir: "testdata/testhelm/appconfdir",
valueFilePath: "/etc/hosts",
wantErr: true,
},
{
name: "arbitrary file locates outside the app dir (with ..)",
appDir: "testdata/testhelm/appconfdir",
valueFilePath: "../../../../../../../../../../../../etc/hosts",
wantErr: true,
},
{
name: "Values file locates allowed remote URL (http)",
appDir: "testdata/testhelm/appconfdir",
valueFilePath: "http://exmaple.com/values.yaml",
wantErr: false,
},
{
name: "Values file locates allowed remote URL (https)",
appDir: "testdata/testhelm/appconfdir",
valueFilePath: "https://exmaple.com/values.yaml",
wantErr: false,
},
{
name: "Values file locates disallowed remote URL (ftp)",
appDir: "testdata/testhelm/appconfdir",
valueFilePath: "ftp://exmaple.com/values.yaml",
wantErr: true,
},
{
name: "Values file is symlink targeting valid values file",
appDir: "testdata/testhelm/appconfdir",
valueFilePath: "valid-symlink",
wantErr: false,
},
{
name: "Values file is symlink targeting invalid values file",
appDir: "testdata/testhelm/appconfdir",
valueFilePath: "invalid-symlink",
wantErr: true,
},
}

for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
err := verifyHelmValueFilePath(tc.appDir, tc.valueFilePath)
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
Loading

0 comments on commit 6512b1f

Please sign in to comment.