1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 07:49:41 +02:00

import libhelm into portainer (#8128)

This commit is contained in:
Matt Hook 2022-11-30 14:25:47 +13:00 committed by GitHub
parent 241440a474
commit d2f6d1e415
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1305 additions and 5 deletions

View file

@ -0,0 +1,29 @@
package binary
import (
"github.com/pkg/errors"
"github.com/portainer/libhelm/options"
)
// Get runs `helm get` with specified get options.
// The get options translate to CLI arguments which are passed in to the helm binary when executing install.
func (hbpm *helmBinaryPackageManager) Get(getOpts options.GetOptions) ([]byte, error) {
if getOpts.Name == "" || getOpts.ReleaseResource == "" {
return nil, errors.New("release name and release resource are required")
}
args := []string{
string(getOpts.ReleaseResource),
getOpts.Name,
}
if getOpts.Namespace != "" {
args = append(args, "--namespace", getOpts.Namespace)
}
result, err := hbpm.runWithKubeConfig("get", args, getOpts.KubernetesClusterAccess)
if err != nil {
return nil, errors.Wrap(err, "failed to run helm get on specified args")
}
return result, nil
}

View file

@ -0,0 +1,58 @@
package binary
import (
"bytes"
"os/exec"
"path"
"runtime"
"github.com/pkg/errors"
"github.com/portainer/libhelm/options"
)
// helmBinaryPackageManager is a wrapper for the helm binary which implements HelmPackageManager
type helmBinaryPackageManager struct {
binaryPath string
}
// NewHelmBinaryPackageManager initializes a new HelmPackageManager service.
func NewHelmBinaryPackageManager(binaryPath string) *helmBinaryPackageManager {
return &helmBinaryPackageManager{binaryPath: binaryPath}
}
// runWithKubeConfig will execute run against the provided Kubernetes cluster with kubeconfig as cli arguments.
func (hbpm *helmBinaryPackageManager) runWithKubeConfig(command string, args []string, kca *options.KubernetesClusterAccess) ([]byte, error) {
cmdArgs := make([]string, 0)
if kca != nil {
cmdArgs = append(cmdArgs, "--kube-apiserver", kca.ClusterServerURL)
cmdArgs = append(cmdArgs, "--kube-token", kca.AuthToken)
cmdArgs = append(cmdArgs, "--kube-ca-file", kca.CertificateAuthorityFile)
}
cmdArgs = append(cmdArgs, args...)
return hbpm.run(command, cmdArgs)
}
// run will execute helm command against the provided Kubernetes cluster.
// The endpointId and authToken are dynamic params (based on the user) that allow helm to execute commands
// in the context of the current user against specified k8s cluster.
func (hbpm *helmBinaryPackageManager) run(command string, args []string) ([]byte, error) {
cmdArgs := make([]string, 0)
cmdArgs = append(cmdArgs, command)
cmdArgs = append(cmdArgs, args...)
helmPath := path.Join(hbpm.binaryPath, "helm")
if runtime.GOOS == "windows" {
helmPath = path.Join(hbpm.binaryPath, "helm.exe")
}
var stderr bytes.Buffer
cmd := exec.Command(helmPath, cmdArgs...)
cmd.Stderr = &stderr
output, err := cmd.Output()
if err != nil {
return nil, errors.Wrap(err, stderr.String())
}
return output, nil
}

View file

@ -0,0 +1,48 @@
package binary
import (
"encoding/json"
"github.com/pkg/errors"
"github.com/portainer/libhelm/options"
"github.com/portainer/libhelm/release"
)
// Install runs `helm install` with specified install options.
// The install options translate to CLI arguments which are passed in to the helm binary when executing install.
func (hbpm *helmBinaryPackageManager) Install(installOpts options.InstallOptions) (*release.Release, error) {
if installOpts.Name == "" {
installOpts.Name = "--generate-name"
}
args := []string{
installOpts.Name,
installOpts.Chart,
"--repo", installOpts.Repo,
"--output", "json",
}
if installOpts.Namespace != "" {
args = append(args, "--namespace", installOpts.Namespace)
}
if installOpts.ValuesFile != "" {
args = append(args, "--values", installOpts.ValuesFile)
}
if installOpts.Wait {
args = append(args, "--wait")
}
if installOpts.PostRenderer != "" {
args = append(args, "--post-renderer", installOpts.PostRenderer)
}
result, err := hbpm.runWithKubeConfig("install", args, installOpts.KubernetesClusterAccess)
if err != nil {
return nil, errors.Wrap(err, "failed to run helm install on specified args")
}
response := &release.Release{}
err = json.Unmarshal(result, &response)
if err != nil {
return nil, errors.Wrap(err, "failed to unmarshal helm install response to Release struct")
}
return response, nil
}

View file

@ -0,0 +1,118 @@
package binary
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/portainer/libhelm/options"
"github.com/stretchr/testify/assert"
)
func createValuesFile(values string) (string, error) {
file, err := os.CreateTemp("", "helm-values")
if err != nil {
return "", err
}
_, err = file.WriteString(values)
if err != nil {
file.Close()
return "", err
}
err = file.Close()
if err != nil {
return "", err
}
return file.Name(), nil
}
// getHelmBinaryPath is helper function to get local helm binary path (if helm is in path)
func getHelmBinaryPath() (string, error) {
path, err := exec.LookPath("helm")
if err != nil {
return "", err
}
dir, err := filepath.Abs(filepath.Dir(path))
if err != nil {
return "", err
}
return dir, nil
}
func Test_Install(t *testing.T) {
ensureIntegrationTest(t)
is := assert.New(t)
path, err := getHelmBinaryPath()
is.NoError(err, "helm binary must exist in path to run tests")
hbpm := NewHelmBinaryPackageManager(path)
t.Run("successfully installs nginx chart with name test-nginx", func(t *testing.T) {
// helm install test-nginx --repo https://charts.bitnami.com/bitnami nginx
installOpts := options.InstallOptions{
Name: "test-nginx",
Chart: "nginx",
Repo: "https://charts.bitnami.com/bitnami",
}
release, err := hbpm.Install(installOpts)
defer hbpm.run("uninstall", []string{"test-nginx"})
is.NoError(err, "should successfully install release", release)
})
t.Run("successfully installs nginx chart with generated name", func(t *testing.T) {
// helm install --generate-name --repo https://charts.bitnami.com/bitnami nginx
installOpts := options.InstallOptions{
Chart: "nginx",
Repo: "https://charts.bitnami.com/bitnami",
}
release, err := hbpm.Install(installOpts)
defer hbpm.run("uninstall", []string{release.Name})
is.NoError(err, "should successfully install release", release)
})
t.Run("successfully installs nginx with values", func(t *testing.T) {
// helm install test-nginx-2 --repo https://charts.bitnami.com/bitnami nginx --values /tmp/helm-values3161785816
values, err := createValuesFile("service:\n port: 8081")
is.NoError(err, "should create a values file")
defer os.Remove(values)
installOpts := options.InstallOptions{
Name: "test-nginx-2",
Chart: "nginx",
Repo: "https://charts.bitnami.com/bitnami",
ValuesFile: values,
}
release, err := hbpm.Install(installOpts)
defer hbpm.run("uninstall", []string{"test-nginx-2"})
is.NoError(err, "should successfully install release", release)
})
t.Run("successfully installs portainer chart with name portainer-test", func(t *testing.T) {
// helm install portainer-test portainer --repo https://portainer.github.io/k8s/
installOpts := options.InstallOptions{
Name: "portainer-test",
Chart: "portainer",
Repo: "https://portainer.github.io/k8s/",
}
release, err := hbpm.Install(installOpts)
defer hbpm.run("uninstall", []string{installOpts.Name})
is.NoError(err, "should successfully install release", release)
})
}
func ensureIntegrationTest(t *testing.T) {
if _, ok := os.LookupEnv("INTEGRATION_TEST"); !ok {
t.Skip("skip an integration test")
}
}

View file

@ -0,0 +1,38 @@
package binary
import (
"encoding/json"
"github.com/pkg/errors"
"github.com/portainer/libhelm/options"
"github.com/portainer/libhelm/release"
)
// List runs `helm list --output json --filter <filter> --selector <selector> --namespace <namespace>` with specified list options.
// The list options translate to CLI args the helm binary
func (hbpm *helmBinaryPackageManager) List(listOpts options.ListOptions) ([]release.ReleaseElement, error) {
args := []string{"--output", "json"}
if listOpts.Filter != "" {
args = append(args, "--filter", listOpts.Filter)
}
if listOpts.Selector != "" {
args = append(args, "--selector", listOpts.Selector)
}
if listOpts.Namespace != "" {
args = append(args, "--namespace", listOpts.Namespace)
}
result, err := hbpm.runWithKubeConfig("list", args, listOpts.KubernetesClusterAccess)
if err != nil {
return []release.ReleaseElement{}, errors.Wrap(err, "failed to run helm list on specified args")
}
response := []release.ReleaseElement{}
err = json.Unmarshal(result, &response)
if err != nil {
return []release.ReleaseElement{}, errors.Wrap(err, "failed to unmarshal helm list response to releastElement list")
}
return response, nil
}

View file

@ -0,0 +1,84 @@
package binary
// Package common implements common functionality for the helm.
// The functionality does not rely on the implementation of `HelmPackageManager`
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"time"
"github.com/pkg/errors"
"github.com/portainer/libhelm/options"
"gopkg.in/yaml.v3"
)
var errRequiredSearchOptions = errors.New("repo is required")
type File struct {
APIVersion string `yaml:"apiVersion" json:"apiVersion"`
Entries map[string][]Entry `yaml:"entries" json:"entries"`
Generated string `yaml:"generated" json:"generated"`
}
type Annotations struct {
Category string `yaml:"category" json:"category"`
}
type Entry struct {
Annotations *Annotations `yaml:"annotations" json:"annotations,omitempty"`
Created string `yaml:"created" json:"created"`
Deprecated bool `yaml:"deprecated" json:"deprecated"`
Description string `yaml:"description" json:"description"`
Digest string `yaml:"digest" json:"digest"`
Home string `yaml:"home" json:"home"`
Name string `yaml:"name" json:"name"`
Sources []string `yaml:"sources" json:"sources"`
Urls []string `yaml:"urls" json:"urls"`
Version string `yaml:"version" json:"version"`
Icon string `yaml:"icon" json:"icon,omitempty"`
}
// SearchRepo downloads the `index.yaml` file for specified repo, parses it and returns JSON to caller.
// The functionality is similar to that of what `helm search repo [chart] --repo <repo>` CLI runs;
// this approach is used instead since the `helm search repo` requires a repo to be added to the global helm cache
func (hbpm *helmBinaryPackageManager) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) {
if searchRepoOpts.Repo == "" {
return nil, errRequiredSearchOptions
}
// The current index.yaml is ~9MB on bitnami.
// At a slow @2mbit download = 40s. @100bit = ~1s.
// I'm seeing 3 - 4s over wifi.
// Give ample time but timeout for now. Can be improved in the future
client := http.Client{
Timeout: 60 * time.Second,
}
url, err := url.ParseRequestURI(searchRepoOpts.Repo)
if err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("invalid helm chart URL: %s", searchRepoOpts.Repo))
}
url.Path = path.Join(url.Path, "index.yaml")
resp, err := client.Get(url.String())
if err != nil {
return nil, errors.Wrap(err, "failed to get index file")
}
var file File
err = yaml.NewDecoder(resp.Body).Decode(&file)
if err != nil {
return nil, errors.Wrap(err, "failed to decode index file")
}
result, err := json.Marshal(file)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal index file")
}
return result, nil
}

View file

@ -0,0 +1,48 @@
package binary
import (
"testing"
"github.com/portainer/libhelm/libhelmtest"
"github.com/portainer/libhelm/options"
"github.com/stretchr/testify/assert"
)
func Test_SearchRepo(t *testing.T) {
libhelmtest.EnsureIntegrationTest(t)
is := assert.New(t)
hpm := NewHelmBinaryPackageManager("")
type testCase struct {
name string
url string
invalid bool
}
tests := []testCase{
{"not a helm repo", "https://portainer.io", true},
{"bitnami helm repo", "https://charts.bitnami.com/bitnami", false},
{"portainer helm repo", "https://portainer.github.io/k8s/", false},
{"gitlap helm repo with trailing slash", "https://charts.gitlab.io/", false},
{"elastic helm repo with trailing slash", "https://helm.elastic.co/", false},
}
for _, test := range tests {
func(tc testCase) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
response, err := hpm.SearchRepo(options.SearchRepoOptions{Repo: tc.url})
if tc.invalid {
is.Errorf(err, "error expected: %s", tc.url)
} else {
is.NoError(err, "no error expected: %s", tc.url)
}
if err == nil {
is.NotEmpty(response, "response expected")
}
})
}(test)
}
}

View file

@ -0,0 +1,29 @@
package binary
import (
"github.com/pkg/errors"
"github.com/portainer/libhelm/options"
)
var errRequiredShowOptions = errors.New("chart, repo and output format are required")
// Show runs `helm show <command> <chart> --repo <repo>` with specified show options.
// The show options translate to CLI arguments which are passed in to the helm binary when executing install.
func (hbpm *helmBinaryPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) {
if showOpts.Chart == "" || showOpts.Repo == "" || showOpts.OutputFormat == "" {
return nil, errRequiredShowOptions
}
args := []string{
string(showOpts.OutputFormat),
showOpts.Chart,
"--repo", showOpts.Repo,
}
result, err := hbpm.run("show", args)
if err != nil {
return nil, errors.Wrap(err, "failed to run helm show on specified args")
}
return result, nil
}

View file

@ -0,0 +1,160 @@
package test
import (
"encoding/json"
"strings"
"github.com/pkg/errors"
"github.com/portainer/libhelm/options"
"github.com/portainer/libhelm/release"
"github.com/portainer/portainer/api/pkg/libhelm"
"gopkg.in/yaml.v3"
)
const (
MockDataIndex = "mock-index"
MockDataChart = "mock-chart"
MockDataReadme = "mock-readme"
MockDataValues = "mock-values"
)
const (
MockReleaseHooks = "mock-release-hooks"
MockReleaseManifest = "mock-release-manifest"
MockReleaseNotes = "mock-release-notes"
MockReleaseValues = "mock-release-values"
)
// helmMockPackageManager is a test package for helm related http handler testing
// Note: this package currently uses a slice in a way that is not thread safe.
// Do not use this package for concurrent tests.
type helmMockPackageManager struct{}
// NewMockHelmBinaryPackageManager initializes a new HelmPackageManager service (a mock instance)
func NewMockHelmBinaryPackageManager(binaryPath string) libhelm.HelmPackageManager {
return &helmMockPackageManager{}
}
var mockCharts = []release.ReleaseElement{}
func newMockReleaseElement(installOpts options.InstallOptions) *release.ReleaseElement {
return &release.ReleaseElement{
Name: installOpts.Name,
Namespace: installOpts.Namespace,
Updated: "date/time",
Status: "deployed",
Chart: installOpts.Chart,
AppVersion: "1.2.3",
}
}
func newMockRelease(re *release.ReleaseElement) *release.Release {
return &release.Release{
Name: re.Name,
Namespace: re.Namespace,
}
}
// Install a helm chart (not thread safe)
func (hpm *helmMockPackageManager) Install(installOpts options.InstallOptions) (*release.Release, error) {
releaseElement := newMockReleaseElement(installOpts)
// Enforce only one chart with the same name per namespace
for i, rel := range mockCharts {
if rel.Name == installOpts.Name && rel.Namespace == installOpts.Namespace {
mockCharts[i] = *releaseElement
return newMockRelease(releaseElement), nil
}
}
mockCharts = append(mockCharts, *releaseElement)
return newMockRelease(releaseElement), nil
}
// Show values/readme/chart etc
func (hpm *helmMockPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) {
switch showOpts.OutputFormat {
case options.ShowChart:
return []byte(MockDataChart), nil
case options.ShowReadme:
return []byte(MockDataReadme), nil
case options.ShowValues:
return []byte(MockDataValues), nil
}
return nil, nil
}
// Get release details - all, hooks, manifest, notes and values
func (hpm *helmMockPackageManager) Get(getOpts options.GetOptions) ([]byte, error) {
switch getOpts.ReleaseResource {
case options.GetAll:
return []byte(strings.Join([]string{MockReleaseHooks, MockReleaseManifest, MockReleaseNotes, MockReleaseValues}, "---\n")), nil
case options.GetHooks:
return []byte(MockReleaseHooks), nil
case options.GetManifest:
return []byte(MockReleaseManifest), nil
case options.GetNotes:
return []byte(MockReleaseNotes), nil
case options.GetValues:
return []byte(MockReleaseValues), nil
default:
return nil, errors.New("invalid release resource")
}
}
// Uninstall a helm chart (not thread safe)
func (hpm *helmMockPackageManager) Uninstall(uninstallOpts options.UninstallOptions) error {
for i, rel := range mockCharts {
if rel.Name == uninstallOpts.Name && rel.Namespace == uninstallOpts.Namespace {
mockCharts = append(mockCharts[:i], mockCharts[i+1:]...)
}
}
return nil
}
// List a helm chart (not thread safe)
func (hpm *helmMockPackageManager) List(listOpts options.ListOptions) ([]release.ReleaseElement, error) {
return mockCharts, nil
}
const mockPortainerIndex = `apiVersion: v1
entries:
portainer:
- apiVersion: v2
appVersion: 2.0.0
created: "2020-12-01T21:51:37.367634957Z"
description: Helm chart used to deploy the Portainer for Kubernetes
digest: f0e13dd3e7a05d17cb35c7879ffa623fd43b2c10ca968203e302b7a6c2764ddb
home: https://www.portainer.io
icon: https://github.com/portainer/portainer/raw/develop/app/assets/ico/apple-touch-icon.png
maintainers:
- email: davidy@funkypenguin.co.nz
name: funkypenguin
url: https://www.funkypenguin.co.nz
name: portainer
sources:
- https://github.com/portainer/k8s
type: application
urls:
- https://github.com/portainer/k8s/releases/download/portainer-1.0.6/portainer-1.0.6.tgz
version: 1.0.6
generated: "2020-08-19T00:00:46.754739363Z"`
func (hbpm *helmMockPackageManager) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) {
// Always return the same repo data no matter what
reader := strings.NewReader(mockPortainerIndex)
var file release.File
err := yaml.NewDecoder(reader).Decode(&file)
if err != nil {
return nil, errors.Wrap(err, "failed to decode index file")
}
result, err := json.Marshal(file)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal index file")
}
return result, nil
}

View file

@ -0,0 +1,29 @@
package binary
import (
"github.com/pkg/errors"
"github.com/portainer/libhelm/options"
)
var errRequiredUninstallOptions = errors.New("release name is required")
// Uninstall runs `helm uninstall <name> --namespace <namespace>` with specified uninstall options.
// The uninstall options translate to CLI arguments which are passed in to the helm binary when executing uninstall.
func (hbpm *helmBinaryPackageManager) Uninstall(uninstallOpts options.UninstallOptions) error {
if uninstallOpts.Name == "" {
return errRequiredUninstallOptions
}
args := []string{uninstallOpts.Name}
if uninstallOpts.Namespace != "" {
args = append(args, "--namespace", uninstallOpts.Namespace)
}
_, err := hbpm.runWithKubeConfig("uninstall", args, uninstallOpts.KubernetesClusterAccess)
if err != nil {
return errors.Wrap(err, "failed to run helm uninstall on specified args")
}
return nil
}