Skip to content

Commit 6512b1f

Browse files
authored
Implement TemplateLocalChart with helm (#5294)
* 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]>
1 parent 91000fe commit 6512b1f

File tree

20 files changed

+757
-0
lines changed

20 files changed

+757
-0
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright 2024 The PipeCD Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package config
16+
17+
type InputHelmOptions struct {
18+
// The release name of helm deployment.
19+
// By default the release name is equal to the application name.
20+
ReleaseName string `json:"releaseName,omitempty"`
21+
// List of values.
22+
SetValues map[string]string `json:"setValues,omitempty"`
23+
// List of value files should be loaded.
24+
ValueFiles []string `json:"valueFiles,omitempty"`
25+
// List of file path for values.
26+
SetFiles map[string]string `json:"setFiles,omitempty"`
27+
// Set of supported Kubernetes API versions.
28+
APIVersions []string `json:"apiVersions,omitempty"`
29+
// Kubernetes version used for Capabilities.KubeVersion
30+
KubeVersion string `json:"kubeVersion,omitempty"`
31+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Copyright 2024 The PipeCD Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package provider
16+
17+
import (
18+
"bytes"
19+
"context"
20+
"fmt"
21+
"net/url"
22+
"os"
23+
"os/exec"
24+
"path/filepath"
25+
"strings"
26+
27+
"go.uber.org/zap"
28+
29+
"github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/config"
30+
)
31+
32+
var (
33+
allowedURLSchemes = []string{"http", "https"}
34+
)
35+
36+
type Helm struct {
37+
execPath string
38+
logger *zap.Logger
39+
}
40+
41+
func NewHelm(path string, logger *zap.Logger) *Helm {
42+
return &Helm{
43+
execPath: path,
44+
logger: logger,
45+
}
46+
}
47+
48+
func (h *Helm) TemplateLocalChart(ctx context.Context, appName, appDir, namespace, chartPath string, opts *config.InputHelmOptions) (string, error) {
49+
releaseName := appName
50+
if opts != nil && opts.ReleaseName != "" {
51+
releaseName = opts.ReleaseName
52+
}
53+
54+
args := []string{
55+
"template",
56+
"--no-hooks",
57+
"--include-crds",
58+
releaseName,
59+
chartPath,
60+
}
61+
62+
if namespace != "" {
63+
args = append(args, fmt.Sprintf("--namespace=%s", namespace))
64+
}
65+
66+
if opts != nil {
67+
for k, v := range opts.SetValues {
68+
args = append(args, "--set", fmt.Sprintf("%s=%s", k, v))
69+
}
70+
for _, v := range opts.ValueFiles {
71+
if err := verifyHelmValueFilePath(appDir, v); err != nil {
72+
h.logger.Error("failed to verify values file path", zap.Error(err))
73+
return "", err
74+
}
75+
args = append(args, "-f", v)
76+
}
77+
for k, v := range opts.SetFiles {
78+
args = append(args, "--set-file", fmt.Sprintf("%s=%s", k, v))
79+
}
80+
for _, v := range opts.APIVersions {
81+
args = append(args, "--api-versions", v)
82+
}
83+
if opts.KubeVersion != "" {
84+
args = append(args, "--kube-version", opts.KubeVersion)
85+
}
86+
}
87+
88+
var stdout, stderr bytes.Buffer
89+
cmd := exec.CommandContext(ctx, h.execPath, args...)
90+
cmd.Dir = appDir
91+
cmd.Stdout = &stdout
92+
cmd.Stderr = &stderr
93+
94+
h.logger.Info(fmt.Sprintf("start templating a local chart (or cloned remote git chart) for application %s", appName),
95+
zap.Any("args", args),
96+
)
97+
98+
if err := cmd.Run(); err != nil {
99+
return stdout.String(), fmt.Errorf("%w: %s", err, stderr.String())
100+
}
101+
return stdout.String(), nil
102+
}
103+
104+
// verifyHelmValueFilePath verifies if the path of the values file references
105+
// a remote URL or inside the path where the application configuration file (i.e. *.pipecd.yaml) is located.
106+
func verifyHelmValueFilePath(appDir, valueFilePath string) error {
107+
url, err := url.Parse(valueFilePath)
108+
if err == nil && url.Scheme != "" {
109+
for _, s := range allowedURLSchemes {
110+
if strings.EqualFold(url.Scheme, s) {
111+
return nil
112+
}
113+
}
114+
115+
return fmt.Errorf("scheme %s is not allowed to load values file", url.Scheme)
116+
}
117+
118+
// valueFilePath is a path where non-default Helm values file is located.
119+
if !filepath.IsAbs(valueFilePath) {
120+
valueFilePath = filepath.Join(appDir, valueFilePath)
121+
}
122+
123+
if isSymlink(valueFilePath) {
124+
if valueFilePath, err = resolveSymlinkToAbsPath(valueFilePath, appDir); err != nil {
125+
return err
126+
}
127+
}
128+
129+
// If a path outside of appDir is specified as the path for the values file,
130+
// it may indicate that someone trying to illegally read a file as values file that
131+
// exists in the environment where Piped is running.
132+
if !strings.HasPrefix(valueFilePath, appDir) {
133+
return fmt.Errorf("values file %s references outside the application configuration directory", valueFilePath)
134+
}
135+
136+
return nil
137+
}
138+
139+
// isSymlink returns the path is whether symbolic link or not.
140+
func isSymlink(path string) bool {
141+
lstat, err := os.Lstat(path)
142+
if err != nil {
143+
return false
144+
}
145+
146+
return lstat.Mode()&os.ModeSymlink == os.ModeSymlink
147+
}
148+
149+
// resolveSymlinkToAbsPath resolves symbolic link to an absolute path.
150+
func resolveSymlinkToAbsPath(path, absParentDir string) (string, error) {
151+
resolved, err := os.Readlink(path)
152+
if err != nil {
153+
return "", err
154+
}
155+
156+
if !filepath.IsAbs(resolved) {
157+
resolved = filepath.Join(absParentDir, resolved)
158+
}
159+
160+
return resolved, nil
161+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Copyright 2024 The PipeCD Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package provider
16+
17+
import (
18+
"context"
19+
"strings"
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
"go.uber.org/zap"
25+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
26+
27+
"github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/toolregistry"
28+
"github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/toolregistry/toolregistrytest"
29+
)
30+
31+
func TestTemplateLocalChart(t *testing.T) {
32+
t.Parallel()
33+
34+
var (
35+
ctx = context.Background()
36+
appName = "testapp"
37+
appDir = "testdata"
38+
chartPath = "testchart"
39+
)
40+
41+
c, err := toolregistrytest.NewToolRegistry(t)
42+
require.NoError(t, err)
43+
t.Cleanup(func() { c.Close() })
44+
45+
r := toolregistry.NewRegistry(c)
46+
helmPath, err := r.Helm(ctx, "3.16.1")
47+
require.NoError(t, err)
48+
49+
helm := NewHelm(helmPath, zap.NewNop())
50+
out, err := helm.TemplateLocalChart(ctx, appName, appDir, "", chartPath, nil)
51+
require.NoError(t, err)
52+
53+
out = strings.TrimPrefix(out, "---")
54+
manifests := strings.Split(out, "---")
55+
assert.Equal(t, 3, len(manifests))
56+
}
57+
58+
func TestTemplateLocalChart_WithNamespace(t *testing.T) {
59+
t.Parallel()
60+
61+
var (
62+
ctx = context.Background()
63+
appName = "testapp"
64+
appDir = "testdata"
65+
chartPath = "testchart"
66+
namespace = "testnamespace"
67+
)
68+
69+
c, err := toolregistrytest.NewToolRegistry(t)
70+
require.NoError(t, err)
71+
t.Cleanup(func() { c.Close() })
72+
73+
r := toolregistry.NewRegistry(c)
74+
helmPath, err := r.Helm(ctx, "3.16.1")
75+
require.NoError(t, err)
76+
77+
helm := NewHelm(helmPath, zap.NewNop())
78+
out, err := helm.TemplateLocalChart(ctx, appName, appDir, namespace, chartPath, nil)
79+
require.NoError(t, err)
80+
81+
out = strings.TrimPrefix(out, "---")
82+
83+
manifests, _ := ParseManifests(out)
84+
for _, manifest := range manifests {
85+
metadata, _, err := unstructured.NestedMap(manifest.Body.Object, "metadata")
86+
require.NoError(t, err)
87+
require.Equal(t, namespace, metadata["namespace"])
88+
}
89+
}
90+
91+
func TestVerifyHelmValueFilePath(t *testing.T) {
92+
t.Parallel()
93+
94+
testcases := []struct {
95+
name string
96+
appDir string
97+
valueFilePath string
98+
wantErr bool
99+
}{
100+
{
101+
name: "Values file locates inside the app dir",
102+
appDir: "testdata/testhelm/appconfdir",
103+
valueFilePath: "values.yaml",
104+
wantErr: false,
105+
},
106+
{
107+
name: "Values file locates inside the app dir (with ..)",
108+
appDir: "testdata/testhelm/appconfdir",
109+
valueFilePath: "../../../testdata/testhelm/appconfdir/values.yaml",
110+
wantErr: false,
111+
},
112+
{
113+
name: "Values file locates under the app dir",
114+
appDir: "testdata/testhelm/appconfdir",
115+
valueFilePath: "dir/values.yaml",
116+
wantErr: false,
117+
},
118+
{
119+
name: "Values file locates under the app dir (with ..)",
120+
appDir: "testdata/testhelm/appconfdir",
121+
valueFilePath: "../../../testdata/testhelm/appconfdir/dir/values.yaml",
122+
wantErr: false,
123+
},
124+
{
125+
name: "arbitrary file locates outside the app dir",
126+
appDir: "testdata/testhelm/appconfdir",
127+
valueFilePath: "/etc/hosts",
128+
wantErr: true,
129+
},
130+
{
131+
name: "arbitrary file locates outside the app dir (with ..)",
132+
appDir: "testdata/testhelm/appconfdir",
133+
valueFilePath: "../../../../../../../../../../../../etc/hosts",
134+
wantErr: true,
135+
},
136+
{
137+
name: "Values file locates allowed remote URL (http)",
138+
appDir: "testdata/testhelm/appconfdir",
139+
valueFilePath: "http://exmaple.com/values.yaml",
140+
wantErr: false,
141+
},
142+
{
143+
name: "Values file locates allowed remote URL (https)",
144+
appDir: "testdata/testhelm/appconfdir",
145+
valueFilePath: "https://exmaple.com/values.yaml",
146+
wantErr: false,
147+
},
148+
{
149+
name: "Values file locates disallowed remote URL (ftp)",
150+
appDir: "testdata/testhelm/appconfdir",
151+
valueFilePath: "ftp://exmaple.com/values.yaml",
152+
wantErr: true,
153+
},
154+
{
155+
name: "Values file is symlink targeting valid values file",
156+
appDir: "testdata/testhelm/appconfdir",
157+
valueFilePath: "valid-symlink",
158+
wantErr: false,
159+
},
160+
{
161+
name: "Values file is symlink targeting invalid values file",
162+
appDir: "testdata/testhelm/appconfdir",
163+
valueFilePath: "invalid-symlink",
164+
wantErr: true,
165+
},
166+
}
167+
168+
for _, tc := range testcases {
169+
t.Run(tc.name, func(t *testing.T) {
170+
err := verifyHelmValueFilePath(tc.appDir, tc.valueFilePath)
171+
if tc.wantErr {
172+
require.Error(t, err)
173+
} else {
174+
require.NoError(t, err)
175+
}
176+
})
177+
}
178+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Patterns to ignore when building packages.
2+
# This supports shell glob matching, relative path matching, and
3+
# negation (prefixed with !). Only one pattern per line.
4+
.DS_Store
5+
# Common VCS dirs
6+
.git/
7+
.gitignore
8+
.bzr/
9+
.bzrignore
10+
.hg/
11+
.hgignore
12+
.svn/
13+
# Common backup files
14+
*.swp
15+
*.bak
16+
*.tmp
17+
*.orig
18+
*~
19+
# Various IDEs
20+
.project
21+
.idea/
22+
*.tmproj
23+
.vscode/

0 commit comments

Comments
 (0)