import libhelm into portainer (#8128)

pull/8140/head
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

@ -9,7 +9,6 @@ import (
"strconv"
"strings"
"github.com/portainer/libhelm"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/build"
@ -40,6 +39,7 @@ import (
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/ldap"
"github.com/portainer/portainer/api/oauth"
"github.com/portainer/portainer/api/pkg/libhelm"
"github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks/deployments"

View File

@ -4,7 +4,6 @@ import (
"net/http"
"github.com/gorilla/mux"
"github.com/portainer/libhelm"
"github.com/portainer/libhelm/options"
httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
@ -12,6 +11,7 @@ import (
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/pkg/libhelm"
)
type requestBouncer interface {

View File

@ -6,7 +6,7 @@ import (
"github.com/pkg/errors"
"github.com/portainer/libhelm"
"github.com/portainer/portainer/api/pkg/libhelm"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"

View File

@ -7,13 +7,13 @@ import (
"github.com/asaskevich/govalidator"
"github.com/pkg/errors"
"github.com/portainer/libhelm"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/pkg/libhelm"
)
type settingsUpdatePayload struct {

View File

@ -7,7 +7,6 @@ import (
"path/filepath"
"time"
"github.com/portainer/libhelm"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/adminmonitor"
"github.com/portainer/portainer/api/apikey"
@ -60,6 +59,7 @@ import (
"github.com/portainer/portainer/api/internal/ssl"
k8s "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/pkg/libhelm"
"github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks/deployments"

7
api/pkg/libhelm/LICENSE Normal file
View File

@ -0,0 +1,7 @@
Copyright 2021 Portainer.io
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

17
api/pkg/libhelm/README.md Normal file
View File

@ -0,0 +1,17 @@
# LibHelm
A helm abstraction for Portainer.
## Installation
```sh
go get github.com/portainer/portainer/api/pkg/libhelm
```
## Tests
### Integration
```sh
INTEGRATION_TEST=1 go test binary/*.go
```

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
}

16
api/pkg/libhelm/helm.go Normal file
View File

@ -0,0 +1,16 @@
package libhelm
import (
"github.com/portainer/libhelm/options"
"github.com/portainer/libhelm/release"
)
// HelmPackageManager represents a service that interfaces with Helm
type HelmPackageManager interface {
Show(showOpts options.ShowOptions) ([]byte, error)
SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error)
Get(getOpts options.GetOptions) ([]byte, error)
List(listOpts options.ListOptions) ([]release.ReleaseElement, error)
Install(installOpts options.InstallOptions) (*release.Release, error)
Uninstall(uninstallOpts options.UninstallOptions) error
}

View File

@ -0,0 +1,13 @@
package libhelmtest
import (
"os"
"testing"
)
// EnsureIntegrationTest disables live integration tests that prevent portainer CI checks from succeeding on github
func EnsureIntegrationTest(t *testing.T) {
if _, ok := os.LookupEnv("INTEGRATION_TEST"); !ok {
t.Skip("skip an integration test")
}
}

View File

@ -0,0 +1,22 @@
package libhelm
import (
"errors"
"github.com/portainer/libhelm/binary"
)
// HelmConfig is a struct that holds the configuration for the Helm package manager
type HelmConfig struct {
BinaryPath string `example:"/portainer/dist"`
}
var errBinaryPathNotSpecified = errors.New("binary path not specified")
// NewHelmPackageManager returns a new instance of HelmPackageManager based on HelmConfig
func NewHelmPackageManager(config HelmConfig) (HelmPackageManager, error) {
if config.BinaryPath != "" {
return binary.NewHelmBinaryPackageManager(config.BinaryPath), nil
}
return nil, errBinaryPathNotSpecified
}

View File

@ -0,0 +1,8 @@
package options
// KubernetesClusterAccess represents core details which can be used to generate KubeConfig file/data
type KubernetesClusterAccess struct {
ClusterServerURL string `example:"https://mycompany.k8s.com"`
CertificateAuthorityFile string `example:"/data/tls/localhost.crt"`
AuthToken string `example:"ey..."`
}

View File

@ -0,0 +1,20 @@
package options
// releaseResource are the supported `helm get` sub-commands
// to see all available sub-commands run `helm get --help`
type releaseResource string
const (
GetAll releaseResource = "all"
GetHooks releaseResource = "hooks"
GetManifest releaseResource = "manifest"
GetNotes releaseResource = "notes"
GetValues releaseResource = "values"
)
type GetOptions struct {
Name string
Namespace string
ReleaseResource releaseResource
KubernetesClusterAccess *KubernetesClusterAccess
}

View File

@ -0,0 +1,12 @@
package options
type InstallOptions struct {
Name string
Chart string
Namespace string
Repo string
Wait bool
ValuesFile string
PostRenderer string
KubernetesClusterAccess *KubernetesClusterAccess
}

View File

@ -0,0 +1,9 @@
package options
// ListOptions are portainer supported options for `helm list`
type ListOptions struct {
Filter string
Selector string
Namespace string
KubernetesClusterAccess *KubernetesClusterAccess
}

View File

@ -0,0 +1,5 @@
package options
type SearchRepoOptions struct {
Repo string
}

View File

@ -0,0 +1,22 @@
package options
// ShowOutputFormat is the format of the output of `helm show`
type ShowOutputFormat string
const (
// ShowAll is the format which shows all the information of a chart
ShowAll ShowOutputFormat = "all"
// ShowChart is the format which only shows the chart's definition
ShowChart ShowOutputFormat = "chart"
// ShowValues is the format which only shows the chart's values
ShowValues ShowOutputFormat = "values"
// ShowReadme is the format which only shows the chart's README
ShowReadme ShowOutputFormat = "readme"
)
// ShowOptions are portainer supported options for `helm install`
type ShowOptions struct {
OutputFormat ShowOutputFormat
Chart string
Repo string
}

View File

@ -0,0 +1,8 @@
package options
// UninstallOptions are portainer supported options for `helm uninstall`
type UninstallOptions struct {
Name string
Namespace string
KubernetesClusterAccess *KubernetesClusterAccess
}

View File

@ -0,0 +1,230 @@
package release
import "github.com/portainer/libhelm/time"
// Release is the struct that holds the information for a helm release.
// The struct definitions have been copied from the offical Helm Golang client/library.
// ReleaseElement is a struct that represents a release
// This is the official struct from the helm project (golang codebase) - exported
type ReleaseElement struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Revision string `json:"revision"`
Updated string `json:"updated"`
Status string `json:"status"`
Chart string `json:"chart"`
AppVersion string `json:"app_version"`
}
// Release describes a deployment of a chart, together with the chart
// and the variables used to deploy that chart.
type Release struct {
// Name is the name of the release
Name string `json:"name,omitempty"`
// Info provides information about a release
// Info *Info `json:"info,omitempty"`
// Chart is the chart that was released.
Chart Chart `json:"chart,omitempty"`
// Config is the set of extra Values added to the chart.
// These values override the default values inside of the chart.
Config map[string]interface{} `json:"config,omitempty"`
// Manifest is the string representation of the rendered template.
Manifest string `json:"manifest,omitempty"`
// Hooks are all of the hooks declared for this release.
Hooks []*Hook `json:"hooks,omitempty"`
// Version is an int which represents the revision of the release.
Version int `json:"version,omitempty"`
// Namespace is the kubernetes namespace of the release.
Namespace string `json:"namespace,omitempty"`
// Labels of the release.
// Disabled encoding into Json cause labels are stored in storage driver metadata field.
Labels map[string]string `json:"-"`
}
// Chart is a helm package that contains metadata, a default config, zero or more
// optionally parameterizable templates, and zero or more charts (dependencies).
type Chart struct {
// Raw contains the raw contents of the files originally contained in the chart archive.
//
// This should not be used except in special cases like `helm show values`,
// where we want to display the raw values, comments and all.
Raw []*File `json:"-"`
// Metadata is the contents of the Chartfile.
Metadata *Metadata `json:"metadata"`
// Lock is the contents of Chart.lock.
Lock *Lock `json:"lock"`
// Templates for this chart.
Templates []*File `json:"templates"`
// Values are default config for this chart.
Values map[string]interface{} `json:"values"`
// Schema is an optional JSON schema for imposing structure on Values
Schema []byte `json:"schema"`
// Files are miscellaneous files in a chart archive,
// e.g. README, LICENSE, etc.
Files []*File `json:"files"`
parent *Chart
dependencies []*Chart
}
// File represents a file as a name/value pair.
//
// By convention, name is a relative path within the scope of the chart's
// base directory.
type File struct {
// Name is the path-like name of the template.
Name string `json:"name"`
// Data is the template as byte data.
Data []byte `json:"data"`
}
// Metadata for a Chart file. This models the structure of a Chart.yaml file.
type Metadata struct {
// The name of the chart. Required.
Name string `json:"name,omitempty"`
// The URL to a relevant project page, git repo, or contact person
Home string `json:"home,omitempty"`
// Source is the URL to the source code of this chart
Sources []string `json:"sources,omitempty"`
// A SemVer 2 conformant version string of the chart. Required.
Version string `json:"version,omitempty"`
// A one-sentence description of the chart
Description string `json:"description,omitempty"`
// A list of string keywords
Keywords []string `json:"keywords,omitempty"`
// A list of name and URL/email address combinations for the maintainer(s)
Maintainers []*Maintainer `json:"maintainers,omitempty"`
// The URL to an icon file.
Icon string `json:"icon,omitempty"`
// The API Version of this chart. Required.
APIVersion string `json:"apiVersion,omitempty"`
// The condition to check to enable chart
Condition string `json:"condition,omitempty"`
// The tags to check to enable chart
Tags string `json:"tags,omitempty"`
// The version of the application enclosed inside of this chart.
AppVersion string `json:"appVersion,omitempty"`
// Whether or not this chart is deprecated
Deprecated bool `json:"deprecated,omitempty"`
// Annotations are additional mappings uninterpreted by Helm,
// made available for inspection by other applications.
Annotations map[string]string `json:"annotations,omitempty"`
// KubeVersion is a SemVer constraint specifying the version of Kubernetes required.
KubeVersion string `json:"kubeVersion,omitempty"`
// Dependencies are a list of dependencies for a chart.
Dependencies []*Dependency `json:"dependencies,omitempty"`
// Specifies the chart type: application or library
Type string `json:"type,omitempty"`
}
// Maintainer describes a Chart maintainer.
type Maintainer struct {
// Name is a user name or organization name
Name string `json:"name,omitempty"`
// Email is an optional email address to contact the named maintainer
Email string `json:"email,omitempty"`
// URL is an optional URL to an address for the named maintainer
URL string `json:"url,omitempty"`
}
// Dependency describes a chart upon which another chart depends.
//
// Dependencies can be used to express developer intent, or to capture the state
// of a chart.
type Dependency struct {
// Name is the name of the dependency.
//
// This must mach the name in the dependency's Chart.yaml.
Name string `json:"name"`
// Version is the version (range) of this chart.
//
// A lock file will always produce a single version, while a dependency
// may contain a semantic version range.
Version string `json:"version,omitempty"`
// The URL to the repository.
//
// Appending `index.yaml` to this string should result in a URL that can be
// used to fetch the repository index.
Repository string `json:"repository"`
// A yaml path that resolves to a boolean, used for enabling/disabling charts (e.g. subchart1.enabled )
Condition string `json:"condition,omitempty"`
// Tags can be used to group charts for enabling/disabling together
Tags []string `json:"tags,omitempty"`
// Enabled bool determines if chart should be loaded
Enabled bool `json:"enabled,omitempty"`
// ImportValues holds the mapping of source values to parent key to be imported. Each item can be a
// string or pair of child/parent sublist items.
ImportValues []interface{} `json:"import-values,omitempty"`
// Alias usable alias to be used for the chart
Alias string `json:"alias,omitempty"`
}
// Lock is a lock file for dependencies.
//
// It represents the state that the dependencies should be in.
type Lock struct {
// Generated is the date the lock file was last generated.
Generated time.Time `json:"generated"`
// Digest is a hash of the dependencies in Chart.yaml.
Digest string `json:"digest"`
// Dependencies is the list of dependencies that this lock file has locked.
Dependencies []*Dependency `json:"dependencies"`
}
// Info describes release information.
type Info struct {
// FirstDeployed is when the release was first deployed.
FirstDeployed time.Time `json:"first_deployed,omitempty"`
// LastDeployed is when the release was last deployed.
LastDeployed time.Time `json:"last_deployed,omitempty"`
// Deleted tracks when this object was deleted.
Deleted time.Time `json:"deleted"`
// Description is human-friendly "log entry" about this release.
Description string `json:"description,omitempty"`
// Status is the current state of the release
Status Status `json:"status,omitempty"`
// Contains the rendered templates/NOTES.txt if available
Notes string `json:"notes,omitempty"`
}
// Status is the status of a release
type Status string
// Hook defines a hook object.
type Hook struct {
Name string `json:"name,omitempty"`
// Kind is the Kubernetes kind.
Kind string `json:"kind,omitempty"`
// Path is the chart-relative path to the template.
Path string `json:"path,omitempty"`
// Manifest is the manifest contents.
Manifest string `json:"manifest,omitempty"`
// Events are the events that this hook fires on.
Events []HookEvent `json:"events,omitempty"`
// LastRun indicates the date/time this was last run.
LastRun HookExecution `json:"last_run,omitempty"`
// Weight indicates the sort order for execution among similar Hook type
Weight int `json:"weight,omitempty"`
// DeletePolicies are the policies that indicate when to delete the hook
DeletePolicies []HookDeletePolicy `json:"delete_policies,omitempty"`
}
// HookEvent specifies the hook event
type HookEvent string
// A HookExecution records the result for the last execution of a hook for a given release.
type HookExecution struct {
// StartedAt indicates the date/time this hook was started
StartedAt time.Time `json:"started_at,omitempty"`
// CompletedAt indicates the date/time this hook was completed.
CompletedAt time.Time `json:"completed_at,omitempty"`
// Phase indicates whether the hook completed successfully
Phase HookPhase `json:"phase"`
}
// A HookPhase indicates the state of a hook execution
type HookPhase string
// HookDeletePolicy specifies the hook delete policy
type HookDeletePolicy string

View File

@ -0,0 +1,91 @@
/*
Copyright The Helm 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 time contains a wrapper for time.Time in the standard library and
// associated methods. This package mainly exists to workaround an issue in Go
// where the serializer doesn't omit an empty value for time:
// https://github.com/golang/go/issues/11939. As such, this can be removed if a
// proposal is ever accepted for Go
package time
import (
"bytes"
"time"
)
// emptyString contains an empty JSON string value to be used as output
var emptyString = `""`
// Time is a convenience wrapper around stdlib time, but with different
// marshalling and unmarshaling for zero values
type Time struct {
time.Time
}
// Now returns the current time. It is a convenience wrapper around time.Now()
func Now() Time {
return Time{time.Now()}
}
func (t Time) MarshalJSON() ([]byte, error) {
if t.Time.IsZero() {
return []byte(emptyString), nil
}
return t.Time.MarshalJSON()
}
func (t *Time) UnmarshalJSON(b []byte) error {
if bytes.Equal(b, []byte("null")) {
return nil
}
// If it is empty, we don't have to set anything since time.Time is not a
// pointer and will be set to the zero value
if bytes.Equal([]byte(emptyString), b) {
return nil
}
return t.Time.UnmarshalJSON(b)
}
func Parse(layout, value string) (Time, error) {
t, err := time.Parse(layout, value)
return Time{Time: t}, err
}
func ParseInLocation(layout, value string, loc *time.Location) (Time, error) {
t, err := time.ParseInLocation(layout, value, loc)
return Time{Time: t}, err
}
func Date(year int, month time.Month, day, hour, min, sec, nsec int, loc *time.Location) Time {
return Time{Time: time.Date(year, month, day, hour, min, sec, nsec, loc)}
}
func Unix(sec int64, nsec int64) Time { return Time{Time: time.Unix(sec, nsec)} }
func (t Time) Add(d time.Duration) Time { return Time{Time: t.Time.Add(d)} }
func (t Time) AddDate(years int, months int, days int) Time {
return Time{Time: t.Time.AddDate(years, months, days)}
}
func (t Time) After(u Time) bool { return t.Time.After(u.Time) }
func (t Time) Before(u Time) bool { return t.Time.Before(u.Time) }
func (t Time) Equal(u Time) bool { return t.Time.Equal(u.Time) }
func (t Time) In(loc *time.Location) Time { return Time{Time: t.Time.In(loc)} }
func (t Time) Local() Time { return Time{Time: t.Time.Local()} }
func (t Time) Round(d time.Duration) Time { return Time{Time: t.Time.Round(d)} }
func (t Time) Sub(u Time) time.Duration { return t.Time.Sub(u.Time) }
func (t Time) Truncate(d time.Duration) Time { return Time{Time: t.Time.Truncate(d)} }
func (t Time) UTC() Time { return Time{Time: t.Time.UTC()} }

View File

@ -0,0 +1,83 @@
/*
Copyright The Helm 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 time
import (
"encoding/json"
"testing"
"time"
)
var (
testingTime, _ = Parse(time.RFC3339, "1977-09-02T22:04:05Z")
testingTimeString = `"1977-09-02T22:04:05Z"`
)
func TestNonZeroValueMarshal(t *testing.T) {
res, err := json.Marshal(testingTime)
if err != nil {
t.Fatal(err)
}
if testingTimeString != string(res) {
t.Errorf("expected a marshaled value of %s, got %s", testingTimeString, res)
}
}
func TestZeroValueMarshal(t *testing.T) {
res, err := json.Marshal(Time{})
if err != nil {
t.Fatal(err)
}
if string(res) != emptyString {
t.Errorf("expected zero value to marshal to empty string, got %s", res)
}
}
func TestNonZeroValueUnmarshal(t *testing.T) {
var myTime Time
err := json.Unmarshal([]byte(testingTimeString), &myTime)
if err != nil {
t.Fatal(err)
}
if !myTime.Equal(testingTime) {
t.Errorf("expected time to be equal to %v, got %v", testingTime, myTime)
}
}
func TestEmptyStringUnmarshal(t *testing.T) {
var myTime Time
err := json.Unmarshal([]byte(emptyString), &myTime)
if err != nil {
t.Fatal(err)
}
if !myTime.IsZero() {
t.Errorf("expected time to be equal to zero value, got %v", myTime)
}
}
func TestZeroValueUnmarshal(t *testing.T) {
// This test ensures that we can unmarshal any time value that was output
// with the current go default value of "0001-01-01T00:00:00Z"
var myTime Time
err := json.Unmarshal([]byte(`"0001-01-01T00:00:00Z"`), &myTime)
if err != nil {
t.Fatal(err)
}
if !myTime.IsZero() {
t.Errorf("expected time to be equal to zero value, got %v", myTime)
}
}

View File

@ -0,0 +1,47 @@
package libhelm
import (
"fmt"
"net/http"
"net/url"
"path"
"strings"
"time"
"github.com/pkg/errors"
)
const invalidChartRepo = "%q is not a valid chart repository or cannot be reached"
func ValidateHelmRepositoryURL(repoUrl string) error {
if repoUrl == "" {
return errors.New("URL is required")
}
url, err := url.ParseRequestURI(repoUrl)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("invalid helm chart URL: %s", repoUrl))
}
if !strings.EqualFold(url.Scheme, "http") && !strings.EqualFold(url.Scheme, "https") {
return errors.New(fmt.Sprintf("invalid helm chart URL: %s", repoUrl))
}
url.Path = path.Join(url.Path, "index.yaml")
var client = &http.Client{
Timeout: time.Second * 10,
}
response, err := client.Head(url.String())
if err != nil {
return errors.Wrapf(err, invalidChartRepo, repoUrl)
}
// Success is indicated with 2xx status codes
statusOK := response.StatusCode >= 200 && response.StatusCode < 300
if !statusOK {
return errors.Errorf(invalidChartRepo, repoUrl)
}
return nil
}

View File

@ -0,0 +1,49 @@
package libhelm
import (
"testing"
"github.com/portainer/libhelm/libhelmtest"
"github.com/stretchr/testify/assert"
)
func Test_ValidateHelmRepositoryURL(t *testing.T) {
libhelmtest.EnsureIntegrationTest(t)
is := assert.New(t)
type testCase struct {
name string
url string
invalid bool
}
tests := []testCase{
{"blank", "", true},
{"slashes", "//", true},
{"slash", "/", true},
{"invalid scheme", "garbage://a.b.c", true},
{"invalid domain", "https://invaliddomain/", true},
{"not helm repo", "http://google.com", true},
{"not valid repo with trailing slash", "http://google.com/", true},
{"not valid repo with trailing slashes", "http://google.com////", true},
{"bitnami helm repo", "https://charts.bitnami.com/bitnami/", false},
{"gitlap helm repo", "https://charts.gitlab.io/", false},
{"portainer helm repo", "https://portainer.github.io/k8s/", false},
{"elastic helm repo", "https://helm.elastic.co/", false},
{"redirect", "https://charts.jetstack.io/", false},
}
for _, test := range tests {
func(tc testCase) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := ValidateHelmRepositoryURL(tc.url)
if tc.invalid {
is.Errorf(err, "error expected: %s", tc.url)
} else {
is.NoError(err, "no error expected: %s", tc.url)
}
})
}(test)
}
}