Compare commits

...

13 Commits

Author SHA1 Message Date
James Player
e75e379b33 Bump version to 2.28.0 (#523) 2025-03-19 11:50:11 +13:00
Steven Kang
2791bd123c fix: cve-2025-22869 develop (#511) 2025-03-17 12:24:39 +13:00
andres-portainer
e1f9b69cd5 feat(edgestack): improve the structure to make JSON operations faster BE-11668 (#475) 2025-03-15 10:10:17 -03:00
andres-portainer
2c05496962 feat(edgeconfigs): parse .env config files for interpolation BE-11673 (#514) 2025-03-15 10:09:22 -03:00
Oscar Zhou
66bcf9223a fix(k8s/config): avoid hardcoded "insecure-skip-tls-verify" in kubeconfig [BE-11651] (#500) 2025-03-14 11:20:41 +13:00
James Player
993f69db37 chore(app): Migrate helm templates list to react (#492) 2025-03-14 10:37:14 +13:00
Ali
58317edb6d fix(namespaces): only show namespaces with access [r8s-251] (#501) 2025-03-14 07:57:06 +13:00
Steven Kang
417891675d fix: ensure no non-admin users have access to system namespaces (#499) 2025-03-13 16:43:56 +13:00
Steven Kang
8b7aef883a fix: display unscheduled applications (#496)
Co-authored-by: JamesPlayer <james.player@portainer.io>
2025-03-13 14:13:18 +13:00
Ali
b5961d79f8 refactor(helm): helm binary to sdk refactor [r8s-229] (#463)
Co-authored-by: stevensbkang <skan070@gmail.com>
2025-03-13 12:20:16 +13:00
LP B
0d25f3f430 fix(app): restore gitops update options (#419) 2025-03-12 14:00:31 +01:00
Steven Kang
798fa2396a feat: kubernets service - display external hostname (#486) 2025-03-12 22:34:00 +13:00
James Player
28b222fffa fix(app): Make sure empty tables don't have select all rows checkbox checked (#489) 2025-03-12 10:34:07 +13:00
107 changed files with 3955 additions and 1353 deletions

View File

@@ -2,18 +2,17 @@ name: Bug Report
description: Create a report to help us improve.
labels: kind/bug,bug/need-confirmation
body:
- type: markdown
attributes:
value: |
# Welcome!
The issue tracker is for reporting bugs. If you have an [idea for a new feature](https://github.com/orgs/portainer/discussions/categories/ideas) or a [general question about Portainer](https://github.com/orgs/portainer/discussions/categories/help) please post in our [GitHub Discussions](https://github.com/orgs/portainer/discussions).
You can also ask for help in our [community Slack channel](https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA).
Please note that we only provide support for current versions of Portainer. You can find a list of supported versions in our [lifecycle policy](https://docs.portainer.io/start/lifecycle).
**DO NOT FILE ISSUES FOR GENERAL SUPPORT QUESTIONS**.
- type: checkboxes
@@ -45,7 +44,7 @@ body:
- type: textarea
attributes:
label: Problem Description
description: A clear and concise description of what the bug is.
description: A clear and concise description of what the bug is.
validations:
required: true
@@ -71,7 +70,7 @@ body:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
4. See error
validations:
required: true
@@ -95,6 +94,7 @@ body:
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.28.0'
- '2.27.1'
- '2.27.0'
- '2.26.1'
@@ -158,7 +158,7 @@ body:
- type: input
attributes:
label: Browser
description: |
description: |
Enter your browser and version. Example: Google Chrome 114.0
validations:
required: false

View File

@@ -49,6 +49,7 @@ import (
"github.com/portainer/portainer/pkg/build"
"github.com/portainer/portainer/pkg/featureflags"
"github.com/portainer/portainer/pkg/libhelm"
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
"github.com/portainer/portainer/pkg/libstack/compose"
"github.com/gofrs/uuid"
@@ -169,8 +170,8 @@ func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheMan
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
}
func initHelmPackageManager(assetsPath string) (libhelm.HelmPackageManager, error) {
return libhelm.NewHelmPackageManager(libhelm.HelmConfig{BinaryPath: assetsPath})
func initHelmPackageManager() (libhelmtypes.HelmPackageManager, error) {
return libhelm.NewHelmPackageManager()
}
func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService {
@@ -437,7 +438,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
helmPackageManager, err := initHelmPackageManager(*flags.Assets)
helmPackageManager, err := initHelmPackageManager()
if err != nil {
log.Fatal().Err(err).Msg("failed initializing helm package manager")
}

View File

@@ -10,7 +10,7 @@ import (
)
const (
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://kubernetes.github.io/ingress-nginx","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
passphrase = "my secret key"
)

View File

@@ -94,6 +94,10 @@ func (m *Migrator) updateEdgeStackStatusForDB100() error {
continue
}
if environmentStatus.Details == nil {
continue
}
statusArray := []portainer.EdgeStackDeploymentStatus{}
if environmentStatus.Details.Pending {
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{

View File

@@ -75,6 +75,10 @@ func (m *Migrator) updateEdgeStackStatusForDB80() error {
for _, edgeStack := range edgeStacks {
for endpointId, status := range edgeStack.Status {
if status.Details == nil {
status.Details = &portainer.EdgeStackStatusDetails{}
}
switch status.Type {
case portainer.EdgeStackStatusPending:
status.Details.Pending = true
@@ -93,10 +97,10 @@ func (m *Migrator) updateEdgeStackStatusForDB80() error {
edgeStack.Status[endpointId] = status
}
err = m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack)
if err != nil {
if err := m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack); err != nil {
return err
}
}
return nil
}

View File

@@ -605,12 +605,12 @@
"GlobalDeploymentOptions": {
"hideStacksFunctionality": false
},
"HelmRepositoryURL": "",
"HelmRepositoryURL": "https://charts.bitnami.com/bitnami",
"InternalAuthSettings": {
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.27.1",
"KubectlShellImage": "portainer/kubectl-shell:2.28.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -943,7 +943,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.27.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.28.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}

View File

@@ -15,15 +15,19 @@ type MultiFilterArgs []struct {
}
// MultiFilterDirForPerDevConfigs filers the given dirEntries with multiple filter args, returns the merged entries for the given device
func MultiFilterDirForPerDevConfigs(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs) []DirEntry {
func MultiFilterDirForPerDevConfigs(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs) ([]DirEntry, []string) {
var filteredDirEntries []DirEntry
var envFiles []string
for _, multiFilterArg := range multiFilterArgs {
tmp := FilterDirForPerDevConfigs(dirEntries, multiFilterArg.FilterKey, configPath, multiFilterArg.FilterType)
tmp, efs := FilterDirForPerDevConfigs(dirEntries, multiFilterArg.FilterKey, configPath, multiFilterArg.FilterType)
filteredDirEntries = append(filteredDirEntries, tmp...)
envFiles = append(envFiles, efs...)
}
return deduplicate(filteredDirEntries)
return deduplicate(filteredDirEntries), envFiles
}
func deduplicate(dirEntries []DirEntry) []DirEntry {
@@ -32,8 +36,7 @@ func deduplicate(dirEntries []DirEntry) []DirEntry {
marks := make(map[string]struct{})
for _, dirEntry := range dirEntries {
_, ok := marks[dirEntry.Name]
if !ok {
if _, ok := marks[dirEntry.Name]; !ok {
marks[dirEntry.Name] = struct{}{}
deduplicatedDirEntries = append(deduplicatedDirEntries, dirEntry)
}
@@ -50,20 +53,25 @@ func deduplicate(dirEntries []DirEntry) []DirEntry {
// 3. For filterType dir:
// dir entry: A/B/C/<deviceName>
// all entries: A/B/C/<deviceName>/*
func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) []DirEntry {
func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) ([]DirEntry, []string) {
var filteredDirEntries []DirEntry
var envFiles []string
for _, dirEntry := range dirEntries {
if shouldIncludeEntry(dirEntry, deviceName, configPath, filterType) {
filteredDirEntries = append(filteredDirEntries, dirEntry)
if shouldParseEnvVars(dirEntry, deviceName, configPath, filterType) {
envFiles = append(envFiles, dirEntry.Name)
}
}
}
return filteredDirEntries
return filteredDirEntries, envFiles
}
func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool {
// Include all entries outside of dir A
if !isInConfigDir(dirEntry, configPath) {
return true
@@ -120,6 +128,15 @@ func shouldIncludeDir(dirEntry DirEntry, deviceName, configPath string) bool {
return strings.HasPrefix(dirEntry.Name, filterPrefix)
}
func shouldParseEnvVars(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool {
if !dirEntry.IsFile {
return false
}
return isInConfigDir(dirEntry, configPath) &&
filepath.Base(dirEntry.Name) == deviceName+".env"
}
func appendTailSeparator(path string) string {
return fmt.Sprintf("%s%c", path, os.PathSeparator)
}

View File

@@ -4,14 +4,17 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
type args struct {
dirEntries []DirEntry
configPath string
multiFilterArgs MultiFilterArgs
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantDirEntries []DirEntry) {
t.Helper()
dirEntries, _ = MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs)
require.Equal(t, wantDirEntries, dirEntries)
}
baseDirEntries := []DirEntry{
@@ -26,69 +29,75 @@ func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
{"configs/folder2/config2", "", true, 420},
}
tests := []struct {
name string
args args
want []DirEntry
}{
{
name: "filter file1",
args: args{
baseDirEntries,
"configs",
MultiFilterArgs{{"file1", portainer.PerDevConfigsTypeFile}},
},
want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3]},
// Filter file1
f(
baseDirEntries,
"configs",
MultiFilterArgs{{"file1", portainer.PerDevConfigsTypeFile}},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3]},
)
// Filter folder1
f(
baseDirEntries,
"configs",
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
)
// Filter file1 and folder1
f(
baseDirEntries,
"configs",
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
)
// Filter file1 and file2
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"file1", portainer.PerDevConfigsTypeFile},
{"file2", portainer.PerDevConfigsTypeFile},
},
{
name: "filter folder1",
args: args{
baseDirEntries,
"configs",
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
},
want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
},
{
name: "filter file1 and folder1",
args: args{
baseDirEntries,
"configs",
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
},
want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
},
{
name: "filter file1 and file2",
args: args{
baseDirEntries,
"configs",
MultiFilterArgs{
{"file1", portainer.PerDevConfigsTypeFile},
{"file2", portainer.PerDevConfigsTypeFile},
},
},
want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[4]},
},
{
name: "filter folder1 and folder2",
args: args{
baseDirEntries,
"configs",
MultiFilterArgs{
{"folder1", portainer.PerDevConfigsTypeDir},
{"folder2", portainer.PerDevConfigsTypeDir},
},
},
want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6], baseDirEntries[7], baseDirEntries[8]},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[4]},
)
// Filter folder1 and folder2
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"folder1", portainer.PerDevConfigsTypeDir},
{"folder2", portainer.PerDevConfigsTypeDir},
},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6], baseDirEntries[7], baseDirEntries[8]},
)
}
func TestMultiFilterDirForPerDevConfigsEnvFiles(t *testing.T) {
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantEnvFiles []string) {
t.Helper()
_, envFiles := MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs)
require.Equal(t, wantEnvFiles, envFiles)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.want, MultiFilterDirForPerDevConfigs(tt.args.dirEntries, tt.args.configPath, tt.args.multiFilterArgs), "MultiFilterDirForPerDevConfigs(%v, %v, %v)", tt.args.dirEntries, tt.args.configPath, tt.args.multiFilterArgs)
})
baseDirEntries := []DirEntry{
{".env", "", true, 420},
{"docker-compose.yaml", "", true, 420},
{"configs", "", false, 420},
{"configs/edge-id/edge-id.env", "", true, 420},
}
f(
baseDirEntries,
"configs",
MultiFilterArgs{{"edge-id", portainer.PerDevConfigsTypeDir}},
[]string{"configs/edge-id/edge-id.env"},
)
}
func TestIsInConfigDir(t *testing.T) {

View File

@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.27.1
// @version 2.28.0
// @description.markdown api-description.md
// @termsOfService

View File

@@ -1,6 +1,7 @@
package helm
import (
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
@@ -8,8 +9,8 @@ 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/pkg/libhelm"
"github.com/portainer/portainer/pkg/libhelm/options"
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/gorilla/mux"
@@ -23,11 +24,11 @@ type Handler struct {
jwtService portainer.JWTService
kubeClusterAccessService kubernetes.KubeClusterAccessService
kubernetesDeployer portainer.KubernetesDeployer
helmPackageManager libhelm.HelmPackageManager
helmPackageManager libhelmtypes.HelmPackageManager
}
// NewHandler creates a handler to manage endpoint group operations.
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelm.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler {
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelmtypes.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler {
h := &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
@@ -57,7 +58,7 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
}
// NewTemplateHandler creates a template handler to manage environment(endpoint) group operations.
func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libhelm.HelmPackageManager) *Handler {
func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libhelmtypes.HelmPackageManager) *Handler {
h := &Handler{
Router: mux.NewRouter(),
helmPackageManager: helmPackageManager,
@@ -78,7 +79,7 @@ func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libh
// getHelmClusterAccess obtains the core k8s cluster access details from request.
// The cluster access includes the cluster server url, the user's bearer token and the tls certificate.
// The cluster access is passed in as kube config CLI params to helm binary.
// The cluster access is passed in as kube config CLI params to helm.
func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.KubernetesClusterAccess, *httperror.HandlerError) {
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
@@ -107,6 +108,9 @@ func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.Kubernet
kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(hostURL, endpoint.ID, true)
return &options.KubernetesClusterAccess{
ClusterName: fmt.Sprintf("%s-%s", "portainer-cluster", endpoint.Name),
ContextName: fmt.Sprintf("%s-%s", "portainer-ctx", endpoint.Name),
UserName: fmt.Sprintf("%s-%s", "portainer-sa-user", tokenData.Username),
ClusterServerURL: kubeConfigInternal.ClusterServerURL,
CertificateAuthorityFile: kubeConfigInternal.CertificateAuthorityFile,
AuthToken: bearerToken,

View File

@@ -13,8 +13,8 @@ import (
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/pkg/libhelm/binary/test"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/stretchr/testify/assert"
)
@@ -34,7 +34,7 @@ func Test_helmDelete(t *testing.T) {
is.NoError(err, "Error initiating jwt service")
kubernetesDeployer := exectest.NewKubernetesDeployer()
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
helmPackageManager := test.NewMockHelmPackageManager()
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)

View File

@@ -99,15 +99,11 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
}
installOpts := options.InstallOptions{
Name: p.Name,
Chart: p.Chart,
Namespace: p.Namespace,
Repo: p.Repo,
KubernetesClusterAccess: &options.KubernetesClusterAccess{
ClusterServerURL: clusterAccess.ClusterServerURL,
CertificateAuthorityFile: clusterAccess.CertificateAuthorityFile,
AuthToken: clusterAccess.AuthToken,
},
Name: p.Name,
Chart: p.Chart,
Namespace: p.Namespace,
Repo: p.Repo,
KubernetesClusterAccess: clusterAccess,
}
if p.Values != "" {

View File

@@ -15,9 +15,9 @@ import (
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/pkg/libhelm/binary/test"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
@@ -38,14 +38,14 @@ func Test_helmInstall(t *testing.T) {
is.NoError(err, "Error initiating jwt service")
kubernetesDeployer := exectest.NewKubernetesDeployer()
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
helmPackageManager := test.NewMockHelmPackageManager()
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
is.NotNil(h, "Handler should not fail")
// Install a single chart. We expect to get these values back
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://kubernetes.github.io/ingress-nginx"}
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://charts.bitnami.com/bitnami"}
optdata, err := json.Marshal(options)
is.NoError(err)

View File

@@ -14,9 +14,9 @@ import (
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/pkg/libhelm/binary/test"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
@@ -37,7 +37,7 @@ func Test_helmList(t *testing.T) {
is.NoError(err, "Error initialising jwt service")
kubernetesDeployer := exectest.NewKubernetesDeployer()
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
helmPackageManager := test.NewMockHelmPackageManager()
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)

View File

@@ -8,19 +8,19 @@ import (
"testing"
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/pkg/libhelm/binary/test"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/stretchr/testify/assert"
)
func Test_helmRepoSearch(t *testing.T) {
is := assert.New(t)
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
helmPackageManager := test.NewMockHelmPackageManager()
h := NewTemplateHandler(helper.NewTestRequestBouncer(), helmPackageManager)
assert.NotNil(t, h, "Handler should not fail")
repos := []string{"https://kubernetes.github.io/ingress-nginx", "https://portainer.github.io/k8s"}
repos := []string{"https://charts.bitnami.com/bitnami", "https://portainer.github.io/k8s"}
for _, repo := range repos {
t.Run(repo, func(t *testing.T) {

View File

@@ -9,14 +9,14 @@ import (
"testing"
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/pkg/libhelm/binary/test"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/stretchr/testify/assert"
)
func Test_helmShow(t *testing.T) {
is := assert.New(t)
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
helmPackageManager := test.NewMockHelmPackageManager()
h := NewTemplateHandler(helper.NewTestRequestBouncer(), helmPackageManager)
is.NotNil(h, "Handler should not fail")
@@ -31,7 +31,7 @@ func Test_helmShow(t *testing.T) {
t.Run(cmd, func(t *testing.T) {
is.NotNil(h, "Handler should not fail")
repoUrlEncoded := url.QueryEscape("https://kubernetes.github.io/ingress-nginx")
repoUrlEncoded := url.QueryEscape("https://charts.bitnami.com/bitnami")
chart := "nginx"
req := httptest.NewRequest("GET", fmt.Sprintf("/templates/helm/%s?repo=%s&chart=%s", cmd, repoUrlEncoded, chart), nil)
rr := httptest.NewRecorder()

View File

@@ -69,7 +69,6 @@ func (handler *Handler) getApplicationsResources(w http.ResponseWriter, r *http.
// @param id path int true "Environment(Endpoint) identifier"
// @param namespace query string true "Namespace name"
// @param nodeName query string true "Node name"
// @param withDependencies query boolean false "Include dependencies in the response"
// @success 200 {array} models.K8sApplication "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
@@ -117,12 +116,6 @@ func (handler *Handler) getAllKubernetesApplications(r *http.Request) ([]models.
return nil, httperror.BadRequest("Unable to parse the namespace query parameter", err)
}
withDependencies, err := request.RetrieveBooleanQueryParameter(r, "withDependencies", true)
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the withDependencies query parameter")
return nil, httperror.BadRequest("Unable to parse the withDependencies query parameter", err)
}
nodeName, err := request.RetrieveQueryParameter(r, "nodeName", true)
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the nodeName query parameter")
@@ -135,7 +128,7 @@ func (handler *Handler) getAllKubernetesApplications(r *http.Request) ([]models.
return nil, httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
}
applications, err := cli.GetApplications(namespace, nodeName, withDependencies)
applications, err := cli.GetApplications(namespace, nodeName)
if err != nil {
if k8serrors.IsUnauthorized(err) {
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Str("namespace", namespace).Str("nodeName", nodeName).Msg("Unable to get the list of applications")

View File

@@ -1,9 +1,14 @@
package kubernetes
import (
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
@@ -162,11 +167,38 @@ func (handler *Handler) buildConfig(r *http.Request, tokenData *portainer.TokenD
func (handler *Handler) buildCluster(r *http.Request, endpoint portainer.Endpoint, isInternal bool) clientV1.NamedCluster {
kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(r.Host, endpoint.ID, isInternal)
selfSignedCert := false
serverUrl, err := url.Parse(kubeConfigInternal.ClusterServerURL)
if err != nil {
log.Warn().Err(err).Msg("Failed to parse server URL")
}
if strings.EqualFold(serverUrl.Scheme, "https") {
var certPem []byte
var err error
if kubeConfigInternal.CertificateAuthorityData != "" {
certPem = []byte(kubeConfigInternal.CertificateAuthorityData)
} else if kubeConfigInternal.CertificateAuthorityFile != "" {
certPem, err = os.ReadFile(kubeConfigInternal.CertificateAuthorityFile)
if err != nil {
log.Warn().Err(err).Msg("Failed to open certificate file")
}
}
if certPem != nil {
selfSignedCert, err = IsSelfSignedCertificate(certPem)
if err != nil {
log.Warn().Err(err).Msg("Failed to verify if certificate is self-signed")
}
}
}
return clientV1.NamedCluster{
Name: buildClusterName(endpoint.Name),
Cluster: clientV1.Cluster{
Server: kubeConfigInternal.ClusterServerURL,
InsecureSkipTLSVerify: true,
InsecureSkipTLSVerify: selfSignedCert,
},
}
}
@@ -215,3 +247,38 @@ func writeFileContent(w http.ResponseWriter, r *http.Request, endpoints []portai
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; %s.json", filenameBase))
return response.JSON(w, config)
}
func IsSelfSignedCertificate(certPem []byte) (bool, error) {
if certPem == nil {
return false, errors.New("certificate data is empty")
}
if !strings.Contains(string(certPem), "BEGIN CERTIFICATE") {
certPem = []byte(fmt.Sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----", string(certPem)))
}
block, _ := pem.Decode(certPem)
if block == nil {
return false, errors.New("failed to decode certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return false, err
}
if cert.Issuer.String() != cert.Subject.String() {
return false, nil
}
roots := x509.NewCertPool()
roots.AddCert(cert)
opts := x509.VerifyOptions{
Roots: roots,
CurrentTime: cert.NotBefore,
}
_, err = cert.Verify(opts)
return err == nil, err
}

View File

@@ -0,0 +1,186 @@
package kubernetes
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsSelfSignedCertificate(t *testing.T) {
tc := []struct {
name string
cert string
expected bool
}{
{
name: "portainer self-signed",
cert: `-----BEGIN CERTIFICATE-----
MIIBUTCB+KADAgECAhBB7psNiJlJd/nRCCKUPVenMAoGCCqGSM49BAMCMAAwHhcN
MjUwMzEzMDQwODI0WhcNMzAwMzEzMDQwODI0WjAAMFkwEwYHKoZIzj0CAQYIKoZI
zj0DAQcDQgAESdGCaXq0r1GDxF89yKjjLeCIixiPDdXAg+lw4NqAWeJq2AOo+8IH
vcCq9bSlYlezK8RzTsbf9Z1m5jRqUEbSjqNUMFIwDgYDVR0PAQH/BAQDAgWgMBMG
A1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHQYDVR0RAQH/BBMwEYIJ
bG9jYWxob3N0hwQAAAAAMAoGCCqGSM49BAMCA0gAMEUCIApLliukFaCZHbc/2pkH
0VDY+fBMb12jhmVpgKh1Cqg9AiEAwFrMQLUkzATUpiHuukdUg5VsUiMIkWTPLglz
E4+1dRc=
-----END CERTIFICATE-----
`,
expected: true,
},
{
name: "portainer self-signed without header",
cert: `MIIBUzCB+aADAgECAhEAjsskPzuCS5BeHjXGwYqc2jAKBggqhkjOPQQDAjAAMB4XDTI1MDMxMzA0MzQyNloXDTMwMDMxMzA0MzQyNlowADBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABITD+dNDLYQbLYDE3UMlTzD61OYRSVkVZspdp1MvZITIG4VOxtfQUqcW3P7OHQdoi52GIQ/GM6iDgxwB1BOyi3mjVDBSMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB0GA1UdEQEB/wQTMBGCCWxvY2FsaG9zdIcEAAAAADAKBggqhkjOPQQDAgNJADBGAiEA8SmyeYLhrnrNLAFcxZp0dk6nMN70XVAfqGnbK/s8NR8CIQDgQdqhfge8QvN2TsH4gg98a9VHDv+RlcOlJ80SS+G/Ww==`,
expected: true,
},
{
name: "custom certificate generated by openssl",
cert: `-----BEGIN CERTIFICATE-----
MIIB9TCCAZugAwIBAgIULTkNYfYHiqfOiX7mKOIGxRefx/YwCgYIKoZIzj0EAwIw
SDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNp
c2NvMRQwEgYDVQQDEwtleGFtcGxlLm5ldDAeFw0yNTAyMjgwNjI3MDBaFw0zNTAy
MjYwNjI3MDBaMAAwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAT3WlLvbGw7wPkQ
3LuHFJEaNrDv3n359JMV1CkjQi3U37u0fJrjd+8o7TxPBYgt9HDD9vsURhy41DNo
g71F2AIto4GqMIGnMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcD
AQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQU+nMxx/VCE9fzrlHI
FX9mF5SRPrkwHwYDVR0jBBgwFoAUOlUIToGwnBOqzZ1dBfOvdKbwNaAwKAYDVR0R
AQH/BB4wHIIaZWRnZS4xNzIuMTcuMjIxLjIwOC5uaXAuaW8wCgYIKoZIzj0EAwID
SAAwRQIgeYrkjY0z/ypMKXZbvbMi8qOK44qoISKkSErBUCBLuwoCIQDRaJA9r931
utpXXnysVGecVXHHKOOl1YhWglmuPvcZhw==
-----END CERTIFICATE-----`,
expected: false,
},
{
name: "google.com certificate",
cert: `-----BEGIN CERTIFICATE-----
MIIOITCCDQmgAwIBAgIQKS0IQxknY8USDjt3IYchljANBgkqhkiG9w0BAQsFADA7
MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNlcnZpY2VzMQww
CgYDVQQDEwNXUjIwHhcNMjUwMjI2MTUzMjU1WhcNMjUwNTIxMTUzMjU0WjAXMRUw
EwYDVQQDDAwqLmdvb2dsZS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARx
nMOmIG3BuO7my/BbF/rGPAMH/JbxBDufbYFQHV+6l5pF5sdT/Zov3X+qsR3IYFl7
F2a0gAUmK1Bq7//zTb3uo4IMDjCCDAowDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQM
MAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFN+aEjBz3PaUtelz
3g9rVTkGRgU0MB8GA1UdIwQYMBaAFN4bHu15FdQ+NyTDIbvsNDltQrIwMFgGCCsG
AQUFBwEBBEwwSjAhBggrBgEFBQcwAYYVaHR0cDovL28ucGtpLmdvb2cvd3IyMCUG
CCsGAQUFBzAChhlodHRwOi8vaS5wa2kuZ29vZy93cjIuY3J0MIIJ5AYDVR0RBIIJ
2zCCCdeCDCouZ29vZ2xlLmNvbYIWKi5hcHBlbmdpbmUuZ29vZ2xlLmNvbYIJKi5i
ZG4uZGV2ghUqLm9yaWdpbi10ZXN0LmJkbi5kZXaCEiouY2xvdWQuZ29vZ2xlLmNv
bYIYKi5jcm93ZHNvdXJjZS5nb29nbGUuY29tghgqLmRhdGFjb21wdXRlLmdvb2ds
ZS5jb22CCyouZ29vZ2xlLmNhggsqLmdvb2dsZS5jbIIOKi5nb29nbGUuY28uaW6C
DiouZ29vZ2xlLmNvLmpwgg4qLmdvb2dsZS5jby51a4IPKi5nb29nbGUuY29tLmFy
gg8qLmdvb2dsZS5jb20uYXWCDyouZ29vZ2xlLmNvbS5icoIPKi5nb29nbGUuY29t
LmNvgg8qLmdvb2dsZS5jb20ubXiCDyouZ29vZ2xlLmNvbS50coIPKi5nb29nbGUu
Y29tLnZuggsqLmdvb2dsZS5kZYILKi5nb29nbGUuZXOCCyouZ29vZ2xlLmZyggsq
Lmdvb2dsZS5odYILKi5nb29nbGUuaXSCCyouZ29vZ2xlLm5sggsqLmdvb2dsZS5w
bIILKi5nb29nbGUucHSCDyouZ29vZ2xlYXBpcy5jboIRKi5nb29nbGV2aWRlby5j
b22CDCouZ3N0YXRpYy5jboIQKi5nc3RhdGljLWNuLmNvbYIPZ29vZ2xlY25hcHBz
LmNughEqLmdvb2dsZWNuYXBwcy5jboIRZ29vZ2xlYXBwcy1jbi5jb22CEyouZ29v
Z2xlYXBwcy1jbi5jb22CDGdrZWNuYXBwcy5jboIOKi5na2VjbmFwcHMuY26CEmdv
b2dsZWRvd25sb2Fkcy5jboIUKi5nb29nbGVkb3dubG9hZHMuY26CEHJlY2FwdGNo
YS5uZXQuY26CEioucmVjYXB0Y2hhLm5ldC5jboIQcmVjYXB0Y2hhLWNuLm5ldIIS
Ki5yZWNhcHRjaGEtY24ubmV0ggt3aWRldmluZS5jboINKi53aWRldmluZS5jboIR
YW1wcHJvamVjdC5vcmcuY26CEyouYW1wcHJvamVjdC5vcmcuY26CEWFtcHByb2pl
Y3QubmV0LmNughMqLmFtcHByb2plY3QubmV0LmNughdnb29nbGUtYW5hbHl0aWNz
LWNuLmNvbYIZKi5nb29nbGUtYW5hbHl0aWNzLWNuLmNvbYIXZ29vZ2xlYWRzZXJ2
aWNlcy1jbi5jb22CGSouZ29vZ2xlYWRzZXJ2aWNlcy1jbi5jb22CEWdvb2dsZXZh
ZHMtY24uY29tghMqLmdvb2dsZXZhZHMtY24uY29tghFnb29nbGVhcGlzLWNuLmNv
bYITKi5nb29nbGVhcGlzLWNuLmNvbYIVZ29vZ2xlb3B0aW1pemUtY24uY29tghcq
Lmdvb2dsZW9wdGltaXplLWNuLmNvbYISZG91YmxlY2xpY2stY24ubmV0ghQqLmRv
dWJsZWNsaWNrLWNuLm5ldIIYKi5mbHMuZG91YmxlY2xpY2stY24ubmV0ghYqLmcu
ZG91YmxlY2xpY2stY24ubmV0gg5kb3VibGVjbGljay5jboIQKi5kb3VibGVjbGlj
ay5jboIUKi5mbHMuZG91YmxlY2xpY2suY26CEiouZy5kb3VibGVjbGljay5jboIR
ZGFydHNlYXJjaC1jbi5uZXSCEyouZGFydHNlYXJjaC1jbi5uZXSCHWdvb2dsZXRy
YXZlbGFkc2VydmljZXMtY24uY29tgh8qLmdvb2dsZXRyYXZlbGFkc2VydmljZXMt
Y24uY29tghhnb29nbGV0YWdzZXJ2aWNlcy1jbi5jb22CGiouZ29vZ2xldGFnc2Vy
dmljZXMtY24uY29tghdnb29nbGV0YWdtYW5hZ2VyLWNuLmNvbYIZKi5nb29nbGV0
YWdtYW5hZ2VyLWNuLmNvbYIYZ29vZ2xlc3luZGljYXRpb24tY24uY29tghoqLmdv
b2dsZXN5bmRpY2F0aW9uLWNuLmNvbYIkKi5zYWZlZnJhbWUuZ29vZ2xlc3luZGlj
YXRpb24tY24uY29tghZhcHAtbWVhc3VyZW1lbnQtY24uY29tghgqLmFwcC1tZWFz
dXJlbWVudC1jbi5jb22CC2d2dDEtY24uY29tgg0qLmd2dDEtY24uY29tggtndnQy
LWNuLmNvbYINKi5ndnQyLWNuLmNvbYILMm1kbi1jbi5uZXSCDSouMm1kbi1jbi5u
ZXSCFGdvb2dsZWZsaWdodHMtY24ubmV0ghYqLmdvb2dsZWZsaWdodHMtY24ubmV0
ggxhZG1vYi1jbi5jb22CDiouYWRtb2ItY24uY29tghRnb29nbGVzYW5kYm94LWNu
LmNvbYIWKi5nb29nbGVzYW5kYm94LWNuLmNvbYIeKi5zYWZlbnVwLmdvb2dsZXNh
bmRib3gtY24uY29tgg0qLmdzdGF0aWMuY29tghQqLm1ldHJpYy5nc3RhdGljLmNv
bYIKKi5ndnQxLmNvbYIRKi5nY3BjZG4uZ3Z0MS5jb22CCiouZ3Z0Mi5jb22CDiou
Z2NwLmd2dDIuY29tghAqLnVybC5nb29nbGUuY29tghYqLnlvdXR1YmUtbm9jb29r
aWUuY29tggsqLnl0aW1nLmNvbYILYW5kcm9pZC5jb22CDSouYW5kcm9pZC5jb22C
EyouZmxhc2guYW5kcm9pZC5jb22CBGcuY26CBiouZy5jboIEZy5jb4IGKi5nLmNv
ggZnb28uZ2yCCnd3dy5nb28uZ2yCFGdvb2dsZS1hbmFseXRpY3MuY29tghYqLmdv
b2dsZS1hbmFseXRpY3MuY29tggpnb29nbGUuY29tghJnb29nbGVjb21tZXJjZS5j
b22CFCouZ29vZ2xlY29tbWVyY2UuY29tgghnZ3BodC5jboIKKi5nZ3BodC5jboIK
dXJjaGluLmNvbYIMKi51cmNoaW4uY29tggh5b3V0dS5iZYILeW91dHViZS5jb22C
DSoueW91dHViZS5jb22CEW11c2ljLnlvdXR1YmUuY29tghMqLm11c2ljLnlvdXR1
YmUuY29tghR5b3V0dWJlZWR1Y2F0aW9uLmNvbYIWKi55b3V0dWJlZWR1Y2F0aW9u
LmNvbYIPeW91dHViZWtpZHMuY29tghEqLnlvdXR1YmVraWRzLmNvbYIFeXQuYmWC
ByoueXQuYmWCGmFuZHJvaWQuY2xpZW50cy5nb29nbGUuY29tghMqLmFuZHJvaWQu
Z29vZ2xlLmNughIqLmNocm9tZS5nb29nbGUuY26CFiouZGV2ZWxvcGVycy5nb29n
bGUuY26CFSouYWlzdHVkaW8uZ29vZ2xlLmNvbTATBgNVHSAEDDAKMAgGBmeBDAEC
ATA2BgNVHR8ELzAtMCugKaAnhiVodHRwOi8vYy5wa2kuZ29vZy93cjIvb0JGWVlh
aHpnVkkuY3JsMIIBBAYKKwYBBAHWeQIEAgSB9QSB8gDwAHcAzxFW7tUufK/zh1vZ
aS6b6RpxZ0qwF+ysAdJbd87MOwgAAAGVQxqxaQAABAMASDBGAiEAk6r74vfyJIaa
hYTWqNRsjl/RpCWq/wyzzMi21zgGmfkCIQCZafyS/fl0tiutICL9aOSnDBRfPYqd
CeNqKOy11EjvigB1AN6FgddQJHxrzcuvVjfF54HGTORu1hdjn480pybJ4r03AAAB
lUMasUkAAAQDAEYwRAIgYfG2iyRnmn8MI86RFDxOQW1/IOBAjQxNfIQ8toZlZkoC
IA1BHw7cqmlTP7Ks+ebX6hGfNlVsgTQS8iYyKL5/BSvTMA0GCSqGSIb3DQEBCwUA
A4IBAQAYSNtoW72rqhPfjV5Ug1ENbbimfqmqiJS4JdzaEFRpftzachTuvx8relaY
+7FAz5y4YULu9LGNjpBRYW8yW9pgfWyc53CCHSkDODguUOMCRo3hdglxZ2d5pJ/8
TQY4zRBd8OHzOAx2kH6jLEj9I0nDie3vowSYm7FCBRLjzfForRNQWmzPu+5hS3De
QM0R2jWpmPcG3ffQ5qQwnAQnP9HCK9oEZ5cFqLvOQWfttj/rzKOz856iSEoRpf8S
wVFRu3Uv2TXQ6UYF2cDfiWCe6/mO35CIynC6FVkunze/Q/2rtaCDttLRYZcLllj8
PSl7nmLhtqDlO7da/S34BFiyyRjN
-----END CERTIFICATE-----
`,
expected: false,
},
{
name: "let's encrypt certificate",
cert: `-----BEGIN CERTIFICATE-----
MIIGMjCCBRqgAwIBAgISBVHH05rEMkaCuDQvABDjiam0MA0GCSqGSIb3DQEBCwUA
MDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQD
EwNSMTAwHhcNMjUwMzEzMDIyMzE2WhcNMjUwNjExMDIyMzE1WjAkMSIwIAYDVQQD
Exlvei1kZW1vLnBvcnRhaW5lcmNsb3VkLmlvMIICIjANBgkqhkiG9w0BAQEFAAOC
Ag8AMIICCgKCAgEAwNCcr9azSaldEwgL54bQScuWBnmw3FMHgEATxDVp2MEawQkV
I3VScUcJWBnlHlb7TUanRC/c/vJGbzc+KDuCRTZ2/Ob2yQ9G5mZjGttBAnBSQPpV
arEEBFCClhVBn4LhLNmIsCjCy25+m0HY/dwWbKjTMT/KxpTa3L3mdmIFa7XNs6W2
vEZGwYM+2JPMJ9DwemVrrrvRqd5vLWTZcWvWJQ7HMfw3PoELpeqyycmxDqd9PCMz
yMp8q3UwLDur3+KfDXGtGOoubxcOuJrpemOe8JeM5cEYEhvOy8D16zmWwWYDT19D
ElFfUbM0GGITpJ41Qie03DvmI0hDYDqTEZfKza967VsvD7K9bFgLHmHdv7gLNutB
FConpziNqslapWwQ5j7bKircxKjRQVkOiXH48m2IUzylqWgJPVMvHukRu0YVnvbt
Q53xNVZQEbjvZmIuz8jqo22Y/1Jr7Plnb1lUvvDznA58MHT0KA4LSZwk9tvMJJCw
vh7AoWB6/Jnl8QVnApOdCa6M/An128rBwgrCmp0wSvhMecTkWC8/gsah0Q5wKFL3
ziBth728Qy8RlNghRUw88e/y4pdGHN8egjK1NpdgsvTFdRNQ8qwu0lx9pO3b6TNQ
qDG5pirXjS/DhPYvZtJRDK6SMTHJNm+0NGdWB8qpNssFrU6u2cRl0533LtECAwEA
AaOCAk0wggJJMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYI
KwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUiQi/3pZamfPxRGPI8DTZ
tej1494wHwYDVR0jBBgwFoAUu7zDR6XkvKnGw6RyDBCNojXhyOgwVwYIKwYBBQUH
AQEESzBJMCIGCCsGAQUFBzABhhZodHRwOi8vcjEwLm8ubGVuY3Iub3JnMCMGCCsG
AQUFBzAChhdodHRwOi8vcjEwLmkubGVuY3Iub3JnLzAkBgNVHREEHTAbghlvei1k
ZW1vLnBvcnRhaW5lcmNsb3VkLmlvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMC4GA1Ud
HwQnMCUwI6AhoB+GHWh0dHA6Ly9yMTAuYy5sZW5jci5vcmcvNTMuY3JsMIIBBAYK
KwYBBAHWeQIEAgSB9QSB8gDwAHcAzPsPaoVxCWX+lZtTzumyfCLphVwNl422qX5U
wP5MDbAAAAGVjYW7/QAABAMASDBGAiEA8CjMOIj7wqQ60BX22A5pDkA23IxZPzwV
1MF5+VSgdqgCIQCZhry5AK2VyZX/cIODEl6eHBCUWS4vHB+J8RxeclKCpAB1AKLj
CuRF772tm3447Udnd1PXgluElNcrXhssxLlQpEfnAAABlY2Fu/QAAAQDAEYwRAIg
bwjJgZJew/1LoL9yzDD1P4Xkd8ezFucxfU3AzlV1XEYCIH5RPyW1HP9GSr+aAx+I
o3inVl1NagJFYiApAPvFmIEgMA0GCSqGSIb3DQEBCwUAA4IBAQATJWi1sJSBstO+
hyH7DsrAtDhiQTOWzUZezBlgCn8hfmA3nX5uKsHyxPPPEQ/GFYOltRD/+34X9kFF
YNzUjJOP0bGk45I1JbspxRRvtbDpk0+dj2VE2toM8vLRDz3+DB4YB2lFofYlex++
16xFzOIE+ZW41qBs3G8InsyHADsaFY2CQ9re/kZvenptU/ax1U2a21JJ3TT2DmXW
AHZYQ5/whVIowsebw1e28I12VhLl2BKn7v4MpCn3GUzBBQAEbJ6TIjHtFKWWnVfH
FisaUX6N4hMzGZVJOsbH4QVBGuNwUshHiD8MSpbans2w+T4bCe11XayerqxFhTao
w/pjiPVy
-----END CERTIFICATE-----
`,
expected: false,
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
actual, err := IsSelfSignedCertificate([]byte(tt.cert))
assert.NoError(t, err)
assert.Equal(t, tt.expected, actual)
})
}
}

View File

@@ -46,7 +46,7 @@ type settingsUpdatePayload struct {
// Whether telemetry is enabled
EnableTelemetry *bool `example:"false"`
// Helm repository URL
HelmRepositoryURL *string `example:"https://kubernetes.github.io/ingress-nginx"`
HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
// Kubectl Shell Image
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default

View File

@@ -35,8 +35,8 @@ type (
}
K8sServiceIngress struct {
IP string `json:"IP"`
Host string `json:"Host"`
IP string `json:"IP"`
Hostname string `json:"Hostname"`
}
// K8sServiceDeleteRequests is a mapping of namespace names to a slice of

View File

@@ -67,7 +67,7 @@ import (
"github.com/portainer/portainer/api/platform"
"github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/pkg/libhelm"
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
"github.com/rs/zerolog/log"
)
@@ -103,7 +103,7 @@ type Server struct {
DockerClientFactory *dockerclient.ClientFactory
KubernetesClientFactory *cli.ClientFactory
KubernetesDeployer portainer.KubernetesDeployer
HelmPackageManager libhelm.HelmPackageManager
HelmPackageManager libhelmtypes.HelmPackageManager
Scheduler *scheduler.Scheduler
ShutdownCtx context.Context
ShutdownTrigger context.CancelFunc

View File

@@ -12,45 +12,58 @@ import (
labels "k8s.io/apimachinery/pkg/labels"
)
// PortainerApplicationResources contains collections of various Kubernetes resources
// associated with a Portainer application.
type PortainerApplicationResources struct {
Pods []corev1.Pod
ReplicaSets []appsv1.ReplicaSet
Deployments []appsv1.Deployment
StatefulSets []appsv1.StatefulSet
DaemonSets []appsv1.DaemonSet
Services []corev1.Service
HorizontalPodAutoscalers []autoscalingv2.HorizontalPodAutoscaler
}
// GetAllKubernetesApplications gets a list of kubernetes workloads (or applications) across all namespaces in the cluster
// if the user is an admin, all namespaces in the current k8s environment(endpoint) are fetched using the fetchApplications function.
// otherwise, namespaces the non-admin user has access to will be used to filter the applications based on the allowed namespaces.
func (kcl *KubeClient) GetApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
func (kcl *KubeClient) GetApplications(namespace, nodeName string) ([]models.K8sApplication, error) {
if kcl.IsKubeAdmin {
return kcl.fetchApplications(namespace, nodeName, withDependencies)
return kcl.fetchApplications(namespace, nodeName)
}
return kcl.fetchApplicationsForNonAdmin(namespace, nodeName, withDependencies)
return kcl.fetchApplicationsForNonAdmin(namespace, nodeName)
}
// fetchApplications fetches the applications in the namespaces the user has access to.
// This function is called when the user is an admin.
func (kcl *KubeClient) fetchApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
func (kcl *KubeClient) fetchApplications(namespace, nodeName string) ([]models.K8sApplication, error) {
podListOptions := metav1.ListOptions{}
if nodeName != "" {
podListOptions.FieldSelector = "spec.nodeName=" + nodeName
}
if !withDependencies {
// TODO: make sure not to fetch services in fetchAllApplicationsListResources from this call
pods, replicaSets, deployments, statefulSets, daemonSets, _, _, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
if err != nil {
return nil, err
}
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, nil, nil)
}
pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
if err != nil {
return nil, err
}
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
applications, err := kcl.convertPodsToApplications(portainerApplicationResources)
if err != nil {
return nil, err
}
unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources)
if err != nil {
return nil, err
}
return append(applications, unhealthyApplications...), nil
}
// fetchApplicationsForNonAdmin fetches the applications in the namespaces the user has access to.
// This function is called when the user is not an admin.
func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string) ([]models.K8sApplication, error) {
log.Debug().Msgf("Fetching applications for non-admin user: %v", kcl.NonAdminNamespaces)
if len(kcl.NonAdminNamespaces) == 0 {
@@ -62,28 +75,24 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string,
podListOptions.FieldSelector = "spec.nodeName=" + nodeName
}
if !withDependencies {
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets(namespace, podListOptions)
if err != nil {
return nil, err
}
return kcl.convertPodsToApplications(pods, replicaSets, nil, nil, nil, nil, nil)
}
pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
if err != nil {
return nil, err
}
applications, err := kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
applications, err := kcl.convertPodsToApplications(portainerApplicationResources)
if err != nil {
return nil, err
}
unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources)
if err != nil {
return nil, err
}
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
results := make([]models.K8sApplication, 0)
for _, application := range applications {
for _, application := range append(applications, unhealthyApplications...) {
if _, ok := nonAdminNamespaceSet[application.ResourcePool]; ok {
results = append(results, application)
}
@@ -93,11 +102,11 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string,
}
// convertPodsToApplications processes pods and converts them to applications, ensuring uniqueness by owner reference.
func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) ([]models.K8sApplication, error) {
func (kcl *KubeClient) convertPodsToApplications(portainerApplicationResources PortainerApplicationResources) ([]models.K8sApplication, error) {
applications := []models.K8sApplication{}
processedOwners := make(map[string]struct{})
for _, pod := range pods {
for _, pod := range portainerApplicationResources.Pods {
if len(pod.OwnerReferences) > 0 {
ownerUID := string(pod.OwnerReferences[0].UID)
if _, exists := processedOwners[ownerUID]; exists {
@@ -106,7 +115,7 @@ func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets
processedOwners[ownerUID] = struct{}{}
}
application, err := kcl.ConvertPodToApplication(pod, replicaSets, deployments, statefulSets, daemonSets, services, hpas, true)
application, err := kcl.ConvertPodToApplication(pod, portainerApplicationResources, true)
if err != nil {
return nil, err
}
@@ -151,7 +160,9 @@ func (kcl *KubeClient) GetApplicationNamesFromConfigMap(configMap models.K8sConf
for _, pod := range pods {
if pod.Namespace == configMap.Namespace {
if isPodUsingConfigMap(&pod, configMap.Name) {
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
ReplicaSets: replicaSets,
}, false)
if err != nil {
return nil, err
}
@@ -168,7 +179,9 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
for _, pod := range pods {
if pod.Namespace == secret.Namespace {
if isPodUsingSecret(&pod, secret.Name) {
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
ReplicaSets: replicaSets,
}, false)
if err != nil {
return nil, err
}
@@ -181,12 +194,12 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
}
// ConvertPodToApplication converts a pod to an application, updating owner references if necessary
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler, withResource bool) (*models.K8sApplication, error) {
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, portainerApplicationResources PortainerApplicationResources, withResource bool) (*models.K8sApplication, error) {
if isReplicaSetOwner(pod) {
updateOwnerReferenceToDeployment(&pod, replicaSets)
updateOwnerReferenceToDeployment(&pod, portainerApplicationResources.ReplicaSets)
}
application := createApplication(&pod, deployments, statefulSets, daemonSets, services, hpas)
application := createApplicationFromPod(&pod, portainerApplicationResources)
if application.ID == "" && application.Name == "" {
return nil, nil
}
@@ -203,9 +216,9 @@ func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []app
return &application, nil
}
// createApplication creates a K8sApplication object from a pod
// createApplicationFromPod creates a K8sApplication object from a pod
// it sets the application name, namespace, kind, image, stack id, stack name, and labels
func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) models.K8sApplication {
func createApplicationFromPod(pod *corev1.Pod, portainerApplicationResources PortainerApplicationResources) models.K8sApplication {
kind := "Pod"
name := pod.Name
@@ -221,120 +234,172 @@ func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefu
switch kind {
case "Deployment":
for _, deployment := range deployments {
for _, deployment := range portainerApplicationResources.Deployments {
if deployment.Name == name && deployment.Namespace == pod.Namespace {
application.ApplicationType = "Deployment"
application.Kind = "Deployment"
application.ID = string(deployment.UID)
application.ResourcePool = deployment.Namespace
application.Name = name
application.Image = deployment.Spec.Template.Spec.Containers[0].Image
application.ApplicationOwner = deployment.Labels["io.portainer.kubernetes.application.owner"]
application.StackID = deployment.Labels["io.portainer.kubernetes.application.stackid"]
application.StackName = deployment.Labels["io.portainer.kubernetes.application.stack"]
application.Labels = deployment.Labels
application.MatchLabels = deployment.Spec.Selector.MatchLabels
application.CreationDate = deployment.CreationTimestamp.Time
application.TotalPodsCount = int(deployment.Status.Replicas)
application.RunningPodsCount = int(deployment.Status.ReadyReplicas)
application.DeploymentType = "Replicated"
application.Metadata = &models.Metadata{
Labels: deployment.Labels,
}
populateApplicationFromDeployment(&application, deployment)
break
}
}
case "StatefulSet":
for _, statefulSet := range statefulSets {
for _, statefulSet := range portainerApplicationResources.StatefulSets {
if statefulSet.Name == name && statefulSet.Namespace == pod.Namespace {
application.Kind = "StatefulSet"
application.ApplicationType = "StatefulSet"
application.ID = string(statefulSet.UID)
application.ResourcePool = statefulSet.Namespace
application.Name = name
application.Image = statefulSet.Spec.Template.Spec.Containers[0].Image
application.ApplicationOwner = statefulSet.Labels["io.portainer.kubernetes.application.owner"]
application.StackID = statefulSet.Labels["io.portainer.kubernetes.application.stackid"]
application.StackName = statefulSet.Labels["io.portainer.kubernetes.application.stack"]
application.Labels = statefulSet.Labels
application.MatchLabels = statefulSet.Spec.Selector.MatchLabels
application.CreationDate = statefulSet.CreationTimestamp.Time
application.TotalPodsCount = int(statefulSet.Status.Replicas)
application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas)
application.DeploymentType = "Replicated"
application.Metadata = &models.Metadata{
Labels: statefulSet.Labels,
}
populateApplicationFromStatefulSet(&application, statefulSet)
break
}
}
case "DaemonSet":
for _, daemonSet := range daemonSets {
for _, daemonSet := range portainerApplicationResources.DaemonSets {
if daemonSet.Name == name && daemonSet.Namespace == pod.Namespace {
application.Kind = "DaemonSet"
application.ApplicationType = "DaemonSet"
application.ID = string(daemonSet.UID)
application.ResourcePool = daemonSet.Namespace
application.Name = name
application.Image = daemonSet.Spec.Template.Spec.Containers[0].Image
application.ApplicationOwner = daemonSet.Labels["io.portainer.kubernetes.application.owner"]
application.StackID = daemonSet.Labels["io.portainer.kubernetes.application.stackid"]
application.StackName = daemonSet.Labels["io.portainer.kubernetes.application.stack"]
application.Labels = daemonSet.Labels
application.MatchLabels = daemonSet.Spec.Selector.MatchLabels
application.CreationDate = daemonSet.CreationTimestamp.Time
application.TotalPodsCount = int(daemonSet.Status.DesiredNumberScheduled)
application.RunningPodsCount = int(daemonSet.Status.NumberReady)
application.DeploymentType = "Global"
application.Metadata = &models.Metadata{
Labels: daemonSet.Labels,
}
populateApplicationFromDaemonSet(&application, daemonSet)
break
}
}
case "Pod":
runningPodsCount := 1
if pod.Status.Phase != corev1.PodRunning {
runningPodsCount = 0
}
application.ApplicationType = "Pod"
application.Kind = "Pod"
application.ID = string(pod.UID)
application.ResourcePool = pod.Namespace
application.Name = pod.Name
application.Image = pod.Spec.Containers[0].Image
application.ApplicationOwner = pod.Labels["io.portainer.kubernetes.application.owner"]
application.StackID = pod.Labels["io.portainer.kubernetes.application.stackid"]
application.StackName = pod.Labels["io.portainer.kubernetes.application.stack"]
application.Labels = pod.Labels
application.MatchLabels = pod.Labels
application.CreationDate = pod.CreationTimestamp.Time
application.TotalPodsCount = 1
application.RunningPodsCount = runningPodsCount
application.DeploymentType = string(pod.Status.Phase)
application.Metadata = &models.Metadata{
Labels: pod.Labels,
}
populateApplicationFromPod(&application, *pod)
}
if application.ID != "" && application.Name != "" && len(services) > 0 {
updateApplicationWithService(&application, services)
if application.ID != "" && application.Name != "" && len(portainerApplicationResources.Services) > 0 {
updateApplicationWithService(&application, portainerApplicationResources.Services)
}
if application.ID != "" && application.Name != "" && len(hpas) > 0 {
updateApplicationWithHorizontalPodAutoscaler(&application, hpas)
if application.ID != "" && application.Name != "" && len(portainerApplicationResources.HorizontalPodAutoscalers) > 0 {
updateApplicationWithHorizontalPodAutoscaler(&application, portainerApplicationResources.HorizontalPodAutoscalers)
}
return application
}
// createApplicationFromDeployment creates a K8sApplication from a Deployment
func createApplicationFromDeployment(deployment appsv1.Deployment) models.K8sApplication {
var app models.K8sApplication
populateApplicationFromDeployment(&app, deployment)
return app
}
// createApplicationFromStatefulSet creates a K8sApplication from a StatefulSet
func createApplicationFromStatefulSet(statefulSet appsv1.StatefulSet) models.K8sApplication {
var app models.K8sApplication
populateApplicationFromStatefulSet(&app, statefulSet)
return app
}
// createApplicationFromDaemonSet creates a K8sApplication from a DaemonSet
func createApplicationFromDaemonSet(daemonSet appsv1.DaemonSet) models.K8sApplication {
var app models.K8sApplication
populateApplicationFromDaemonSet(&app, daemonSet)
return app
}
func populateApplicationFromDeployment(application *models.K8sApplication, deployment appsv1.Deployment) {
application.ApplicationType = "Deployment"
application.Kind = "Deployment"
application.ID = string(deployment.UID)
application.ResourcePool = deployment.Namespace
application.Name = deployment.Name
application.ApplicationOwner = deployment.Labels["io.portainer.kubernetes.application.owner"]
application.StackID = deployment.Labels["io.portainer.kubernetes.application.stackid"]
application.StackName = deployment.Labels["io.portainer.kubernetes.application.stack"]
application.Labels = deployment.Labels
application.MatchLabels = deployment.Spec.Selector.MatchLabels
application.CreationDate = deployment.CreationTimestamp.Time
application.TotalPodsCount = 0
if deployment.Spec.Replicas != nil {
application.TotalPodsCount = int(*deployment.Spec.Replicas)
}
application.RunningPodsCount = int(deployment.Status.ReadyReplicas)
application.DeploymentType = "Replicated"
application.Metadata = &models.Metadata{
Labels: deployment.Labels,
}
// If the deployment has containers, use the first container's image
if len(deployment.Spec.Template.Spec.Containers) > 0 {
application.Image = deployment.Spec.Template.Spec.Containers[0].Image
}
}
func populateApplicationFromStatefulSet(application *models.K8sApplication, statefulSet appsv1.StatefulSet) {
application.Kind = "StatefulSet"
application.ApplicationType = "StatefulSet"
application.ID = string(statefulSet.UID)
application.ResourcePool = statefulSet.Namespace
application.Name = statefulSet.Name
application.ApplicationOwner = statefulSet.Labels["io.portainer.kubernetes.application.owner"]
application.StackID = statefulSet.Labels["io.portainer.kubernetes.application.stackid"]
application.StackName = statefulSet.Labels["io.portainer.kubernetes.application.stack"]
application.Labels = statefulSet.Labels
application.MatchLabels = statefulSet.Spec.Selector.MatchLabels
application.CreationDate = statefulSet.CreationTimestamp.Time
application.TotalPodsCount = 0
if statefulSet.Spec.Replicas != nil {
application.TotalPodsCount = int(*statefulSet.Spec.Replicas)
}
application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas)
application.DeploymentType = "Replicated"
application.Metadata = &models.Metadata{
Labels: statefulSet.Labels,
}
// If the statefulSet has containers, use the first container's image
if len(statefulSet.Spec.Template.Spec.Containers) > 0 {
application.Image = statefulSet.Spec.Template.Spec.Containers[0].Image
}
}
func populateApplicationFromDaemonSet(application *models.K8sApplication, daemonSet appsv1.DaemonSet) {
application.Kind = "DaemonSet"
application.ApplicationType = "DaemonSet"
application.ID = string(daemonSet.UID)
application.ResourcePool = daemonSet.Namespace
application.Name = daemonSet.Name
application.ApplicationOwner = daemonSet.Labels["io.portainer.kubernetes.application.owner"]
application.StackID = daemonSet.Labels["io.portainer.kubernetes.application.stackid"]
application.StackName = daemonSet.Labels["io.portainer.kubernetes.application.stack"]
application.Labels = daemonSet.Labels
application.MatchLabels = daemonSet.Spec.Selector.MatchLabels
application.CreationDate = daemonSet.CreationTimestamp.Time
application.TotalPodsCount = int(daemonSet.Status.DesiredNumberScheduled)
application.RunningPodsCount = int(daemonSet.Status.NumberReady)
application.DeploymentType = "Global"
application.Metadata = &models.Metadata{
Labels: daemonSet.Labels,
}
if len(daemonSet.Spec.Template.Spec.Containers) > 0 {
application.Image = daemonSet.Spec.Template.Spec.Containers[0].Image
}
}
func populateApplicationFromPod(application *models.K8sApplication, pod corev1.Pod) {
runningPodsCount := 1
if pod.Status.Phase != corev1.PodRunning {
runningPodsCount = 0
}
application.ApplicationType = "Pod"
application.Kind = "Pod"
application.ID = string(pod.UID)
application.ResourcePool = pod.Namespace
application.Name = pod.Name
application.ApplicationOwner = pod.Labels["io.portainer.kubernetes.application.owner"]
application.StackID = pod.Labels["io.portainer.kubernetes.application.stackid"]
application.StackName = pod.Labels["io.portainer.kubernetes.application.stack"]
application.Labels = pod.Labels
application.MatchLabels = pod.Labels
application.CreationDate = pod.CreationTimestamp.Time
application.TotalPodsCount = 1
application.RunningPodsCount = runningPodsCount
application.DeploymentType = string(pod.Status.Phase)
application.Metadata = &models.Metadata{
Labels: pod.Labels,
}
// If the pod has containers, use the first container's image
if len(pod.Spec.Containers) > 0 {
application.Image = pod.Spec.Containers[0].Image
}
}
// updateApplicationWithService updates the application with the services that match the application's selector match labels
// and are in the same namespace as the application
func updateApplicationWithService(application *models.K8sApplication, services []corev1.Service) {
@@ -410,7 +475,9 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap
for _, pod := range pods {
if pod.Namespace == configMap.Namespace {
if isPodUsingConfigMap(&pod, configMap.Name) {
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
ReplicaSets: replicaSets,
}, false)
if err != nil {
return nil, err
}
@@ -436,7 +503,9 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
for _, pod := range pods {
if pod.Namespace == secret.Namespace {
if isPodUsingSecret(&pod, secret.Name) {
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
ReplicaSets: replicaSets,
}, false)
if err != nil {
return nil, err
}
@@ -454,3 +523,84 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
return configurationOwners, nil
}
// fetchUnhealthyApplications fetches applications that failed to schedule any pods
// due to issues like missing resource limits or other scheduling constraints
func fetchUnhealthyApplications(resources PortainerApplicationResources) ([]models.K8sApplication, error) {
var unhealthyApplications []models.K8sApplication
// Process Deployments
for _, deployment := range resources.Deployments {
if hasNoScheduledPods(deployment) {
app := createApplicationFromDeployment(deployment)
addRelatedResourcesToApplication(&app, resources)
unhealthyApplications = append(unhealthyApplications, app)
}
}
// Process StatefulSets
for _, statefulSet := range resources.StatefulSets {
if hasNoScheduledPods(statefulSet) {
app := createApplicationFromStatefulSet(statefulSet)
addRelatedResourcesToApplication(&app, resources)
unhealthyApplications = append(unhealthyApplications, app)
}
}
// Process DaemonSets
for _, daemonSet := range resources.DaemonSets {
if hasNoScheduledPods(daemonSet) {
app := createApplicationFromDaemonSet(daemonSet)
addRelatedResourcesToApplication(&app, resources)
unhealthyApplications = append(unhealthyApplications, app)
}
}
return unhealthyApplications, nil
}
// addRelatedResourcesToApplication adds Services and HPA information to the application
func addRelatedResourcesToApplication(app *models.K8sApplication, resources PortainerApplicationResources) {
if app.ID == "" || app.Name == "" {
return
}
if len(resources.Services) > 0 {
updateApplicationWithService(app, resources.Services)
}
if len(resources.HorizontalPodAutoscalers) > 0 {
updateApplicationWithHorizontalPodAutoscaler(app, resources.HorizontalPodAutoscalers)
}
}
// hasNoScheduledPods checks if a workload has completely failed to schedule any pods
// it checks for no replicas desired, i.e. nothing to schedule and see if any pods are running
// if any pods exist at all (even if not ready), it returns false
func hasNoScheduledPods(obj interface{}) bool {
switch resource := obj.(type) {
case appsv1.Deployment:
if resource.Status.Replicas > 0 {
return false
}
return resource.Status.ReadyReplicas == 0 && resource.Status.AvailableReplicas == 0
case appsv1.StatefulSet:
if resource.Status.Replicas > 0 {
return false
}
return resource.Status.ReadyReplicas == 0 && resource.Status.CurrentReplicas == 0
case appsv1.DaemonSet:
if resource.Status.CurrentNumberScheduled > 0 || resource.Status.NumberMisscheduled > 0 {
return false
}
return resource.Status.NumberReady == 0 && resource.Status.DesiredNumberScheduled > 0
default:
return false
}
}

View File

@@ -0,0 +1,461 @@
package cli
import (
"context"
"testing"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/stretchr/testify/assert"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes/fake"
)
// Helper functions to create test resources
func createTestDeployment(name, namespace string, replicas int32) *appsv1.Deployment {
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
UID: types.UID("deploy-" + name),
Labels: map[string]string{
"app": name,
},
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{
"app": name,
},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"app": name,
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: name,
Image: "nginx:latest",
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{},
Requests: corev1.ResourceList{},
},
},
},
},
},
},
Status: appsv1.DeploymentStatus{
Replicas: replicas,
ReadyReplicas: replicas,
},
}
}
func createTestReplicaSet(name, namespace, deploymentName string) *appsv1.ReplicaSet {
return &appsv1.ReplicaSet{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
UID: types.UID("rs-" + name),
OwnerReferences: []metav1.OwnerReference{
{
Kind: "Deployment",
Name: deploymentName,
UID: types.UID("deploy-" + deploymentName),
},
},
},
Spec: appsv1.ReplicaSetSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{
"app": deploymentName,
},
},
},
}
}
func createTestStatefulSet(name, namespace string, replicas int32) *appsv1.StatefulSet {
return &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
UID: types.UID("sts-" + name),
Labels: map[string]string{
"app": name,
},
},
Spec: appsv1.StatefulSetSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{
"app": name,
},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"app": name,
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: name,
Image: "redis:latest",
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{},
Requests: corev1.ResourceList{},
},
},
},
},
},
},
Status: appsv1.StatefulSetStatus{
Replicas: replicas,
ReadyReplicas: replicas,
},
}
}
func createTestDaemonSet(name, namespace string) *appsv1.DaemonSet {
return &appsv1.DaemonSet{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
UID: types.UID("ds-" + name),
Labels: map[string]string{
"app": name,
},
},
Spec: appsv1.DaemonSetSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{
"app": name,
},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"app": name,
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: name,
Image: "fluentd:latest",
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{},
Requests: corev1.ResourceList{},
},
},
},
},
},
},
Status: appsv1.DaemonSetStatus{
DesiredNumberScheduled: 2,
NumberReady: 2,
},
}
}
func createTestPod(name, namespace, ownerKind, ownerName string, isRunning bool) *corev1.Pod {
phase := corev1.PodPending
if isRunning {
phase = corev1.PodRunning
}
var ownerReferences []metav1.OwnerReference
if ownerKind != "" && ownerName != "" {
ownerReferences = []metav1.OwnerReference{
{
Kind: ownerKind,
Name: ownerName,
UID: types.UID(ownerKind + "-" + ownerName),
},
}
}
return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
UID: types.UID("pod-" + name),
OwnerReferences: ownerReferences,
Labels: map[string]string{
"app": ownerName,
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "container-" + name,
Image: "busybox:latest",
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{},
Requests: corev1.ResourceList{},
},
},
},
},
Status: corev1.PodStatus{
Phase: phase,
},
}
}
func createTestService(name, namespace string, selector map[string]string) *corev1.Service {
return &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
UID: types.UID("svc-" + name),
},
Spec: corev1.ServiceSpec{
Selector: selector,
Type: corev1.ServiceTypeClusterIP,
},
}
}
func TestGetApplications(t *testing.T) {
t.Run("Admin user - Mix of deployments, statefulsets and daemonsets with and without pods", func(t *testing.T) {
// Create a fake K8s client
fakeClient := fake.NewSimpleClientset()
// Setup the test namespace
namespace := "test-namespace"
defaultNamespace := "default"
// Create resources in the test namespace
// 1. Deployment with pods
deployWithPods := createTestDeployment("deploy-with-pods", namespace, 2)
_, err := fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployWithPods, metav1.CreateOptions{})
assert.NoError(t, err)
replicaSet := createTestReplicaSet("rs-deploy-with-pods", namespace, "deploy-with-pods")
_, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(context.TODO(), replicaSet, metav1.CreateOptions{})
assert.NoError(t, err)
pod1 := createTestPod("pod1-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true)
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod1, metav1.CreateOptions{})
assert.NoError(t, err)
pod2 := createTestPod("pod2-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true)
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod2, metav1.CreateOptions{})
assert.NoError(t, err)
// 2. Deployment without pods (scaled to 0)
deployNoPods := createTestDeployment("deploy-no-pods", namespace, 0)
_, err = fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployNoPods, metav1.CreateOptions{})
assert.NoError(t, err)
// 3. StatefulSet with pods
stsWithPods := createTestStatefulSet("sts-with-pods", namespace, 1)
_, err = fakeClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), stsWithPods, metav1.CreateOptions{})
assert.NoError(t, err)
pod3 := createTestPod("pod1-sts", namespace, "StatefulSet", "sts-with-pods", true)
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod3, metav1.CreateOptions{})
assert.NoError(t, err)
// 4. StatefulSet without pods
stsNoPods := createTestStatefulSet("sts-no-pods", namespace, 0)
_, err = fakeClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), stsNoPods, metav1.CreateOptions{})
assert.NoError(t, err)
// 5. DaemonSet with pods
dsWithPods := createTestDaemonSet("ds-with-pods", namespace)
_, err = fakeClient.AppsV1().DaemonSets(namespace).Create(context.TODO(), dsWithPods, metav1.CreateOptions{})
assert.NoError(t, err)
pod4 := createTestPod("pod1-ds", namespace, "DaemonSet", "ds-with-pods", true)
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod4, metav1.CreateOptions{})
assert.NoError(t, err)
pod5 := createTestPod("pod2-ds", namespace, "DaemonSet", "ds-with-pods", true)
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod5, metav1.CreateOptions{})
assert.NoError(t, err)
// 6. Naked Pod (no owner reference)
nakedPod := createTestPod("naked-pod", namespace, "", "", true)
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), nakedPod, metav1.CreateOptions{})
assert.NoError(t, err)
// 7. Resources in another namespace
deployOtherNs := createTestDeployment("deploy-other-ns", defaultNamespace, 1)
_, err = fakeClient.AppsV1().Deployments(defaultNamespace).Create(context.TODO(), deployOtherNs, metav1.CreateOptions{})
assert.NoError(t, err)
podOtherNs := createTestPod("pod-other-ns", defaultNamespace, "Deployment", "deploy-other-ns", true)
_, err = fakeClient.CoreV1().Pods(defaultNamespace).Create(context.TODO(), podOtherNs, metav1.CreateOptions{})
assert.NoError(t, err)
// 8. Add a service (dependency)
service := createTestService("svc-deploy", namespace, map[string]string{"app": "deploy-with-pods"})
_, err = fakeClient.CoreV1().Services(namespace).Create(context.TODO(), service, metav1.CreateOptions{})
assert.NoError(t, err)
// Create the KubeClient with admin privileges
kubeClient := &KubeClient{
cli: fakeClient,
instanceID: "test-instance",
IsKubeAdmin: true,
}
// Test cases
// 1. All resources, no filtering
t.Run("All resources with dependencies", func(t *testing.T) {
apps, err := kubeClient.GetApplications("", "")
assert.NoError(t, err)
// We expect 7 resources: 2 deployments + 2 statefulsets + 1 daemonset + 1 naked pod + 1 deployment in other namespace
// Note: Each controller with pods should count once, not per pod
assert.Equal(t, 7, len(apps))
// Verify one of the deployments has services attached
appsWithServices := []models.K8sApplication{}
for _, app := range apps {
if len(app.Services) > 0 {
appsWithServices = append(appsWithServices, app)
}
}
assert.Equal(t, 1, len(appsWithServices))
assert.Equal(t, "deploy-with-pods", appsWithServices[0].Name)
})
// 2. Filter by namespace
t.Run("Filter by namespace", func(t *testing.T) {
apps, err := kubeClient.GetApplications(namespace, "")
assert.NoError(t, err)
// We expect 6 resources in the test namespace
assert.Equal(t, 6, len(apps))
// Verify resources from other namespaces are not included
for _, app := range apps {
assert.Equal(t, namespace, app.ResourcePool)
}
})
})
t.Run("Non-admin user - Resources filtered by accessible namespaces", func(t *testing.T) {
// Create a fake K8s client
fakeClient := fake.NewSimpleClientset()
// Setup the test namespaces
namespace1 := "allowed-ns"
namespace2 := "restricted-ns"
// Create resources in the allowed namespace
sts1 := createTestStatefulSet("sts-allowed", namespace1, 1)
_, err := fakeClient.AppsV1().StatefulSets(namespace1).Create(context.TODO(), sts1, metav1.CreateOptions{})
assert.NoError(t, err)
pod1 := createTestPod("pod-allowed", namespace1, "StatefulSet", "sts-allowed", true)
_, err = fakeClient.CoreV1().Pods(namespace1).Create(context.TODO(), pod1, metav1.CreateOptions{})
assert.NoError(t, err)
// Add a StatefulSet without pods in the allowed namespace
stsNoPods := createTestStatefulSet("sts-no-pods-allowed", namespace1, 0)
_, err = fakeClient.AppsV1().StatefulSets(namespace1).Create(context.TODO(), stsNoPods, metav1.CreateOptions{})
assert.NoError(t, err)
// Create resources in the restricted namespace
sts2 := createTestStatefulSet("sts-restricted", namespace2, 1)
_, err = fakeClient.AppsV1().StatefulSets(namespace2).Create(context.TODO(), sts2, metav1.CreateOptions{})
assert.NoError(t, err)
pod2 := createTestPod("pod-restricted", namespace2, "StatefulSet", "sts-restricted", true)
_, err = fakeClient.CoreV1().Pods(namespace2).Create(context.TODO(), pod2, metav1.CreateOptions{})
assert.NoError(t, err)
// Create the KubeClient with non-admin privileges (only allowed namespace1)
kubeClient := &KubeClient{
cli: fakeClient,
instanceID: "test-instance",
IsKubeAdmin: false,
NonAdminNamespaces: []string{namespace1},
}
// Test that only resources from allowed namespace are returned
apps, err := kubeClient.GetApplications("", "")
assert.NoError(t, err)
// We expect 2 resources from the allowed namespace (1 sts with pod + 1 sts without pod)
assert.Equal(t, 2, len(apps))
// Verify resources are from the allowed namespace
for _, app := range apps {
assert.Equal(t, namespace1, app.ResourcePool)
assert.Equal(t, "StatefulSet", app.Kind)
}
// Verify names of returned resources
stsNames := make(map[string]bool)
for _, app := range apps {
stsNames[app.Name] = true
}
assert.True(t, stsNames["sts-allowed"], "Expected StatefulSet 'sts-allowed' was not found")
assert.True(t, stsNames["sts-no-pods-allowed"], "Expected StatefulSet 'sts-no-pods-allowed' was not found")
})
t.Run("Filter by node name", func(t *testing.T) {
// Create a fake K8s client
fakeClient := fake.NewSimpleClientset()
// Setup test namespace
namespace := "node-filter-ns"
nodeName := "worker-node-1"
// Create a deployment with pods on specific node
deploy := createTestDeployment("node-deploy", namespace, 2)
_, err := fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deploy, metav1.CreateOptions{})
assert.NoError(t, err)
// Create ReplicaSet for the deployment
rs := createTestReplicaSet("rs-node-deploy", namespace, "node-deploy")
_, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(context.TODO(), rs, metav1.CreateOptions{})
assert.NoError(t, err)
// Create 2 pods, one on the specified node, one on a different node
pod1 := createTestPod("pod-on-node", namespace, "ReplicaSet", "rs-node-deploy", true)
pod1.Spec.NodeName = nodeName
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod1, metav1.CreateOptions{})
assert.NoError(t, err)
pod2 := createTestPod("pod-other-node", namespace, "ReplicaSet", "rs-node-deploy", true)
pod2.Spec.NodeName = "worker-node-2"
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod2, metav1.CreateOptions{})
assert.NoError(t, err)
// Create the KubeClient
kubeClient := &KubeClient{
cli: fakeClient,
instanceID: "test-instance",
IsKubeAdmin: true,
}
// Test filtering by node name
apps, err := kubeClient.GetApplications(namespace, nodeName)
assert.NoError(t, err)
// We expect to find only the pod on the specified node
assert.Equal(t, 1, len(apps))
if len(apps) > 0 {
assert.Equal(t, "node-deploy", apps[0].Name)
}
})
}

View File

@@ -24,7 +24,7 @@ func (kcl *KubeClient) GetConfigMaps(namespace string) ([]models.K8sConfigMap, e
// fetchConfigMapsForNonAdmin fetches the configMaps in the namespaces the user has access to.
// This function is called when the user is not an admin.
func (kcl *KubeClient) fetchConfigMapsForNonAdmin(namespace string) ([]models.K8sConfigMap, error) {
log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces)
log.Debug().Msgf("Fetching configMaps for non-admin user: %v", kcl.NonAdminNamespaces)
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
@@ -102,7 +102,7 @@ func parseConfigMap(configMap *corev1.ConfigMap, withData bool) models.K8sConfig
func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8sConfigMap) ([]models.K8sConfigMap, error) {
updatedConfigMaps := make([]models.K8sConfigMap, len(configMaps))
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
}
@@ -110,7 +110,7 @@ func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8s
for index, configMap := range configMaps {
updatedConfigMap := configMap
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, pods, replicaSets)
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to get applications from config map. Error: %w", err)
}

View File

@@ -265,9 +265,12 @@ func isSystemNamespace(namespace *corev1.Namespace) bool {
return systemLabelValue == "true"
}
systemNamespaces := defaultSystemNamespaces()
return isSystemDefaultNamespace(namespace.Name)
}
_, isSystem := systemNamespaces[namespace.Name]
func isSystemDefaultNamespace(namespace string) bool {
systemNamespaces := defaultSystemNamespaces()
_, isSystem := systemNamespaces[namespace]
return isSystem
}
@@ -390,7 +393,9 @@ func (kcl *KubeClient) CombineNamespaceWithResourceQuota(namespace portainer.K8s
func (kcl *KubeClient) buildNonAdminNamespacesMap() map[string]struct{} {
nonAdminNamespaceSet := make(map[string]struct{}, len(kcl.NonAdminNamespaces))
for _, namespace := range kcl.NonAdminNamespaces {
nonAdminNamespaceSet[namespace] = struct{}{}
if !isSystemDefaultNamespace(namespace) {
nonAdminNamespaceSet[namespace] = struct{}{}
}
}
return nonAdminNamespaceSet

View File

@@ -11,7 +11,6 @@ import (
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -110,7 +109,7 @@ func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountNam
},
}
shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(ctx, podSpec, metav1.CreateOptions{})
shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(context.TODO(), podSpec, metav1.CreateOptions{})
if err != nil {
return nil, errors.Wrap(err, "error creating shell pod")
}
@@ -158,7 +157,7 @@ func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPha
case <-ctx.Done():
return ctx.Err()
default:
pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(ctx, pod.Name, metav1.GetOptions{})
pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(context.TODO(), pod.Name, metav1.GetOptions{})
if err != nil {
return err
}
@@ -172,70 +171,67 @@ func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPha
}
}
// fetchAllPodsAndReplicaSets fetches all pods and replica sets across the cluster, i.e. all namespaces
func (kcl *KubeClient) fetchAllPodsAndReplicaSets(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, false, false)
}
// fetchAllApplicationsListResources fetches all pods, replica sets, stateful sets, and daemon sets across the cluster, i.e. all namespaces
// this is required for the applications list view
func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) (PortainerApplicationResources, error) {
return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, true, true)
}
// fetchResourcesWithOwnerReferences fetches pods and other resources based on owner references
func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) (PortainerApplicationResources, error) {
pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.Background(), podListOptions)
if err != nil {
if k8serrors.IsNotFound(err) {
return nil, nil, nil, nil, nil, nil, nil, nil
return PortainerApplicationResources{}, nil
}
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list pods across the cluster: %w", err)
return PortainerApplicationResources{}, fmt.Errorf("unable to list pods across the cluster: %w", err)
}
// if replicaSet owner reference exists, fetch the replica sets
// this also means that the deployments will be fetched because deployments own replica sets
replicaSets := &appsv1.ReplicaSetList{}
deployments := &appsv1.DeploymentList{}
if containsReplicaSetOwnerReference(pods) {
replicaSets, err = kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list replica sets across the cluster: %w", err)
}
deployments, err = kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list deployments across the cluster: %w", err)
}
portainerApplicationResources := PortainerApplicationResources{
Pods: pods.Items,
}
statefulSets := &appsv1.StatefulSetList{}
if includeStatefulSets && containsStatefulSetOwnerReference(pods) {
statefulSets, err = kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{})
replicaSets, err := kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return PortainerApplicationResources{}, fmt.Errorf("unable to list replica sets across the cluster: %w", err)
}
portainerApplicationResources.ReplicaSets = replicaSets.Items
deployments, err := kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return PortainerApplicationResources{}, fmt.Errorf("unable to list deployments across the cluster: %w", err)
}
portainerApplicationResources.Deployments = deployments.Items
if includeStatefulSets {
statefulSets, err := kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list stateful sets across the cluster: %w", err)
return PortainerApplicationResources{}, fmt.Errorf("unable to list stateful sets across the cluster: %w", err)
}
portainerApplicationResources.StatefulSets = statefulSets.Items
}
daemonSets := &appsv1.DaemonSetList{}
if includeDaemonSets && containsDaemonSetOwnerReference(pods) {
daemonSets, err = kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{})
if includeDaemonSets {
daemonSets, err := kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
return PortainerApplicationResources{}, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
}
portainerApplicationResources.DaemonSets = daemonSets.Items
}
services, err := kcl.cli.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list services across the cluster: %w", err)
return PortainerApplicationResources{}, fmt.Errorf("unable to list services across the cluster: %w", err)
}
portainerApplicationResources.Services = services.Items
hpas, err := kcl.cli.AutoscalingV2().HorizontalPodAutoscalers(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
return PortainerApplicationResources{}, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
}
portainerApplicationResources.HorizontalPodAutoscalers = hpas.Items
return pods.Items, replicaSets.Items, deployments.Items, statefulSets.Items, daemonSets.Items, services.Items, hpas.Items, nil
return portainerApplicationResources, nil
}
// isPodUsingConfigMap checks if a pod is using a specific ConfigMap

View File

@@ -10,7 +10,6 @@ import (
rbacv1 "k8s.io/api/rbac/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetRoles gets all the roles for either at the cluster level or a given namespace in a k8s endpoint.
@@ -137,7 +136,7 @@ func (kcl *KubeClient) DeleteRoles(reqs models.K8sRoleDeleteRequests) error {
for _, name := range reqs[namespace] {
client := kcl.cli.RbacV1().Roles(namespace)
role, err := client.Get(context.Background(), name, v1.GetOptions{})
role, err := client.Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue

View File

@@ -7,11 +7,9 @@ import (
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
"github.com/rs/zerolog/log"
corev1 "k8s.io/api/rbac/v1"
rbacv1 "k8s.io/api/rbac/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetRoleBindings gets all the roleBindings for either at the cluster level or a given namespace in a k8s endpoint.
@@ -98,7 +96,7 @@ func (kcl *KubeClient) isSystemRoleBinding(rb *rbacv1.RoleBinding) bool {
return false
}
func (kcl *KubeClient) getRole(namespace, name string) (*corev1.Role, error) {
func (kcl *KubeClient) getRole(namespace, name string) (*rbacv1.Role, error) {
client := kcl.cli.RbacV1().Roles(namespace)
return client.Get(context.Background(), name, metav1.GetOptions{})
}
@@ -111,7 +109,7 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
for _, name := range reqs[namespace] {
client := kcl.cli.RbacV1().RoleBindings(namespace)
roleBinding, err := client.Get(context.Background(), name, v1.GetOptions{})
roleBinding, err := client.Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
@@ -125,7 +123,7 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
log.Error().Str("role_name", name).Msg("ignoring delete of 'system' role binding, not allowed")
}
if err := client.Delete(context.Background(), name, v1.DeleteOptions{}); err != nil {
if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
errors = append(errors, err)
}
}

View File

@@ -31,7 +31,7 @@ func (kcl *KubeClient) GetSecrets(namespace string) ([]models.K8sSecret, error)
// getSecretsForNonAdmin fetches the secrets in the namespaces the user has access to.
// This function is called when the user is not an admin.
func (kcl *KubeClient) getSecretsForNonAdmin(namespace string) ([]models.K8sSecret, error) {
log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces)
log.Debug().Msgf("Fetching secrets for non-admin user: %v", kcl.NonAdminNamespaces)
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
@@ -118,7 +118,7 @@ func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret {
func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret) ([]models.K8sSecret, error) {
updatedSecrets := make([]models.K8sSecret, len(secrets))
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
}
@@ -126,7 +126,7 @@ func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret
for index, secret := range secrets {
updatedSecret := secret
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, pods, replicaSets)
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to get applications from secret. Error: %w", err)
}

View File

@@ -81,8 +81,8 @@ func parseService(service corev1.Service) models.K8sServiceInfo {
ingressStatus := make([]models.K8sServiceIngress, 0)
for _, status := range service.Status.LoadBalancer.Ingress {
ingressStatus = append(ingressStatus, models.K8sServiceIngress{
IP: status.IP,
Host: status.Hostname,
IP: status.IP,
Hostname: status.Hostname,
})
}
@@ -130,7 +130,7 @@ func (kcl *KubeClient) convertToK8sService(info models.K8sServiceInfo) corev1.Se
for _, i := range info.IngressStatus {
service.Status.LoadBalancer.Ingress = append(
service.Status.LoadBalancer.Ingress,
corev1.LoadBalancerIngress{IP: i.IP, Hostname: i.Host},
corev1.LoadBalancerIngress{IP: i.IP, Hostname: i.Hostname},
)
}
@@ -174,7 +174,7 @@ func (kcl *KubeClient) UpdateService(namespace string, info models.K8sServiceInf
func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error) {
if containsServiceWithSelector(services) {
updatedServices := make([]models.K8sServiceInfo, len(services))
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
}
@@ -182,7 +182,7 @@ func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServ
for index, service := range services {
updatedService := service
application, err := kcl.GetApplicationFromServiceSelector(pods, service, replicaSets)
application, err := kcl.GetApplicationFromServiceSelector(portainerApplicationResources.Pods, service, portainerApplicationResources.ReplicaSets)
if err != nil {
return services, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to get application from service. Error: %w", err)
}

View File

@@ -5,7 +5,6 @@ import (
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/models/kubernetes"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
corev1 "k8s.io/api/core/v1"
@@ -92,7 +91,7 @@ func (kcl *KubeClient) isSystemServiceAccount(namespace string) bool {
// DeleteServices processes a K8sServiceDeleteRequest by deleting each service
// in its given namespace.
func (kcl *KubeClient) DeleteServiceAccounts(reqs kubernetes.K8sServiceAccountDeleteRequests) error {
func (kcl *KubeClient) DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error {
var errors []error
for namespace := range reqs {
for _, serviceName := range reqs[namespace] {

View File

@@ -7,7 +7,6 @@ import (
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
storagev1 "k8s.io/api/storage/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
@@ -265,7 +264,12 @@ func (kcl *KubeClient) updateVolumesWithOwningApplications(volumes *[]models.K8s
if pod.Spec.Volumes != nil {
for _, podVolume := range pod.Spec.Volumes {
if podVolume.VolumeSource.PersistentVolumeClaim != nil && podVolume.VolumeSource.PersistentVolumeClaim.ClaimName == volume.PersistentVolumeClaim.Name && pod.Namespace == volume.PersistentVolumeClaim.Namespace {
application, err := kcl.ConvertPodToApplication(pod, replicaSetItems, deploymentItems, statefulSetItems, daemonSetItems, []corev1.Service{}, []autoscalingv2.HorizontalPodAutoscaler{}, false)
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
ReplicaSets: replicaSetItems,
Deployments: deploymentItems,
StatefulSets: statefulSetItems,
DaemonSets: daemonSetItems,
}, false)
if err != nil {
log.Error().Err(err).Msg("Failed to convert pod to application")
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to convert pod to application. Error: %w", err)

View File

@@ -309,7 +309,7 @@ type (
// FileVersion is the version of the stack file, used to detect changes
FileVersion int `json:"FileVersion"`
// ConfigHash is the commit hash of the git repository used for deploying the stack
ConfigHash string `json:"ConfigHash"`
ConfigHash string `json:"ConfigHash,omitempty"`
}
// EdgeStack represents an edge stack
@@ -353,24 +353,24 @@ type (
// EE only feature
DeploymentInfo StackDeploymentInfo
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
ReadyRePullImage bool
ReadyRePullImage bool `json:"ReadyRePullImage,omitempty"`
// Deprecated
Details EdgeStackStatusDetails
Details *EdgeStackStatusDetails `json:"Details,omitempty"`
// Deprecated
Error string
Error string `json:"Error,omitempty"`
// Deprecated
Type EdgeStackStatusType `json:"Type"`
Type EdgeStackStatusType `json:"Type,omitempty"`
}
// EdgeStackDeploymentStatus represents an edge stack deployment status
EdgeStackDeploymentStatus struct {
Time int64
Type EdgeStackStatusType
Error string
Error string `json:"Error,omitempty"`
// EE only feature
RollbackTo *int
Version int `json:"Version,omitempty"`
RollbackTo *int `json:"RollbackTo,omitempty"`
Version int `json:"Version,omitempty"`
}
// EdgeStackStatusType represents an edge stack status type
@@ -588,7 +588,7 @@ type (
// User identifier
UserID UserID `json:"UserId" example:"1"`
// Helm repository URL
URL string `json:"URL" example:"https://kubernetes.github.io/ingress-nginx"`
URL string `json:"URL" example:"https://charts.bitnami.com/bitnami"`
}
// QuayRegistryData represents data required for Quay registry to work
@@ -984,8 +984,8 @@ type (
KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"`
// Whether telemetry is enabled
EnableTelemetry bool `json:"EnableTelemetry" example:"false"`
// Helm repository URL, defaults to ""
HelmRepositoryURL string `json:"HelmRepositoryURL"`
// Helm repository URL, defaults to "https://charts.bitnami.com/bitnami"
HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"`
// KubectlImage, defaults to portainer/kubectl-shell
KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"`
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
@@ -1544,7 +1544,7 @@ type (
GetConfigMaps(namespace string) ([]models.K8sConfigMap, error)
GetSecrets(namespace string) ([]models.K8sSecret, error)
GetIngressControllers() (models.K8sIngressControllers, error)
GetApplications(namespace, nodename string, withDependencies bool) ([]models.K8sApplication, error)
GetApplications(namespace, nodename string) ([]models.K8sApplication, error)
GetMetrics() (models.K8sMetrics, error)
GetStorage() ([]KubernetesStorageClassConfig, error)
CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error
@@ -1637,9 +1637,9 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.27.1"
APIVersion = "2.28.0"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "LTS"
APIVersionSupport = "STS"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
@@ -1673,8 +1673,8 @@ const (
DefaultEdgeAgentCheckinIntervalInSeconds = 5
// DefaultTemplatesURL represents the URL to the official templates supported by Portainer
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/v3/templates.json"
// DefaultHelmrepositoryURL set to empty string until oci support is added
DefaultHelmRepositoryURL = ""
// DefaultHelmrepositoryURL represents the URL to the official templates supported by Bitnami
DefaultHelmRepositoryURL = "https://charts.bitnami.com/bitnami"
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
DefaultUserSessionTimeout = "8h"
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared

View File

@@ -1,9 +0,0 @@
.helm-template-item-details {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
.helm-template-item-details .helm-template-item-details-sub {
width: 100%;
}

View File

@@ -1,40 +0,0 @@
<!-- helm chart -->
<div ng-class="{ 'blocklist-item--selected': $ctrl.model.Selected }" class="blocklist-item template-item mx-0" ng-click="$ctrl.onSelect($ctrl.model)" role="listitem">
<div class="blocklist-item-box">
<!-- helmchart-image -->
<span class="shrink-0">
<fallback-image src="$ctrl.model.icon" fallback-icon="$ctrl.fallbackIcon" class-name="'blocklist-item-logo h-16 w-auto'" size="'3xl'"></fallback-image>
</span>
<!-- helmchart-details -->
<div class="col-sm-12 helm-template-item-details">
<!-- blocklist-item-line1 -->
<div class="blocklist-item-line">
<span>
<span class="blocklist-item-title">
{{ $ctrl.model.name }}
</span>
<span class="space-left blocklist-item-subtitle">
<span class="vertical-center">
<pr-icon icon="'svg-helm'" mode="'primary'"></pr-icon>
</span>
<span> Helm </span>
</span>
</span>
</div>
<!-- !blocklist-item-line1 -->
<span class="blocklist-item-actions" ng-transclude="actions"></span>
<!-- blocklist-item-line2 -->
<div class="blocklist-item-line helm-template-item-details-sub">
<span class="blocklist-item-desc">
{{ $ctrl.model.description }}
</span>
<span class="small text-muted" ng-if="$ctrl.model.annotations.category">
{{ $ctrl.model.annotations.category }}
</span>
</div>
<!-- !blocklist-item-line2 -->
</div>
<!-- !helmchart-details -->
</div>
<!-- !helm chart -->
</div>

View File

@@ -1,17 +0,0 @@
import angular from 'angular';
import './helm-templates-list-item.css';
import { HelmIcon } from '../../HelmIcon';
angular.module('portainer.kubernetes').component('helmTemplatesListItem', {
templateUrl: './helm-templates-list-item.html',
bindings: {
model: '<',
onSelect: '<',
},
transclude: {
actions: '?templateItemActions',
},
controller() {
this.fallbackIcon = HelmIcon;
},
});

View File

@@ -1,43 +0,0 @@
export default class HelmTemplatesListController {
/* @ngInject */
constructor($async, $scope, HelmService, Notifications) {
this.$async = $async;
this.$scope = $scope;
this.HelmService = HelmService;
this.Notifications = Notifications;
this.state = {
textFilter: '',
selectedCategory: '',
categories: [],
};
this.updateCategories = this.updateCategories.bind(this);
this.onCategoryChange = this.onCategoryChange.bind(this);
}
async updateCategories() {
try {
const annotationCategories = this.charts
.map((t) => t.annotations) // get annotations
.filter((a) => a) // filter out undefined/nulls
.map((c) => c.category); // get annotation category
const availableCategories = [...new Set(annotationCategories)].sort(); // unique and sort
this.state.categories = availableCategories.map((cat) => ({ label: cat, value: cat }));
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve helm charts categories');
}
}
onCategoryChange(value) {
return this.$scope.$evalAsync(() => {
this.state.selectedCategory = value || '';
});
}
$onChanges() {
if (this.charts.length > 0) {
this.updateCategories();
}
}
}

View File

@@ -1,79 +0,0 @@
<section class="datatable" aria-label="Helm charts">
<div class="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1 !px-0">
<div class="toolBarTitle vertical-center"> {{ $ctrl.titleText }} </div>
<div class="searchBar vertical-center !mr-0">
<pr-icon icon="'search'" class="searchIcon"></pr-icon>
<input
type="text"
data-cy="helm-templates-search"
class="searchInput"
ng-model="$ctrl.state.textFilter"
placeholder="Search..."
auto-focus
ng-model-options="{ debounce: 300 }"
aria-label="Search input"
/>
</div>
<div class="w-1/5">
<por-select
placeholder="'Select a category'"
value="$ctrl.state.selectedCategory"
options="$ctrl.state.categories"
on-change="($ctrl.onCategoryChange)"
is-clearable="true"
bind-to-body="true"
></por-select>
</div>
</div>
<div class="w-full">
<div class="small text-muted mb-2"
>Select the Helm chart to use. Bring further Helm charts into your selection list via
<a ui-sref="portainer.account({'#': 'helm-repositories'})">User settings - Helm repositories</a>.</div
>
<div class="relative flex w-fit gap-1 rounded-lg bg-gray-modern-3 p-4 text-sm th-highcontrast:bg-legacy-grey-3 th-dark:bg-legacy-grey-3 mt-2">
<div class="mt-0.5 shrink-0">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-lightbulb h-4 text-warning-7 th-highcontrast:text-warning-6 th-dark:text-warning-6"
>
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"></path>
<path d="M9 18h6"></path>
<path d="M10 22h4"></path>
</svg>
</div>
<div>
<p class="align-middle text-[0.9em] font-medium pr-10 mb-2">Disclaimer</p>
<div class="small">
At present Portainer does not support OCI format Helm charts. Support for OCI charts will be available in a future release.<br />
If you would like to provide feedback on OCI support or get access to early releases to test this functionality,
<a href="https://bit.ly/3WVkayl" target="_blank" rel="noopener noreferrer">please get in touch</a>.
</div>
</div>
</div>
</div>
<div class="blocklist !px-0" role="list">
<helm-templates-list-item
ng-repeat="chart in allCharts = ($ctrl.charts | filter:$ctrl.state.textFilter | filter: $ctrl.state.selectedCategory)"
model="chart"
type-label="helm"
on-select="($ctrl.selectAction)"
>
</helm-templates-list-item>
<div ng-if="!$ctrl.loading && !allCharts.length && $ctrl.charts.length !== 0" class="text-muted small mt-4"> No Helm charts found </div>
<div ng-if="$ctrl.loading" class="text-muted text-center">
Loading...
<div class="text-muted text-center"> Initial download of Helm charts can take a few minutes </div>
</div>
<div ng-if="!$ctrl.loading && $ctrl.charts.length === 0" class="text-muted text-center"> No helm charts available. </div>
</div>
</section>

View File

@@ -1,14 +0,0 @@
import angular from 'angular';
import controller from './helm-templates-list.controller';
angular.module('portainer.kubernetes').component('helmTemplatesList', {
templateUrl: './helm-templates-list.html',
controller,
bindings: {
loading: '<',
titleText: '@',
charts: '<',
tableKey: '@',
selectAction: '<',
},
});

View File

@@ -101,7 +101,7 @@
<div class="row" ng-if="!$ctrl.state.chart">
<div class="col-sm-12 p-0">
<helm-templates-list
title-text="Helm chart"
title-text="'Helm chart'"
charts="$ctrl.state.charts"
table-key="$ctrl.state.charts"
select-action="$ctrl.selectHelmChart"

View File

@@ -58,6 +58,8 @@ import { AppDeploymentTypeFormSection } from '@/react/kubernetes/applications/co
import { EnvironmentVariablesFormSection } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/EnvironmentVariablesFormSection';
import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/kubeEnvVarValidationSchema';
import { IntegratedAppsDatatable } from '@/react/kubernetes/components/IntegratedAppsDatatable/IntegratedAppsDatatable';
import { HelmTemplatesList } from '@/react/kubernetes/helm/HelmTemplates/HelmTemplatesList';
import { HelmTemplatesListItem } from '@/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem';
import { namespacesModule } from './namespaces';
import { clusterManagementModule } from './clusterManagement';
@@ -205,6 +207,19 @@ export const ngModule = angular
'tableTitle',
'dataCy',
])
)
.component(
'helmTemplatesList',
r2a(withUIRouter(withCurrentUser(HelmTemplatesList)), [
'loading',
'titleText',
'charts',
'selectAction',
])
)
.component(
'helmTemplatesListItem',
r2a(HelmTemplatesListItem, ['model', 'onSelect', 'actions'])
);
export const componentsModule = ngModule.name;

View File

@@ -3,6 +3,7 @@ import _ from 'lodash-es';
import angular from 'angular';
import KubernetesResourcePoolConverter from 'Kubernetes/converters/resourcePool';
import KubernetesResourceQuotaHelper from 'Kubernetes/helpers/resourceQuotaHelper';
import { getNamespaces } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
/* @ngInject */
export function KubernetesResourcePoolService(
@@ -11,7 +12,8 @@ export function KubernetesResourcePoolService(
KubernetesNamespaceService,
KubernetesResourceQuotaService,
KubernetesIngressService,
KubernetesPortainerNamespaces
KubernetesPortainerNamespaces,
EndpointProvider
) {
return {
get,
@@ -37,9 +39,14 @@ export function KubernetesResourcePoolService(
// getting the quota for all namespaces is costly by default, so disable getting it by default
async function getAll({ getQuota = false }) {
const namespaces = await KubernetesNamespaceService.get();
const namespaces = await getNamespaces(EndpointProvider.endpointID());
// there is a lot of downstream logic using the angular namespace type with a '.Status' field (not '.Status.phase'), so format the status here to match this logic
const namespacesFormattedStatus = namespaces.map((namespace) => ({
...namespace,
Status: namespace.Status.phase,
}));
const pools = await Promise.all(
_.map(namespaces, async (namespace) => {
_.map(namespacesFormattedStatus, async (namespace) => {
const name = namespace.Name;
const pool = KubernetesResourcePoolConverter.apiToResourcePool(namespace);
if (getQuota) {

View File

@@ -6,13 +6,13 @@ import PortainerError from '@/portainer/error';
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { getDeploymentOptions } from '@/react/portainer/environments/environment.service';
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods';
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods';
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
class KubernetesDeployController {
/* @ngInject */

View File

@@ -7,7 +7,7 @@ import { gitFormRefField } from './git-form-ref-field';
export const gitFormModule = angular
.module('portainer.app.components.git-form', [])
.component('gitForm', gitForm)
.component('gitForm', gitForm) // kube deploy + docker stack create
.component('gitFormAuthFieldset', gitFormAuthFieldset)
.component('gitFormAutoUpdateFieldset', gitFormAutoUpdate)
.component('gitFormRefField', gitFormRefField).name;

View File

@@ -29,6 +29,7 @@ export const gitFormModule = angular
'webhookId',
'webhooksDocs',
'createdFromCustomTemplateId',
'isAutoUpdateVisible',
])
)
.component(

View File

@@ -155,6 +155,9 @@ describe('Datatable', () => {
);
expect(screen.getByText('No data available')).toBeInTheDocument();
const selectAllCheckbox: HTMLInputElement =
screen.getByLabelText('Select all rows');
expect(selectAllCheckbox.checked).toBe(false);
});
it('selects/deselects only page rows when select all is clicked', () => {

View File

@@ -3,7 +3,8 @@ import { ColumnDef, Row, Table } from '@tanstack/react-table';
import { Checkbox } from '@@/form-components/Checkbox';
function allRowsSelected<T>(table: Table<T>) {
return table.getCoreRowModel().rows.every((row) => row.getIsSelected());
const { rows } = table.getCoreRowModel();
return rows.length > 0 && rows.every((row) => row.getIsSelected());
}
function someRowsSelected<T>(table: Table<T>) {

View File

@@ -139,6 +139,7 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
}
baseWebhookUrl={baseEdgeStackWebhookUrl()}
webhookId={webhookId}
isAutoUpdateVisible={isBE}
/>
{isBE && (

View File

@@ -4,6 +4,7 @@ import { FormikErrors } from 'formik';
import { GitForm } from '@/react/portainer/gitops/GitForm';
import { GitFormModel } from '@/react/portainer/gitops/types';
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { BoxSelector } from '@@/BoxSelector';
import { WebEditorForm } from '@@/WebEditorForm';
@@ -109,6 +110,7 @@ export function KubeManifestForm({
}
baseWebhookUrl={baseEdgeStackWebhookUrl()}
webhookId={webhookId}
isAutoUpdateVisible={isBE}
/>
)}
</>

View File

@@ -57,7 +57,6 @@ export function ApplicationsDatatable({
const applicationsQuery = useApplications(environmentId, {
refetchInterval: tableState.autoRefreshRate * 1000,
namespace: tableState.namespace,
withDependencies: true,
});
const ingressesQuery = useIngresses(environmentId);
const ingresses = ingressesQuery.data ?? [];

View File

@@ -38,7 +38,6 @@ export function ApplicationsStacksDatatable({
const applicationsQuery = useApplications(environmentId, {
refetchInterval: tableState.autoRefreshRate * 1000,
namespace: tableState.namespace,
withDependencies: true,
});
const ingressesQuery = useIngresses(environmentId);
const ingresses = ingressesQuery.data ?? [];

View File

@@ -3,7 +3,6 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
export type GetAppsParams = {
namespace?: string;
nodeName?: string;
withDependencies?: boolean;
};
export const queryKeys = {

View File

@@ -11,7 +11,6 @@ import { queryKeys } from './query-keys';
type GetAppsParams = {
namespace?: string;
nodeName?: string;
withDependencies?: boolean;
};
type GetAppsQueryOptions = {

View File

@@ -33,7 +33,7 @@ export function isExternalApplication(application: Application) {
function getDeploymentRunningPods(deployment: Deployment): number {
const availableReplicas = deployment.status?.availableReplicas ?? 0;
const totalReplicas = deployment.status?.replicas ?? 0;
const totalReplicas = deployment.spec?.replicas ?? 0;
const unavailableReplicas = deployment.status?.unavailableReplicas ?? 0;
return availableReplicas || totalReplicas - unavailableReplicas;
}

View File

@@ -62,19 +62,19 @@ describe('HelmApplicationView', () => {
expect(await screen.findByText('Helm details')).toBeInTheDocument();
// Check for the release details
expect(screen.getByText('Release')).toBeInTheDocument();
expect(await screen.findByText('Release')).toBeInTheDocument();
// Check for the table content
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Chart')).toBeInTheDocument();
expect(screen.getByText('App version')).toBeInTheDocument();
expect(await screen.findByText('Name')).toBeInTheDocument();
expect(await screen.findByText('Chart')).toBeInTheDocument();
expect(await screen.findByText('App version')).toBeInTheDocument();
// Check for the actual values
expect(screen.getByTestId('k8sAppDetail-appName')).toHaveTextContent(
expect(await screen.findByTestId('k8sAppDetail-appName')).toHaveTextContent(
'test-release'
);
expect(screen.getByText('test-chart-1.0.0')).toBeInTheDocument();
expect(screen.getByText('1.0.0')).toBeInTheDocument();
expect(await screen.findByText('test-chart-1.0.0')).toBeInTheDocument();
expect(await screen.findByText('1.0.0')).toBeInTheDocument();
});
it('should display error message when API request fails', async () => {

View File

@@ -1,37 +1,13 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import { PageHeader } from '@/react/components/PageHeader';
import { Widget, WidgetBody, WidgetTitle } from '@/react/components/Widget';
import helm from '@/assets/ico/vendor/helm.svg?c';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { ViewLoading } from '@@/ViewLoading';
import { Alert } from '@@/Alert';
import { useHelmRelease } from './queries/useHelmRelease';
import { HelmDetailsWidget } from './HelmDetailsWidget';
export function HelmApplicationView() {
const { params } = useCurrentStateAndParams();
const environmentId = useEnvironmentId();
const name = params.name as string;
const namespace = params.namespace as string;
const {
data: release,
isLoading,
error,
} = useHelmRelease(environmentId, name, namespace);
if (isLoading) {
return <ViewLoading />;
}
if (error || !release) {
return (
<Alert color="error" title="Failed to load Helm application details" />
);
}
const { name, namespace } = params;
return (
<>
@@ -46,32 +22,7 @@ export function HelmApplicationView() {
<div className="row">
<div className="col-sm-12">
<Widget>
<WidgetTitle icon={helm} title="Release" />
<WidgetBody>
<table className="table">
<tbody>
<tr>
<td className="!border-none w-40">Name</td>
<td
className="!border-none min-w-[140px]"
data-cy="k8sAppDetail-appName"
>
{release.name}
</td>
</tr>
<tr>
<td className="!border-t">Chart</td>
<td className="!border-t">{release.chart}</td>
</tr>
<tr>
<td>App version</td>
<td>{release.app_version}</td>
</tr>
</tbody>
</table>
</WidgetBody>
</Widget>
<HelmDetailsWidget name={name} namespace={namespace} />
</div>
</div>
</>

View File

@@ -0,0 +1,67 @@
import {
Loading,
Widget,
WidgetBody,
WidgetTitle,
} from '@/react/components/Widget';
import helm from '@/assets/ico/vendor/helm.svg?c';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Alert } from '@@/Alert';
import { useHelmRelease } from './queries/useHelmRelease';
interface HelmDetailsWidgetProps {
name: string;
namespace: string;
}
export function HelmDetailsWidget({ name, namespace }: HelmDetailsWidgetProps) {
const environmentId = useEnvironmentId();
const {
data: release,
isInitialLoading,
isError,
} = useHelmRelease(environmentId, name, namespace);
return (
<Widget>
<WidgetTitle icon={helm} title="Release" />
<WidgetBody>
{isInitialLoading && <Loading />}
{isError && (
<Alert
color="error"
title="Failed to load Helm application details"
/>
)}
{!isInitialLoading && !isError && release && (
<table className="table">
<tbody>
<tr>
<td className="!border-none w-40">Name</td>
<td
className="!border-none min-w-[140px]"
data-cy="k8sAppDetail-appName"
>
{release.name}
</td>
</tr>
<tr>
<td className="!border-t">Chart</td>
<td className="!border-t">{release.chart}</td>
</tr>
<tr>
<td>App version</td>
<td>{release.app_version}</td>
</tr>
</tbody>
</table>
)}
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,190 @@
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { UserViewModel } from '@/portainer/models/user';
import { HelmTemplatesList } from './HelmTemplatesList';
import { Chart } from './HelmTemplatesListItem';
// Sample test data
const mockCharts: Chart[] = [
{
name: 'test-chart-1',
description: 'Test Chart 1 Description',
annotations: {
category: 'database',
},
},
{
name: 'test-chart-2',
description: 'Test Chart 2 Description',
annotations: {
category: 'database',
},
},
{
name: 'nginx-chart',
description: 'Nginx Web Server',
annotations: {
category: 'web',
},
},
];
const selectActionMock = vi.fn();
function renderComponent({
loading = false,
titleText = 'Test Helm Templates',
charts = mockCharts,
selectAction = selectActionMock,
} = {}) {
const user = new UserViewModel({ Username: 'user' });
const Wrapped = withTestQueryProvider(
withUserProvider(
withTestRouter(() => (
<HelmTemplatesList
loading={loading}
titleText={titleText}
charts={charts}
selectAction={selectAction}
/>
)),
user
)
);
return { ...render(<Wrapped />), user };
}
describe('HelmTemplatesList', () => {
beforeEach(() => {
selectActionMock.mockClear();
});
it('should display title and charts list', async () => {
renderComponent();
// Check for the title
expect(screen.getByText('Test Helm Templates')).toBeInTheDocument();
// Check for charts
expect(screen.getByText('test-chart-1')).toBeInTheDocument();
expect(screen.getByText('Test Chart 1 Description')).toBeInTheDocument();
expect(screen.getByText('nginx-chart')).toBeInTheDocument();
expect(screen.getByText('Nginx Web Server')).toBeInTheDocument();
});
it('should call selectAction when a chart is clicked', async () => {
renderComponent();
// Find the first chart item
const firstChartItem = screen.getByText('test-chart-1').closest('button');
expect(firstChartItem).not.toBeNull();
// Click on the chart item
if (firstChartItem) {
fireEvent.click(firstChartItem);
}
// Check if selectAction was called with the correct chart
expect(selectActionMock).toHaveBeenCalledWith(mockCharts[0]);
});
it('should filter charts by text search', async () => {
renderComponent();
const user = userEvent.setup();
// Find search input and type "nginx"
const searchInput = screen.getByPlaceholderText('Search...');
await user.type(searchInput, 'nginx');
// Wait 300ms for debounce
await new Promise((resolve) => {
setTimeout(() => {
resolve(undefined);
}, 300);
});
// Should show only nginx chart
expect(screen.getByText('nginx-chart')).toBeInTheDocument();
expect(screen.queryByText('test-chart-1')).not.toBeInTheDocument();
expect(screen.queryByText('test-chart-2')).not.toBeInTheDocument();
});
it('should filter charts by category', async () => {
renderComponent();
const user = userEvent.setup();
// Find the category select
const categorySelect = screen.getByText('Select a category');
await user.click(categorySelect);
// Select "web" category
const webCategory = screen.getByText('web', {
selector: '[tabindex="-1"]',
});
await user.click(webCategory);
// Should show only web category charts
expect(screen.queryByText('nginx-chart')).toBeInTheDocument();
expect(screen.queryByText('test-chart-1')).not.toBeInTheDocument();
expect(screen.queryByText('test-chart-2')).not.toBeInTheDocument();
});
it('should show loading message when loading prop is true', async () => {
renderComponent({ loading: true });
// Check for loading message
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(
screen.getByText('Initial download of Helm charts can take a few minutes')
).toBeInTheDocument();
});
it('should show empty message when no charts are available', async () => {
renderComponent({ charts: [] });
// Check for empty message
expect(screen.getByText('No helm charts available.')).toBeInTheDocument();
});
it('should show no results message when search has no matches', async () => {
renderComponent();
const user = userEvent.setup();
// Find search input and type text that won't match any charts
const searchInput = screen.getByPlaceholderText('Search...');
await user.type(searchInput, 'nonexistent chart');
// Wait 300ms for debounce
await new Promise((resolve) => {
setTimeout(() => {
resolve(undefined);
}, 300);
});
// Check for no results message
expect(screen.getByText('No Helm charts found')).toBeInTheDocument();
});
it('should handle keyboard navigation and selection', async () => {
renderComponent();
const user = userEvent.setup();
// Find the first chart item
const firstChartItem = screen.getByText('test-chart-1').closest('button');
expect(firstChartItem).not.toBeNull();
// Focus and press Enter
if (firstChartItem) {
(firstChartItem as HTMLElement).focus();
await user.keyboard('{Enter}');
}
// Check if selectAction was called with the correct chart
expect(selectActionMock).toHaveBeenCalledWith(mockCharts[0]);
});
});

View File

@@ -0,0 +1,179 @@
import { useState, useMemo } from 'react';
import { PortainerSelect } from '@/react/components/form-components/PortainerSelect';
import { Link } from '@/react/components/Link';
import { InsightsBox } from '@@/InsightsBox';
import { SearchBar } from '@@/datatables/SearchBar';
import { Chart, HelmTemplatesListItem } from './HelmTemplatesListItem';
interface Props {
loading: boolean;
titleText: string;
charts?: Chart[];
selectAction: (chart: Chart) => void;
}
/**
* Get categories from charts
* @param charts - The charts to get the categories from
* @returns Categories
*/
function getCategories(charts: Chart[]) {
const annotationCategories = charts
.map((chart) => chart.annotations?.category) // get category
.filter((c): c is string => !!c); // filter out nulls/undefined
const availableCategories = [...new Set(annotationCategories)].sort(); // unique and sort
// Create options array in the format expected by PortainerSelect
return availableCategories.map((cat) => ({
label: cat,
value: cat,
}));
}
/**
* Get filtered charts
* @param charts - The charts to get the filtered charts from
* @param textFilter - The text filter
* @param selectedCategory - The selected category
* @returns Filtered charts
*/
function getFilteredCharts(
charts: Chart[],
textFilter: string,
selectedCategory: string | null
) {
return charts.filter((chart) => {
// Text filter
if (
textFilter &&
!chart.name.toLowerCase().includes(textFilter.toLowerCase()) &&
!chart.description.toLowerCase().includes(textFilter.toLowerCase())
) {
return false;
}
// Category filter
if (
selectedCategory &&
(!chart.annotations || chart.annotations.category !== selectedCategory)
) {
return false;
}
return true;
});
}
export function HelmTemplatesList({
loading,
titleText,
charts = [],
selectAction,
}: Props) {
const [textFilter, setTextFilter] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const categories = useMemo(() => getCategories(charts), [charts]);
const filteredCharts = useMemo(
() => getFilteredCharts(charts, textFilter, selectedCategory),
[charts, textFilter, selectedCategory]
);
return (
<section className="datatable" aria-label="Helm charts">
<div className="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1 !px-0">
<div className="toolBarTitle vertical-center">{titleText}</div>
<SearchBar
value={textFilter}
onChange={(value) => setTextFilter(value)}
placeholder="Search..."
data-cy="helm-templates-search"
className="!mr-0 h-9"
/>
<div className="w-full sm:w-1/5">
<PortainerSelect
placeholder="Select a category"
value={selectedCategory}
options={categories}
onChange={(value) => setSelectedCategory(value)}
isClearable
bindToBody
data-cy="helm-category-select"
/>
</div>
</div>
<div className="w-fit">
<div className="small text-muted mb-2">
Select the Helm chart to use. Bring further Helm charts into your
selection list via{' '}
<Link
to="portainer.account"
params={{ '#': 'helm-repositories' }}
data-cy="helm-repositories-link"
>
User settings - Helm repositories
</Link>
.
</div>
<InsightsBox
header="Disclaimer"
type="slim"
content={
<>
At present Portainer does not support OCI format Helm charts.
Support for OCI charts will be available in a future release.
<br />
If you would like to provide feedback on OCI support or get access
to early releases to test this functionality,{' '}
<a
href="https://bit.ly/3WVkayl"
target="_blank"
rel="noopener noreferrer"
>
please get in touch
</a>
.
</>
}
/>
</div>
<div className="blocklist !px-0" role="list">
{filteredCharts.map((chart) => (
<HelmTemplatesListItem
key={chart.name}
model={chart}
onSelect={selectAction}
/>
))}
{filteredCharts.length === 0 && textFilter && (
<div className="text-muted small mt-4">No Helm charts found</div>
)}
{loading && (
<div className="text-muted text-center">
Loading...
<div className="text-muted text-center">
Initial download of Helm charts can take a few minutes
</div>
</div>
)}
{!loading && charts.length === 0 && (
<div className="text-muted text-center">
No helm charts available.
</div>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { HelmIcon } from '@/kubernetes/components/helm/helm-templates/HelmIcon';
import { FallbackImage } from '@/react/components/FallbackImage';
import Svg from '@@/Svg';
export interface Chart {
name: string;
description: string;
icon?: string;
annotations?: {
category?: string;
};
}
interface HelmTemplatesListItemProps {
model: Chart;
onSelect: (model: Chart) => void;
actions?: React.ReactNode;
}
export function HelmTemplatesListItem(props: HelmTemplatesListItemProps) {
const { model, onSelect, actions } = props;
function handleSelect() {
onSelect(model);
}
return (
<button
type="button"
className="blocklist-item mx-0 bg-inherit text-start"
onClick={handleSelect}
tabIndex={0}
>
<div className="blocklist-item-box">
<span className="shrink-0">
<FallbackImage
src={model.icon}
fallbackIcon={HelmIcon}
className="blocklist-item-logo h-16 w-auto"
alt="Helm chart icon"
/>
</span>
<div className="col-sm-12 flex flex-wrap justify-between gap-2">
<div className="blocklist-item-line">
<span>
<span className="blocklist-item-title">{model.name}</span>
<span className="space-left blocklist-item-subtitle">
<span className="vertical-center">
<Svg icon="helm" className="icon icon-primary" />
</span>
<span> Helm </span>
</span>
</span>
</div>
<span className="blocklist-item-actions">{actions}</span>
<div className="blocklist-item-line w-full">
<span className="blocklist-item-desc">{model.description}</span>
{model.annotations?.category && (
<span className="small text-muted">
{model.annotations.category}
</span>
)}
</div>
</div>
</div>
</button>
);
}

View File

@@ -30,7 +30,6 @@ export function NamespaceAppsDatatable({ namespace }: { namespace: string }) {
const applicationsQuery = useApplications(environmentId, {
refetchInterval: tableState.autoRefreshRate * 1000,
namespace,
withDependencies: true,
});
const applications = applicationsQuery.data ?? [];

View File

@@ -0,0 +1,38 @@
import { CellContext } from '@tanstack/react-table';
import { ServiceRowData } from '../types';
import { columnHelper } from './helper';
export const externalHost = columnHelper.accessor(
(row) => {
if (row.Type === 'ExternalName') {
return row.ExternalName || '-';
}
return (
row.IngressStatus?.map((status) => status.Hostname)
.filter(Boolean)
.join(' ,') || '-'
);
},
{
header: 'External Host',
id: 'externalHost',
cell: Cell,
}
);
function Cell({ row }: CellContext<ServiceRowData, string>) {
if (row.original.Type === 'ExternalName') {
return <div>{row.original.ExternalName || '-'}</div>;
}
return (
<div>
{row.original.IngressStatus?.map((status) => status.Hostname)
.filter(Boolean)
.join(' ,') || '-'}
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { type } from './type';
import { namespace } from './namespace';
import { ports } from './ports';
import { clusterIP } from './clusterIP';
import { externalHost } from './externalHost';
import { externalIP } from './externalIP';
import { targetPorts } from './targetPorts';
import { application } from './application';
@@ -16,6 +17,7 @@ export const columns = [
ports,
targetPorts,
clusterIP,
externalHost,
externalIP,
created,
];

View File

@@ -7,7 +7,6 @@ import { RefField } from '@/react/portainer/gitops/RefField';
import { GitFormUrlField } from '@/react/portainer/gitops/GitFormUrlField';
import { DeployMethod, GitFormModel } from '@/react/portainer/gitops/types';
import { TimeWindowDisplay } from '@/react/portainer/gitops/TimeWindowDisplay';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { FormSection } from '@@/form-components/FormSection';
import { validateForm } from '@@/form-components/validate-form';
@@ -35,6 +34,7 @@ interface Props {
webhookId?: string;
webhooksDocs?: string;
createdFromCustomTemplateId?: number;
isAutoUpdateVisible?: boolean;
}
export function GitForm({
@@ -51,6 +51,7 @@ export function GitForm({
webhookId,
webhooksDocs,
createdFromCustomTemplateId,
isAutoUpdateVisible = true,
}: Props) {
const [value, setValue] = useState(initialValue); // TODO: remove this state when form is not inside angularjs
@@ -117,7 +118,7 @@ export function GitForm({
/>
)}
{isBE && value.AutoUpdate && (
{isAutoUpdateVisible && value.AutoUpdate && (
<AutoUpdateFieldset
environmentType={environmentType}
webhookId={webhookId || ''}

View File

@@ -1,6 +1,6 @@
{
"docker": "v27.5.1",
"helm": "v3.17.0",
"kubectl": "v1.32.1",
"helm": "v3.17.2",
"kubectl": "v1.32.2",
"mingit": "2.48.1.1"
}

View File

@@ -23,7 +23,6 @@ GIT_COMMIT_HASH=${GIT_COMMIT_HASH:-$(git rev-parse --short HEAD)}
# populate dependencies versions
DOCKER_VERSION=$(jq -r '.docker' < "${BINARY_VERSION_FILE}")
HELM_VERSION=$(jq -r '.helm' < "${BINARY_VERSION_FILE}")
KUBECTL_VERSION=$(jq -r '.kubectl' < "${BINARY_VERSION_FILE}")
COMPOSE_VERSION=$(go list -m -f '{{.Version}}' github.com/docker/compose/v2)
@@ -52,7 +51,6 @@ ldflags="-s -X 'github.com/portainer/liblicense.LicenseServerBaseURL=https://api
-X 'github.com/portainer/portainer/pkg/build.GoVersion=${GO_VERSION}' \
-X 'github.com/portainer/portainer/pkg/build.DepComposeVersion=${COMPOSE_VERSION}' \
-X 'github.com/portainer/portainer/pkg/build.DepDockerVersion=${DOCKER_VERSION}' \
-X 'github.com/portainer/portainer/pkg/build.DepHelmVersion=${HELM_VERSION}' \
-X 'github.com/portainer/portainer/pkg/build.DepKubectlVersion=${KUBECTL_VERSION}'"
echo "$ldflags"

View File

@@ -17,12 +17,10 @@ echo "Checking and downloading binaries for docker ${dockerVersion}, helm ${helm
# Determine the binary file names based on the platform
dockerBinary="dist/docker"
helmBinary="dist/helm"
kubectlBinary="dist/kubectl"
if [ "$PLATFORM" == "windows" ]; then
dockerBinary="dist/docker.exe"
helmBinary="dist/helm.exe"
kubectlBinary="dist/kubectl.exe"
fi
@@ -34,14 +32,6 @@ else
echo "Docker binary already exists, skipping download."
fi
# Check and download helm binary
if [ ! -f "$helmBinary" ]; then
echo "Downloading helm binary..."
/usr/bin/env bash ./build/download_helm_binary.sh "$PLATFORM" "$ARCH" "$helmVersion"
else
echo "Helm binary already exists, skipping download."
fi
# Check and download kubectl binary
if [ ! -f "$kubectlBinary" ]; then
echo "Downloading kubectl binary..."

View File

@@ -1,21 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 3 ]]; then
echo "Illegal number of parameters" >&2
exit 1
fi
PLATFORM=$1
ARCH=$2
HELM_VERSION=$3
HELM_DIST="helm-$HELM_VERSION-$PLATFORM-$ARCH"
if [[ ${PLATFORM} == "windows" ]]; then
wget --tries=3 --waitretry=30 --quiet -O tmp.zip "https://get.helm.sh/${HELM_DIST}.zip" && unzip -o -j tmp.zip "${PLATFORM}-${ARCH}/helm.exe" -d dist && rm -f tmp.zip
else
wget -qO- "https://get.helm.sh/${HELM_DIST}.tar.gz" | tar -x -z --strip-components 1 "${PLATFORM}-${ARCH}/helm"
mv "helm" "dist/helm"
chmod +x "dist/helm"
fi

View File

@@ -11,7 +11,6 @@ LABEL org.opencontainers.image.title="Portainer" \
com.docker.extension.additional-urls="[{\"title\":\"Website\",\"url\":\"https://www.portainer.io?utm_campaign=DockerCon&utm_source=DockerDesktop\"},{\"title\":\"Documentation\",\"url\":\"https://docs.portainer.io\"},{\"title\":\"Support\",\"url\":\"https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA\"}]"
COPY dist/docker /
COPY dist/helm /
COPY dist/kubectl /
COPY dist/mustache-templates /mustache-templates/
COPY dist/portainer /

View File

@@ -11,7 +11,6 @@ LABEL org.opencontainers.image.title="Portainer" \
com.docker.extension.additional-urls="[{\"title\":\"Website\",\"url\":\"https://www.portainer.io?utm_campaign=DockerCon&utm_source=DockerDesktop\"},{\"title\":\"Documentation\",\"url\":\"https://docs.portainer.io\"},{\"title\":\"Support\",\"url\":\"https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA\"}]"
COPY dist/docker /
COPY dist/helm /
COPY dist/kubectl /
COPY dist/mustache-templates /mustache-templates/
COPY dist/portainer /

View File

@@ -10,7 +10,6 @@ USER ContainerAdministrator
COPY dist/mingit/ mingit/
COPY dist/docker.exe /
COPY dist/helm.exe /
COPY dist/kubectl.exe /
COPY dist/mustache-templates /mustache-templates/
COPY dist/portainer.exe /

126
go.mod
View File

@@ -30,6 +30,7 @@ require (
github.com/gorilla/csrf v1.7.2
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.0
github.com/hashicorp/go-version v1.7.0
github.com/hashicorp/golang-lru v0.5.4
github.com/joho/godotenv v1.4.0
github.com/jpillora/chisel v1.10.0
@@ -47,17 +48,18 @@ require (
github.com/urfave/negroni v1.0.0
github.com/viney-shih/go-lock v1.1.1
go.etcd.io/bbolt v1.3.11
golang.org/x/crypto v0.31.0
golang.org/x/crypto v0.35.0
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
golang.org/x/mod v0.21.0
golang.org/x/oauth2 v0.23.0
golang.org/x/sync v0.10.0
golang.org/x/sync v0.11.0
gopkg.in/alecthomas/kingpin.v2 v2.2.6
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.29.2
k8s.io/apimachinery v0.29.2
k8s.io/client-go v0.29.2
k8s.io/metrics v0.27.4
helm.sh/helm/v3 v3.17.1
k8s.io/api v0.32.1
k8s.io/apimachinery v0.32.1
k8s.io/client-go v0.32.1
k8s.io/metrics v0.32.1
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78
)
@@ -69,8 +71,12 @@ require (
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c // indirect
github.com/BurntSushi/toml v1.3.2 // indirect
github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/MakeNowJust/heredoc v1.0.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/ProtonMail/go-crypto v1.1.3 // indirect
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
@@ -88,9 +94,11 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/buger/goterm v1.0.4 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chai2010/gettext-go v1.0.2 // indirect
github.com/cloudflare/cfssl v1.6.4 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/containerd/console v1.0.4 // indirect
@@ -105,8 +113,8 @@ require (
github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect
github.com/containers/ocicrypt v1.1.10 // indirect
github.com/containers/storage v1.53.0 // indirect
github.com/cyphar/filepath-securejoin v0.2.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/cyphar/filepath-securejoin v0.3.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/buildx v0.18.0 // indirect
github.com/docker/cli-docs-tool v0.8.0 // indirect
@@ -119,40 +127,49 @@ require (
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/evanphx/json-patch v4.12.0+incompatible // indirect
github.com/evanphx/json-patch v5.9.0+incompatible // indirect
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsevents v0.2.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.1 // indirect
github.com/go-errors/errors v1.4.2 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-gorp/gorp/v3 v3.1.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.22.10 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/btree v1.0.1 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect
github.com/gosuri/uitable v0.0.4 // indirect
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/in-toto/in-toto-golang v0.9.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/jpillora/ansi v1.0.3 // indirect
@@ -162,22 +179,28 @@ require (
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/leodido/go-urn v1.2.2 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/miekg/pkcs11 v1.1.1 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/buildkit v0.17.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/locker v1.0.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/spdystream v0.2.0 // indirect
github.com/moby/spdystream v0.5.0 // indirect
github.com/moby/sys/capability v0.4.0 // indirect
github.com/moby/sys/mountinfo v0.7.2 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
@@ -188,28 +211,34 @@ require (
github.com/moby/term v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/opencontainers/runtime-spec v1.2.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.17.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.19.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/r3labs/sse v0.0.0-20210224172625-26fe804710bc // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rubenv/sql-migrate v1.7.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.0 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect
@@ -223,44 +252,55 @@ require (
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
github.com/ulikunitz/xz v0.5.11 // indirect
github.com/vbatts/tar-split v0.11.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.50.0 // indirect
go.opentelemetry.io/otel v1.25.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
go.opentelemetry.io/otel v1.28.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.25.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0 // indirect
go.opentelemetry.io/otel/metric v1.25.0 // indirect
go.opentelemetry.io/otel/sdk v1.25.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.21.0 // indirect
go.opentelemetry.io/otel/trace v1.25.0 // indirect
go.opentelemetry.io/proto/otlp v1.1.0 // indirect
go.opentelemetry.io/otel/metric v1.28.0 // indirect
go.opentelemetry.io/otel/sdk v1.28.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.28.0 // indirect
go.opentelemetry.io/otel/trace v1.28.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.27.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.6.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/term v0.29.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/time v0.7.0 // indirect
google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/grpc v1.68.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/klog/v2 v2.110.1 // indirect
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect
k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
k8s.io/apiextensions-apiserver v0.32.1 // indirect
k8s.io/apiserver v0.32.1 // indirect
k8s.io/cli-runtime v0.32.1 // indirect
k8s.io/component-base v0.32.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
k8s.io/kubectl v0.32.1 // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
oras.land/oras-go v1.2.5 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/kustomize/api v0.18.0 // indirect
sigs.k8s.io/kustomize/kyaml v0.18.1 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
tags.cncf.io/container-device-interface v0.8.0 // indirect
)

317
go.sum
View File

@@ -1,9 +1,7 @@
cloud.google.com/go v0.112.0 h1:tpFCD7hpHFlQ8yPwT3x+QeXqc2T6+n6T+hmABHfDUSM=
cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg=
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 h1:59MxjQVfjXsBpLy+dbd2/ELV5ofnUkUZBvWSC85sheA=
@@ -15,12 +13,22 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg6
github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c h1:/IBSNwUN8+eKzUzbJPqhK839ygXJ82sde8x3ogr6R28=
github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
@@ -90,7 +98,11 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw=
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
github.com/bugsnag/bugsnag-go v1.0.5-0.20150529004307-13fd6b8acda0 h1:s7+5BfS4WFJoVF9pnB8kBk03S7pZXRdKamnV0FOl5Sc=
@@ -106,13 +118,13 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk=
github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA=
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
github.com/cloudflare/cfssl v1.6.4 h1:NMOvfrEjFfC63K3SGXgAnFdsgkmiq4kATme5BfcqrO8=
github.com/cloudflare/cfssl v1.6.4/go.mod h1:8b3CQMxfWPAeom3zBnGJ6sd+G1NkL5TXqmDXacb+1J0=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 h1:QVw89YDxXxEe+l8gU8ETbOasdwEV+avkR75ZzsVV9WI=
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
github.com/compose-spec/compose-go/v2 v2.4.5 h1:p4ih4Jb6VgGPLPxh3fSFVKAjFHtZd+7HVLCSFzcFx9Y=
@@ -161,14 +173,17 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM=
github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs=
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
github.com/denisenkom/go-mssqldb v0.0.0-20191128021309-1d7a30a10f73/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2 h1:aBfCb7iqHmDEIp6fBvC/hQUddQfg+3qdYjwzaiP9Hnc=
github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2/go.mod h1:WHNsWjnIn2V1LYOrME7e8KxSeKunYHsxEm4am0BUtcI=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/buildx v0.18.0 h1:rSauXHeJt90NvtXrLK5J992Eb0UPJZs2vV3u1zTf1nE=
@@ -209,13 +224,19 @@ github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxER
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/envoyproxy/protoc-gen-validate v1.1.0 h1:tntQDh69XqOCOZsDz0lVJQez/2L6Uu2PdjCQwWCJ3bM=
github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84=
github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls=
github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4=
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsevents v0.2.0 h1:BRlvlqjvNTfogHfeBOFvSC9N0Ddy+wzQCQukyoD7o/c=
github.com/fsnotify/fsevents v0.2.0/go.mod h1:B3eEk39i4hz8y1zaWS/wPrAP4O6wkIl7HQwKBr1qH/w=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
@@ -223,12 +244,16 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw=
github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 h1:gWvniJ4GbFfkf700kykAImbLiEMU0Q3QN9hQ26Js1pU=
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814/go.mod h1:secRm32Ro77eD23BmPVbgLbWN+JWDw7pJszenjxI4bI=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-asn1-ber/asn1-ber v1.5.1 h1:pDbRAunXzIUXfx4CB2QJFv5IuPiuoW+sWvr/Us009o8=
github.com/go-asn1-ber/asn1-ber v1.5.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8=
@@ -237,24 +262,26 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.13.0 h1:vLn5wlGIh/X78El6r3Jr+30W16Blk0CTcxTYcYPWi5E=
github.com/go-git/go-git/v5 v5.13.0/go.mod h1:Wjo7/JyVKtQgUNdXYXIepzWfJQkUEIGvkvVkiXRR/zw=
github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs=
github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-ldap/ldap/v3 v3.4.1 h1:fU/0xli6HY02ocbMuozHAYsaHLcnkLjvho2r5a34BUU=
github.com/go-ldap/ldap/v3 v3.4.1/go.mod h1:iYS1MdmrmceOJ1QOTnRXrIs7i3kloqtmGQjRvjKpyMg=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.22.10 h1:4y86NVn7Z2yYd6pfS4Z+Nyh3aAUL3Nul+LMbhFKy0gA=
github.com/go-openapi/swag v0.22.10/go.mod h1:Cnn8BYtRlx6BNE3DPN86f/xkapGIcLWzh3CLEb4C1jI=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -264,13 +291,15 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
github.com/go-playground/validator/v10 v10.12.0 h1:E4gtWgxWxp8YSxExrQFv5BpCahla0PVF2oTTEYaWQGI=
github.com/go-playground/validator/v10 v10.12.0/go.mod h1:hCAPuzYvKdP33pxWa+2+6AIKXEKqjIUyqsNCtbsSJrA=
github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.0.0 h1:dhn8MZ1gZ0mzeodTG3jt5Vj/o87xZKuNAprG2mQfMfc=
github.com/go-viper/mapstructure/v2 v2.0.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
@@ -293,6 +322,10 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v1.8.2 h1:H5XSIre1MB5NbPYFp+i1NBbb5qN1W8Y8YAQoAYbkm8k=
github.com/gomodule/redigo v1.8.2/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0=
github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
github.com/google/certificate-transparency-go v1.0.10-0.20180222191210-5ab67e519c93/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg=
github.com/google/certificate-transparency-go v1.1.4 h1:hCyXHDbtqlr/lMXU0D4WgbalXL0Zk4dSWWMbPV8VrqY=
github.com/google/certificate-transparency-go v1.1.4/go.mod h1:D6lvbfwckhNrbM9WVl1EVeMOyzC19mpIjMOI4nxBHtQ=
@@ -305,24 +338,29 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg=
github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU=
github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY=
github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo=
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA=
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -339,8 +377,8 @@ github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uG
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/in-toto/in-toto-golang v0.9.0 h1:tHny7ac4KgtsfrG6ybU8gVOZux2H8jN05AXJ9EBM1XU=
github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3lUTQd+eF9HdeMo=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
@@ -357,8 +395,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jmoiron/sqlx v1.3.3 h1:j82X0bf7oQ27XeqxicSZsTU5suPwKElg3oyxNn43iTk=
github.com/jmoiron/sqlx v1.3.3/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
@@ -402,9 +440,17 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/leodido/go-urn v1.2.2 h1:7z68G0FCGvDk646jz1AelTYNYWrTNm0bEcFAo147wt4=
github.com/leodido/go-urn v1.2.2/go.mod h1:kUaIbLZWttglzwNuG0pgsh5vuV6u2YcGBYz1hIPjtOQ=
github.com/lib/pq v0.0.0-20150723085316-0dad96c0b94f/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
github.com/magiconair/properties v1.5.3 h1:C8fxWnhYyME3n0klPOhVM7PtYUB3eV1W3DeFmN3j53Y=
github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
@@ -423,19 +469,27 @@ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU=
github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/mapstructure v0.0.0-20150613213606-2caf8efc9366/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/buildkit v0.17.2 h1:/jgk/MuXbA7jeXMkknOpHYB+Ct4aNvQHkBB7SxD3D4U=
github.com/moby/buildkit v0.17.2/go.mod h1:vr5vltV8wt4F2jThbNOChfbAklJ0DOW11w36v210hOg=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -444,8 +498,8 @@ github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg=
github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8=
github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/moby/sys/capability v0.4.0 h1:4D4mI6KlNtWMCM1Z/K0i7RV1FkX+DBDHKVJpCndZoHk=
github.com/moby/sys/capability v0.4.0/go.mod h1:4g9IK291rVkms3LKCDOoYlnV8xKwoDTpIrNEE35Wq0I=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
@@ -469,6 +523,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0=
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -480,12 +536,12 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4=
github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
@@ -505,6 +561,10 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI=
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -513,24 +573,27 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY=
github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg=
github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.0.0-20180110214958-89604d197083/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
@@ -544,11 +607,14 @@ github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/rubenv/sql-migrate v1.7.1 h1:f/o0WgfO/GqNuVg+6801K/KW3WdDSupzSjDYODmiUq4=
github.com/rubenv/sql-migrate v1.7.1/go.mod h1:Ob2Psprc0/3ggbM6wCzyYVFFuc6FyZrb2AS+ezLDFb4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rwtodd/Go.Sed v0.0.0-20210816025313-55464686f9ef/go.mod h1:8AEUvGVi2uQ5b24BIhcr0GCcpd/RNAFWaN2CJFrWIIQ=
github.com/secure-systems-lab/go-securesystemslib v0.8.0 h1:mr5An6X45Kb2nddcFlbmfHkLguCE9laoZCUzEEpIZXA=
@@ -563,6 +629,8 @@ github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b h1:h+3JX2VoWTFuyQ
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b/go.mod h1:/yeG0My1xr/u+HZrFQ1tOQQQQrOawfyMUH13ai5brBc=
github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
@@ -575,8 +643,9 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EE
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/spdx/tools-golang v0.5.3 h1:ialnHeEYUC4+hkm5vJm4qz2x+oEJbS0mAMFrNXdQraY=
github.com/spdx/tools-golang v0.5.3/go.mod h1:/ETOahiAo96Ob0/RAIBmFZw6XN0yTnyr/uFZm2NTMhI=
github.com/spf13/cast v0.0.0-20150508191742-4d07383ffe94 h1:JmfC365KywYwHB946TTiQWEb8kqPY+pybPLoGE9GgVk=
github.com/spf13/cast v0.0.0-20150508191742-4d07383ffe94/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v0.0.1/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
@@ -592,6 +661,8 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -632,6 +703,8 @@ github.com/viney-shih/go-lock v1.1.1 h1:SwzDPPAiHpcwGCr5k8xD15d2gQSo8d4roRYd7TDV
github.com/viney-shih/go-lock v1.1.1/go.mod h1:Yijm78Ljteb3kRiJrbLAxVntkUukGu5uzSxq/xV7OO8=
github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b h1:FsyNrX12e5BkplJq7wKOLk0+C6LZ+KGXvuEcKUYm5ss=
github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
@@ -641,9 +714,17 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI=
github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50 h1:hlE8//ciYMztlGpl/VA+Zm1AcTPHYkHJPbHqE6WJUXE=
github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA=
github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f h1:ERexzlUfuTvpE74urLSbIQW0Z/6hF9t8U4NsJLaioAY=
github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg=
github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc h1:zkGwegkOW709y0oiAraH/3D8njopUR/pARHv4tZZ6pw=
github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc/go.mod h1:FM4U1E3NzlNMRnSUTU3P1UdukWhYGifqEsjk9fn7BCk=
github.com/zmap/zlint/v3 v3.1.0 h1:WjVytZo79m/L1+/Mlphl09WBob6YTGljN5IGWZFpAv0=
@@ -652,34 +733,34 @@ go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 h1:SpGay3w+nEwMpfVnbqOLH5gY52/foP8RE8UzTZ1pdSE=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1/go.mod h1:4UoMYEZOC0yN/sPGH76KPkkU7zgiEWYWL9vwmbnTJPE=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 h1:gbhw/u49SS3gkPWiYweQNJGm/uJN5GkI/FrosxSHT7A=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1/go.mod h1:GnOaBaFQ2we3b9AGWJpsBa7v1S5RlQzlC3O7dRMxZhM=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.50.0 h1:cEPbyTSEHlQR89XVlyo78gqluF8Y3oMeBkXGWzQsfXY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.50.0/go.mod h1:DKdbWcT4GH1D0Y3Sqt/PFXt2naRKDWtU+eE6oLdFNA8=
go.opentelemetry.io/otel v1.25.0 h1:gldB5FfhRl7OJQbUHt/8s0a7cE8fbsPAtdpRaApKy4k=
go.opentelemetry.io/otel v1.25.0/go.mod h1:Wa2ds5NOXEMkCmUou1WA7ZBfLTHWIsp034OVD7AO+Vg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 h1:jd0+5t/YynESZqsSyPz+7PAFdEop0dlN0+PkyHYo8oI=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0/go.mod h1:U707O40ee1FpQGyhvqnzmCJm1Wh6OX6GGBVn0E6Uyyk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0 h1:bflGWrfYyuulcdxf14V6n9+CoQcu5SAAdHmDPAJnlps=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0/go.mod h1:qcTO4xHAxZLaLxPd60TdE88rxtItPHgHWqOhOGRr0as=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.25.0 h1:dT33yIHtmsqpixFsSQPwNeY5drM9wTcoL8h0FWF4oGM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.25.0/go.mod h1:h95q0LBGh7hlAC08X2DhSeyIG02YQ0UyioTCVAqRPmc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0 h1:tIqheXEFWAZ7O8A7m+J0aPTmpJN3YQ7qetUAdkkkKpk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0/go.mod h1:nUeKExfxAQVbiVFn32YXpXZZHZ61Cc3s3Rn1pDBGAb0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 h1:qFffATk0X+HD+f1Z8lswGiOQYKHRlzfmdJm0wEaVrFA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0 h1:Mbi5PKN7u322woPa85d7ebZ+SOvEoPvoiBu+ryHWgfA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0/go.mod h1:e7ciERRhZaOZXVjx5MiL8TK5+Xv7G5Gv5PA2ZDEJdL8=
go.opentelemetry.io/otel/metric v1.25.0 h1:LUKbS7ArpFL/I2jJHdJcqMGxkRdxpPHE0VU/D4NuEwA=
go.opentelemetry.io/otel/metric v1.25.0/go.mod h1:rkDLUSd2lC5lq2dFNrX9LGAbINP5B7WBkC78RXCpH5s=
go.opentelemetry.io/otel/sdk v1.25.0 h1:PDryEJPC8YJZQSyLY5eqLeafHtG+X7FWnf3aXMtxbqo=
go.opentelemetry.io/otel/sdk v1.25.0/go.mod h1:oFgzCM2zdsxKzz6zwpTZYLLQsFwc+K0daArPdIhuxkw=
go.opentelemetry.io/otel/sdk/metric v1.21.0 h1:smhI5oD714d6jHE6Tie36fPx4WDFIg+Y6RfAY4ICcR0=
go.opentelemetry.io/otel/sdk/metric v1.21.0/go.mod h1:FJ8RAsoPGv/wYMgBdUJXOm+6pzFY3YdljnXtv1SBE8Q=
go.opentelemetry.io/otel/trace v1.25.0 h1:tqukZGLwQYRIFtSQM2u2+yfMVTgGVeqRLPUYx1Dq6RM=
go.opentelemetry.io/otel/trace v1.25.0/go.mod h1:hCCs70XM/ljO+BeQkyFnbK28SBIJ/Emuha+ccrCRT7I=
go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI=
go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY=
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE=
go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg=
go.opentelemetry.io/otel/sdk/metric v1.28.0 h1:OkuaKgKrgAbYrrY0t92c+cC+2F6hsFNnCQArXCKlg08=
go.opentelemetry.io/otel/sdk/metric v1.28.0/go.mod h1:cWPjykihLAPvXKi4iZc1dpER3Jdq2Z0YLse3moQUCpg=
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
@@ -695,8 +776,8 @@ golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWP
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -727,8 +808,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -755,29 +836,29 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -805,6 +886,8 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
@@ -826,26 +909,44 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
k8s.io/api v0.29.2 h1:hBC7B9+MU+ptchxEqTNW2DkUosJpp1P+Wn6YncZ474A=
k8s.io/api v0.29.2/go.mod h1:sdIaaKuU7P44aoyyLlikSLayT6Vb7bvJNCX105xZXY0=
k8s.io/apimachinery v0.29.2 h1:EWGpfJ856oj11C52NRCHuU7rFDwxev48z+6DSlGNsV8=
k8s.io/apimachinery v0.29.2/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU=
k8s.io/client-go v0.29.2 h1:FEg85el1TeZp+/vYJM7hkDlSTFZ+c5nnK44DJ4FyoRg=
k8s.io/client-go v0.29.2/go.mod h1:knlvFZE58VpqbQpJNbCbctTVXcd35mMyAAwBdpt4jrA=
k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0=
k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780=
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA=
k8s.io/metrics v0.27.4 h1:2s04bods7rA507iouGbxD55YrKNlFjLYzm30noOl9Sk=
k8s.io/metrics v0.27.4/go.mod h1:kRvfhFC7wCQEFvu6H92uiV7v05z3Ty/vtluYT5D2Xpk=
k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI=
k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
helm.sh/helm/v3 v3.17.1 h1:gzVoAD+qVuoJU6KDMSAeo0xRJ6N1znRxz3wyuXRmJDk=
helm.sh/helm/v3 v3.17.1/go.mod h1:nvreuhuR+j78NkQcLC3TYoprCKStLyw5P4T7E5itv2w=
k8s.io/api v0.32.1 h1:f562zw9cy+GvXzXf0CKlVQ7yHJVYzLfL6JAS4kOAaOc=
k8s.io/api v0.32.1/go.mod h1:/Yi/BqkuueW1BgpoePYBRdDYfjPF5sgTr5+YqDZra5k=
k8s.io/apiextensions-apiserver v0.32.1 h1:hjkALhRUeCariC8DiVmb5jj0VjIc1N0DREP32+6UXZw=
k8s.io/apiextensions-apiserver v0.32.1/go.mod h1:sxWIGuGiYov7Io1fAS2X06NjMIk5CbRHc2StSmbaQto=
k8s.io/apimachinery v0.32.1 h1:683ENpaCBjma4CYqsmZyhEzrGz6cjn1MY/X2jB2hkZs=
k8s.io/apimachinery v0.32.1/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
k8s.io/apiserver v0.32.1 h1:oo0OozRos66WFq87Zc5tclUX2r0mymoVHRq8JmR7Aak=
k8s.io/apiserver v0.32.1/go.mod h1:UcB9tWjBY7aryeI5zAgzVJB/6k7E97bkr1RgqDz0jPw=
k8s.io/cli-runtime v0.32.1 h1:19nwZPlYGJPUDbhAxDIS2/oydCikvKMHsxroKNGA2mM=
k8s.io/cli-runtime v0.32.1/go.mod h1:NJPbeadVFnV2E7B7vF+FvU09mpwYlZCu8PqjzfuOnkY=
k8s.io/client-go v0.32.1 h1:otM0AxdhdBIaQh7l1Q0jQpmo7WOFIk5FFa4bg6YMdUU=
k8s.io/client-go v0.32.1/go.mod h1:aTTKZY7MdxUaJ/KiUs8D+GssR9zJZi77ZqtzcGXIiDg=
k8s.io/component-base v0.32.1 h1:/5IfJ0dHIKBWysGV0yKTFfacZ5yNV1sulPh3ilJjRZk=
k8s.io/component-base v0.32.1/go.mod h1:j1iMMHi/sqAHeG5z+O9BFNCF698a1u0186zkjMZQ28w=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y=
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4=
k8s.io/kubectl v0.32.1 h1:/btLtXLQUU1rWx8AEvX9jrb9LaI6yeezt3sFALhB8M8=
k8s.io/kubectl v0.32.1/go.mod h1:sezNuyWi1STk4ZNPVRIFfgjqMI6XMf+oCVLjZen/pFQ=
k8s.io/metrics v0.32.1 h1:Ou4nrEtZS2vFf7OJCf9z3+2kr0A00kQzfoSwxg0gXps=
k8s.io/metrics v0.32.1/go.mod h1:cLnai9XKYby1tNMX+xe8p9VLzTqrxYPcmqfCBoWObcM=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
oras.land/oras-go v1.2.5 h1:XpYuAwAb0DfQsunIyMfeET92emK8km3W4yEzZvUbsTo=
oras.land/oras-go v1.2.5/go.mod h1:PuAwRShRZCsZb7g8Ar3jKKQR/2A/qN+pkYxIOd/FAoo=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo=
sigs.k8s.io/kustomize/api v0.18.0 h1:hTzp67k+3NEVInwz5BHyzc9rGxIauoXferXyjv5lWPo=
sigs.k8s.io/kustomize/api v0.18.0/go.mod h1:f8isXnX+8b+SGLHQ6yO4JG1rdkZlvhaCf/uZbLVMb0U=
sigs.k8s.io/kustomize/kyaml v0.18.1 h1:WvBo56Wzw3fjS+7vBjN6TeivvpbW9GmRaWZ9CIVmt4E=
sigs.k8s.io/kustomize/kyaml v0.18.1/go.mod h1:C3L2BFVU1jgcddNBE1TxuVLgS46TjObMwW5FT9FcjYo=
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA=
sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78 h1:SqYE5+A2qvRhErbsXFfUEUmpWEKxxRSMgGLkvRAFOV4=
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78/go.mod h1:B7Wf0Ya4DHF9Yw+qfZuJijQYkWicqDa+79Ytmmq3Kjg=
tags.cncf.io/container-device-interface v0.8.0 h1:8bCFo/g9WODjWx3m6EYl3GfUG31eKJbaggyBDxEldRc=

View File

@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
"version": "2.27.1",
"version": "2.28.0",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"

View File

@@ -42,9 +42,6 @@ var (
// DepDockerVersion is the version of the Docker binary shipped with the application.
DepDockerVersion string
// DepHelmVersion is the version of the Helm binary shipped with the application.
DepHelmVersion string
// DepKubectlVersion is the version of the Kubectl binary shipped with the application.
DepKubectlVersion string
)
@@ -92,7 +89,6 @@ func GetBuildInfo() BuildInfo {
func GetDependenciesInfo() DependenciesInfo {
return DependenciesInfo{
DockerVersion: DepDockerVersion,
HelmVersion: DepHelmVersion,
KubectlVersion: DepKubectlVersion,
ComposeVersion: DepComposeVersion,
}

View File

@@ -1,29 +0,0 @@
package binary
import (
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/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, getOpts.Env)
if err != nil {
return nil, errors.Wrap(err, "failed to run helm get on specified args")
}
return result, nil
}

View File

@@ -1,62 +0,0 @@
package binary
import (
"bytes"
"os"
"os/exec"
"path"
"runtime"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/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, env []string) ([]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, env)
}
// 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, env []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
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, env...)
output, err := cmd.Output()
if err != nil {
return nil, errors.Wrap(err, stderr.String())
}
return output, nil
}

View File

@@ -1,48 +0,0 @@
package binary
import (
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/pkg/errors"
"github.com/segmentio/encoding/json"
)
// 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, installOpts.Env)
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

@@ -1,118 +0,0 @@
package binary
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/portainer/portainer/pkg/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://kubernetes.github.io/ingress-nginx nginx
installOpts := options.InstallOptions{
Name: "test-nginx",
Chart: "nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
release, err := hbpm.Install(installOpts)
defer hbpm.run("uninstall", []string{"test-nginx"}, nil)
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://kubernetes.github.io/ingress-nginx nginx
installOpts := options.InstallOptions{
Chart: "nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
release, err := hbpm.Install(installOpts)
defer hbpm.run("uninstall", []string{release.Name}, nil)
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://kubernetes.github.io/ingress-nginx 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://kubernetes.github.io/ingress-nginx",
ValuesFile: values,
}
release, err := hbpm.Install(installOpts)
defer hbpm.run("uninstall", []string{"test-nginx-2"}, nil)
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}, nil)
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

@@ -1,38 +0,0 @@
package binary
import (
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/pkg/errors"
"github.com/segmentio/encoding/json"
)
// 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, listOpts.Env)
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

@@ -1,118 +0,0 @@
package binary
// Package common implements common functionality for the helm.
// The functionality does not rely on the implementation of `HelmPackageManager`
import (
"net/http"
"net/url"
"path"
"time"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/pkg/errors"
"github.com/segmentio/encoding/json"
"gopkg.in/yaml.v3"
)
var errRequiredSearchOptions = errors.New("repo is required")
var errInvalidRepoURL = errors.New("the request failed since either the Helm repository was not found or the index.yaml is not valid")
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
}
client := searchRepoOpts.Client
if client == nil {
// 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: 300 * time.Second,
Transport: http.DefaultTransport,
}
}
// Allow redirect behavior to be overridden if specified.
if client.CheckRedirect == nil {
client.CheckRedirect = defaultCheckRedirect
}
url, err := url.ParseRequestURI(searchRepoOpts.Repo)
if err != nil {
return nil, errors.Wrap(err, "invalid helm chart URL: "+searchRepoOpts.Repo)
}
url.Path = path.Join(url.Path, "index.yaml")
resp, err := client.Get(url.String())
if err != nil {
return nil, errInvalidRepoURL
}
defer resp.Body.Close()
var file File
err = yaml.NewDecoder(resp.Body).Decode(&file)
if err != nil {
return nil, errInvalidRepoURL
}
// Validate index.yaml
if file.APIVersion == "" || file.Entries == nil {
return nil, errInvalidRepoURL
}
result, err := json.Marshal(file)
if err != nil {
return nil, errInvalidRepoURL
}
return result, nil
}
// defaultCheckRedirect is a default CheckRedirect for helm
// We don't allow redirects to URLs not ending in index.yaml
// After that we follow the go http client behavior which is to stop
// after a maximum of 10 redirects
func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
// The request url must end in index.yaml
if path.Base(req.URL.Path) != "index.yaml" {
return errors.New("the request URL must end in index.yaml")
}
// default behavior below
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
return nil
}

View File

@@ -1,50 +0,0 @@
package binary
import (
"testing"
"github.com/portainer/portainer/pkg/libhelm/libhelmtest"
"github.com/portainer/portainer/pkg/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},
{"ingress helm repo", "https://kubernetes.github.io/ingress-nginx", 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},
{"fabric8.io helm repo with trailing slash", "https://fabric8.io/helm/", false},
{"lensesio helm repo without trailing slash", "https://lensesio.github.io/kafka-helm-charts", 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

@@ -1,29 +0,0 @@
package binary
import (
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/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, showOpts.Env)
if err != nil {
return nil, errors.New("the request failed since either the Helm repository was not found or the chart does not exist")
}
return result, nil
}

View File

@@ -1,29 +0,0 @@
package binary
import (
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/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, uninstallOpts.Env)
if err != nil {
return errors.Wrap(err, "failed to run helm uninstall on specified args")
}
return nil
}

View File

@@ -1,22 +1,11 @@
package libhelm
import (
"errors"
"github.com/portainer/portainer/pkg/libhelm/binary"
"github.com/portainer/portainer/pkg/libhelm/sdk"
"github.com/portainer/portainer/pkg/libhelm/types"
)
// 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
func NewHelmPackageManager() (types.HelmPackageManager, error) {
return sdk.NewHelmSDKPackageManager(), nil
}

View File

@@ -2,6 +2,9 @@ package options
// KubernetesClusterAccess represents core details which can be used to generate KubeConfig file/data
type KubernetesClusterAccess struct {
ClusterName string `example:"portainer-cluster-endpoint-1"`
ContextName string `example:"portainer-ctx-endpoint-1"`
UserName string `example:"portainer-user-endpoint-1"`
ClusterServerURL string `example:"https://mycompany.k8s.com"`
CertificateAuthorityFile string `example:"/data/tls/localhost.crt"`
AuthToken string `example:"ey..."`

165
pkg/libhelm/sdk/client.go Normal file
View File

@@ -0,0 +1,165 @@
package sdk
import (
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chartutil"
"helm.sh/helm/v3/pkg/cli"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/client-go/discovery"
"k8s.io/client-go/discovery/cached/memory"
"k8s.io/client-go/rest"
"k8s.io/client-go/restmapper"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/clientcmd/api"
)
// newRESTClientGetter creates a custom RESTClientGetter using the provided client config
type clientConfigGetter struct {
clientConfig clientcmd.ClientConfig
namespace string
}
// initActionConfig initializes the action configuration with kubernetes config
func (hspm *HelmSDKPackageManager) initActionConfig(actionConfig *action.Configuration, namespace string, k8sAccess *options.KubernetesClusterAccess) error {
// If namespace is not provided, use the default namespace
if namespace == "" {
namespace = "default"
}
if k8sAccess == nil {
// Use default kubeconfig
settings := cli.New()
clientGetter := settings.RESTClientGetter()
return actionConfig.Init(clientGetter, namespace, "secret", hspm.logf)
}
// Create client config
configAPI := generateConfigAPI(namespace, k8sAccess)
clientConfig := clientcmd.NewDefaultClientConfig(*configAPI, &clientcmd.ConfigOverrides{})
// Create a custom RESTClientGetter that uses our in-memory config
clientGetter, err := newRESTClientGetter(clientConfig, namespace)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("cluster_name", k8sAccess.ClusterName).
Str("cluster_url", k8sAccess.ClusterServerURL).
Str("user_name", k8sAccess.UserName).
Err(err).
Msg("failed to create client getter")
return err
}
return actionConfig.Init(clientGetter, namespace, "secret", hspm.logf)
}
// generateConfigAPI generates a new kubeconfig configuration
func generateConfigAPI(namespace string, k8sAccess *options.KubernetesClusterAccess) *api.Config {
// Create in-memory kubeconfig configuration
configAPI := api.NewConfig()
// Create cluster
cluster := api.NewCluster()
cluster.Server = k8sAccess.ClusterServerURL
if k8sAccess.CertificateAuthorityFile != "" {
// If we have a CA file, use it
cluster.CertificateAuthority = k8sAccess.CertificateAuthorityFile
} else {
// Otherwise skip TLS verification
cluster.InsecureSkipTLSVerify = true
}
// Create auth info with token
authInfo := api.NewAuthInfo()
authInfo.Token = k8sAccess.AuthToken
// Create context
context := api.NewContext()
context.Cluster = k8sAccess.ClusterName
context.AuthInfo = k8sAccess.UserName
context.Namespace = namespace
// Add to config
configAPI.Clusters[k8sAccess.ClusterName] = cluster
configAPI.AuthInfos[k8sAccess.UserName] = authInfo
configAPI.Contexts[k8sAccess.ContextName] = context
configAPI.CurrentContext = k8sAccess.ContextName
return configAPI
}
func newRESTClientGetter(clientConfig clientcmd.ClientConfig, namespace string) (*clientConfigGetter, error) {
if clientConfig == nil {
log.Error().
Str("context", "HelmClient").
Msg("client config is nil")
return nil, errors.New("client config provided during the helm client initialization was nil. Check the kubernetes cluster access configuration")
}
return &clientConfigGetter{
clientConfig: clientConfig,
namespace: namespace,
}, nil
}
func (c *clientConfigGetter) ToRESTConfig() (*rest.Config, error) {
if c.clientConfig == nil {
log.Error().
Str("context", "HelmClient").
Msg("client config is nil")
return nil, errors.New("client config provided during the helm client initialization was nil. Check the kubernetes cluster access configuration")
}
return c.clientConfig.ClientConfig()
}
func (c *clientConfigGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
config, err := c.ToRESTConfig()
if err != nil {
return nil, err
}
// Create the discovery client
discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
if err != nil {
return nil, err
}
// Wrap the discovery client with a cached discovery client
return memory.NewMemCacheClient(discoveryClient), nil
}
func (c *clientConfigGetter) ToRESTMapper() (meta.RESTMapper, error) {
discoveryClient, err := c.ToDiscoveryClient()
if err != nil {
return nil, err
}
// Create a REST mapper from the discovery client
return restmapper.NewDeferredDiscoveryRESTMapper(discoveryClient), nil
}
func (c *clientConfigGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig {
return c.clientConfig
}
// parseValues parses YAML values data into a map
func (hspm *HelmSDKPackageManager) parseValues(data []byte) (map[string]any, error) {
// Use Helm's built-in chartutil.ReadValues which properly handles the conversion
// from map[interface{}]interface{} to map[string]interface{}
return chartutil.ReadValues(data)
}
// logf is a log helper function for Helm
func (hspm *HelmSDKPackageManager) logf(format string, v ...any) {
// Use zerolog for structured logging
log.Debug().
Str("context", "HelmClient").
Msgf(format, v...)
}

View File

@@ -0,0 +1,156 @@
package sdk
import (
"testing"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v3/pkg/action"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/clientcmd/api"
)
func Test_InitActionConfig(t *testing.T) {
is := assert.New(t)
hspm := NewHelmSDKPackageManager()
t.Run("with nil k8sAccess should use default kubeconfig", func(t *testing.T) {
actionConfig := new(action.Configuration)
err := hspm.(*HelmSDKPackageManager).initActionConfig(actionConfig, "default", nil)
// The function should not fail by design, even when not running in a k8s environment
is.NoError(err, "should not return error when not in k8s environment")
})
t.Run("with k8sAccess should create in-memory config", func(t *testing.T) {
actionConfig := new(action.Configuration)
k8sAccess := &options.KubernetesClusterAccess{
ClusterServerURL: "https://kubernetes.default.svc",
AuthToken: "test-token",
}
// The function should not fail by design
err := hspm.(*HelmSDKPackageManager).initActionConfig(actionConfig, "default", k8sAccess)
is.NoError(err, "should not return error when using in-memory config")
})
t.Run("with k8sAccess and CA file should create config with CA", func(t *testing.T) {
actionConfig := new(action.Configuration)
k8sAccess := &options.KubernetesClusterAccess{
ClusterServerURL: "https://kubernetes.default.svc",
AuthToken: "test-token",
CertificateAuthorityFile: "/path/to/ca.crt",
}
// The function should not fail by design
err := hspm.(*HelmSDKPackageManager).initActionConfig(actionConfig, "default", k8sAccess)
is.NoError(err, "should not return error when using in-memory config with CA")
})
}
func Test_ClientConfigGetter(t *testing.T) {
is := assert.New(t)
// Create a mock client config
configAPI := api.NewConfig()
// Create cluster
cluster := api.NewCluster()
cluster.Server = "https://kubernetes.default.svc"
cluster.InsecureSkipTLSVerify = true
// Create auth info
authInfo := api.NewAuthInfo()
authInfo.Token = "test-token"
// Create context
context := api.NewContext()
context.Cluster = "test-cluster"
context.AuthInfo = "test-user"
context.Namespace = "default"
// Add to config
configAPI.Clusters["test-cluster"] = cluster
configAPI.AuthInfos["test-user"] = authInfo
configAPI.Contexts["test-context"] = context
configAPI.CurrentContext = "test-context"
clientConfig := clientcmd.NewDefaultClientConfig(*configAPI, &clientcmd.ConfigOverrides{})
// Create client config getter
clientGetter, err := newRESTClientGetter(clientConfig, "default")
is.NoError(err, "should not return error when creating client getter")
// Test ToRESTConfig
restConfig, err := clientGetter.ToRESTConfig()
is.NoError(err, "should not return error when creating REST config")
is.NotNil(restConfig, "should return non-nil REST config")
is.Equal("https://kubernetes.default.svc", restConfig.Host, "host should be https://kubernetes.default.svc")
is.Equal("test-token", restConfig.BearerToken, "bearer token should be test-token")
// Test ToDiscoveryClient
discoveryClient, err := clientGetter.ToDiscoveryClient()
is.NoError(err, "should not return error when creating discovery client")
is.NotNil(discoveryClient, "should return non-nil discovery client")
// Test ToRESTMapper
restMapper, err := clientGetter.ToRESTMapper()
is.NoError(err, "should not return error when creating REST mapper")
is.NotNil(restMapper, "should return non-nil REST mapper")
// Test ToRawKubeConfigLoader
config := clientGetter.ToRawKubeConfigLoader()
is.NotNil(config, "should return non-nil config loader")
}
func Test_ParseValues(t *testing.T) {
is := assert.New(t)
hspm := NewHelmSDKPackageManager()
t.Run("should parse valid YAML values", func(t *testing.T) {
yamlData := []byte(`
service:
type: ClusterIP
port: 80
resources:
limits:
cpu: 100m
memory: 128Mi
`)
values, err := hspm.(*HelmSDKPackageManager).parseValues(yamlData)
is.NoError(err, "should parse valid YAML without error")
is.NotNil(values, "should return non-nil values")
// Verify structure
service, ok := values["service"].(map[string]interface{})
is.True(ok, "service should be a map")
is.Equal("ClusterIP", service["type"], "service type should be ClusterIP")
is.Equal(float64(80), service["port"], "service port should be 80")
resources, ok := values["resources"].(map[string]interface{})
is.True(ok, "resources should be a map")
limits, ok := resources["limits"].(map[string]interface{})
is.True(ok, "limits should be a map")
is.Equal("100m", limits["cpu"], "cpu limit should be 100m")
is.Equal("128Mi", limits["memory"], "memory limit should be 128Mi")
})
t.Run("should handle invalid YAML", func(t *testing.T) {
yamlData := []byte(`
service:
type: ClusterIP
port: 80
invalid yaml
`)
_, err := hspm.(*HelmSDKPackageManager).parseValues(yamlData)
is.Error(err, "should return error for invalid YAML")
})
t.Run("should handle empty YAML", func(t *testing.T) {
yamlData := []byte(``)
values, err := hspm.(*HelmSDKPackageManager).parseValues(yamlData)
is.NoError(err, "should not return error for empty YAML")
is.NotNil(values, "should return non-nil values for empty YAML")
is.Len(values, 0, "should return empty map for empty YAML")
})
}

210
pkg/libhelm/sdk/install.go Normal file
View File

@@ -0,0 +1,210 @@
package sdk
import (
"os"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/downloader"
"helm.sh/helm/v3/pkg/getter"
"helm.sh/helm/v3/pkg/postrender"
)
// Install implements the HelmPackageManager interface by using the Helm SDK to install a chart.
func (hspm *HelmSDKPackageManager) Install(installOpts options.InstallOptions) (*release.Release, error) {
log.Debug().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
Str("name", installOpts.Name).
Str("namespace", installOpts.Namespace).
Str("repo", installOpts.Repo).
Bool("wait", installOpts.Wait).
Msg("Installing Helm chart")
if installOpts.Name == "" {
log.Error().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
Str("name", installOpts.Name).
Str("namespace", installOpts.Namespace).
Str("repo", installOpts.Repo).
Bool("wait", installOpts.Wait).
Msg("Name is required for helm release installation")
return nil, errors.New("name is required for helm release installation")
}
// Initialize action configuration with kubernetes config
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, installOpts.Namespace, installOpts.KubernetesClusterAccess)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
Str("namespace", installOpts.Namespace).
Err(err).
Msg("Failed to initialize helm configuration for helm release installation")
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release installation")
}
installClient, err := initInstallClient(actionConfig, installOpts)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to initialize helm install client for helm release installation")
return nil, errors.Wrap(err, "failed to initialize helm install client for helm release installation")
}
values, err := hspm.GetHelmValuesFromFile(installOpts.ValuesFile)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to get Helm values from file for helm release installation")
return nil, errors.Wrap(err, "failed to get Helm values from file for helm release installation")
}
chart, err := hspm.loadAndValidateChart(installClient, installOpts)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to load and validate chart for helm release installation")
return nil, errors.Wrap(err, "failed to load and validate chart for helm release installation")
}
// Run the installation
log.Info().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
Str("name", installOpts.Name).
Str("namespace", installOpts.Namespace).
Msg("Running chart installation for helm release")
helmRelease, err := installClient.Run(chart, values)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
Str("name", installOpts.Name).
Str("namespace", installOpts.Namespace).
Err(err).
Msg("Failed to install helm chart for helm release installation")
return nil, errors.Wrap(err, "helm was not able to install the chart for helm release installation")
}
return &release.Release{
Name: helmRelease.Name,
Namespace: helmRelease.Namespace,
Chart: release.Chart{
Metadata: &release.Metadata{
Name: helmRelease.Chart.Metadata.Name,
Version: helmRelease.Chart.Metadata.Version,
AppVersion: helmRelease.Chart.Metadata.AppVersion,
},
},
Labels: helmRelease.Labels,
Version: helmRelease.Version,
Manifest: helmRelease.Manifest,
}, nil
}
// loadAndValidateChart locates and loads the chart, and validates it.
// it also checks for chart dependencies and updates them if necessary.
// it returns the chart information.
func (hspm *HelmSDKPackageManager) loadAndValidateChart(installClient *action.Install, installOpts options.InstallOptions) (*chart.Chart, error) {
// Locate and load the chart
chartPath, err := installClient.ChartPathOptions.LocateChart(installOpts.Chart, hspm.settings)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
Err(err).
Msg("Failed to locate chart for helm release installation")
return nil, errors.Wrapf(err, "failed to find the helm chart at the path: %s/%s", installOpts.Repo, installOpts.Chart)
}
chartReq, err := loader.Load(chartPath)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart_path", chartPath).
Err(err).
Msg("Failed to load chart for helm release installation")
return nil, errors.Wrap(err, "failed to load chart for helm release installation")
}
// Check chart dependencies to make sure all are present in /charts
if chartDependencies := chartReq.Metadata.Dependencies; chartDependencies != nil {
if err := action.CheckDependencies(chartReq, chartDependencies); err != nil {
err = errors.Wrap(err, "failed to check chart dependencies for helm release installation")
if !installClient.DependencyUpdate {
return nil, err
}
log.Debug().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
Msg("Updating chart dependencies for helm release installation")
providers := getter.All(hspm.settings)
manager := &downloader.Manager{
Out: os.Stdout,
ChartPath: chartPath,
Keyring: installClient.ChartPathOptions.Keyring,
SkipUpdate: false,
Getters: providers,
RepositoryConfig: hspm.settings.RepositoryConfig,
RepositoryCache: hspm.settings.RepositoryCache,
Debug: hspm.settings.Debug,
}
if err := manager.Update(); err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
Err(err).
Msg("Failed to update chart dependencies for helm release installation")
return nil, errors.Wrap(err, "failed to update chart dependencies for helm release installation")
}
// Reload the chart with the updated Chart.lock file.
if chartReq, err = loader.Load(chartPath); err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart_path", chartPath).
Err(err).
Msg("Failed to reload chart after dependency update for helm release installation")
return nil, errors.Wrap(err, "failed to reload chart after dependency update for helm release installation")
}
}
}
return chartReq, nil
}
// initInstallClient initializes the install client with the given options
// and return the install client.
func initInstallClient(actionConfig *action.Configuration, installOpts options.InstallOptions) (*action.Install, error) {
installClient := action.NewInstall(actionConfig)
installClient.CreateNamespace = true
installClient.DependencyUpdate = true
installClient.ReleaseName = installOpts.Name
installClient.Namespace = installOpts.Namespace
installClient.ChartPathOptions.RepoURL = installOpts.Repo
installClient.Wait = installOpts.Wait
if installOpts.PostRenderer != "" {
postRenderer, err := postrender.NewExec(installOpts.PostRenderer)
if err != nil {
return nil, errors.Wrap(err, "failed to create post renderer")
}
installClient.PostRenderer = postRenderer
}
return installClient, nil
}

View File

@@ -0,0 +1,190 @@
package sdk
import (
"os"
"testing"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/test"
"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
}
func Test_Install(t *testing.T) {
test.EnsureIntegrationTest(t)
is := assert.New(t)
// Create a new SDK package manager
hspm := NewHelmSDKPackageManager()
t.Run("successfully installs nginx chart with name test-nginx", func(t *testing.T) {
// SDK equivalent of: helm install test-nginx --repo https://kubernetes.github.io/ingress-nginx nginx
installOpts := options.InstallOptions{
Name: "test-nginx",
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
release, err := hspm.Install(installOpts)
if release != nil {
defer hspm.Uninstall(options.UninstallOptions{
Name: "test-nginx",
})
}
is.NoError(err, "should successfully install release")
is.NotNil(release, "should return non-nil release")
is.Equal("test-nginx", release.Name, "release name should match")
is.Equal(1, release.Version, "release version should be 1")
is.NotEmpty(release.Manifest, "release manifest should not be empty")
})
t.Run("successfully installs nginx with values", func(t *testing.T) {
// SDK equivalent of: helm install test-nginx-2 --repo https://kubernetes.github.io/ingress-nginx 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: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
ValuesFile: values,
}
release, err := hspm.Install(installOpts)
if release != nil {
defer hspm.Uninstall(options.UninstallOptions{
Name: "test-nginx-2",
})
}
is.NoError(err, "should successfully install release")
is.NotNil(release, "should return non-nil release")
is.Equal("test-nginx-2", release.Name, "release name should match")
is.Equal(1, release.Version, "release version should be 1")
is.NotEmpty(release.Manifest, "release manifest should not be empty")
})
t.Run("successfully installs portainer chart with name portainer-test", func(t *testing.T) {
// SDK equivalent of: 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 := hspm.Install(installOpts)
if release != nil {
defer hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name,
})
}
is.NoError(err, "should successfully install release")
is.NotNil(release, "should return non-nil release")
is.Equal("portainer-test", release.Name, "release name should match")
is.Equal(1, release.Version, "release version should be 1")
is.NotEmpty(release.Manifest, "release manifest should not be empty")
})
t.Run("install with values as string", func(t *testing.T) {
// First create a values file since InstallOptions doesn't support values as string directly
values, err := createValuesFile("service:\n port: 8082")
is.NoError(err, "should create a values file")
defer os.Remove(values)
// Install with values file
installOpts := options.InstallOptions{
Name: "test-nginx-3",
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
ValuesFile: values,
}
release, err := hspm.Install(installOpts)
if release != nil {
defer hspm.Uninstall(options.UninstallOptions{
Name: "test-nginx-3",
})
}
is.NoError(err, "should successfully install release")
is.NotNil(release, "should return non-nil release")
is.Equal("test-nginx-3", release.Name, "release name should match")
})
t.Run("install with namespace", func(t *testing.T) {
// Install with namespace
installOpts := options.InstallOptions{
Name: "test-nginx-4",
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
Namespace: "default",
}
release, err := hspm.Install(installOpts)
if release != nil {
defer hspm.Uninstall(options.UninstallOptions{
Name: "test-nginx-4",
Namespace: "default",
})
}
is.NoError(err, "should successfully install release")
is.NotNil(release, "should return non-nil release")
is.Equal("test-nginx-4", release.Name, "release name should match")
is.Equal("default", release.Namespace, "release namespace should match")
})
t.Run("returns an error when name is not provided", func(t *testing.T) {
installOpts := options.InstallOptions{
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
_, err := hspm.Install(installOpts)
is.Error(err, "should return an error when name is not provided")
is.Equal(err.Error(), "name is required")
})
t.Run("install with invalid chart", func(t *testing.T) {
// Install with invalid chart
installOpts := options.InstallOptions{
Name: "test-invalid",
Chart: "non-existent-chart",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
_, err := hspm.Install(installOpts)
is.Error(err, "should return error when chart doesn't exist")
is.Equal(err.Error(), "failed to find the helm chart at the path: https://kubernetes.github.io/ingress-nginx/non-existent-chart")
})
t.Run("install with invalid repo", func(t *testing.T) {
// Install with invalid repo
installOpts := options.InstallOptions{
Name: "test-invalid-repo",
Chart: "nginx",
Repo: "https://non-existent-repo.example.com",
}
_, err := hspm.Install(installOpts)
is.Error(err, "should return error when repo doesn't exist")
})
}

108
pkg/libhelm/sdk/list.go Normal file
View File

@@ -0,0 +1,108 @@
package sdk
import (
"fmt"
"strconv"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
sdkrelease "helm.sh/helm/v3/pkg/release"
)
// List implements the HelmPackageManager interface by using the Helm SDK to list releases.
// It returns a slice of ReleaseElement.
func (hspm *HelmSDKPackageManager) List(listOpts options.ListOptions) ([]release.ReleaseElement, error) {
log.Debug().
Str("context", "HelmClient").
Str("namespace", listOpts.Namespace).
Str("filter", listOpts.Filter).
Str("selector", listOpts.Selector).
Msg("Listing Helm releases")
// Initialize action configuration with kubernetes config
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, listOpts.Namespace, listOpts.KubernetesClusterAccess)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", listOpts.Namespace).
Err(err).
Msg("Failed to initialize helm configuration")
return nil, errors.Wrap(err, "failed to initialize helm configuration")
}
listClient, err := initListClient(actionConfig, listOpts)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to initialize helm list client")
}
// Run the list operation
releases, err := listClient.Run()
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to list helm releases")
return []release.ReleaseElement{}, errors.Wrap(err, "failed to list helm releases")
}
// Convert from SDK release type to our release element type and return
return convertToReleaseElements(releases), nil
}
// convertToReleaseElements converts from the SDK release type to our release element type
func convertToReleaseElements(releases []*sdkrelease.Release) []release.ReleaseElement {
elements := make([]release.ReleaseElement, len(releases))
for i, rel := range releases {
chartName := fmt.Sprintf("%s-%s", rel.Chart.Metadata.Name, rel.Chart.Metadata.Version)
elements[i] = release.ReleaseElement{
Name: rel.Name,
Namespace: rel.Namespace,
Revision: strconv.Itoa(rel.Version),
Updated: rel.Info.LastDeployed.String(),
Status: string(rel.Info.Status),
Chart: chartName,
AppVersion: rel.Chart.Metadata.AppVersion,
}
}
return elements
}
// initListClient initializes the list client with the given options
// and return the list client.
func initListClient(actionConfig *action.Configuration, listOpts options.ListOptions) (*action.List, error) {
listClient := action.NewList(actionConfig)
// Configure list options
if listOpts.Filter != "" {
listClient.Filter = listOpts.Filter
}
if listOpts.Selector != "" {
listClient.Selector = listOpts.Selector
}
// If no namespace is specified in options, list across all namespaces
if listOpts.Namespace == "" {
listClient.AllNamespaces = true
}
// No limit by default
listClient.Limit = 0
// Show all releases, even if in a pending or failed state
listClient.All = true
// Set state mask to ensure proper filtering by status
listClient.SetStateMask()
return listClient, nil
}

View File

@@ -0,0 +1,72 @@
package sdk
import (
"testing"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/time"
)
func Test_ConvertToReleaseElements(t *testing.T) {
is := assert.New(t)
// Create mock releases
releases := []*release.Release{
{
Name: "release1",
Namespace: "default",
Version: 1,
Info: &release.Info{
Status: release.StatusDeployed,
LastDeployed: time.Now(),
},
Chart: &chart.Chart{
Metadata: &chart.Metadata{
Name: "chart1",
Version: "1.0.0",
AppVersion: "1.0.0",
},
},
},
{
Name: "release2",
Namespace: "kube-system",
Version: 2,
Info: &release.Info{
Status: release.StatusFailed,
LastDeployed: time.Now(),
},
Chart: &chart.Chart{
Metadata: &chart.Metadata{
Name: "chart2",
Version: "2.0.0",
AppVersion: "2.0.0",
},
},
},
}
// Convert to release elements
elements := convertToReleaseElements(releases)
// Verify conversion
is.Len(elements, 2, "should return 2 release elements")
// Verify first release
is.Equal("release1", elements[0].Name, "first release name should be release1")
is.Equal("default", elements[0].Namespace, "first release namespace should be default")
is.Equal("1", elements[0].Revision, "first release revision should be 1")
is.Equal(string(release.StatusDeployed), elements[0].Status, "first release status should be deployed")
is.Equal("chart1-1.0.0", elements[0].Chart, "first release chart should be chart1-1.0.0")
is.Equal("1.0.0", elements[0].AppVersion, "first release app version should be 1.0.0")
// Verify second release
is.Equal("release2", elements[1].Name, "second release name should be release2")
is.Equal("kube-system", elements[1].Namespace, "second release namespace should be kube-system")
is.Equal("2", elements[1].Revision, "second release revision should be 2")
is.Equal(string(release.StatusFailed), elements[1].Status, "second release status should be failed")
is.Equal("chart2-2.0.0", elements[1].Chart, "second release chart should be chart2-2.0.0")
is.Equal("2.0.0", elements[1].AppVersion, "second release app version should be 2.0.0")
}

View File

@@ -0,0 +1,23 @@
package sdk
import (
"time"
"github.com/portainer/portainer/pkg/libhelm/types"
"helm.sh/helm/v3/pkg/cli"
)
// HelmSDKPackageManager is a wrapper for the helm SDK which implements HelmPackageManager
type HelmSDKPackageManager struct {
settings *cli.EnvSettings
timeout time.Duration
}
// NewHelmSDKPackageManager initializes a new HelmPackageManager service using the Helm SDK
func NewHelmSDKPackageManager() types.HelmPackageManager {
settings := cli.New()
return &HelmSDKPackageManager{
settings: settings,
timeout: 300 * time.Second, // 5 minutes default timeout
}
}

View File

@@ -0,0 +1,29 @@
package sdk
import (
"testing"
"time"
"github.com/portainer/portainer/pkg/libhelm/types"
"github.com/stretchr/testify/assert"
)
func Test_NewHelmSDKPackageManager(t *testing.T) {
is := assert.New(t)
// Test that NewHelmSDKPackageManager returns a non-nil HelmPackageManager
manager := NewHelmSDKPackageManager()
is.NotNil(manager, "should return non-nil HelmPackageManager")
// Test that the returned manager is of the correct type
_, ok := manager.(*HelmSDKPackageManager)
is.True(ok, "should return a *HelmSDKPackageManager")
// Test that the manager has the expected fields
sdkManager := manager.(*HelmSDKPackageManager)
is.NotNil(sdkManager.settings, "should have non-nil settings")
is.Equal(300*time.Second, sdkManager.timeout, "should have 5 minute timeout")
// Test that the manager implements the HelmPackageManager interface
var _ types.HelmPackageManager = manager
}

View File

@@ -0,0 +1,351 @@
package sdk
import (
"net/url"
"os"
"path/filepath"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/getter"
"helm.sh/helm/v3/pkg/repo"
)
var (
errRequiredSearchOptions = errors.New("repo is required")
errInvalidRepoURL = errors.New("the request failed since either the Helm repository was not found or the index.yaml is not valid")
)
type RepoIndex struct {
APIVersion string `json:"apiVersion"`
Entries map[string][]ChartInfo `json:"entries"`
Generated string `json:"generated"`
}
// SearchRepo downloads the `index.yaml` file for specified repo, parses it and returns JSON to caller.
func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) {
// Validate input options
if err := validateSearchRepoOptions(searchRepoOpts); err != nil {
log.Error().
Str("context", "HelmClient").
Str("repo", searchRepoOpts.Repo).
Err(err).
Msg("Missing required search repo options")
return nil, err
}
log.Debug().
Str("context", "HelmClient").
Str("repo", searchRepoOpts.Repo).
Msg("Searching repository")
// Parse and validate the repository URL
repoURL, err := parseRepoURL(searchRepoOpts.Repo)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("repo", searchRepoOpts.Repo).
Err(err).
Msg("Invalid repository URL")
return nil, err
}
// Set up Helm CLI environment
repoSettings := cli.New()
// Ensure all required Helm directories exist
if err := ensureHelmDirectoriesExist(repoSettings); err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to ensure Helm directories exist")
return nil, errors.Wrap(err, "failed to ensure Helm directories exist")
}
// Download the index file and update repository configuration
indexPath, err := downloadRepoIndex(repoURL.String(), repoSettings, searchRepoOpts.Repo)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("repo_url", repoURL.String()).
Err(err).
Msg("Failed to download repository index")
return nil, err
}
// Load and parse the index file
log.Debug().
Str("context", "HelmClient").
Str("index_path", indexPath).
Msg("Loading index file")
indexFile, err := loadIndexFile(indexPath)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("index_path", indexPath).
Err(err).
Msg("Failed to load index file")
return nil, err
}
// Convert the index file to our response format
result, err := convertIndexToResponse(indexFile)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to convert index to response format")
return nil, errors.Wrap(err, "failed to convert index to response format")
}
log.Debug().
Str("context", "HelmClient").
Str("repo", searchRepoOpts.Repo).
Int("entries_count", len(indexFile.Entries)).
Msg("Successfully searched repository")
return json.Marshal(result)
}
// validateSearchRepoOptions validates the required search repository options.
func validateSearchRepoOptions(opts options.SearchRepoOptions) error {
if opts.Repo == "" {
return errRequiredSearchOptions
}
return nil
}
// parseRepoURL parses and validates the repository URL.
func parseRepoURL(repoURL string) (*url.URL, error) {
parsedURL, err := url.ParseRequestURI(repoURL)
if err != nil {
return nil, errors.Wrap(err, "invalid helm chart URL: "+repoURL)
}
return parsedURL, nil
}
// downloadRepoIndex downloads the index.yaml file from the repository and updates
// the repository configuration.
func downloadRepoIndex(repoURLString string, repoSettings *cli.EnvSettings, repoName string) (string, error) {
log.Debug().
Str("context", "helm_sdk_repo_index").
Str("repo_url", repoURLString).
Str("repo_name", repoName).
Msg("Creating chart repository object")
// Create chart repository object
rep, err := repo.NewChartRepository(
&repo.Entry{
URL: repoURLString,
},
getter.All(repoSettings),
)
if err != nil {
log.Error().
Str("context", "helm_sdk_repo_index").
Str("repo_url", repoURLString).
Err(err).
Msg("Failed to create chart repository object")
return "", errInvalidRepoURL
}
// Load repository configuration file
f, err := repo.LoadFile(repoSettings.RepositoryConfig)
if err != nil {
log.Error().
Str("context", "helm_sdk_repo_index").
Str("repo_config", repoSettings.RepositoryConfig).
Err(err).
Msg("Failed to load repo config")
return "", errors.Wrap(err, "failed to load repo config")
}
// Download the index file
log.Debug().
Str("context", "helm_sdk_repo_index").
Str("repo_url", repoURLString).
Msg("Downloading index file")
indexPath, err := rep.DownloadIndexFile()
if err != nil {
log.Error().
Str("context", "helm_sdk_repo_index").
Str("repo_url", repoURLString).
Err(err).
Msg("Failed to download index file")
return "", errors.Wrapf(err, "looks like %q is not a valid chart repository or cannot be reached", repoURLString)
}
// Update repository configuration
c := repo.Entry{
Name: repoName,
URL: repoURLString,
}
f.Update(&c)
// Write updated configuration
repoFile := repoSettings.RepositoryConfig
if err := f.WriteFile(repoFile, 0644); err != nil {
log.Error().
Str("context", "helm_sdk_repo_index").
Str("repo_file", repoSettings.RepositoryConfig).
Err(err).
Msg("Failed to write repository configuration")
return "", errors.Wrap(err, "failed to write repository configuration")
}
log.Debug().
Str("context", "helm_sdk_repo_index").
Str("index_path", indexPath).
Msg("Successfully downloaded index file")
return indexPath, nil
}
// loadIndexFile loads the index file from the given path.
func loadIndexFile(indexPath string) (*repo.IndexFile, error) {
indexFile, err := repo.LoadIndexFile(indexPath)
if err != nil {
return nil, errors.Wrapf(err, "failed to load downloaded index file: %s", indexPath)
}
return indexFile, nil
}
// convertIndexToResponse converts the Helm index file to our response format.
func convertIndexToResponse(indexFile *repo.IndexFile) (RepoIndex, error) {
result := RepoIndex{
APIVersion: indexFile.APIVersion,
Entries: make(map[string][]ChartInfo),
Generated: indexFile.Generated.String(),
}
// Convert Helm SDK types to our response types
for name, charts := range indexFile.Entries {
result.Entries[name] = convertChartsToChartInfo(charts)
}
return result, nil
}
// convertChartsToChartInfo converts Helm chart entries to ChartInfo objects.
func convertChartsToChartInfo(charts []*repo.ChartVersion) []ChartInfo {
chartInfos := make([]ChartInfo, len(charts))
for i, chart := range charts {
chartInfos[i] = ChartInfo{
Name: chart.Name,
Version: chart.Version,
AppVersion: chart.AppVersion,
Description: chart.Description,
Deprecated: chart.Deprecated,
Created: chart.Created.String(),
Digest: chart.Digest,
Home: chart.Home,
Sources: chart.Sources,
URLs: chart.URLs,
Icon: chart.Icon,
Annotations: chart.Annotations,
}
}
return chartInfos
}
// ChartInfo represents a Helm chart in the repository index
type ChartInfo struct {
Name string `json:"name"`
Version string `json:"version"`
AppVersion string `json:"appVersion"`
Description string `json:"description"`
Deprecated bool `json:"deprecated"`
Created string `json:"created"`
Digest string `json:"digest"`
Home string `json:"home"`
Sources []string `json:"sources"`
URLs []string `json:"urls"`
Icon string `json:"icon,omitempty"`
Annotations any `json:"annotations,omitempty"`
}
// ensureHelmDirectoriesExist checks and creates required Helm directories if they don't exist
func ensureHelmDirectoriesExist(settings *cli.EnvSettings) error {
log.Debug().
Str("context", "helm_sdk_dirs").
Msg("Ensuring Helm directories exist")
// List of directories to ensure exist
directories := []string{
filepath.Dir(settings.RepositoryConfig), // Repository config directory
settings.RepositoryCache, // Repository cache directory
filepath.Dir(settings.RegistryConfig), // Registry config directory
settings.PluginsDirectory, // Plugins directory
}
// Create each directory if it doesn't exist
for _, dir := range directories {
if dir == "" {
continue // Skip empty paths
}
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0700); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("directory", dir).
Err(err).
Msg("Failed to create directory")
return errors.Wrapf(err, "failed to create directory: %s", dir)
}
}
}
// Ensure registry config file exists
if settings.RegistryConfig != "" {
if _, err := os.Stat(settings.RegistryConfig); os.IsNotExist(err) {
// Create the directory if it doesn't exist
dir := filepath.Dir(settings.RegistryConfig)
if err := os.MkdirAll(dir, 0700); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("directory", dir).
Err(err).
Msg("Failed to create directory")
return errors.Wrapf(err, "failed to create directory: %s", dir)
}
// Create an empty registry config file
if _, err := os.Create(settings.RegistryConfig); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("file", settings.RegistryConfig).
Err(err).
Msg("Failed to create registry config file")
return errors.Wrapf(err, "failed to create registry config file: %s", settings.RegistryConfig)
}
}
}
// Ensure repository config file exists
if settings.RepositoryConfig != "" {
if _, err := os.Stat(settings.RepositoryConfig); os.IsNotExist(err) {
// Create an empty repository config file with default yaml structure
f := repo.NewFile()
if err := f.WriteFile(settings.RepositoryConfig, 0644); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("file", settings.RepositoryConfig).
Err(err).
Msg("Failed to create repository config file")
return errors.Wrapf(err, "failed to create repository config file: %s", settings.RepositoryConfig)
}
}
}
log.Debug().
Str("context", "helm_sdk_dirs").
Msg("Successfully ensured all Helm directories exist")
return nil
}

View File

@@ -0,0 +1,84 @@
package sdk
import (
"encoding/json"
"testing"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/stretchr/testify/assert"
)
type testCase struct {
name string
url string
invalid bool
}
var tests = []testCase{
{"not a helm repo", "https://portainer.io", true},
{"ingress helm repo", "https://kubernetes.github.io/ingress-nginx", false},
{"portainer helm repo", "https://portainer.github.io/k8s/", false},
{"elastic helm repo with trailing slash", "https://helm.elastic.co/", false},
{"lensesio helm repo without trailing slash", "https://lensesio.github.io/kafka-helm-charts", false},
}
func Test_SearchRepo(t *testing.T) {
is := assert.New(t)
// Create a new SDK package manager
hspm := NewHelmSDKPackageManager()
for _, test := range tests {
func(tc testCase) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
response, err := hspm.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)
}
t.Run("search repo with keyword", func(t *testing.T) {
// Search for charts with keyword
searchOpts := options.SearchRepoOptions{
Repo: "https://kubernetes.github.io/ingress-nginx",
}
responseBytes, err := hspm.SearchRepo(searchOpts)
// The function should not fail by design, even when not running in a k8s environment
is.NoError(err, "should not return error when not in k8s environment")
is.NotNil(responseBytes, "should return non-nil response")
is.NotEmpty(responseBytes, "should return non-empty response")
// Parse the ext response
var repoIndex RepoIndex
err = json.Unmarshal(responseBytes, &repoIndex)
is.NoError(err, "should parse JSON response without error")
is.NotEmpty(repoIndex, "should have at least one chart")
// Verify charts structure apiVersion, entries, generated
is.Equal("v1", repoIndex.APIVersion, "apiVersion should be v1")
is.NotEmpty(repoIndex.Entries, "entries should not be empty")
is.NotEmpty(repoIndex.Generated, "generated should not be empty")
// there should be at least one chart
is.Greater(len(repoIndex.Entries), 0, "should have at least one chart")
})
t.Run("search repo with empty repo URL", func(t *testing.T) {
// Search with empty repo URL
searchOpts := options.SearchRepoOptions{
Repo: "",
}
_, err := hspm.SearchRepo(searchOpts)
is.Error(err, "should return error when repo URL is empty")
})
}

131
pkg/libhelm/sdk/show.go Normal file
View File

@@ -0,0 +1,131 @@
package sdk
import (
"fmt"
"os"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
)
var errRequiredShowOptions = errors.New("chart, repo and output format are required")
// Show implements the HelmPackageManager interface by using the Helm SDK to show chart information.
// It supports showing chart values, readme, and chart details based on the provided ShowOptions.
func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) {
if showOpts.Chart == "" || showOpts.Repo == "" || showOpts.OutputFormat == "" {
log.Error().
Str("context", "HelmClient").
Str("chart", showOpts.Chart).
Str("repo", showOpts.Repo).
Str("output_format", string(showOpts.OutputFormat)).
Msg("Missing required show options")
return nil, errRequiredShowOptions
}
log.Debug().
Str("context", "HelmClient").
Str("chart", showOpts.Chart).
Str("repo", showOpts.Repo).
Str("output_format", string(showOpts.OutputFormat)).
Msg("Showing chart information")
// Initialize action configuration
actionConfig := new(action.Configuration)
if err := actionConfig.Init(nil, "", "", func(format string, v ...interface{}) {}); err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to initialize helm configuration")
return nil, fmt.Errorf("failed to initialize helm configuration: %w", err)
}
// Create temporary directory for chart download
tempDir, err := os.MkdirTemp("", "helm-show-*")
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to create temp directory")
return nil, fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tempDir)
// Create showClient action
showClient, err := initShowClient(actionConfig, showOpts)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to initialize helm show client")
return nil, fmt.Errorf("failed to initialize helm show client: %w", err)
}
// Locate and load the chart
log.Debug().
Str("context", "HelmClient").
Str("chart", showOpts.Chart).
Str("repo", showOpts.Repo).
Msg("Locating chart")
chartPath, err := showClient.ChartPathOptions.LocateChart(showOpts.Chart, hspm.settings)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart", showOpts.Chart).
Str("repo", showOpts.Repo).
Err(err).
Msg("Failed to locate chart")
return nil, fmt.Errorf("failed to locate chart: %w", err)
}
// Get the output based on the requested format
output, err := showClient.Run(chartPath)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart_path", chartPath).
Str("output_format", string(showOpts.OutputFormat)).
Err(err).
Msg("Failed to show chart info")
return nil, fmt.Errorf("failed to show chart info: %w", err)
}
log.Debug().
Str("context", "HelmClient").
Str("chart", showOpts.Chart).
Int("output_size", len(output)).
Msg("Successfully retrieved chart information")
return []byte(output), nil
}
// initShowClient initializes the show client with the given options
// and return the show client.
func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOptions) (*action.Show, error) {
showClient := action.NewShowWithConfig(action.ShowAll, actionConfig)
showClient.ChartPathOptions.RepoURL = showOpts.Repo
showClient.ChartPathOptions.Version = "" // Latest version
// Set output type based on ShowOptions
switch showOpts.OutputFormat {
case options.ShowAll:
showClient.OutputFormat = action.ShowAll
case options.ShowChart:
showClient.OutputFormat = action.ShowChart
case options.ShowValues:
showClient.OutputFormat = action.ShowValues
case options.ShowReadme:
showClient.OutputFormat = action.ShowReadme
default:
log.Error().
Str("context", "HelmClient").
Str("output_format", string(showOpts.OutputFormat)).
Msg("Unsupported output format")
return nil, fmt.Errorf("unsupported output format: %s", showOpts.OutputFormat)
}
return showClient, nil
}

View File

@@ -0,0 +1,102 @@
package sdk
import (
"testing"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/stretchr/testify/assert"
)
func Test_Show(t *testing.T) {
test.EnsureIntegrationTest(t)
is := assert.New(t)
// Create a new SDK package manager
hspm := NewHelmSDKPackageManager()
// install the ingress-nginx chart to test the show command
installOpts := options.InstallOptions{
Name: "ingress-nginx",
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
release, err := hspm.Install(installOpts)
if release != nil || err != nil {
defer hspm.Uninstall(options.UninstallOptions{
Name: "ingress-nginx",
})
}
t.Run("show requires chart, repo and output format", func(t *testing.T) {
showOpts := options.ShowOptions{
Chart: "",
Repo: "",
OutputFormat: "",
}
_, err := hspm.Show(showOpts)
is.Error(err, "should return error when required options are missing")
is.Contains(err.Error(), "chart, repo and output format are required", "error message should indicate required options")
})
t.Run("show chart values", func(t *testing.T) {
showOpts := options.ShowOptions{
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
OutputFormat: options.ShowValues,
}
values, err := hspm.Show(showOpts)
is.NoError(err, "should not return error when not in k8s environment")
is.NotEmpty(values, "should return non-empty values")
})
t.Run("show chart readme", func(t *testing.T) {
showOpts := options.ShowOptions{
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
OutputFormat: options.ShowReadme,
}
readme, err := hspm.Show(showOpts)
is.NoError(err, "should not return error when not in k8s environment")
is.NotEmpty(readme, "should return non-empty readme")
})
t.Run("show chart definition", func(t *testing.T) {
showOpts := options.ShowOptions{
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
OutputFormat: options.ShowChart,
}
chart, err := hspm.Show(showOpts)
is.NoError(err, "should not return error when not in k8s environment")
is.NotNil(chart, "should return non-nil chart definition")
})
t.Run("show all chart info", func(t *testing.T) {
showOpts := options.ShowOptions{
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
OutputFormat: options.ShowAll,
}
info, err := hspm.Show(showOpts)
is.NoError(err, "should not return error when not in k8s environment")
is.NotEmpty(info, "should return non-empty chart info")
})
t.Run("show with invalid output format", func(t *testing.T) {
// Show with invalid output format
showOpts := options.ShowOptions{
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
OutputFormat: "invalid",
}
_, err := hspm.Show(showOpts)
is.Error(err, "should return error with invalid output format")
is.Contains(err.Error(), "unsupported output format", "error message should indicate invalid output format")
})
}

View File

@@ -0,0 +1,70 @@
package sdk
import (
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
)
// Uninstall implements the HelmPackageManager interface by using the Helm SDK to uninstall a release.
func (hspm *HelmSDKPackageManager) Uninstall(uninstallOpts options.UninstallOptions) error {
if uninstallOpts.Name == "" {
log.Error().
Str("context", "HelmClient").
Msg("Release name is required")
return errors.New("release name is required")
}
log.Debug().
Str("context", "HelmClient").
Str("release", uninstallOpts.Name).
Str("namespace", uninstallOpts.Namespace).
Msg("Uninstalling Helm release")
// Initialize action configuration with kubernetes config
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, uninstallOpts.Namespace, uninstallOpts.KubernetesClusterAccess)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("release", uninstallOpts.Name).
Str("namespace", uninstallOpts.Namespace).
Err(err).
Msg("Failed to initialize helm configuration")
return errors.Wrap(err, "failed to initialize helm configuration")
}
// Create uninstallClient action
uninstallClient := action.NewUninstall(actionConfig)
// 'foreground' means the parent object remains in a "terminating" state until all of its children are deleted. This ensures that all dependent resources are completely removed before finalizing the deletion of the parent resource.
uninstallClient.DeletionPropagation = "foreground" // "background" or "orphan"
// Run the uninstallation
log.Info().
Str("context", "HelmClient").
Str("release", uninstallOpts.Name).
Str("namespace", uninstallOpts.Namespace).
Msg("Running uninstallation")
result, err := uninstallClient.Run(uninstallOpts.Name)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("release", uninstallOpts.Name).
Str("namespace", uninstallOpts.Namespace).
Err(err).
Msg("Failed to uninstall helm release")
return errors.Wrap(err, "failed to uninstall helm release")
}
if result != nil {
log.Debug().
Str("context", "HelmClient").
Str("release", uninstallOpts.Name).
Str("release_info", result.Release.Info.Description).
Msg("Uninstall result details")
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More