-
Notifications
You must be signed in to change notification settings - Fork 153
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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]>
- Loading branch information
Showing
20 changed files
with
757 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"` | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
178
pkg/app/pipedv1/plugin/kubernetes/provider/helm_test.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
}) | ||
} | ||
} |
23 changes: 23 additions & 0 deletions
23
pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/.helmignore
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/ |
Oops, something went wrong.