Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49b7831cb8 | |||
| 78127f8f3d | |||
| c474322889 | |||
| 83527da1a8 | |||
| 7c8bef84b1 | |||
| 5b3dba130b | |||
| 4039c3a693 | |||
| b1dceb15e4 | |||
| 2feaacddb9 | |||
| 65e0344975 | |||
| 915beecce3 | |||
| fbabeb098f | |||
| d5981a4be9 | |||
| b0de6d41b7 | |||
| 3898b9e09e | |||
| c0a4a9ab5c | |||
| b9a68e9f31 | |||
| 52afa6cf67 | |||
| 1abb77aea5 | |||
| ab824da5d7 | |||
| ded33a33a0 | |||
| 4bd9569e63 | |||
| 9e04145875 | |||
| 3c6f61134e | |||
| 9ac8641f7e | |||
| 0fddedc1a9 | |||
| 2e6a3a42be | |||
| a245e93902 | |||
| d1f48ce043 | |||
| 2c1156da75 | |||
| 5ed95ce714 | |||
| 3e5ec79b21 | |||
| 157c83deee | |||
| 2865fd6b84 | |||
| 96285817ab | |||
| c2c1ac70f8 | |||
| b73f846397 | |||
| a43bb23bef | |||
| c93b2fedb4 | |||
| 156b223287 |
@@ -2,17 +2,18 @@ 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
|
||||
@@ -44,7 +45,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
|
||||
|
||||
@@ -70,7 +71,7 @@ body:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -91,13 +92,9 @@ body:
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Portainer version
|
||||
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 [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
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.1'
|
||||
- '2.28.0'
|
||||
- '2.27.3'
|
||||
- '2.27.2'
|
||||
- '2.27.1'
|
||||
- '2.27.0'
|
||||
- '2.26.1'
|
||||
@@ -114,6 +111,20 @@ body:
|
||||
- '2.21.2'
|
||||
- '2.21.1'
|
||||
- '2.21.0'
|
||||
- '2.20.3'
|
||||
- '2.20.2'
|
||||
- '2.20.1'
|
||||
- '2.20.0'
|
||||
- '2.19.5'
|
||||
- '2.19.4'
|
||||
- '2.19.3'
|
||||
- '2.19.2'
|
||||
- '2.19.1'
|
||||
- '2.19.0'
|
||||
- '2.18.4'
|
||||
- '2.18.3'
|
||||
- '2.18.2'
|
||||
- '2.18.1'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -151,7 +162,7 @@ body:
|
||||
- type: input
|
||||
attributes:
|
||||
label: Browser
|
||||
description: |
|
||||
description: |
|
||||
Enter your browser and version. Example: Google Chrome 114.0
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -49,7 +49,6 @@ 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"
|
||||
@@ -170,8 +169,8 @@ func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheMan
|
||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
|
||||
}
|
||||
|
||||
func initHelmPackageManager() (libhelmtypes.HelmPackageManager, error) {
|
||||
return libhelm.NewHelmPackageManager()
|
||||
func initHelmPackageManager(assetsPath string) (libhelm.HelmPackageManager, error) {
|
||||
return libhelm.NewHelmPackageManager(libhelm.HelmConfig{BinaryPath: assetsPath})
|
||||
}
|
||||
|
||||
func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService {
|
||||
@@ -438,7 +437,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
|
||||
|
||||
helmPackageManager, err := initHelmPackageManager()
|
||||
helmPackageManager, err := initHelmPackageManager(*flags.Assets)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing helm package manager")
|
||||
}
|
||||
|
||||
@@ -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://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}`
|
||||
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}`
|
||||
passphrase = "my secret key"
|
||||
)
|
||||
|
||||
|
||||
@@ -22,8 +22,6 @@ type Service struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ dataservices.EndpointRelationService = &Service{}
|
||||
|
||||
func (service *Service) BucketName() string {
|
||||
return BucketName
|
||||
}
|
||||
@@ -111,18 +109,6 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
return service.connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
|
||||
})
|
||||
}
|
||||
|
||||
func (service *Service) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
return service.connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
|
||||
func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
|
||||
deletedRelation, _ := service.EndpointRelation(endpointID)
|
||||
|
||||
@@ -13,8 +13,6 @@ type ServiceTx struct {
|
||||
tx portainer.Transaction
|
||||
}
|
||||
|
||||
var _ dataservices.EndpointRelationService = &ServiceTx{}
|
||||
|
||||
func (service ServiceTx) BucketName() string {
|
||||
return BucketName
|
||||
}
|
||||
@@ -76,66 +74,6 @@ func (service ServiceTx) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
for _, endpointID := range endpointIDs {
|
||||
rel, err := service.EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel.EdgeStacks[edgeStackID] = true
|
||||
|
||||
identifier := service.service.connection.ConvertToKey(int(endpointID))
|
||||
err = service.tx.UpdateObject(BucketName, identifier, rel)
|
||||
cache.Del(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
service.service.mu.Lock()
|
||||
service.service.endpointRelationsCache = nil
|
||||
service.service.mu.Unlock()
|
||||
|
||||
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
|
||||
edgeStack.NumDeployments += len(endpointIDs)
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("could not update the number of deployments")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service ServiceTx) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
for _, endpointID := range endpointIDs {
|
||||
rel, err := service.EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
delete(rel.EdgeStacks, edgeStackID)
|
||||
|
||||
identifier := service.service.connection.ConvertToKey(int(endpointID))
|
||||
err = service.tx.UpdateObject(BucketName, identifier, rel)
|
||||
cache.Del(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
service.service.mu.Lock()
|
||||
service.service.endpointRelationsCache = nil
|
||||
service.service.mu.Unlock()
|
||||
|
||||
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
|
||||
edgeStack.NumDeployments -= len(endpointIDs)
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("could not update the number of deployments")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
|
||||
func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
|
||||
deletedRelation, _ := service.EndpointRelation(endpointID)
|
||||
|
||||
@@ -115,8 +115,6 @@ type (
|
||||
EndpointRelation(EndpointID portainer.EndpointID) (*portainer.EndpointRelation, error)
|
||||
Create(endpointRelation *portainer.EndpointRelation) error
|
||||
UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error
|
||||
AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
|
||||
RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
|
||||
DeleteEndpointRelation(EndpointID portainer.EndpointID) error
|
||||
BucketName() string
|
||||
}
|
||||
@@ -159,7 +157,6 @@ type (
|
||||
|
||||
SnapshotService interface {
|
||||
BaseCRUD[portainer.Snapshot, portainer.EndpointID]
|
||||
ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portainer.Snapshot, error)
|
||||
}
|
||||
|
||||
// SSLSettingsService represents a service for managing application settings
|
||||
|
||||
@@ -38,16 +38,3 @@ func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||
func (service *Service) Create(snapshot *portainer.Snapshot) error {
|
||||
return service.Connection.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
|
||||
}
|
||||
|
||||
func (service *Service) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portainer.Snapshot, error) {
|
||||
var snapshot *portainer.Snapshot
|
||||
|
||||
err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
snapshot, err = service.Tx(tx).ReadWithoutSnapshotRaw(ID)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return snapshot, err
|
||||
}
|
||||
|
||||
@@ -12,26 +12,3 @@ type ServiceTx struct {
|
||||
func (service ServiceTx) Create(snapshot *portainer.Snapshot) error {
|
||||
return service.Tx.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
|
||||
}
|
||||
|
||||
func (service ServiceTx) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portainer.Snapshot, error) {
|
||||
var snapshot struct {
|
||||
Docker *struct {
|
||||
X struct{} `json:"DockerSnapshotRaw"`
|
||||
*portainer.DockerSnapshot
|
||||
} `json:"Docker"`
|
||||
|
||||
portainer.Snapshot
|
||||
}
|
||||
|
||||
identifier := service.Connection.ConvertToKey(int(ID))
|
||||
|
||||
if err := service.Tx.GetObject(service.Bucket, identifier, &snapshot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if snapshot.Docker != nil {
|
||||
snapshot.Snapshot.Docker = snapshot.Docker.DockerSnapshot
|
||||
}
|
||||
|
||||
return &snapshot.Snapshot, nil
|
||||
}
|
||||
|
||||
@@ -94,10 +94,6 @@ func (m *Migrator) updateEdgeStackStatusForDB100() error {
|
||||
continue
|
||||
}
|
||||
|
||||
if environmentStatus.Details == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
statusArray := []portainer.EdgeStackDeploymentStatus{}
|
||||
if environmentStatus.Details.Pending {
|
||||
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
|
||||
|
||||
@@ -75,10 +75,6 @@ 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
|
||||
@@ -97,10 +93,10 @@ func (m *Migrator) updateEdgeStackStatusForDB80() error {
|
||||
edgeStack.Status[endpointId] = status
|
||||
}
|
||||
|
||||
if err := m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack); err != nil {
|
||||
err = m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -605,12 +605,12 @@
|
||||
"GlobalDeploymentOptions": {
|
||||
"hideStacksFunctionality": false
|
||||
},
|
||||
"HelmRepositoryURL": "https://charts.bitnami.com/bitnami",
|
||||
"HelmRepositoryURL": "",
|
||||
"InternalAuthSettings": {
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.29.2",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.27.6",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -943,7 +943,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.29.2\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.27.6\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -4,11 +4,17 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type kubernetesMockDeployer struct {
|
||||
portainer.KubernetesDeployer
|
||||
}
|
||||
type kubernetesMockDeployer struct{}
|
||||
|
||||
// NewKubernetesDeployer creates a mock kubernetes deployer
|
||||
func NewKubernetesDeployer() *kubernetesMockDeployer {
|
||||
func NewKubernetesDeployer() portainer.KubernetesDeployer {
|
||||
return &kubernetesMockDeployer{}
|
||||
}
|
||||
|
||||
func (deployer *kubernetesMockDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (deployer *kubernetesMockDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *porta
|
||||
return err
|
||||
}
|
||||
|
||||
args = append(args, "stack", "rm", "--detach=false", stack.Name)
|
||||
args = append(args, "stack", "rm", stack.Name)
|
||||
|
||||
return runCommandAndCaptureStdErr(command, args, nil, "")
|
||||
}
|
||||
|
||||
@@ -15,19 +15,15 @@ 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, []string) {
|
||||
func MultiFilterDirForPerDevConfigs(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs) []DirEntry {
|
||||
var filteredDirEntries []DirEntry
|
||||
|
||||
var envFiles []string
|
||||
|
||||
for _, multiFilterArg := range multiFilterArgs {
|
||||
tmp, efs := FilterDirForPerDevConfigs(dirEntries, multiFilterArg.FilterKey, configPath, multiFilterArg.FilterType)
|
||||
tmp := FilterDirForPerDevConfigs(dirEntries, multiFilterArg.FilterKey, configPath, multiFilterArg.FilterType)
|
||||
filteredDirEntries = append(filteredDirEntries, tmp...)
|
||||
|
||||
envFiles = append(envFiles, efs...)
|
||||
}
|
||||
|
||||
return deduplicate(filteredDirEntries), envFiles
|
||||
return deduplicate(filteredDirEntries)
|
||||
}
|
||||
|
||||
func deduplicate(dirEntries []DirEntry) []DirEntry {
|
||||
@@ -36,7 +32,8 @@ func deduplicate(dirEntries []DirEntry) []DirEntry {
|
||||
marks := make(map[string]struct{})
|
||||
|
||||
for _, dirEntry := range dirEntries {
|
||||
if _, ok := marks[dirEntry.Name]; !ok {
|
||||
_, ok := marks[dirEntry.Name]
|
||||
if !ok {
|
||||
marks[dirEntry.Name] = struct{}{}
|
||||
deduplicatedDirEntries = append(deduplicatedDirEntries, dirEntry)
|
||||
}
|
||||
@@ -47,33 +44,34 @@ func deduplicate(dirEntries []DirEntry) []DirEntry {
|
||||
|
||||
// FilterDirForPerDevConfigs filers the given dirEntries, returns entries for the given device
|
||||
// For given configPath A/B/C, return entries:
|
||||
// 1. all entries outside of dir A/B/C
|
||||
// 2. For filterType file:
|
||||
// 1. all entries outside of dir A
|
||||
// 2. dir entries A, A/B, A/B/C
|
||||
// 3. For filterType file:
|
||||
// file entries: A/B/C/<deviceName> and A/B/C/<deviceName>.*
|
||||
// 3. For filterType dir:
|
||||
// 4. 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, []string) {
|
||||
func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) []DirEntry {
|
||||
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, envFiles
|
||||
return filteredDirEntries
|
||||
}
|
||||
|
||||
func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool {
|
||||
|
||||
// Include all entries outside of dir A
|
||||
if !isInConfigDir(dirEntry, configPath) {
|
||||
if !isInConfigRootDir(dirEntry, configPath) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Include dir entries A, A/B, A/B/C
|
||||
if isParentDir(dirEntry, configPath) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -92,9 +90,21 @@ func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filter
|
||||
return false
|
||||
}
|
||||
|
||||
func isInConfigDir(dirEntry DirEntry, configPath string) bool {
|
||||
// return true if entry name starts with "A/B"
|
||||
return strings.HasPrefix(dirEntry.Name, appendTailSeparator(configPath))
|
||||
func isInConfigRootDir(dirEntry DirEntry, configPath string) bool {
|
||||
// get the first element of the configPath
|
||||
rootDir := strings.Split(configPath, string(os.PathSeparator))[0]
|
||||
|
||||
// return true if entry name starts with "A/"
|
||||
return strings.HasPrefix(dirEntry.Name, appendTailSeparator(rootDir))
|
||||
}
|
||||
|
||||
func isParentDir(dirEntry DirEntry, configPath string) bool {
|
||||
if dirEntry.IsFile {
|
||||
return false
|
||||
}
|
||||
|
||||
// return true for dir entries A, A/B, A/B/C
|
||||
return strings.HasPrefix(appendTailSeparator(configPath), appendTailSeparator(dirEntry.Name))
|
||||
}
|
||||
|
||||
func shouldIncludeFile(dirEntry DirEntry, deviceName, configPath string) bool {
|
||||
@@ -128,15 +138,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -4,17 +4,14 @@ import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
|
||||
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantDirEntries []DirEntry) {
|
||||
t.Helper()
|
||||
|
||||
dirEntries, _ = MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs)
|
||||
require.Equal(t, wantDirEntries, dirEntries)
|
||||
type args struct {
|
||||
dirEntries []DirEntry
|
||||
configPath string
|
||||
multiFilterArgs MultiFilterArgs
|
||||
}
|
||||
|
||||
baseDirEntries := []DirEntry{
|
||||
@@ -29,94 +26,67 @@ func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
|
||||
{"configs/folder2/config2", "", true, 420},
|
||||
}
|
||||
|
||||
// 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},
|
||||
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]},
|
||||
},
|
||||
[]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},
|
||||
{
|
||||
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[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)
|
||||
}
|
||||
|
||||
baseDirEntries := []DirEntry{
|
||||
{".env", "", true, 420},
|
||||
{"docker-compose.yaml", "", true, 420},
|
||||
{"configs", "", false, 420},
|
||||
{"configs/edge-id/edge-id.env", "", true, 420},
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
f(
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{{"edge-id", portainer.PerDevConfigsTypeDir}},
|
||||
[]string{"configs/edge-id/edge-id.env"},
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
func TestIsInConfigDir(t *testing.T) {
|
||||
f := func(dirEntry DirEntry, configPath string, expect bool) {
|
||||
t.Helper()
|
||||
|
||||
actual := isInConfigDir(dirEntry, configPath)
|
||||
assert.Equal(t, expect, actual)
|
||||
}
|
||||
|
||||
f(DirEntry{Name: "edge-configs"}, "edge-configs", false)
|
||||
f(DirEntry{Name: "edge-configs_backup"}, "edge-configs", false)
|
||||
f(DirEntry{Name: "edge-configs/standalone-edge-agent-standard"}, "edge-configs", true)
|
||||
f(DirEntry{Name: "parent/edge-configs/"}, "edge-configs", false)
|
||||
f(DirEntry{Name: "edgestacktest"}, "edgestacktest/edge-configs", false)
|
||||
f(DirEntry{Name: "edgestacktest/edgeconfigs-test.yaml"}, "edgestacktest/edge-configs", false)
|
||||
f(DirEntry{Name: "edgestacktest/file1.conf"}, "edgestacktest/edge-configs", false)
|
||||
f(DirEntry{Name: "edgeconfigs-test.yaml"}, "edgestacktest/edge-configs", false)
|
||||
f(DirEntry{Name: "edgestacktest/edge-configs"}, "edgestacktest/edge-configs", false)
|
||||
f(DirEntry{Name: "edgestacktest/edge-configs/standalone-edge-agent-async"}, "edgestacktest/edge-configs", true)
|
||||
f(DirEntry{Name: "edgestacktest/edge-configs/abc.txt"}, "edgestacktest/edge-configs", true)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ type edgeGroupUpdatePayload struct {
|
||||
}
|
||||
|
||||
func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error {
|
||||
if len(payload.Name) == 0 {
|
||||
return errors.New("invalid Edge group name")
|
||||
}
|
||||
|
||||
if payload.Dynamic && len(payload.TagIDs) == 0 {
|
||||
return errors.New("tagIDs is mandatory for a dynamic Edge group")
|
||||
}
|
||||
@@ -31,7 +35,7 @@ func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id EdgeGroupUpdate
|
||||
// @id EgeGroupUpdate
|
||||
// @summary Updates an EdgeGroup
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_groups
|
||||
|
||||
@@ -138,23 +138,57 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
|
||||
return nil, nil, errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
|
||||
}
|
||||
|
||||
oldRelatedEnvironmentsSet := set.ToSet(oldRelatedEnvironmentIDs)
|
||||
newRelatedEnvironmentsSet := set.ToSet(newRelatedEnvironmentIDs)
|
||||
oldRelatedSet := set.ToSet(oldRelatedEnvironmentIDs)
|
||||
newRelatedSet := set.ToSet(newRelatedEnvironmentIDs)
|
||||
|
||||
relatedEnvironmentsToAdd := newRelatedEnvironmentsSet.Difference(oldRelatedEnvironmentsSet)
|
||||
relatedEnvironmentsToRemove := oldRelatedEnvironmentsSet.Difference(newRelatedEnvironmentsSet)
|
||||
|
||||
if len(relatedEnvironmentsToRemove) > 0 {
|
||||
if err := tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStackID); err != nil {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to remove edge stack relations from the database")
|
||||
endpointsToRemove := set.Set[portainer.EndpointID]{}
|
||||
for endpointID := range oldRelatedSet {
|
||||
if !newRelatedSet[endpointID] {
|
||||
endpointsToRemove[endpointID] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(relatedEnvironmentsToAdd) > 0 {
|
||||
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.Keys(), edgeStackID); err != nil {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to add edge stack relations to the database")
|
||||
for endpointID := range endpointsToRemove {
|
||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
continue
|
||||
}
|
||||
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
|
||||
}
|
||||
|
||||
delete(relation.EdgeStacks, edgeStackID)
|
||||
|
||||
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
|
||||
}
|
||||
}
|
||||
|
||||
return newRelatedEnvironmentIDs, relatedEnvironmentsToAdd, nil
|
||||
endpointsToAdd := set.Set[portainer.EndpointID]{}
|
||||
for endpointID := range newRelatedSet {
|
||||
if !oldRelatedSet[endpointID] {
|
||||
endpointsToAdd[endpointID] = true
|
||||
}
|
||||
}
|
||||
|
||||
for endpointID := range endpointsToAdd {
|
||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
||||
if err != nil && !tx.IsErrObjectNotFound(err) {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
|
||||
}
|
||||
|
||||
if relation == nil {
|
||||
relation = &portainer.EndpointRelation{
|
||||
EndpointID: endpointID,
|
||||
EdgeStacks: map[portainer.EdgeStackID]bool{},
|
||||
}
|
||||
}
|
||||
relation.EdgeStacks[edgeStackID] = true
|
||||
|
||||
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
|
||||
}
|
||||
}
|
||||
|
||||
return newRelatedEnvironmentIDs, endpointsToAdd, nil
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ import (
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment(Endpoint) identifier"
|
||||
// @param excludeSnapshot query bool false "if true, the snapshot data won't be retrieved"
|
||||
// @param excludeSnapshotRaw query bool false "if true, the SnapshotRaw field won't be retrieved"
|
||||
// @success 200 {object} portainer.Endpoint "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 404 "Environment(Endpoint) not found"
|
||||
@@ -39,7 +37,8 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
|
||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Permission denied to access environment", err)
|
||||
}
|
||||
|
||||
@@ -52,11 +51,9 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
|
||||
endpointutils.UpdateEdgeEndpointHeartbeat(endpoint, settings)
|
||||
endpoint.ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion()
|
||||
|
||||
excludeSnapshot, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshot", true)
|
||||
excludeRaw, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshotRaw", true)
|
||||
|
||||
if !excludeSnapshot {
|
||||
if err := handler.SnapshotService.FillSnapshotData(endpoint, !excludeRaw); err != nil {
|
||||
if !excludeSnapshot(r) {
|
||||
err = handler.SnapshotService.FillSnapshotData(endpoint)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to add snapshot data", err)
|
||||
}
|
||||
}
|
||||
@@ -86,3 +83,9 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
return response.JSON(w, endpoint)
|
||||
}
|
||||
|
||||
func excludeSnapshot(r *http.Request) bool {
|
||||
excludeSnapshot, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshot", true)
|
||||
|
||||
return excludeSnapshot
|
||||
}
|
||||
|
||||
@@ -38,19 +38,15 @@ const (
|
||||
// @param tagIds query []int false "search environments(endpoints) with these tags (depends on tagsPartialMatch)"
|
||||
// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags"
|
||||
// @param endpointIds query []int false "will return only these environments(endpoints)"
|
||||
// @param excludeIds query []int false "will exclude these environments(endpoints)"
|
||||
// @param provisioned query bool false "If true, will return environment(endpoint) that were provisioned"
|
||||
// @param agentVersions query []string false "will return only environments with on of these agent versions"
|
||||
// @param edgeAsync query bool false "if exists true show only edge async agents, false show only standard edge agents. if missing, will show both types (relevant only for edge agents)"
|
||||
// @param edgeDeviceUntrusted query bool false "if true, show only untrusted edge agents, if false show only trusted edge agents (relevant only for edge agents)"
|
||||
// @param edgeCheckInPassedSeconds query number false "if bigger then zero, show only edge agents that checked-in in the last provided seconds (relevant only for edge agents)"
|
||||
// @param excludeSnapshots query bool false "if true, the snapshot data won't be retrieved"
|
||||
// @param excludeSnapshotRaw query bool false "if true, the SnapshotRaw field won't be retrieved"
|
||||
// @param name query string false "will return only environments(endpoints) with this name"
|
||||
// @param edgeStackId query portainer.EdgeStackID false "will return the environements of the specified edge stack"
|
||||
// @param edgeStackStatus query string false "only applied when edgeStackId exists. Filter the returned environments based on their deployment status in the stack (not the environment status!)" Enum("Pending", "Ok", "Error", "Acknowledged", "Remove", "RemoteUpdateSuccess", "ImagesPulled")
|
||||
// @param edgeGroupIds query []int false "List environments(endpoints) of these edge groups"
|
||||
// @param excludeEdgeGroupIds query []int false "Exclude environments(endpoints) of these edge groups"
|
||||
// @success 200 {array} portainer.Endpoint "Endpoints"
|
||||
// @failure 500 "Server error"
|
||||
// @router /endpoints [get]
|
||||
@@ -63,7 +59,6 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
|
||||
sortField, _ := request.RetrieveQueryParameter(r, "sort", true)
|
||||
sortOrder, _ := request.RetrieveQueryParameter(r, "order", true)
|
||||
excludeRaw, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshotRaw", true)
|
||||
|
||||
endpointGroups, err := handler.DataStore.EndpointGroup().ReadAll()
|
||||
if err != nil {
|
||||
@@ -110,16 +105,14 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
|
||||
for idx := range paginatedEndpoints {
|
||||
hideFields(&paginatedEndpoints[idx])
|
||||
|
||||
paginatedEndpoints[idx].ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion()
|
||||
if paginatedEndpoints[idx].EdgeCheckinInterval == 0 {
|
||||
paginatedEndpoints[idx].EdgeCheckinInterval = settings.EdgeAgentCheckinInterval
|
||||
}
|
||||
|
||||
endpointutils.UpdateEdgeEndpointHeartbeat(&paginatedEndpoints[idx], settings)
|
||||
|
||||
if !query.excludeSnapshots {
|
||||
if err := handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx], !excludeRaw); err != nil {
|
||||
err = handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx])
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to add snapshot data", err)
|
||||
}
|
||||
}
|
||||
@@ -127,7 +120,6 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
|
||||
w.Header().Set("X-Total-Count", strconv.Itoa(filteredEndpointCount))
|
||||
w.Header().Set("X-Total-Available", strconv.Itoa(totalAvailableEndpoints))
|
||||
|
||||
return response.JSON(w, paginatedEndpoints)
|
||||
}
|
||||
|
||||
@@ -138,8 +130,18 @@ func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []porta
|
||||
|
||||
endpointCount := len(endpoints)
|
||||
|
||||
start = min(max(start, 0), endpointCount)
|
||||
end := min(start+limit, endpointCount)
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
if start > endpointCount {
|
||||
start = endpointCount
|
||||
}
|
||||
|
||||
end := start + limit
|
||||
if end > endpointCount {
|
||||
end = endpointCount
|
||||
}
|
||||
|
||||
return endpoints[start:end]
|
||||
}
|
||||
@@ -149,10 +151,8 @@ func getEndpointGroup(groupID portainer.EndpointGroupID, groups []portainer.Endp
|
||||
for _, group := range groups {
|
||||
if group.ID == groupID {
|
||||
endpointGroup = group
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return endpointGroup
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
}
|
||||
|
||||
if err := handler.SnapshotService.FillSnapshotData(endpoint, true); err != nil {
|
||||
if err := handler.SnapshotService.FillSnapshotData(endpoint); err != nil {
|
||||
return httperror.InternalServerError("Unable to add snapshot data", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,8 +37,6 @@ type EnvironmentsQuery struct {
|
||||
edgeStackId portainer.EdgeStackID
|
||||
edgeStackStatus *portainer.EdgeStackStatusType
|
||||
excludeIds []portainer.EndpointID
|
||||
edgeGroupIds []portainer.EdgeGroupID
|
||||
excludeEdgeGroupIds []portainer.EdgeGroupID
|
||||
}
|
||||
|
||||
func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
@@ -79,16 +77,6 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
edgeGroupIDs, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "edgeGroupIds")
|
||||
if err != nil {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
excludeEdgeGroupIds, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "excludeEdgeGroupIds")
|
||||
if err != nil {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
agentVersions := getArrayQueryParameter(r, "agentVersions")
|
||||
|
||||
name, _ := request.RetrieveQueryParameter(r, "name", true)
|
||||
@@ -129,8 +117,6 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
edgeCheckInPassedSeconds: edgeCheckInPassedSeconds,
|
||||
edgeStackId: portainer.EdgeStackID(edgeStackId),
|
||||
edgeStackStatus: edgeStackStatus,
|
||||
edgeGroupIds: edgeGroupIDs,
|
||||
excludeEdgeGroupIds: excludeEdgeGroupIds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -157,14 +143,6 @@ func (handler *Handler) filterEndpointsByQuery(
|
||||
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
|
||||
}
|
||||
|
||||
if len(query.edgeGroupIds) > 0 {
|
||||
filteredEndpoints, edgeGroups = filterEndpointsByEdgeGroupIDs(filteredEndpoints, edgeGroups, query.edgeGroupIds)
|
||||
}
|
||||
|
||||
if len(query.excludeEdgeGroupIds) > 0 {
|
||||
filteredEndpoints, edgeGroups = filterEndpointsByExcludeEdgeGroupIDs(filteredEndpoints, edgeGroups, query.excludeEdgeGroupIds)
|
||||
}
|
||||
|
||||
if query.name != "" {
|
||||
filteredEndpoints = filterEndpointsByName(filteredEndpoints, query.name)
|
||||
}
|
||||
@@ -317,70 +295,6 @@ func filterEndpointsByGroupIDs(endpoints []portainer.Endpoint, endpointGroupIDs
|
||||
return endpoints[:n]
|
||||
}
|
||||
|
||||
func filterEndpointsByEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeGroupIDs []portainer.EdgeGroupID) ([]portainer.Endpoint, []portainer.EdgeGroup) {
|
||||
edgeGroupIDFilterSet := make(map[portainer.EdgeGroupID]struct{}, len(edgeGroupIDs))
|
||||
for _, id := range edgeGroupIDs {
|
||||
edgeGroupIDFilterSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
n := 0
|
||||
for _, edgeGroup := range edgeGroups {
|
||||
if _, exists := edgeGroupIDFilterSet[edgeGroup.ID]; exists {
|
||||
edgeGroups[n] = edgeGroup
|
||||
n++
|
||||
}
|
||||
}
|
||||
edgeGroups = edgeGroups[:n]
|
||||
|
||||
endpointIDSet := make(map[portainer.EndpointID]struct{})
|
||||
for _, edgeGroup := range edgeGroups {
|
||||
for _, endpointID := range edgeGroup.Endpoints {
|
||||
endpointIDSet[endpointID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
n = 0
|
||||
for _, endpoint := range endpoints {
|
||||
if _, exists := endpointIDSet[endpoint.ID]; exists {
|
||||
endpoints[n] = endpoint
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
return endpoints[:n], edgeGroups
|
||||
}
|
||||
|
||||
func filterEndpointsByExcludeEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGroups []portainer.EdgeGroup, excludeEdgeGroupIds []portainer.EdgeGroupID) ([]portainer.Endpoint, []portainer.EdgeGroup) {
|
||||
excludeEdgeGroupIDSet := make(map[portainer.EdgeGroupID]struct{}, len(excludeEdgeGroupIds))
|
||||
for _, id := range excludeEdgeGroupIds {
|
||||
excludeEdgeGroupIDSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
n := 0
|
||||
excludeEndpointIDSet := make(map[portainer.EndpointID]struct{})
|
||||
for _, edgeGroup := range edgeGroups {
|
||||
if _, ok := excludeEdgeGroupIDSet[edgeGroup.ID]; ok {
|
||||
for _, endpointID := range edgeGroup.Endpoints {
|
||||
excludeEndpointIDSet[endpointID] = struct{}{}
|
||||
}
|
||||
} else {
|
||||
edgeGroups[n] = edgeGroup
|
||||
n++
|
||||
}
|
||||
}
|
||||
edgeGroups = edgeGroups[:n]
|
||||
|
||||
n = 0
|
||||
for _, endpoint := range endpoints {
|
||||
if _, ok := excludeEndpointIDSet[endpoint.ID]; !ok {
|
||||
endpoints[n] = endpoint
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
return endpoints[:n], edgeGroups
|
||||
}
|
||||
|
||||
func filterEndpointsBySearchCriteria(
|
||||
endpoints []portainer.Endpoint,
|
||||
endpointGroups []portainer.EndpointGroup,
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.29.2
|
||||
// @version 2.27.6
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -9,8 +8,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"
|
||||
@@ -24,11 +23,11 @@ type Handler struct {
|
||||
jwtService portainer.JWTService
|
||||
kubeClusterAccessService kubernetes.KubeClusterAccessService
|
||||
kubernetesDeployer portainer.KubernetesDeployer
|
||||
helmPackageManager libhelmtypes.HelmPackageManager
|
||||
helmPackageManager libhelm.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 libhelmtypes.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler {
|
||||
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelm.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
requestBouncer: bouncer,
|
||||
@@ -54,19 +53,11 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||
h.Handle("/{id}/kubernetes/helm",
|
||||
httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost)
|
||||
|
||||
// `helm get all [RELEASE_NAME]`
|
||||
h.Handle("/{id}/kubernetes/helm/{release}",
|
||||
httperror.LoggerHandler(h.helmGet)).Methods(http.MethodGet)
|
||||
|
||||
// `helm history [RELEASE_NAME]`
|
||||
h.Handle("/{id}/kubernetes/helm/{release}/history",
|
||||
httperror.LoggerHandler(h.helmGetHistory)).Methods(http.MethodGet)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// NewTemplateHandler creates a template handler to manage environment(endpoint) group operations.
|
||||
func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libhelmtypes.HelmPackageManager) *Handler {
|
||||
func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libhelm.HelmPackageManager) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
helmPackageManager: helmPackageManager,
|
||||
@@ -87,7 +78,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.
|
||||
// The cluster access is passed in as kube config CLI params to helm binary.
|
||||
func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.KubernetesClusterAccess, *httperror.HandlerError) {
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
@@ -116,9 +107,6 @@ 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,
|
||||
|
||||
@@ -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.NewMockHelmPackageManager()
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
|
||||
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
|
||||
|
||||
@@ -42,7 +42,7 @@ func Test_helmDelete(t *testing.T) {
|
||||
|
||||
// Install a single chart directly, to be deleted by the handler
|
||||
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
|
||||
h.helmPackageManager.Upgrade(options)
|
||||
h.helmPackageManager.Install(options)
|
||||
|
||||
t.Run("helmDelete succeeds with admin user", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodDelete, "/1/kubernetes/helm/"+options.Name, nil)
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
_ "github.com/portainer/portainer/pkg/libhelm/release"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id HelmGet
|
||||
// @summary Get a helm release
|
||||
// @description Get details of a helm release by release name
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags helm
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment(Endpoint) identifier"
|
||||
// @param name path string true "Helm release name"
|
||||
// @param namespace query string false "specify an optional namespace"
|
||||
// @param showResources query boolean false "show resources of the release"
|
||||
// @param revision query int false "specify an optional revision"
|
||||
// @success 200 {object} release.Release "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."
|
||||
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
||||
// @failure 404 "Unable to find an environment with the specified identifier."
|
||||
// @failure 500 "Server error occurred while attempting to retrieve the release."
|
||||
// @router /endpoints/{id}/kubernetes/helm/{name} [get]
|
||||
func (handler *Handler) helmGet(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
release, err := request.RetrieveRouteVariableValue(r, "release")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("No release specified", err)
|
||||
}
|
||||
|
||||
clusterAccess, httperr := handler.getHelmClusterAccess(r)
|
||||
if httperr != nil {
|
||||
return httperr
|
||||
}
|
||||
|
||||
// build the get options
|
||||
getOpts := options.GetOptions{
|
||||
KubernetesClusterAccess: clusterAccess,
|
||||
Name: release,
|
||||
}
|
||||
namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
|
||||
// optional namespace. The library defaults to "default"
|
||||
if namespace != "" {
|
||||
getOpts.Namespace = namespace
|
||||
}
|
||||
showResources, _ := request.RetrieveBooleanQueryParameter(r, "showResources", true)
|
||||
getOpts.ShowResources = showResources
|
||||
revision, _ := request.RetrieveNumericQueryParameter(r, "revision", true)
|
||||
// optional revision. The library defaults to the latest revision if not specified
|
||||
if revision > 0 {
|
||||
getOpts.Revision = revision
|
||||
}
|
||||
|
||||
releases, err := handler.helmPackageManager.Get(getOpts)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Helm returned an error", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, releases)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/exec/exectest"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
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/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"
|
||||
)
|
||||
|
||||
func Test_helmGet(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
err := store.Endpoint().Create(&portainer.Endpoint{ID: 1})
|
||||
is.NoError(err, "Error creating environment")
|
||||
|
||||
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
|
||||
is.NoError(err, "Error creating a user")
|
||||
|
||||
jwtService, err := jwt.NewService("1h", store)
|
||||
is.NoError(err, "Error initiating jwt service")
|
||||
|
||||
kubernetesDeployer := exectest.NewKubernetesDeployer()
|
||||
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, to be retrieved by the handler
|
||||
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
|
||||
h.helmPackageManager.Upgrade(options)
|
||||
|
||||
t.Run("helmGet sucessfuly retrieves helm release", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm/"+options.Name+"?namespace="+options.Namespace, nil)
|
||||
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
|
||||
req = req.WithContext(ctx)
|
||||
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
data := release.Release{}
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
is.NoError(err, "ReadAll should not return error")
|
||||
json.Unmarshal(body, &data)
|
||||
is.Equal(http.StatusOK, rr.Code, "Status should be 200")
|
||||
is.Equal("nginx-1", data.Name)
|
||||
})
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
_ "github.com/portainer/portainer/pkg/libhelm/release"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id HelmGetHistory
|
||||
// @summary Get a historical list of releases
|
||||
// @description Get a historical list of releases by release name
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags helm
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment(Endpoint) identifier"
|
||||
// @param name path string true "Helm release name"
|
||||
// @param namespace query string false "specify an optional namespace"
|
||||
// @success 200 {array} release.Release "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."
|
||||
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
||||
// @failure 404 "Unable to find an environment with the specified identifier."
|
||||
// @failure 500 "Server error occurred while attempting to retrieve the historical list of releases."
|
||||
// @router /endpoints/{id}/kubernetes/helm/{release}/history [get]
|
||||
func (handler *Handler) helmGetHistory(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
release, err := request.RetrieveRouteVariableValue(r, "release")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("No release specified", err)
|
||||
}
|
||||
|
||||
clusterAccess, httperr := handler.getHelmClusterAccess(r)
|
||||
if httperr != nil {
|
||||
return httperr
|
||||
}
|
||||
|
||||
historyOptions := options.HistoryOptions{
|
||||
KubernetesClusterAccess: clusterAccess,
|
||||
Name: release,
|
||||
}
|
||||
|
||||
// optional namespace. The library defaults to "default"
|
||||
namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
|
||||
if namespace != "" {
|
||||
historyOptions.Namespace = namespace
|
||||
}
|
||||
|
||||
releases, err := handler.helmPackageManager.GetHistory(historyOptions)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Helm returned an error", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, releases)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/exec/exectest"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
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/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"
|
||||
)
|
||||
|
||||
func Test_helmGetHistory(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
err := store.Endpoint().Create(&portainer.Endpoint{ID: 1})
|
||||
is.NoError(err, "Error creating environment")
|
||||
|
||||
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
|
||||
is.NoError(err, "Error creating a user")
|
||||
|
||||
jwtService, err := jwt.NewService("1h", store)
|
||||
is.NoError(err, "Error initiating jwt service")
|
||||
|
||||
kubernetesDeployer := exectest.NewKubernetesDeployer()
|
||||
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, to be retrieved by the handler
|
||||
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
|
||||
h.helmPackageManager.Upgrade(options)
|
||||
|
||||
t.Run("helmGetHistory sucessfuly retrieves helm release history", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm/"+options.Name+"/history?namespace="+options.Namespace, nil)
|
||||
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
|
||||
req = req.WithContext(ctx)
|
||||
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
data := []release.Release{}
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
is.NoError(err, "ReadAll should not return error")
|
||||
json.Unmarshal(body, &data)
|
||||
is.Equal(http.StatusOK, rr.Code, "Status should be 200")
|
||||
is.Equal(1, len(data))
|
||||
is.Equal("nginx-1", data[0].Name)
|
||||
})
|
||||
}
|
||||
@@ -99,11 +99,15 @@ 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: clusterAccess,
|
||||
Name: p.Name,
|
||||
Chart: p.Chart,
|
||||
Namespace: p.Namespace,
|
||||
Repo: p.Repo,
|
||||
KubernetesClusterAccess: &options.KubernetesClusterAccess{
|
||||
ClusterServerURL: clusterAccess.ClusterServerURL,
|
||||
CertificateAuthorityFile: clusterAccess.CertificateAuthorityFile,
|
||||
AuthToken: clusterAccess.AuthToken,
|
||||
},
|
||||
}
|
||||
|
||||
if p.Values != "" {
|
||||
@@ -125,7 +129,7 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
|
||||
installOpts.ValuesFile = file.Name()
|
||||
}
|
||||
|
||||
release, err := handler.helmPackageManager.Upgrade(installOpts)
|
||||
release, err := handler.helmPackageManager.Install(installOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -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.NewMockHelmPackageManager()
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
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://charts.bitnami.com/bitnami"}
|
||||
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://kubernetes.github.io/ingress-nginx"}
|
||||
optdata, err := json.Marshal(options)
|
||||
is.NoError(err)
|
||||
|
||||
|
||||
@@ -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,13 +37,13 @@ func Test_helmList(t *testing.T) {
|
||||
is.NoError(err, "Error initialising jwt service")
|
||||
|
||||
kubernetesDeployer := exectest.NewKubernetesDeployer()
|
||||
helmPackageManager := test.NewMockHelmPackageManager()
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
|
||||
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
|
||||
|
||||
// Install a single chart. We expect to get these values back
|
||||
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
|
||||
h.helmPackageManager.Upgrade(options)
|
||||
h.helmPackageManager.Install(options)
|
||||
|
||||
t.Run("helmList", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm", nil)
|
||||
|
||||
@@ -8,19 +8,19 @@ import (
|
||||
"testing"
|
||||
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/pkg/libhelm/test"
|
||||
"github.com/portainer/portainer/pkg/libhelm/binary/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_helmRepoSearch(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
helmPackageManager := test.NewMockHelmPackageManager()
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
h := NewTemplateHandler(helper.NewTestRequestBouncer(), helmPackageManager)
|
||||
|
||||
assert.NotNil(t, h, "Handler should not fail")
|
||||
|
||||
repos := []string{"https://charts.bitnami.com/bitnami", "https://portainer.github.io/k8s"}
|
||||
repos := []string{"https://kubernetes.github.io/ingress-nginx", "https://portainer.github.io/k8s"}
|
||||
|
||||
for _, repo := range repos {
|
||||
t.Run(repo, func(t *testing.T) {
|
||||
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
"testing"
|
||||
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/pkg/libhelm/test"
|
||||
"github.com/portainer/portainer/pkg/libhelm/binary/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_helmShow(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
helmPackageManager := test.NewMockHelmPackageManager()
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
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://charts.bitnami.com/bitnami")
|
||||
repoUrlEncoded := url.QueryEscape("https://kubernetes.github.io/ingress-nginx")
|
||||
chart := "nginx"
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/templates/helm/%s?repo=%s&chart=%s", cmd, repoUrlEncoded, chart), nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
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"
|
||||
@@ -167,48 +162,11 @@ 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)
|
||||
|
||||
if isInternal {
|
||||
return clientV1.NamedCluster{
|
||||
Name: buildClusterName(endpoint.Name),
|
||||
Cluster: clientV1.Cluster{
|
||||
Server: kubeConfigInternal.ClusterServerURL,
|
||||
InsecureSkipTLSVerify: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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: selfSignedCert,
|
||||
InsecureSkipTLSVerify: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -257,38 +215,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -146,11 +146,13 @@ func (handler *Handler) getAllKubernetesConfigMaps(r *http.Request) ([]models.K8
|
||||
}
|
||||
|
||||
if isUsed {
|
||||
err = cli.SetConfigMapsIsUsed(&configMaps)
|
||||
configMapsWithApplications, err := cli.CombineConfigMapsWithApplications(configMaps)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesConfigMaps").Msg("Unable to combine configMaps with associated applications")
|
||||
return nil, httperror.InternalServerError("Unable to combine configMaps with associated applications", err)
|
||||
}
|
||||
|
||||
return configMapsWithApplications, nil
|
||||
}
|
||||
|
||||
return configMaps, nil
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/portainer/portainer/pkg/libkubectl"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type describeResourceResponse struct {
|
||||
Describe string `json:"describe"`
|
||||
}
|
||||
|
||||
// @id DescribeResource
|
||||
// @summary Get a description of a kubernetes resource
|
||||
// @description Get a description of a kubernetes resource.
|
||||
// @description **Access policy**: Authenticated user.
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @param name query string true "Resource name"
|
||||
// @param kind query string true "Resource kind"
|
||||
// @param namespace query string false "Namespace"
|
||||
// @success 200 {object} describeResourceResponse "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."
|
||||
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
||||
// @failure 404 "Unable to find an environment with the specified identifier."
|
||||
// @failure 500 "Server error occurred while attempting to retrieve resource description"
|
||||
// @router /kubernetes/{id}/describe [get]
|
||||
func (handler *Handler) describeResource(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
name, err := request.RetrieveQueryParameter(r, "name", false)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter name")
|
||||
return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter name. Error: ", err)
|
||||
}
|
||||
|
||||
kind, err := request.RetrieveQueryParameter(r, "kind", false)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter kind")
|
||||
return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter kind. Error: ", err)
|
||||
}
|
||||
|
||||
namespace, err := request.RetrieveQueryParameter(r, "namespace", true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter namespace")
|
||||
return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter namespace. Error: ", err)
|
||||
}
|
||||
|
||||
// fetches the token and the correct server URL for the endpoint, similar to getHelmClusterAccess
|
||||
libKubectlAccess, err := handler.getLibKubectlAccess(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("an error occurred during the describeResource operation, failed to get libKubectlAccess. Error: ", err)
|
||||
}
|
||||
|
||||
client, err := libkubectl.NewClient(libKubectlAccess, namespace, "", true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "describeResource").Msg("Failed to create kubernetes client")
|
||||
return httperror.InternalServerError("an error occurred during the describeResource operation, failed to create kubernetes client. Error: ", err)
|
||||
}
|
||||
|
||||
out, err := client.Describe(namespace, name, kind)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "describeResource").Msg("Failed to describe kubernetes resource")
|
||||
return httperror.InternalServerError("an error occurred during the describeResource operation, failed to describe kubernetes resource. Error: ", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, describeResourceResponse{Describe: out})
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libkubectl"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -103,7 +102,6 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
||||
endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost)
|
||||
endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/cluster_role_bindings/delete", httperror.LoggerHandler(h.deleteClusterRoleBindings)).Methods(http.MethodPost)
|
||||
endpointRouter.Handle("/describe", httperror.LoggerHandler(h.describeResource)).Methods(http.MethodGet)
|
||||
|
||||
// namespaces
|
||||
// in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?)
|
||||
@@ -271,36 +269,3 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *Handler) getLibKubectlAccess(r *http.Request) (*libkubectl.ClientAccess, error) {
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
|
||||
bearerToken, _, err := handler.JwtService.GenerateToken(tokenData)
|
||||
if err != nil {
|
||||
return nil, httperror.Unauthorized("Unauthorized", err)
|
||||
}
|
||||
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to find the Kubernetes endpoint associated to the request.", err)
|
||||
}
|
||||
|
||||
sslSettings, err := handler.DataStore.SSLSettings().Settings()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err)
|
||||
}
|
||||
|
||||
hostURL := "localhost"
|
||||
if !sslSettings.SelfSigned {
|
||||
hostURL = r.Host
|
||||
}
|
||||
|
||||
kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(hostURL, endpoint.ID, true)
|
||||
return &libkubectl.ClientAccess{
|
||||
Token: bearerToken,
|
||||
ServerUrl: kubeConfigInternal.ClusterServerURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -130,11 +130,13 @@ func (handler *Handler) getAllKubernetesSecrets(r *http.Request) ([]models.K8sSe
|
||||
}
|
||||
|
||||
if isUsed {
|
||||
err = cli.SetSecretsIsUsed(&secrets)
|
||||
secretsWithApplications, err := cli.CombineSecretsWithApplications(secrets)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "GetAllKubernetesSecrets").Msg("Unable to combine secrets with associated applications")
|
||||
return nil, httperror.InternalServerError("unable to combine secrets with associated applications. Error: ", err)
|
||||
}
|
||||
|
||||
return secretsWithApplications, nil
|
||||
}
|
||||
|
||||
return secrets, nil
|
||||
|
||||
@@ -46,7 +46,7 @@ type settingsUpdatePayload struct {
|
||||
// Whether telemetry is enabled
|
||||
EnableTelemetry *bool `example:"false"`
|
||||
// Helm repository URL
|
||||
HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
|
||||
HelmRepositoryURL *string `example:"https://kubernetes.github.io/ingress-nginx"`
|
||||
// Kubectl Shell Image
|
||||
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
|
||||
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
|
||||
|
||||
@@ -33,7 +33,7 @@ func (handler *Handler) systemNodesCount(w http.ResponseWriter, r *http.Request)
|
||||
var nodes int
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if err := snapshot.FillSnapshotData(handler.dataStore, &endpoint, false); err != nil {
|
||||
if err := snapshot.FillSnapshotData(handler.dataStore, &endpoint); err != nil {
|
||||
return httperror.InternalServerError("Unable to add snapshot data", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ func (payload *teamMembershipCreatePayload) Validate(r *http.Request) error {
|
||||
// @produce json
|
||||
// @param body body teamMembershipCreatePayload true "Team membership details"
|
||||
// @success 200 {object} portainer.TeamMembership "Success"
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied to manage memberships"
|
||||
// @failure 409 "Team membership already registered"
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
// @produce json
|
||||
// @param id path int true "Team identifier"
|
||||
// @success 200 {object} portainer.Team "Success"
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "Team not found"
|
||||
|
||||
@@ -30,6 +30,7 @@ func (payload *teamUpdatePayload) Validate(r *http.Request) error {
|
||||
// @param id path int true "Team identifier"
|
||||
// @param body body teamUpdatePayload true "Team details"
|
||||
// @success 200 {object} portainer.Team "Success"
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "Team not found"
|
||||
|
||||
@@ -52,12 +52,12 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
|
||||
teamLeaderRouter := h.NewRoute().Subrouter()
|
||||
teamLeaderRouter.Use(bouncer.TeamLeaderAccess)
|
||||
|
||||
authenticatedRouter := h.NewRoute().Subrouter()
|
||||
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
|
||||
|
||||
restrictedRouter := h.NewRoute().Subrouter()
|
||||
restrictedRouter.Use(bouncer.RestrictedAccess)
|
||||
|
||||
authenticatedRouter := h.NewRoute().Subrouter()
|
||||
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
|
||||
|
||||
publicRouter := h.NewRoute().Subrouter()
|
||||
publicRouter.Use(bouncer.PublicAccess)
|
||||
|
||||
@@ -65,6 +65,7 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
|
||||
restrictedRouter.Handle("/users", httperror.LoggerHandler(h.userList)).Methods(http.MethodGet)
|
||||
|
||||
authenticatedRouter.Handle("/users/me", httperror.LoggerHandler(h.userInspectMe)).Methods(http.MethodGet)
|
||||
restrictedRouter.Handle("/users/me", httperror.LoggerHandler(h.userInspectMe)).Methods(http.MethodGet)
|
||||
restrictedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userInspect)).Methods(http.MethodGet)
|
||||
authenticatedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userUpdate)).Methods(http.MethodPut)
|
||||
adminRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userDelete)).Methods(http.MethodDelete)
|
||||
|
||||
@@ -50,7 +50,7 @@ type accessTokenResponse struct {
|
||||
// @produce json
|
||||
// @param id path int true "User identifier"
|
||||
// @param body body userAccessTokenCreatePayload true "details"
|
||||
// @success 200 {object} accessTokenResponse "Success"
|
||||
// @success 200 {object} accessTokenResponse "Created"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 401 "Unauthorized"
|
||||
// @failure 403 "Permission denied"
|
||||
@@ -115,7 +115,7 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
|
||||
return httperror.InternalServerError("Internal Server Error", err)
|
||||
}
|
||||
|
||||
return response.JSONWithStatus(w, accessTokenResponse{rawAPIKey, *apiKey}, http.StatusOK)
|
||||
return response.JSONWithStatus(w, accessTokenResponse{rawAPIKey, *apiKey}, http.StatusCreated)
|
||||
}
|
||||
|
||||
func (handler *Handler) usesInternalAuthentication(userid portainer.UserID) (bool, error) {
|
||||
|
||||
@@ -60,7 +60,7 @@ func Test_userCreateAccessToken(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusOK, rr.Code)
|
||||
is.Equal(http.StatusCreated, rr.Code)
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
is.NoError(err, "ReadAll should not return error")
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func WithPanicLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Error().
|
||||
Any("panic", err).
|
||||
Str("method", req.Method).
|
||||
Str("url", req.URL.String()).
|
||||
Str("stack", string(debug.Stack())).
|
||||
Msg("Panic in request handler")
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
@@ -35,8 +35,8 @@ type (
|
||||
}
|
||||
|
||||
K8sServiceIngress struct {
|
||||
IP string `json:"IP"`
|
||||
Hostname string `json:"Hostname"`
|
||||
IP string `json:"IP"`
|
||||
Host string `json:"Host"`
|
||||
}
|
||||
|
||||
// K8sServiceDeleteRequests is a mapping of namespace names to a slice of
|
||||
|
||||
@@ -224,7 +224,7 @@ func (transport *Transport) getDockerID() (string, error) {
|
||||
if transport.snapshotService != nil {
|
||||
endpoint := portainer.Endpoint{ID: transport.endpoint.ID}
|
||||
|
||||
if err := transport.snapshotService.FillSnapshotData(&endpoint, true); err == nil && len(endpoint.Snapshots) > 0 {
|
||||
if err := transport.snapshotService.FillSnapshotData(&endpoint); err == nil && len(endpoint.Snapshots) > 0 {
|
||||
if dockerID, err := snapshot.FetchDockerID(endpoint.Snapshots[0]); err == nil {
|
||||
transport.dockerID = dockerID
|
||||
return dockerID, nil
|
||||
|
||||
+3
-3
@@ -67,7 +67,7 @@ import (
|
||||
"github.com/portainer/portainer/api/platform"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
|
||||
"github.com/portainer/portainer/pkg/libhelm"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -103,7 +103,7 @@ type Server struct {
|
||||
DockerClientFactory *dockerclient.ClientFactory
|
||||
KubernetesClientFactory *cli.ClientFactory
|
||||
KubernetesDeployer portainer.KubernetesDeployer
|
||||
HelmPackageManager libhelmtypes.HelmPackageManager
|
||||
HelmPackageManager libhelm.HelmPackageManager
|
||||
Scheduler *scheduler.Scheduler
|
||||
ShutdownCtx context.Context
|
||||
ShutdownTrigger context.CancelFunc
|
||||
@@ -337,7 +337,7 @@ func (server *Server) Start() error {
|
||||
|
||||
handler := adminMonitor.WithRedirect(offlineGate.WaitingMiddleware(time.Minute, server.Handler))
|
||||
|
||||
handler = middlewares.WithPanicLogger(middlewares.WithSlowRequestsLogger(handler))
|
||||
handler = middlewares.WithSlowRequestsLogger(handler)
|
||||
|
||||
handler, err := csrf.WithProtect(handler)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package edge
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
@@ -15,19 +13,21 @@ func EdgeGroupRelatedEndpoints(edgeGroup *portainer.EdgeGroup, endpoints []porta
|
||||
return edgeGroup.Endpoints
|
||||
}
|
||||
|
||||
endpointGroupsMap := map[portainer.EndpointGroupID]*portainer.EndpointGroup{}
|
||||
for i, group := range endpointGroups {
|
||||
endpointGroupsMap[group.ID] = &endpointGroups[i]
|
||||
}
|
||||
|
||||
endpointIDs := []portainer.EndpointID{}
|
||||
for _, endpoint := range endpoints {
|
||||
if !endpointutils.IsEdgeEndpoint(&endpoint) {
|
||||
continue
|
||||
}
|
||||
|
||||
endpointGroup := endpointGroupsMap[endpoint.GroupID]
|
||||
if edgeGroupRelatedToEndpoint(edgeGroup, &endpoint, endpointGroup) {
|
||||
var endpointGroup portainer.EndpointGroup
|
||||
for _, group := range endpointGroups {
|
||||
if endpoint.GroupID == group.ID {
|
||||
endpointGroup = group
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if edgeGroupRelatedToEndpoint(edgeGroup, &endpoint, &endpointGroup) {
|
||||
endpointIDs = append(endpointIDs, endpoint.ID)
|
||||
}
|
||||
}
|
||||
@@ -72,11 +72,17 @@ func GetEndpointsFromEdgeGroups(edgeGroupIDs []portainer.EdgeGroupID, datastore
|
||||
// edgeGroupRelatedToEndpoint returns true if edgeGroup is associated with environment(endpoint)
|
||||
func edgeGroupRelatedToEndpoint(edgeGroup *portainer.EdgeGroup, endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup) bool {
|
||||
if !edgeGroup.Dynamic {
|
||||
return slices.Contains(edgeGroup.Endpoints, endpoint.ID)
|
||||
for _, endpointID := range edgeGroup.Endpoints {
|
||||
if endpoint.ID == endpointID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
endpointTags := tag.Set(endpoint.TagIDs)
|
||||
if endpointGroup != nil && endpointGroup.TagIDs != nil {
|
||||
if endpointGroup.TagIDs != nil {
|
||||
endpointTags = tag.Union(endpointTags, tag.Set(endpointGroup.TagIDs))
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
edgetypes "github.com/portainer/portainer/api/internal/edge/types"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@@ -99,15 +100,12 @@ func (service *Service) PersistEdgeStack(
|
||||
stack.ManifestPath = manifestPath
|
||||
stack.ProjectPath = projectPath
|
||||
stack.EntryPoint = composePath
|
||||
stack.NumDeployments = len(relatedEndpointIds)
|
||||
|
||||
if err := tx.EdgeStack().Create(stack.ID, stack); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEndpointIds, stack.ID); err != nil {
|
||||
return nil, fmt.Errorf("unable to add endpoint relations: %w", err)
|
||||
}
|
||||
|
||||
if err := service.updateEndpointRelations(tx, stack.ID, relatedEndpointIds); err != nil {
|
||||
return nil, fmt.Errorf("unable to update endpoint relations: %w", err)
|
||||
}
|
||||
@@ -150,8 +148,25 @@ func (service *Service) DeleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
|
||||
return errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
|
||||
}
|
||||
|
||||
if err := tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEndpointIds, edgeStackID); err != nil {
|
||||
return errors.WithMessage(err, "unable to remove environment relation in database")
|
||||
for _, endpointID := range relatedEndpointIds {
|
||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
log.Warn().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Msg("Unable to find endpoint relation in database, skipping")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return errors.WithMessage(err, "Unable to find environment relation in database")
|
||||
}
|
||||
|
||||
delete(relation.EdgeStacks, edgeStackID)
|
||||
|
||||
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
|
||||
return errors.WithMessage(err, "Unable to persist environment relation in database")
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.EdgeStack().DeleteEdgeStack(edgeStackID); err != nil {
|
||||
|
||||
@@ -170,8 +170,8 @@ func (service *Service) Create(snapshot portainer.Snapshot) error {
|
||||
return service.dataStore.Snapshot().Create(&snapshot)
|
||||
}
|
||||
|
||||
func (service *Service) FillSnapshotData(endpoint *portainer.Endpoint, includeRaw bool) error {
|
||||
return FillSnapshotData(service.dataStore, endpoint, includeRaw)
|
||||
func (service *Service) FillSnapshotData(endpoint *portainer.Endpoint) error {
|
||||
return FillSnapshotData(service.dataStore, endpoint)
|
||||
}
|
||||
|
||||
func (service *Service) snapshotKubernetesEndpoint(endpoint *portainer.Endpoint) error {
|
||||
@@ -328,16 +328,8 @@ func FetchDockerID(snapshot portainer.DockerSnapshot) (string, error) {
|
||||
return info.Swarm.Cluster.ID, nil
|
||||
}
|
||||
|
||||
func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, includeRaw bool) error {
|
||||
var snapshot *portainer.Snapshot
|
||||
var err error
|
||||
|
||||
if includeRaw {
|
||||
snapshot, err = tx.Snapshot().Read(endpoint.ID)
|
||||
} else {
|
||||
snapshot, err = tx.Snapshot().ReadWithoutSnapshotRaw(endpoint.ID)
|
||||
}
|
||||
|
||||
func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error {
|
||||
snapshot, err := tx.Snapshot().Read(endpoint.ID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{}
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices/errors"
|
||||
)
|
||||
|
||||
var _ dataservices.DataStore = &testDatastore{}
|
||||
|
||||
type testDatastore struct {
|
||||
customTemplate dataservices.CustomTemplateService
|
||||
edgeGroup dataservices.EdgeGroupService
|
||||
@@ -110,11 +108,9 @@ type datastoreOption = func(d *testDatastore)
|
||||
func NewDatastore(options ...datastoreOption) *testDatastore {
|
||||
conn, _ := database.NewDatabase("boltdb", "", nil)
|
||||
d := testDatastore{connection: conn}
|
||||
|
||||
for _, o := range options {
|
||||
o(&d)
|
||||
}
|
||||
|
||||
return &d
|
||||
}
|
||||
|
||||
@@ -130,7 +126,6 @@ func (s *stubSettingsService) Settings() (*portainer.Settings, error) {
|
||||
|
||||
func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error {
|
||||
s.settings = settings
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -143,16 +138,20 @@ func WithSettingsService(settings *portainer.Settings) datastoreOption {
|
||||
}
|
||||
|
||||
type stubUserService struct {
|
||||
dataservices.UserService
|
||||
|
||||
users []portainer.User
|
||||
}
|
||||
|
||||
func (s *stubUserService) BucketName() string { return "users" }
|
||||
func (s *stubUserService) ReadAll() ([]portainer.User, error) { return s.users, nil }
|
||||
func (s *stubUserService) BucketName() string { return "users" }
|
||||
func (s *stubUserService) Read(ID portainer.UserID) (*portainer.User, error) { return nil, nil }
|
||||
func (s *stubUserService) UserByUsername(username string) (*portainer.User, error) { return nil, nil }
|
||||
func (s *stubUserService) ReadAll() ([]portainer.User, error) { return s.users, nil }
|
||||
func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
|
||||
return s.users, nil
|
||||
}
|
||||
func (s *stubUserService) Create(user *portainer.User) error { return nil }
|
||||
func (s *stubUserService) Update(ID portainer.UserID, user *portainer.User) error { return nil }
|
||||
func (s *stubUserService) Delete(ID portainer.UserID) error { return nil }
|
||||
func (s *stubUserService) Exists(ID portainer.UserID) (bool, error) { return false, nil }
|
||||
|
||||
// WithUsers testDatastore option that will instruct testDatastore to return provided users
|
||||
func WithUsers(us []portainer.User) datastoreOption {
|
||||
@@ -162,13 +161,35 @@ func WithUsers(us []portainer.User) datastoreOption {
|
||||
}
|
||||
|
||||
type stubEdgeJobService struct {
|
||||
dataservices.EdgeJobService
|
||||
|
||||
jobs []portainer.EdgeJob
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) BucketName() string { return "edgejobs" }
|
||||
func (s *stubEdgeJobService) ReadAll() ([]portainer.EdgeJob, error) { return s.jobs, nil }
|
||||
func (s *stubEdgeJobService) Read(ID portainer.EdgeJobID) (*portainer.EdgeJob, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) Create(edgeJob *portainer.EdgeJob) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) CreateWithID(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) Update(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) UpdateEdgeJobFunc(ID portainer.EdgeJobID, updateFunc func(edgeJob *portainer.EdgeJob)) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubEdgeJobService) Delete(ID portainer.EdgeJobID) error { return nil }
|
||||
func (s *stubEdgeJobService) GetNextIdentifier() int { return 0 }
|
||||
func (s *stubEdgeJobService) Exists(ID portainer.EdgeJobID) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// WithEdgeJobs option will instruct testDatastore to return provided jobs
|
||||
func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
|
||||
@@ -178,8 +199,6 @@ func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
|
||||
}
|
||||
|
||||
type stubEndpointRelationService struct {
|
||||
dataservices.EndpointRelationService
|
||||
|
||||
relations []portainer.EndpointRelation
|
||||
}
|
||||
|
||||
@@ -198,6 +217,10 @@ func (s *stubEndpointRelationService) EndpointRelation(ID portainer.EndpointID)
|
||||
return nil, errors.ErrObjectNotFound
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) Create(EndpointRelation *portainer.EndpointRelation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.EndpointID, relation *portainer.EndpointRelation) error {
|
||||
for i, r := range s.relations {
|
||||
if r.EndpointID == ID {
|
||||
@@ -208,29 +231,10 @@ func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.Endpoi
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
for _, endpointID := range endpointIDs {
|
||||
for i, r := range s.relations {
|
||||
if r.EndpointID == endpointID {
|
||||
s.relations[i].EdgeStacks[edgeStackID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
for _, endpointID := range endpointIDs {
|
||||
for i, r := range s.relations {
|
||||
if r.EndpointID == endpointID {
|
||||
delete(s.relations[i].EdgeStacks, edgeStackID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) DeleteEndpointRelation(ID portainer.EndpointID) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubEndpointRelationService) GetNextIdentifier() int { return 0 }
|
||||
|
||||
// WithEndpointRelations option will instruct testDatastore to return provided jobs
|
||||
func WithEndpointRelations(relations []portainer.EndpointRelation) datastoreOption {
|
||||
@@ -330,7 +334,6 @@ func (s *stubEndpointService) EndpointsByTeamID(teamID portainer.TeamID) ([]port
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return endpoints, nil
|
||||
}
|
||||
|
||||
@@ -342,19 +345,29 @@ func WithEndpoints(endpoints []portainer.Endpoint) datastoreOption {
|
||||
}
|
||||
|
||||
type stubStacksService struct {
|
||||
dataservices.StackService
|
||||
stacks []portainer.Stack
|
||||
}
|
||||
|
||||
func (s *stubStacksService) BucketName() string { return "stacks" }
|
||||
|
||||
func (s *stubStacksService) Create(stack *portainer.Stack) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubStacksService) Update(ID portainer.StackID, stack *portainer.Stack) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubStacksService) Delete(ID portainer.StackID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubStacksService) Read(ID portainer.StackID) (*portainer.Stack, error) {
|
||||
for _, stack := range s.stacks {
|
||||
if stack.ID == ID {
|
||||
return &stack, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.ErrObjectNotFound
|
||||
}
|
||||
|
||||
@@ -370,7 +383,6 @@ func (s *stubStacksService) StacksByEndpointID(endpointID portainer.EndpointID)
|
||||
result = append(result, stack)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -382,7 +394,6 @@ func (s *stubStacksService) RefreshableStacks() ([]portainer.Stack, error) {
|
||||
result = append(result, stack)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -392,7 +403,6 @@ func (s *stubStacksService) StackByName(name string) (*portainer.Stack, error) {
|
||||
return &stack, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.ErrObjectNotFound
|
||||
}
|
||||
|
||||
@@ -404,7 +414,6 @@ func (s *stubStacksService) StacksByName(name string) ([]portainer.Stack, error)
|
||||
result = append(result, stack)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -414,7 +423,6 @@ func (s *stubStacksService) StackByWebhookID(webhookID string) (*portainer.Stack
|
||||
return &stack, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.ErrObjectNotFound
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,46 @@ func (kcl *KubeClient) GetApplicationsResource(namespace, node string) (models.K
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
// GetApplicationsFromConfigMap gets a list of applications that use a specific ConfigMap
|
||||
// by checking all pods in the same namespace as the ConfigMap
|
||||
func (kcl *KubeClient) GetApplicationNamesFromConfigMap(configMap models.K8sConfigMap, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]string, error) {
|
||||
applications := []string{}
|
||||
for _, pod := range pods {
|
||||
if pod.Namespace == configMap.Namespace {
|
||||
if isPodUsingConfigMap(&pod, configMap.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applications = append(applications, application.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return applications, nil
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]string, error) {
|
||||
applications := []string{}
|
||||
for _, pod := range pods {
|
||||
if pod.Namespace == secret.Namespace {
|
||||
if isPodUsingSecret(&pod, secret.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applications = append(applications, application.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return applications, nil
|
||||
}
|
||||
|
||||
// ConvertPodToApplication converts a pod to an application, updating owner references if necessary
|
||||
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, portainerApplicationResources PortainerApplicationResources, withResource bool) (*models.K8sApplication, error) {
|
||||
if isReplicaSetOwner(pod) {
|
||||
@@ -433,23 +473,23 @@ func (kcl *KubeClient) GetApplicationFromServiceSelector(pods []corev1.Pod, serv
|
||||
func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap models.K8sConfigMap, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]models.K8sConfigurationOwnerResource, error) {
|
||||
configurationOwners := []models.K8sConfigurationOwnerResource{}
|
||||
for _, pod := range pods {
|
||||
if isPodUsingConfigMap(&pod, configMap) {
|
||||
kind := "Pod"
|
||||
name := pod.Name
|
||||
if pod.Namespace == configMap.Namespace {
|
||||
if isPodUsingConfigMap(&pod, configMap.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(pod.OwnerReferences) > 0 {
|
||||
kind = pod.OwnerReferences[0].Kind
|
||||
name = pod.OwnerReferences[0].Name
|
||||
if application != nil {
|
||||
configurationOwners = append(configurationOwners, models.K8sConfigurationOwnerResource{
|
||||
Name: application.Name,
|
||||
ResourceKind: application.Kind,
|
||||
Id: application.UID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if isReplicaSetOwner(pod) {
|
||||
updateOwnerReferenceToDeployment(&pod, replicaSets)
|
||||
}
|
||||
|
||||
configurationOwners = append(configurationOwners, models.K8sConfigurationOwnerResource{
|
||||
Name: name,
|
||||
ResourceKind: kind,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,23 +501,23 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap
|
||||
func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models.K8sSecret, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]models.K8sConfigurationOwnerResource, error) {
|
||||
configurationOwners := []models.K8sConfigurationOwnerResource{}
|
||||
for _, pod := range pods {
|
||||
if isPodUsingSecret(&pod, secret) {
|
||||
kind := "Pod"
|
||||
name := pod.Name
|
||||
if pod.Namespace == secret.Namespace {
|
||||
if isPodUsingSecret(&pod, secret.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(pod.OwnerReferences) > 0 {
|
||||
kind = pod.OwnerReferences[0].Kind
|
||||
name = pod.OwnerReferences[0].Name
|
||||
if application != nil {
|
||||
configurationOwners = append(configurationOwners, models.K8sConfigurationOwnerResource{
|
||||
Name: application.Name,
|
||||
ResourceKind: application.Kind,
|
||||
Id: application.UID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if isReplicaSetOwner(pod) {
|
||||
updateOwnerReferenceToDeployment(&pod, replicaSets)
|
||||
}
|
||||
|
||||
configurationOwners = append(configurationOwners, models.K8sConfigurationOwnerResource{
|
||||
Name: name,
|
||||
ResourceKind: kind,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
@@ -96,28 +95,35 @@ func parseConfigMap(configMap *corev1.ConfigMap, withData bool) models.K8sConfig
|
||||
return result
|
||||
}
|
||||
|
||||
// SetConfigMapsIsUsed combines the config maps with the applications that use them.
|
||||
// CombineConfigMapsWithApplications combines the config maps with the applications that use them.
|
||||
// the function fetches all the pods and replica sets in the cluster and checks if the config map is used by any of the pods.
|
||||
// if the config map is used by a pod, the application that uses the pod is added to the config map.
|
||||
// otherwise, the config map is returned as is.
|
||||
func (kcl *KubeClient) SetConfigMapsIsUsed(configMaps *[]models.K8sConfigMap) error {
|
||||
func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8sConfigMap) ([]models.K8sConfigMap, error) {
|
||||
updatedConfigMaps := make([]models.K8sConfigMap, len(configMaps))
|
||||
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("an error occurred during the SetConfigMapsIsUsed operation, unable to fetch Portainer application resources. Error: %w", err)
|
||||
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
|
||||
}
|
||||
|
||||
for i := range *configMaps {
|
||||
configMap := &(*configMaps)[i]
|
||||
for index, configMap := range configMaps {
|
||||
updatedConfigMap := configMap
|
||||
|
||||
for _, pod := range portainerApplicationResources.Pods {
|
||||
if isPodUsingConfigMap(&pod, *configMap) {
|
||||
configMap.IsUsed = true
|
||||
break
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
if len(applicationConfigurationOwners) > 0 {
|
||||
updatedConfigMap.ConfigurationOwnerResources = applicationConfigurationOwners
|
||||
updatedConfigMap.IsUsed = true
|
||||
}
|
||||
|
||||
updatedConfigMaps[index] = updatedConfigMap
|
||||
}
|
||||
|
||||
return nil
|
||||
return updatedConfigMaps, nil
|
||||
}
|
||||
|
||||
// CombineConfigMapWithApplications combines the config map with the applications that use it.
|
||||
@@ -135,22 +141,20 @@ func (kcl *KubeClient) CombineConfigMapWithApplications(configMap models.K8sConf
|
||||
break
|
||||
}
|
||||
|
||||
var replicaSets *appsv1.ReplicaSetList
|
||||
if containsReplicaSetOwner {
|
||||
replicaSets, err = kcl.cli.AppsV1().ReplicaSets(configMap.Namespace).List(context.Background(), metav1.ListOptions{})
|
||||
replicaSets, err := kcl.cli.AppsV1().ReplicaSets(configMap.Namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return models.K8sConfigMap{}, fmt.Errorf("an error occurred during the CombineConfigMapWithApplications operation, unable to get replica sets. Error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, pods.Items, replicaSets.Items)
|
||||
if err != nil {
|
||||
return models.K8sConfigMap{}, fmt.Errorf("an error occurred during the CombineConfigMapWithApplications operation, unable to get applications from config map. Error: %w", err)
|
||||
}
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, pods.Items, replicaSets.Items)
|
||||
if err != nil {
|
||||
return models.K8sConfigMap{}, fmt.Errorf("an error occurred during the CombineConfigMapWithApplications operation, unable to get applications from config map. Error: %w", err)
|
||||
}
|
||||
|
||||
if len(applicationConfigurationOwners) > 0 {
|
||||
configMap.ConfigurationOwnerResources = applicationConfigurationOwners
|
||||
configMap.IsUsed = true
|
||||
if len(applicationConfigurationOwners) > 0 {
|
||||
configMap.ConfigurationOwnerResources = applicationConfigurationOwners
|
||||
}
|
||||
}
|
||||
|
||||
return configMap, nil
|
||||
|
||||
@@ -265,12 +265,9 @@ func isSystemNamespace(namespace *corev1.Namespace) bool {
|
||||
return systemLabelValue == "true"
|
||||
}
|
||||
|
||||
return isSystemDefaultNamespace(namespace.Name)
|
||||
}
|
||||
|
||||
func isSystemDefaultNamespace(namespace string) bool {
|
||||
systemNamespaces := defaultSystemNamespaces()
|
||||
_, isSystem := systemNamespaces[namespace]
|
||||
|
||||
_, isSystem := systemNamespaces[namespace.Name]
|
||||
return isSystem
|
||||
}
|
||||
|
||||
@@ -393,9 +390,7 @@ 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 {
|
||||
if !isSystemDefaultNamespace(namespace) {
|
||||
nonAdminNamespaceSet[namespace] = struct{}{}
|
||||
}
|
||||
nonAdminNamespaceSet[namespace] = struct{}{}
|
||||
}
|
||||
|
||||
return nonAdminNamespaceSet
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -236,20 +235,16 @@ func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podLi
|
||||
}
|
||||
|
||||
// isPodUsingConfigMap checks if a pod is using a specific ConfigMap
|
||||
func isPodUsingConfigMap(pod *corev1.Pod, configMap models.K8sConfigMap) bool {
|
||||
if pod.Namespace != configMap.Namespace {
|
||||
return false
|
||||
}
|
||||
|
||||
func isPodUsingConfigMap(pod *corev1.Pod, configMapName string) bool {
|
||||
for _, volume := range pod.Spec.Volumes {
|
||||
if volume.ConfigMap != nil && volume.ConfigMap.Name == configMap.Name {
|
||||
if volume.ConfigMap != nil && volume.ConfigMap.Name == configMapName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, container := range pod.Spec.Containers {
|
||||
for _, env := range container.Env {
|
||||
if env.ValueFrom != nil && env.ValueFrom.ConfigMapKeyRef != nil && env.ValueFrom.ConfigMapKeyRef.Name == configMap.Name {
|
||||
if env.ValueFrom != nil && env.ValueFrom.ConfigMapKeyRef != nil && env.ValueFrom.ConfigMapKeyRef.Name == configMapName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -259,20 +254,16 @@ func isPodUsingConfigMap(pod *corev1.Pod, configMap models.K8sConfigMap) bool {
|
||||
}
|
||||
|
||||
// isPodUsingSecret checks if a pod is using a specific Secret
|
||||
func isPodUsingSecret(pod *corev1.Pod, secret models.K8sSecret) bool {
|
||||
if pod.Namespace != secret.Namespace {
|
||||
return false
|
||||
}
|
||||
|
||||
func isPodUsingSecret(pod *corev1.Pod, secretName string) bool {
|
||||
for _, volume := range pod.Spec.Volumes {
|
||||
if volume.Secret != nil && volume.Secret.SecretName == secret.Name {
|
||||
if volume.Secret != nil && volume.Secret.SecretName == secretName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, container := range pod.Spec.Containers {
|
||||
for _, env := range container.Env {
|
||||
if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil && env.ValueFrom.SecretKeyRef.Name == secret.Name {
|
||||
if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil && env.ValueFrom.SecretKeyRef.Name == secretName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/rs/zerolog/log"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -112,28 +111,34 @@ func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret {
|
||||
return result
|
||||
}
|
||||
|
||||
// SetSecretsIsUsed combines the secrets with the applications that use them.
|
||||
// CombineSecretsWithApplications combines the secrets with the applications that use them.
|
||||
// the function fetches all the pods and replica sets in the cluster and checks if the secret is used by any of the pods.
|
||||
// if the secret is used by a pod, the application that uses the pod is added to the secret.
|
||||
// otherwise, the secret is returned as is.
|
||||
func (kcl *KubeClient) SetSecretsIsUsed(secrets *[]models.K8sSecret) error {
|
||||
func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret) ([]models.K8sSecret, error) {
|
||||
updatedSecrets := make([]models.K8sSecret, len(secrets))
|
||||
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("an error occurred during the SetSecretsIsUsed operation, unable to fetch Portainer application resources. Error: %w", err)
|
||||
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
|
||||
}
|
||||
|
||||
for i := range *secrets {
|
||||
secret := &(*secrets)[i]
|
||||
for index, secret := range secrets {
|
||||
updatedSecret := secret
|
||||
|
||||
for _, pod := range portainerApplicationResources.Pods {
|
||||
if isPodUsingSecret(&pod, *secret) {
|
||||
secret.IsUsed = true
|
||||
break
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
if len(applicationConfigurationOwners) > 0 {
|
||||
updatedSecret.ConfigurationOwnerResources = applicationConfigurationOwners
|
||||
}
|
||||
|
||||
updatedSecrets[index] = updatedSecret
|
||||
}
|
||||
|
||||
return nil
|
||||
return updatedSecrets, nil
|
||||
}
|
||||
|
||||
// CombineSecretWithApplications combines the secret with the applications that use it.
|
||||
@@ -151,22 +156,20 @@ func (kcl *KubeClient) CombineSecretWithApplications(secret models.K8sSecret) (m
|
||||
break
|
||||
}
|
||||
|
||||
var replicaSets *appsv1.ReplicaSetList
|
||||
if containsReplicaSetOwner {
|
||||
replicaSets, err = kcl.cli.AppsV1().ReplicaSets(secret.Namespace).List(context.Background(), metav1.ListOptions{})
|
||||
replicaSets, err := kcl.cli.AppsV1().ReplicaSets(secret.Namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return models.K8sSecret{}, fmt.Errorf("an error occurred during the CombineSecretWithApplications operation, unable to get replica sets. Error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, pods.Items, replicaSets.Items)
|
||||
if err != nil {
|
||||
return models.K8sSecret{}, fmt.Errorf("an error occurred during the CombineSecretWithApplications operation, unable to get applications from secret. Error: %w", err)
|
||||
}
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, pods.Items, replicaSets.Items)
|
||||
if err != nil {
|
||||
return models.K8sSecret{}, fmt.Errorf("an error occurred during the CombineSecretWithApplications operation, unable to get applications from secret. Error: %w", err)
|
||||
}
|
||||
|
||||
if len(applicationConfigurationOwners) > 0 {
|
||||
secret.ConfigurationOwnerResources = applicationConfigurationOwners
|
||||
secret.IsUsed = true
|
||||
if len(applicationConfigurationOwners) > 0 {
|
||||
secret.ConfigurationOwnerResources = applicationConfigurationOwners
|
||||
}
|
||||
}
|
||||
|
||||
return secret, nil
|
||||
|
||||
@@ -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,
|
||||
Hostname: status.Hostname,
|
||||
IP: status.IP,
|
||||
Host: 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.Hostname},
|
||||
corev1.LoadBalancerIngress{IP: i.IP, Hostname: i.Host},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,6 @@ func (service *kubeClusterAccessService) GetClusterDetails(hostURL string, endpo
|
||||
Str("host_URL", hostURL).
|
||||
Str("HTTPS_bind_address", service.httpsBindAddr).
|
||||
Str("base_URL", baseURL).
|
||||
Bool("is_internal", isInternal).
|
||||
Msg("kubeconfig")
|
||||
|
||||
clusterServerURL, err := url.JoinPath("https://", hostURL, baseURL, "/api/endpoints/", strconv.Itoa(int(endpointID)), "/kubernetes")
|
||||
|
||||
+16
-16
@@ -310,7 +310,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,omitempty"`
|
||||
ConfigHash string `json:"ConfigHash"`
|
||||
}
|
||||
|
||||
// EdgeStack represents an edge stack
|
||||
@@ -354,24 +354,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 `json:"ReadyRePullImage,omitempty"`
|
||||
ReadyRePullImage bool
|
||||
|
||||
// Deprecated
|
||||
Details *EdgeStackStatusDetails `json:"Details,omitempty"`
|
||||
Details EdgeStackStatusDetails
|
||||
// Deprecated
|
||||
Error string `json:"Error,omitempty"`
|
||||
Error string
|
||||
// Deprecated
|
||||
Type EdgeStackStatusType `json:"Type,omitempty"`
|
||||
Type EdgeStackStatusType `json:"Type"`
|
||||
}
|
||||
|
||||
// EdgeStackDeploymentStatus represents an edge stack deployment status
|
||||
EdgeStackDeploymentStatus struct {
|
||||
Time int64
|
||||
Type EdgeStackStatusType
|
||||
Error string `json:"Error,omitempty"`
|
||||
Error string
|
||||
// EE only feature
|
||||
RollbackTo *int `json:"RollbackTo,omitempty"`
|
||||
Version int `json:"Version,omitempty"`
|
||||
RollbackTo *int
|
||||
Version int `json:"Version,omitempty"`
|
||||
}
|
||||
|
||||
// EdgeStackStatusType represents an edge stack status type
|
||||
@@ -589,7 +589,7 @@ type (
|
||||
// User identifier
|
||||
UserID UserID `json:"UserId" example:"1"`
|
||||
// Helm repository URL
|
||||
URL string `json:"URL" example:"https://charts.bitnami.com/bitnami"`
|
||||
URL string `json:"URL" example:"https://kubernetes.github.io/ingress-nginx"`
|
||||
}
|
||||
|
||||
// QuayRegistryData represents data required for Quay registry to work
|
||||
@@ -985,8 +985,8 @@ type (
|
||||
KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"`
|
||||
// Whether telemetry is enabled
|
||||
EnableTelemetry bool `json:"EnableTelemetry" example:"false"`
|
||||
// Helm repository URL, defaults to "https://charts.bitnami.com/bitnami"
|
||||
HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"`
|
||||
// Helm repository URL, defaults to ""
|
||||
HelmRepositoryURL string `json:"HelmRepositoryURL"`
|
||||
// KubectlImage, defaults to portainer/kubectl-shell
|
||||
KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"`
|
||||
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
|
||||
@@ -1623,7 +1623,7 @@ type (
|
||||
Start()
|
||||
SetSnapshotInterval(snapshotInterval string) error
|
||||
SnapshotEndpoint(endpoint *Endpoint) error
|
||||
FillSnapshotData(endpoint *Endpoint, includeRaw bool) error
|
||||
FillSnapshotData(endpoint *Endpoint) error
|
||||
}
|
||||
|
||||
// SwarmStackManager represents a service to manage Swarm stacks
|
||||
@@ -1638,9 +1638,9 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.29.2"
|
||||
APIVersion = "2.27.6"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "STS"
|
||||
APIVersionSupport = "LTS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
@@ -1674,8 +1674,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 represents the URL to the official templates supported by Bitnami
|
||||
DefaultHelmRepositoryURL = "https://charts.bitnami.com/bitnami"
|
||||
// DefaultHelmrepositoryURL set to empty string until oci support is added
|
||||
DefaultHelmRepositoryURL = ""
|
||||
// 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
|
||||
|
||||
+6114
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { buildImageFullURIFromModel, imageContainsURL, fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
|
||||
import { buildImageFullURIFromModel, imageContainsURL } from '@/react/docker/images/utils';
|
||||
|
||||
angular.module('portainer.docker').factory('ImageHelper', ImageHelperFactory);
|
||||
function ImageHelperFactory() {
|
||||
@@ -18,12 +18,8 @@ function ImageHelperFactory() {
|
||||
* @param {PorImageRegistryModel} registry
|
||||
*/
|
||||
function createImageConfigForContainer(imageModel) {
|
||||
const fromImage = buildImageFullURIFromModel(imageModel);
|
||||
const { tag, repo } = fullURIIntoRepoAndTag(fromImage);
|
||||
return {
|
||||
fromImage,
|
||||
tag,
|
||||
repo,
|
||||
fromImage: buildImageFullURIFromModel(imageModel),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -207,9 +207,9 @@ angular.module('portainer.docker').controller('ContainerController', [
|
||||
async function commitContainerAsync() {
|
||||
$scope.config.commitInProgress = true;
|
||||
const registryModel = $scope.config.RegistryModel;
|
||||
const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
const imageConfig = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
try {
|
||||
await commitContainer(endpoint.Id, { container: $transition$.params().id, repo, tag });
|
||||
await commitContainer(endpoint.Id, { container: $transition$.params().id, repo: imageConfig.fromImage });
|
||||
Notifications.success('Image created', $transition$.params().id);
|
||||
$state.reload();
|
||||
} catch (err) {
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<por-switch-field
|
||||
checked="$ctrl.formValues.disableBindMountsForRegularUsers"
|
||||
name="'disableBindMountsForRegularUsers'"
|
||||
label="'Hide bind mounts for non-administrators'"
|
||||
label="'Disable bind mounts for non-administrators'"
|
||||
tooltip="'When enabled, regular users will not be able to use bind mounts when creating containers.'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
on-change="($ctrl.onChangeDisableBindMountsForRegularUsers)"
|
||||
@@ -79,7 +79,7 @@
|
||||
<por-switch-field
|
||||
checked="$ctrl.formValues.disablePrivilegedModeForRegularUsers"
|
||||
name="'disablePrivilegedModeForRegularUsers'"
|
||||
label="'Hide privileged mode for non-administrators'"
|
||||
label="'Disable privileged mode for non-administrators'"
|
||||
tooltip="'When enabled, regular users will not be able to use privileged mode when creating containers.'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
on-change="($ctrl.onChangeDisablePrivilegedModeForRegularUsers)"
|
||||
@@ -91,7 +91,7 @@
|
||||
<por-switch-field
|
||||
checked="$ctrl.formValues.disableHostNamespaceForRegularUsers"
|
||||
name="'disableHostNamespaceForRegularUsers'"
|
||||
label="'Hide the use of host PID 1 for non-administrators'"
|
||||
label="'Disable the use of host PID 1 for non-administrators'"
|
||||
tooltip="'Prevent users from accessing the host filesystem through the host PID namespace.'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
on-change="($ctrl.onChangeDisableHostNamespaceForRegularUsers)"
|
||||
@@ -103,7 +103,7 @@
|
||||
<por-switch-field
|
||||
checked="$ctrl.formValues.disableStackManagementForRegularUsers"
|
||||
name="'disableStackManagementForRegularUsers'"
|
||||
label="'Hide the use of Stacks for non-administrators'"
|
||||
label="'Disable the use of Stacks for non-administrators'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
on-change="($ctrl.onChangeDisableStackManagementForRegularUsers)"
|
||||
></por-switch-field>
|
||||
@@ -114,7 +114,7 @@
|
||||
<por-switch-field
|
||||
checked="$ctrl.formValues.disableDeviceMappingForRegularUsers"
|
||||
name="'disableDeviceMappingForRegularUsers'"
|
||||
label="'Hide device mappings for non-administrators'"
|
||||
label="'Disable device mappings for non-administrators'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
on-change="($ctrl.onChangeDisableDeviceMappingForRegularUsers)"
|
||||
></por-switch-field>
|
||||
@@ -125,7 +125,7 @@
|
||||
<por-switch-field
|
||||
checked="$ctrl.formValues.disableContainerCapabilitiesForRegularUsers"
|
||||
name="'disableContainerCapabilitiesForRegularUsers'"
|
||||
label="'Hide container capabilities for non-administrators'"
|
||||
label="'Disable container capabilities for non-administrators'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
on-change="($ctrl.onChangeDisableContainerCapabilitiesForRegularUsers)"
|
||||
></por-switch-field>
|
||||
@@ -136,7 +136,7 @@
|
||||
<por-switch-field
|
||||
checked="$ctrl.formValues.disableSysctlSettingForRegularUsers"
|
||||
name="'disableSysctlSettingForRegularUsers'"
|
||||
label="'Hide sysctl settings for non-administrators'"
|
||||
label="'Disable sysctl settings for non-administrators'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
on-change="($ctrl.onChangeDisableSysctlSettingForRegularUsers)"
|
||||
></por-switch-field>
|
||||
@@ -146,7 +146,7 @@
|
||||
<div class="form-group" ng-if="$ctrl.isContainerEditDisabled()">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
<pr-icon icon="'info'" mode="'primary'" class-name="'mr-0.5'"></pr-icon>
|
||||
Note: The recreate/duplicate/edit feature is currently hidden (for non-admin users) by one or more security settings.
|
||||
Note: The recreate/duplicate/edit feature is currently disabled (for non-admin users) by one or more security settings.
|
||||
</span>
|
||||
</div>
|
||||
<!-- !security -->
|
||||
|
||||
@@ -2,6 +2,7 @@ import _ from 'lodash-es';
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
import { fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
|
||||
|
||||
angular.module('portainer.docker').controller('ImageController', [
|
||||
'$async',
|
||||
@@ -70,7 +71,8 @@ angular.module('portainer.docker').controller('ImageController', [
|
||||
$scope.tagImage = function () {
|
||||
const registryModel = $scope.formValues.RegistryModel;
|
||||
|
||||
const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
const image = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
const { repo, tag } = fullURIIntoRepoAndTag(image.fromImage);
|
||||
|
||||
ImageService.tagImage($transition$.params().id, repo, tag)
|
||||
.then(function success() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
import { fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
|
||||
|
||||
angular.module('portainer.docker').controller('ImportImageController', [
|
||||
'$scope',
|
||||
@@ -33,7 +34,8 @@ angular.module('portainer.docker').controller('ImportImageController', [
|
||||
async function tagImage(id) {
|
||||
const registryModel = $scope.formValues.RegistryModel;
|
||||
if (registryModel.Image) {
|
||||
const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
const image = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
const { repo, tag } = fullURIIntoRepoAndTag(image.fromImage);
|
||||
try {
|
||||
await ImageService.tagImage(id, repo, tag);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,281 +1,274 @@
|
||||
<page-header title="'Service details'" breadcrumbs="[{label:'Services', link:'docker.services'}, service.Name]" reload="true"> </page-header>
|
||||
|
||||
<div ng-if="!isLoading">
|
||||
<div class="row">
|
||||
<div ng-if="isUpdating" class="col-lg-12 col-md-12 col-xs-12">
|
||||
<div class="alert alert-info" role="alert" id="service-update-alert">
|
||||
<p>This service is being updated. Editing this service is currently disabled.</p>
|
||||
<a ui-sref="docker.services.service({id: service.Id}, {reload: true})">Refresh to see if this service has finished updated.</a>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div ng-if="isUpdating" class="col-lg-12 col-md-12 col-xs-12">
|
||||
<div class="alert alert-info" role="alert" id="service-update-alert">
|
||||
<p>This service is being updated. Editing this service is currently disabled.</p>
|
||||
<a ui-sref="docker.services.service({id: service.Id}, {reload: true})">Refresh to see if this service has finished updated.</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-9 col-md-9 col-xs-9">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="shuffle" title-text="Service details"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="w-1/5">Name</td>
|
||||
<td ng-if="applicationState.endpoint.apiVersion <= 1.24">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="service.Name"
|
||||
ng-change="updateServiceAttribute(service, 'Name')"
|
||||
ng-disabled="isUpdating"
|
||||
data-cy="docker-service-edit-name"
|
||||
/>
|
||||
</td>
|
||||
<td ng-if="applicationState.endpoint.apiVersion >= 1.25"> {{ service.Name }} </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ID</td>
|
||||
<td> {{ service.Id }} </td>
|
||||
</tr>
|
||||
<tr ng-if="service.CreatedAt">
|
||||
<td>Created at</td>
|
||||
<td>{{ service.CreatedAt | getisodate }}</td>
|
||||
</tr>
|
||||
<tr ng-if="service.UpdatedAt">
|
||||
<td>Last updated at</td>
|
||||
<td>{{ service.UpdatedAt | getisodate }}</td>
|
||||
</tr>
|
||||
<tr ng-if="service.Version">
|
||||
<td>Version</td>
|
||||
<td>{{ service.Version }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Scheduling mode</td>
|
||||
<td>{{ service.Mode }}</td>
|
||||
</tr>
|
||||
<tr ng-if="service.Mode === 'replicated'">
|
||||
<td>Replicas</td>
|
||||
<td>
|
||||
<span ng-if="service.Mode === 'replicated'">
|
||||
<input
|
||||
class="input-sm"
|
||||
type="number"
|
||||
data-cy="docker-service-edit-replicas-input"
|
||||
ng-model="service.Replicas"
|
||||
ng-change="updateServiceAttribute(service, 'Replicas')"
|
||||
disable-authorization="DockerServiceUpdate"
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Image</td>
|
||||
<td>{{ service.Image }}</td>
|
||||
</tr>
|
||||
<tr ng-if="isAdmin && applicationState.endpoint.type !== 4">
|
||||
<td>
|
||||
<div class="inline-flex items-center">
|
||||
<div> Service webhook </div>
|
||||
<portainer-tooltip
|
||||
message="'Webhook (or callback URI) used to automate the update of this service. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this service.'"
|
||||
>
|
||||
</portainer-tooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex flex-wrap items-center">
|
||||
<por-switch-field
|
||||
label-class="'!mr-0'"
|
||||
checked="WebhookExists"
|
||||
disabled="disabledWebhookButton(WebhookExists)"
|
||||
on-change="(onWebhookChange)"
|
||||
></por-switch-field>
|
||||
<span ng-if="webhookURL">
|
||||
<span class="text-muted">{{ webhookURL | truncatelr }}</span>
|
||||
<button type="button" class="btn btn-sm btn-primary btn-sm space-left" ng-if="webhookURL" ng-click="copyWebhook()">
|
||||
<pr-icon icon="'copy'" class-name="'mr-1'"></pr-icon>
|
||||
Copy link
|
||||
</button>
|
||||
<span>
|
||||
<pr-icon id="copyNotification" icon="'check'" mode="'success'" style="display: none"></pr-icon>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr authorization="DockerServiceLogs, DockerServiceUpdate, DockerServiceDelete">
|
||||
<td colspan="2">
|
||||
<p class="small text-muted" authorization="DockerServiceUpdate">
|
||||
Note: you can only rollback one level of changes. Clicking the rollback button without making a new change will undo your previous rollback </p
|
||||
><div class="flex flex-wrap gap-x-2 gap-y-1">
|
||||
<a
|
||||
authorization="DockerServiceLogs"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.3"
|
||||
class="btn btn-primary btn-sm"
|
||||
type="button"
|
||||
ui-sref="docker.services.service.logs({id: service.Id})"
|
||||
>
|
||||
<pr-icon icon="'file-text'"></pr-icon>Service logs</a
|
||||
>
|
||||
<button
|
||||
authorization="DockerServiceUpdate"
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="state.updateInProgress || isUpdating"
|
||||
ng-click="forceUpdateService(service)"
|
||||
button-spinner="state.updateInProgress"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.25"
|
||||
>
|
||||
<span ng-hide="state.updateInProgress" class="vertical-center">
|
||||
<pr-icon icon="'refresh-cw'"></pr-icon>
|
||||
Update the service</span
|
||||
>
|
||||
<span ng-show="state.updateInProgress">Update in progress...</span>
|
||||
</button>
|
||||
<button
|
||||
authorization="DockerServiceUpdate"
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="state.rollbackInProgress || isUpdating"
|
||||
ng-click="rollbackService(service)"
|
||||
button-spinner="state.rollbackInProgress"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.25"
|
||||
>
|
||||
<span ng-hide="state.rollbackInProgress" class="vertical-center">
|
||||
<pr-icon icon="'rotate-ccw'"></pr-icon>
|
||||
Rollback the service</span
|
||||
>
|
||||
<span ng-show="state.rollbackInProgress">Rollback in progress...</span>
|
||||
</button>
|
||||
<button
|
||||
authorization="DockerServiceDelete"
|
||||
type="button"
|
||||
class="btn btn-danger btn-sm !ml-0"
|
||||
ng-disabled="state.deletionInProgress || isUpdating"
|
||||
ng-click="removeService()"
|
||||
button-spinner="state.deletionInProgress"
|
||||
>
|
||||
<span ng-hide="state.deletionInProgress" class="vertical-center">
|
||||
<pr-icon icon="'trash-2'"></pr-icon>
|
||||
Delete the service</span
|
||||
>
|
||||
<span ng-show="state.deletionInProgress">Deletion in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
<rd-widget-footer authorization="DockerServiceUpdate">
|
||||
<p class="small text-muted">
|
||||
Do you need help? View the Docker Service documentation <a href="https://docs.docker.com/engine/reference/commandline/service_update/" target="self">here</a>.
|
||||
</p>
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-primary" ng-disabled="!hasChanges(service, ['Mode', 'Replicas', 'Name', 'Webhooks'])" ng-click="updateService(service)"
|
||||
>Apply changes</button
|
||||
>
|
||||
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['Mode', 'Replicas', 'Name'])">Reset changes</a></li>
|
||||
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-footer>
|
||||
</rd-widget>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-3 col-xs-3">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="menu" title-text="Quick navigation"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<ul class="nav nav-pills nav-stacked">
|
||||
<li><a href ng-click="goToItem('service-env-variables')">Environment variables</a></li>
|
||||
<li><a href ng-click="goToItem('service-container-image')">Container image</a></li>
|
||||
<li><a href ng-click="goToItem('service-container-labels')">Container labels</a></li>
|
||||
<li><a href ng-click="goToItem('service-mounts')">Mounts</a></li>
|
||||
<li><a href ng-click="goToItem('service-network-specs')">Network & published ports</a></li>
|
||||
<li><a href ng-click="goToItem('service-resources')">Resource limits & reservations</a></li>
|
||||
<li><a href ng-click="goToItem('service-placement-constraints')">Placement constraints</a></li>
|
||||
<li ng-if="applicationState.endpoint.apiVersion >= 1.3"><a href ng-click="goToItem('service-placement-preferences')">Placement preferences</a></li>
|
||||
<li><a href ng-click="goToItem('service-restart-policy')">Restart policy</a></li>
|
||||
<li><a href ng-click="goToItem('service-update-config')">Update configuration</a></li>
|
||||
<li><a href ng-click="goToItem('service-logging')">Logging</a></li>
|
||||
<li><a href ng-click="goToItem('service-labels')">Service labels</a></li>
|
||||
<li><a href ng-click="goToItem('service-configs')">Configs</a></li>
|
||||
<li ng-if="applicationState.endpoint.apiVersion >= 1.25"><a href ng-click="goToItem('service-secrets')">Secrets</a></li>
|
||||
<li><a href ng-click="goToItem('service-tasks')">Tasks</a></li>
|
||||
</ul>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- access-control-panel -->
|
||||
<access-control-panel
|
||||
ng-if="service"
|
||||
resource-id="service.Id"
|
||||
resource-control="service.ResourceControl"
|
||||
resource-type="resourceType"
|
||||
on-update-success="(onUpdateResourceControlSuccess)"
|
||||
environment-id="endpoint.Id"
|
||||
>
|
||||
</access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="container-specs">Container specification</h3>
|
||||
<div id="service-container-spec" class="padding-top" ng-include="'app/docker/views/services/edit/includes/container-specs.html'"></div>
|
||||
<div id="service-container-image" class="padding-top" ng-include="'app/docker/views/services/edit/includes/image.html'"></div>
|
||||
<div id="service-env-variables" class="padding-top" ng-include="'app/docker/views/services/edit/includes/environmentvariables.html'"></div>
|
||||
<div id="service-container-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/containerlabels.html'"></div>
|
||||
<div id="service-mounts" class="padding-top" ng-include="'app/docker/views/services/edit/includes/mounts.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="service-network-specs">Networks & ports</h3>
|
||||
<div id="service-networks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/networks.html'"></div>
|
||||
|
||||
<docker-service-ports-mapping-field
|
||||
id="service-published-ports"
|
||||
class="block padding-top"
|
||||
values="formValues.ports"
|
||||
on-change="(onChangePorts)"
|
||||
has-changes="hasChanges(service, ['Ports'])"
|
||||
on-reset="(onResetPorts)"
|
||||
on-submit="(onSubmit)"
|
||||
></docker-service-ports-mapping-field>
|
||||
|
||||
<div id="service-hosts-entries" class="padding-top" ng-include="'app/docker/views/services/edit/includes/hosts.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="service-specs">Service specification</h3>
|
||||
<div id="service-resources" class="padding-top" ng-include="'app/docker/views/services/edit/includes/resources.html'"></div>
|
||||
<div id="service-placement-constraints" class="padding-top" ng-include="'app/docker/views/services/edit/includes/constraints.html'"></div>
|
||||
<div
|
||||
id="service-placement-preferences"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.3"
|
||||
class="padding-top"
|
||||
ng-include="'app/docker/views/services/edit/includes/placementPreferences.html'"
|
||||
></div>
|
||||
<div id="service-restart-policy" class="padding-top" ng-include="'app/docker/views/services/edit/includes/restart.html'"></div>
|
||||
<div id="service-update-config" class="padding-top" ng-include="'app/docker/views/services/edit/includes/updateconfig.html'"></div>
|
||||
<div id="service-logging" class="padding-top" ng-include="'app/docker/views/services/edit/includes/logging.html'"></div>
|
||||
<div id="service-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/servicelabels.html'"></div>
|
||||
<div id="service-configs" class="padding-top" ng-include="'app/docker/views/services/edit/includes/configs.html'"></div>
|
||||
<div id="service-secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" class="padding-top" ng-include="'app/docker/views/services/edit/includes/secrets.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="service-tasks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/tasks.html'"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-9 col-md-9 col-xs-9">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="shuffle" title-text="Service details"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="w-1/5">Name</td>
|
||||
<td ng-if="applicationState.endpoint.apiVersion <= 1.24">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="service.Name"
|
||||
ng-change="updateServiceAttribute(service, 'Name')"
|
||||
ng-disabled="isUpdating"
|
||||
data-cy="docker-service-edit-name"
|
||||
/>
|
||||
</td>
|
||||
<td ng-if="applicationState.endpoint.apiVersion >= 1.25"> {{ service.Name }} </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ID</td>
|
||||
<td> {{ service.Id }} </td>
|
||||
</tr>
|
||||
<tr ng-if="service.CreatedAt">
|
||||
<td>Created at</td>
|
||||
<td>{{ service.CreatedAt | getisodate }}</td>
|
||||
</tr>
|
||||
<tr ng-if="service.UpdatedAt">
|
||||
<td>Last updated at</td>
|
||||
<td>{{ service.UpdatedAt | getisodate }}</td>
|
||||
</tr>
|
||||
<tr ng-if="service.Version">
|
||||
<td>Version</td>
|
||||
<td>{{ service.Version }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Scheduling mode</td>
|
||||
<td>{{ service.Mode }}</td>
|
||||
</tr>
|
||||
<tr ng-if="service.Mode === 'replicated'">
|
||||
<td>Replicas</td>
|
||||
<td>
|
||||
<span ng-if="service.Mode === 'replicated'">
|
||||
<input
|
||||
class="input-sm"
|
||||
type="number"
|
||||
data-cy="docker-service-edit-replicas-input"
|
||||
ng-model="service.Replicas"
|
||||
ng-change="updateServiceAttribute(service, 'Replicas')"
|
||||
disable-authorization="DockerServiceUpdate"
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Image</td>
|
||||
<td>{{ service.Image }}</td>
|
||||
</tr>
|
||||
<tr ng-if="isAdmin && applicationState.endpoint.type !== 4">
|
||||
<td>
|
||||
<div class="inline-flex items-center">
|
||||
<div> Service webhook </div>
|
||||
<portainer-tooltip
|
||||
message="'Webhook (or callback URI) used to automate the update of this service. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this service.'"
|
||||
>
|
||||
</portainer-tooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex flex-wrap items-center">
|
||||
<por-switch-field label-class="'!mr-0'" checked="WebhookExists" disabled="disabledWebhookButton(WebhookExists)" on-change="(onWebhookChange)"></por-switch-field>
|
||||
<span ng-if="webhookURL">
|
||||
<span class="text-muted">{{ webhookURL | truncatelr }}</span>
|
||||
<button type="button" class="btn btn-sm btn-primary btn-sm space-left" ng-if="webhookURL" ng-click="copyWebhook()">
|
||||
<pr-icon icon="'copy'" class-name="'mr-1'"></pr-icon>
|
||||
Copy link
|
||||
</button>
|
||||
<span>
|
||||
<pr-icon id="copyNotification" icon="'check'" mode="'success'" style="display: none"></pr-icon>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr authorization="DockerServiceLogs, DockerServiceUpdate, DockerServiceDelete">
|
||||
<td colspan="2">
|
||||
<p class="small text-muted" authorization="DockerServiceUpdate">
|
||||
Note: you can only rollback one level of changes. Clicking the rollback button without making a new change will undo your previous rollback </p
|
||||
><div class="flex flex-wrap gap-x-2 gap-y-1">
|
||||
<a
|
||||
authorization="DockerServiceLogs"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.3"
|
||||
class="btn btn-primary btn-sm"
|
||||
type="button"
|
||||
ui-sref="docker.services.service.logs({id: service.Id})"
|
||||
>
|
||||
<pr-icon icon="'file-text'"></pr-icon>Service logs</a
|
||||
>
|
||||
<button
|
||||
authorization="DockerServiceUpdate"
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="state.updateInProgress || isUpdating"
|
||||
ng-click="forceUpdateService(service)"
|
||||
button-spinner="state.updateInProgress"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.25"
|
||||
>
|
||||
<span ng-hide="state.updateInProgress" class="vertical-center">
|
||||
<pr-icon icon="'refresh-cw'"></pr-icon>
|
||||
Update the service</span
|
||||
>
|
||||
<span ng-show="state.updateInProgress">Update in progress...</span>
|
||||
</button>
|
||||
<button
|
||||
authorization="DockerServiceUpdate"
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="state.rollbackInProgress || isUpdating"
|
||||
ng-click="rollbackService(service)"
|
||||
button-spinner="state.rollbackInProgress"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.25"
|
||||
>
|
||||
<span ng-hide="state.rollbackInProgress" class="vertical-center">
|
||||
<pr-icon icon="'rotate-ccw'"></pr-icon>
|
||||
Rollback the service</span
|
||||
>
|
||||
<span ng-show="state.rollbackInProgress">Rollback in progress...</span>
|
||||
</button>
|
||||
<button
|
||||
authorization="DockerServiceDelete"
|
||||
type="button"
|
||||
class="btn btn-danger btn-sm !ml-0"
|
||||
ng-disabled="state.deletionInProgress || isUpdating"
|
||||
ng-click="removeService()"
|
||||
button-spinner="state.deletionInProgress"
|
||||
>
|
||||
<span ng-hide="state.deletionInProgress" class="vertical-center">
|
||||
<pr-icon icon="'trash-2'"></pr-icon>
|
||||
Delete the service</span
|
||||
>
|
||||
<span ng-show="state.deletionInProgress">Deletion in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
<rd-widget-footer authorization="DockerServiceUpdate">
|
||||
<p class="small text-muted">
|
||||
Do you need help? View the Docker Service documentation <a href="https://docs.docker.com/engine/reference/commandline/service_update/" target="self">here</a>.
|
||||
</p>
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-primary" ng-disabled="!hasChanges(service, ['Mode', 'Replicas', 'Name', 'Webhooks'])" ng-click="updateService(service)"
|
||||
>Apply changes</button
|
||||
>
|
||||
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['Mode', 'Replicas', 'Name'])">Reset changes</a></li>
|
||||
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-footer>
|
||||
</rd-widget>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-3 col-xs-3">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="menu" title-text="Quick navigation"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<ul class="nav nav-pills nav-stacked">
|
||||
<li><a href ng-click="goToItem('service-env-variables')">Environment variables</a></li>
|
||||
<li><a href ng-click="goToItem('service-container-image')">Container image</a></li>
|
||||
<li><a href ng-click="goToItem('service-container-labels')">Container labels</a></li>
|
||||
<li><a href ng-click="goToItem('service-mounts')">Mounts</a></li>
|
||||
<li><a href ng-click="goToItem('service-network-specs')">Network & published ports</a></li>
|
||||
<li><a href ng-click="goToItem('service-resources')">Resource limits & reservations</a></li>
|
||||
<li><a href ng-click="goToItem('service-placement-constraints')">Placement constraints</a></li>
|
||||
<li ng-if="applicationState.endpoint.apiVersion >= 1.3"><a href ng-click="goToItem('service-placement-preferences')">Placement preferences</a></li>
|
||||
<li><a href ng-click="goToItem('service-restart-policy')">Restart policy</a></li>
|
||||
<li><a href ng-click="goToItem('service-update-config')">Update configuration</a></li>
|
||||
<li><a href ng-click="goToItem('service-logging')">Logging</a></li>
|
||||
<li><a href ng-click="goToItem('service-labels')">Service labels</a></li>
|
||||
<li><a href ng-click="goToItem('service-configs')">Configs</a></li>
|
||||
<li ng-if="applicationState.endpoint.apiVersion >= 1.25"><a href ng-click="goToItem('service-secrets')">Secrets</a></li>
|
||||
<li><a href ng-click="goToItem('service-tasks')">Tasks</a></li>
|
||||
</ul>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- access-control-panel -->
|
||||
<access-control-panel
|
||||
ng-if="service"
|
||||
resource-id="service.Id"
|
||||
resource-control="service.ResourceControl"
|
||||
resource-type="resourceType"
|
||||
on-update-success="(onUpdateResourceControlSuccess)"
|
||||
environment-id="endpoint.Id"
|
||||
>
|
||||
</access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="container-specs">Container specification</h3>
|
||||
<div id="service-container-spec" class="padding-top" ng-include="'app/docker/views/services/edit/includes/container-specs.html'"></div>
|
||||
<div id="service-container-image" class="padding-top" ng-include="'app/docker/views/services/edit/includes/image.html'"></div>
|
||||
<div id="service-env-variables" class="padding-top" ng-include="'app/docker/views/services/edit/includes/environmentvariables.html'"></div>
|
||||
<div id="service-container-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/containerlabels.html'"></div>
|
||||
<div id="service-mounts" class="padding-top" ng-include="'app/docker/views/services/edit/includes/mounts.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="service-network-specs">Networks & ports</h3>
|
||||
<div id="service-networks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/networks.html'"></div>
|
||||
|
||||
<docker-service-ports-mapping-field
|
||||
id="service-published-ports"
|
||||
class="block padding-top"
|
||||
values="formValues.ports"
|
||||
on-change="(onChangePorts)"
|
||||
has-changes="hasChanges(service, ['Ports'])"
|
||||
on-reset="(onResetPorts)"
|
||||
on-submit="(onSubmit)"
|
||||
></docker-service-ports-mapping-field>
|
||||
|
||||
<div id="service-hosts-entries" class="padding-top" ng-include="'app/docker/views/services/edit/includes/hosts.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="service-specs">Service specification</h3>
|
||||
<div id="service-resources" class="padding-top" ng-include="'app/docker/views/services/edit/includes/resources.html'"></div>
|
||||
<div id="service-placement-constraints" class="padding-top" ng-include="'app/docker/views/services/edit/includes/constraints.html'"></div>
|
||||
<div
|
||||
id="service-placement-preferences"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.3"
|
||||
class="padding-top"
|
||||
ng-include="'app/docker/views/services/edit/includes/placementPreferences.html'"
|
||||
></div>
|
||||
<div id="service-restart-policy" class="padding-top" ng-include="'app/docker/views/services/edit/includes/restart.html'"></div>
|
||||
<div id="service-update-config" class="padding-top" ng-include="'app/docker/views/services/edit/includes/updateconfig.html'"></div>
|
||||
<div id="service-logging" class="padding-top" ng-include="'app/docker/views/services/edit/includes/logging.html'"></div>
|
||||
<div id="service-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/servicelabels.html'"></div>
|
||||
<div id="service-configs" class="padding-top" ng-include="'app/docker/views/services/edit/includes/configs.html'"></div>
|
||||
<div id="service-secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" class="padding-top" ng-include="'app/docker/views/services/edit/includes/secrets.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="service-tasks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/tasks.html'"></div>
|
||||
|
||||
@@ -731,7 +731,6 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
};
|
||||
|
||||
function initView() {
|
||||
$scope.isLoading = true;
|
||||
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
||||
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
|
||||
|
||||
@@ -856,9 +855,6 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
$scope.secrets = [];
|
||||
$scope.configs = [];
|
||||
Notifications.error('Failure', err, 'Unable to retrieve service details');
|
||||
})
|
||||
.finally(() => {
|
||||
$scope.isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncInterva
|
||||
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
|
||||
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
|
||||
import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector';
|
||||
import { AssociatedEdgeGroupEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeGroupEnvironmentsSelector';
|
||||
|
||||
const ngModule = angular
|
||||
.module('portainer.edge.react.components', [])
|
||||
@@ -62,15 +61,6 @@ const ngModule = angular
|
||||
'value',
|
||||
'error',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'associatedEdgeGroupEnvironmentsSelector',
|
||||
r2a(withReactQuery(AssociatedEdgeGroupEnvironmentsSelector), [
|
||||
'onChange',
|
||||
'value',
|
||||
'error',
|
||||
'edgeGroupId',
|
||||
])
|
||||
);
|
||||
|
||||
export const componentsModule = ngModule.name;
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
.helm-template-item-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.helm-template-item-details .helm-template-item-details-sub {
|
||||
width: 100%;
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<!-- 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>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
},
|
||||
});
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<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="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>
|
||||
|
||||
<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="!allCharts.length" 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>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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: '<',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import _ from 'lodash-es';
|
||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { HelmIcon } from './HelmIcon';
|
||||
export default class HelmTemplatesController {
|
||||
/* @ngInject */
|
||||
constructor($analytics, $async, $state, $window, $anchorScroll, Authentication, HelmService, KubernetesResourcePoolService, Notifications) {
|
||||
this.$analytics = $analytics;
|
||||
this.$async = $async;
|
||||
this.$window = $window;
|
||||
this.$state = $state;
|
||||
this.$anchorScroll = $anchorScroll;
|
||||
this.Authentication = Authentication;
|
||||
this.HelmService = HelmService;
|
||||
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
|
||||
this.Notifications = Notifications;
|
||||
|
||||
this.fallbackIcon = HelmIcon;
|
||||
|
||||
this.editorUpdate = this.editorUpdate.bind(this);
|
||||
this.uiCanExit = this.uiCanExit.bind(this);
|
||||
this.installHelmchart = this.installHelmchart.bind(this);
|
||||
this.getHelmValues = this.getHelmValues.bind(this);
|
||||
this.selectHelmChart = this.selectHelmChart.bind(this);
|
||||
this.getHelmRepoURLs = this.getHelmRepoURLs.bind(this);
|
||||
this.getLatestCharts = this.getLatestCharts.bind(this);
|
||||
this.getResourcePools = this.getResourcePools.bind(this);
|
||||
this.clearHelmChart = this.clearHelmChart.bind(this);
|
||||
|
||||
$window.onbeforeunload = () => {
|
||||
if (this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
clearHelmChart() {
|
||||
this.state.chart = null;
|
||||
this.onSelectHelmChart('');
|
||||
}
|
||||
|
||||
editorUpdate(contentvalues) {
|
||||
if (this.state.originalvalues === contentvalues) {
|
||||
this.state.isEditorDirty = false;
|
||||
} else {
|
||||
this.state.values = contentvalues;
|
||||
this.state.isEditorDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
if (this.state.isEditorDirty) {
|
||||
return confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
|
||||
async installHelmchart() {
|
||||
this.state.actionInProgress = true;
|
||||
try {
|
||||
const payload = {
|
||||
Name: this.name,
|
||||
Repo: this.state.chart.repo,
|
||||
Chart: this.state.chart.name,
|
||||
Values: this.state.values,
|
||||
Namespace: this.namespace,
|
||||
};
|
||||
await this.HelmService.install(this.endpoint.Id, payload);
|
||||
this.Notifications.success('Success', 'Helm chart successfully installed');
|
||||
this.$analytics.eventTrack('kubernetes-helm-install', { category: 'kubernetes', metadata: { 'chart-name': this.state.chart.name } });
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('kubernetes.applications');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Installation error', err);
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
async getHelmValues() {
|
||||
this.state.loadingValues = true;
|
||||
try {
|
||||
const { values } = await this.HelmService.values(this.state.chart.repo, this.state.chart.name);
|
||||
this.state.values = values;
|
||||
this.state.originalvalues = values;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve helm chart values.');
|
||||
} finally {
|
||||
this.state.loadingValues = false;
|
||||
}
|
||||
}
|
||||
|
||||
async selectHelmChart(chart) {
|
||||
window.scrollTo(0, 0);
|
||||
this.state.showCustomValues = false;
|
||||
this.state.chart = chart;
|
||||
this.onSelectHelmChart(chart.name);
|
||||
await this.getHelmValues();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description This function is used to get the helm repo urls for the endpoint and user
|
||||
* @returns {Promise<string[]>} list of helm repo urls
|
||||
*/
|
||||
async getHelmRepoURLs() {
|
||||
this.state.reposLoading = true;
|
||||
try {
|
||||
// fetch globally set helm repo and user helm repos (parallel)
|
||||
const { GlobalRepository, UserRepositories } = await this.HelmService.getHelmRepositories(this.user.ID);
|
||||
this.state.globalRepository = GlobalRepository;
|
||||
const userHelmReposUrls = UserRepositories.map((repo) => repo.URL);
|
||||
const uniqueHelmRepos = [...new Set([GlobalRepository, ...userHelmReposUrls])].map((url) => url.toLowerCase()).filter((url) => url); // remove duplicates and blank, to lowercase
|
||||
this.state.repos = uniqueHelmRepos;
|
||||
return uniqueHelmRepos;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve helm repo urls.');
|
||||
} finally {
|
||||
this.state.reposLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description This function is used to fetch the respective index.yaml files for the provided helm repo urls
|
||||
* @param {string[]} helmRepos list of helm repositories
|
||||
* @param {bool} append append charts returned from repo to existing list of helm charts
|
||||
*/
|
||||
async getLatestCharts(helmRepos) {
|
||||
this.state.chartsLoading = true;
|
||||
try {
|
||||
const promiseList = helmRepos.map((repo) => this.HelmService.search(repo));
|
||||
// fetch helm charts from all the provided helm repositories (parallel)
|
||||
// Promise.allSettled is used to account for promise failure(s) - in cases the user has provided invalid helm repo
|
||||
const chartPromises = await Promise.allSettled(promiseList);
|
||||
const latestCharts = chartPromises
|
||||
.filter((tp) => tp.status === 'fulfilled') // remove failed promises
|
||||
.map((tp) => ({ entries: tp.value.entries, repo: helmRepos[chartPromises.indexOf(tp)] })) // extract chart entries with respective repo data
|
||||
.flatMap(
|
||||
({ entries, repo }) => Object.values(entries).map((charts) => ({ ...charts[0], repo })) // flatten chart entries to single array with respective repo
|
||||
);
|
||||
|
||||
this.state.charts = latestCharts;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve helm repo charts.');
|
||||
} finally {
|
||||
this.state.chartsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async getResourcePools() {
|
||||
this.state.resourcePoolsLoading = true;
|
||||
try {
|
||||
const resourcePools = await this.KubernetesResourcePoolService.get();
|
||||
|
||||
const nonSystemNamespaces = resourcePools.filter(
|
||||
(resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && resourcePool.Namespace.Status === 'Active'
|
||||
);
|
||||
this.state.resourcePools = _.sortBy(nonSystemNamespaces, ({ Namespace }) => (Namespace.Name === 'default' ? 0 : 1));
|
||||
this.state.resourcePool = this.state.resourcePools[0];
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve initial helm data.');
|
||||
} finally {
|
||||
this.state.resourcePoolsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
return this.$async(async () => {
|
||||
this.user = this.Authentication.getUserDetails();
|
||||
|
||||
this.state = {
|
||||
appName: '',
|
||||
chart: null,
|
||||
showCustomValues: false,
|
||||
actionInProgress: false,
|
||||
resourcePools: [],
|
||||
resourcePool: '',
|
||||
values: null,
|
||||
originalvalues: null,
|
||||
repos: [],
|
||||
charts: [],
|
||||
loadingValues: false,
|
||||
isEditorDirty: false,
|
||||
chartsLoading: false,
|
||||
resourcePoolsLoading: false,
|
||||
viewReady: false,
|
||||
isAdmin: this.Authentication.isAdmin(),
|
||||
globalRepository: undefined,
|
||||
};
|
||||
|
||||
const helmRepos = await this.getHelmRepoURLs();
|
||||
if (helmRepos) {
|
||||
await Promise.all([this.getLatestCharts(helmRepos), this.getResourcePools()]);
|
||||
}
|
||||
if (this.state.charts.length > 0 && this.$state.params.chartName) {
|
||||
const chart = this.state.charts.find((chart) => chart.name === this.$state.params.chartName);
|
||||
if (chart) {
|
||||
this.selectHelmChart(chart);
|
||||
}
|
||||
}
|
||||
|
||||
this.state.viewReady = true;
|
||||
});
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
this.state.isEditorDirty = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<div class="row">
|
||||
<!-- helmchart-form -->
|
||||
<div class="col-sm-12 p-0" ng-if="$ctrl.state.chart">
|
||||
<rd-widget>
|
||||
<div class="flex">
|
||||
<div class="basis-3/4 rounded-[8px] m-2 bg-gray-4 th-highcontrast:bg-black th-highcontrast:text-white th-dark:bg-gray-iron-10 th-dark:text-white">
|
||||
<div class="vertical-center p-5">
|
||||
<fallback-image src="$ctrl.state.chart.icon" fallback-icon="$ctrl.fallbackIcon" class-name="'h-16 w-16'" size="'lg'"></fallback-image>
|
||||
<div class="font-medium ml-4">
|
||||
<div class="toolBarTitle text-[24px] mb-2">
|
||||
{{ $ctrl.state.chart.name }}
|
||||
<span class="space-left text-[14px] vertical-center font-normal">
|
||||
<pr-icon icon="'svg-helm'" mode="'primary'"></pr-icon>
|
||||
Helm
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-muted text-xs" ng-bind-html="$ctrl.state.chart.description"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="basis-1/4">
|
||||
<div class="h-full w-full vertical-center justify-end pr-5">
|
||||
<button type="button" class="btn btn-sm btn-link !text-gray-8 hover:no-underline th-highcontrast:!text-white th-dark:!text-white" ng-click="$ctrl.clearHelmChart()">
|
||||
Clear selection
|
||||
<pr-icon icon="'x'" class="ml-1"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget>
|
||||
|
||||
<form class="form-horizontal" name="$ctrl.helmTemplateCreationForm">
|
||||
<div class="form-group mt-4">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
ng-if="!$ctrl.state.showCustomValues && !$ctrl.state.loadingValues"
|
||||
class="btn btn-xs btn-default vertical-center !ml-0 mr-2"
|
||||
ng-click="$ctrl.state.showCustomValues = true;"
|
||||
>
|
||||
<pr-icon icon="'plus'" class="vertical-center"></pr-icon>
|
||||
Show custom values
|
||||
</button>
|
||||
<span class="small interactive vertical-center" ng-if="$ctrl.state.loadingValues" role="status">
|
||||
<inline-loader children="'Loading values.yaml...'" />
|
||||
</span>
|
||||
<button ng-if="$ctrl.state.showCustomValues" class="btn btn-xs btn-default vertical-center !ml-0 mr-2" ng-click="$ctrl.state.showCustomValues = false;">
|
||||
<pr-icon icon="'minus'" class="vertical-center"></pr-icon>
|
||||
Hide custom values
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- values override -->
|
||||
<div ng-if="$ctrl.state.showCustomValues">
|
||||
<!-- web-editor -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<web-editor-form
|
||||
identifier="helm-app-creation-editor"
|
||||
value="$ctrl.state.values"
|
||||
on-change="($ctrl.editorUpdate)"
|
||||
yml="true"
|
||||
placeholder="Define or paste the content of your values yaml file here"
|
||||
>
|
||||
<editor-description class="vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
<span>
|
||||
You can get more information about Helm values file format in the
|
||||
<a href="https://helm.sh/docs/chart_template_guide/values_files/" target="_blank" class="hyperlink">official documentation</a>.
|
||||
</span>
|
||||
</editor-description>
|
||||
</web-editor-form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !web-editor -->
|
||||
</div>
|
||||
<!-- !values override -->
|
||||
<!-- helm actions -->
|
||||
<div class="col-sm-12 form-section-title"> Actions </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="!$ctrl.state.resourcePool || $ctrl.state.loadingValues || $ctrl.state.actionInProgress || !$ctrl.name"
|
||||
ng-click="$ctrl.installHelmchart()"
|
||||
button-spinner="$ctrl.state.actionInProgress"
|
||||
data-cy="helm-install"
|
||||
>
|
||||
<span ng-hide="$ctrl.state.actionInProgress">Install</span>
|
||||
<span ng-hide="!$ctrl.state.actionInProgress">Installing Helm chart</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !helm actions -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- helmchart-form -->
|
||||
</div>
|
||||
|
||||
<!-- Helm Charts Component -->
|
||||
<div class="row" ng-if="!$ctrl.state.chart">
|
||||
<div class="col-sm-12 p-0">
|
||||
<helm-templates-list
|
||||
title-text="Helm chart"
|
||||
charts="$ctrl.state.charts"
|
||||
table-key="$ctrl.state.charts"
|
||||
select-action="$ctrl.selectHelmChart"
|
||||
loading="$ctrl.state.chartsLoading || $ctrl.state.resourcePoolsLoading"
|
||||
>
|
||||
</helm-templates-list>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Helm Charts Component -->
|
||||
@@ -0,0 +1,14 @@
|
||||
import angular from 'angular';
|
||||
import controller from './helm-templates.controller';
|
||||
|
||||
angular.module('portainer.kubernetes').component('helmTemplatesView', {
|
||||
templateUrl: './helm-templates.html',
|
||||
controller,
|
||||
bindings: {
|
||||
endpoint: '<',
|
||||
namespace: '<',
|
||||
stackName: '<',
|
||||
onSelectHelmChart: '<',
|
||||
name: '<',
|
||||
},
|
||||
});
|
||||
@@ -58,7 +58,6 @@ 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 { HelmTemplates } from '@/react/kubernetes/helm/HelmTemplates/HelmTemplates';
|
||||
|
||||
import { namespacesModule } from './namespaces';
|
||||
import { clusterManagementModule } from './clusterManagement';
|
||||
@@ -206,14 +205,6 @@ export const ngModule = angular
|
||||
'tableTitle',
|
||||
'dataCy',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'helmTemplatesView',
|
||||
r2a(withUIRouter(withCurrentUser(HelmTemplates)), [
|
||||
'onSelectHelmChart',
|
||||
'namespace',
|
||||
'name',
|
||||
])
|
||||
);
|
||||
|
||||
export const componentsModule = ngModule.name;
|
||||
|
||||
@@ -22,8 +22,6 @@ import { VolumesView } from '@/react/kubernetes/volumes/ListView/VolumesView';
|
||||
import { NamespaceView } from '@/react/kubernetes/namespaces/ItemView/NamespaceView';
|
||||
import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView';
|
||||
import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView';
|
||||
import { ClusterView } from '@/react/kubernetes/cluster/ClusterView';
|
||||
import { HelmApplicationView } from '@/react/kubernetes/helm/HelmApplicationView';
|
||||
|
||||
export const viewsModule = angular
|
||||
.module('portainer.kubernetes.react.views', [])
|
||||
@@ -80,14 +78,6 @@ export const viewsModule = angular
|
||||
[]
|
||||
)
|
||||
)
|
||||
.component(
|
||||
'kubernetesHelmApplicationView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(HelmApplicationView))), [])
|
||||
)
|
||||
.component(
|
||||
'kubernetesClusterView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(ClusterView))), [])
|
||||
)
|
||||
.component(
|
||||
'kubernetesConfigureView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(ConfigureView))), [])
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import PortainerError from 'Portainer/error';
|
||||
|
||||
export default class KubernetesHelmApplicationController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, Authentication, Notifications, HelmService) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.Authentication = Authentication;
|
||||
this.Notifications = Notifications;
|
||||
this.HelmService = HelmService;
|
||||
}
|
||||
|
||||
/**
|
||||
* APPLICATION
|
||||
*/
|
||||
async getHelmApplication() {
|
||||
try {
|
||||
this.state.dataLoading = true;
|
||||
const releases = await this.HelmService.listReleases(this.endpoint.Id, { filter: `^${this.state.params.name}$`, namespace: this.state.params.namespace });
|
||||
if (releases.length > 0) {
|
||||
this.state.release = releases[0];
|
||||
} else {
|
||||
throw new PortainerError(`Release ${this.state.params.name} not found`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve helm application details');
|
||||
} finally {
|
||||
this.state.dataLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
return this.$async(async () => {
|
||||
this.state = {
|
||||
dataLoading: true,
|
||||
viewReady: false,
|
||||
params: {
|
||||
name: this.$state.params.name,
|
||||
namespace: this.$state.params.namespace,
|
||||
},
|
||||
release: {
|
||||
name: undefined,
|
||||
chart: undefined,
|
||||
app_version: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
await this.getHelmApplication();
|
||||
this.state.viewReady = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.release-table tr {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: 1fr 4fr;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<page-header
|
||||
ng-if="$ctrl.state.viewReady"
|
||||
title="'Helm details'"
|
||||
breadcrumbs="[{label:'Applications', link:'kubernetes.applications'}, $ctrl.state.params.name]"
|
||||
reload="true"
|
||||
></page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="$ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="$ctrl.state.viewReady">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<div class="toolBar vertical-center w-full flex-wrap !gap-x-5 !gap-y-1 p-5">
|
||||
<div class="toolBarTitle vertical-center">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'svg-helm'"></pr-icon>
|
||||
</div>
|
||||
|
||||
Release
|
||||
</div>
|
||||
</div>
|
||||
<rd-widget-body>
|
||||
<table class="table">
|
||||
<tbody class="release-table">
|
||||
<tr>
|
||||
<td class="vertical-center">Name</td>
|
||||
<td class="vertical-center !p-2" data-cy="k8sAppDetail-appName">
|
||||
{{ $ctrl.state.release.name }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="vertical-center">Chart</td>
|
||||
<td class="vertical-center !p-2">
|
||||
{{ $ctrl.state.release.chart }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="vertical-center">App version</td>
|
||||
<td class="vertical-center !p-2">
|
||||
{{ $ctrl.state.release.app_version }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
import angular from 'angular';
|
||||
import controller from './helm.controller';
|
||||
import './helm.css';
|
||||
|
||||
angular.module('portainer.kubernetes').component('kubernetesHelmApplicationView', {
|
||||
templateUrl: './helm.html',
|
||||
controller,
|
||||
bindings: {
|
||||
endpoint: '<',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
<page-header ng-if="ctrl.state.viewReady" title="'Cluster'" breadcrumbs="['Cluster information']" reload="true"></page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="ctrl.state.viewReady">
|
||||
<div class="row" ng-if="ctrl.isAdmin">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<!-- resource-reservation -->
|
||||
<form class="form-horizontal" ng-if="ctrl.resourceReservation">
|
||||
<kubernetes-resource-reservation
|
||||
description="Resource reservation represents the total amount of resource assigned to all the applications inside the cluster."
|
||||
cpu-reservation="ctrl.resourceReservation.CPU"
|
||||
cpu-limit="ctrl.CPULimit"
|
||||
memory-reservation="ctrl.resourceReservation.Memory"
|
||||
memory-limit="ctrl.MemoryLimit"
|
||||
display-usage="ctrl.hasResourceUsageAccess()"
|
||||
cpu-usage="ctrl.resourceUsage.CPU"
|
||||
memory-usage="ctrl.resourceUsage.Memory"
|
||||
>
|
||||
</kubernetes-resource-reservation>
|
||||
</form>
|
||||
<!-- !resource-reservation -->
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<kube-nodes-datatable></kube-nodes-datatable>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
angular.module('portainer.kubernetes').component('kubernetesClusterView', {
|
||||
templateUrl: './cluster.html',
|
||||
controller: 'KubernetesClusterController',
|
||||
controllerAs: 'ctrl',
|
||||
bindings: {
|
||||
endpoint: '<',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import angular from 'angular';
|
||||
import _ from 'lodash-es';
|
||||
import filesizeParser from 'filesize-parser';
|
||||
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
|
||||
import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models';
|
||||
import { getMetricsForAllNodes, getTotalResourcesForAllApplications } from '@/react/kubernetes/metrics/metrics.ts';
|
||||
|
||||
class KubernetesClusterController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, Notifications, LocalStorage, Authentication, KubernetesNodeService, KubernetesApplicationService, KubernetesEndpointService, EndpointService) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.Authentication = Authentication;
|
||||
this.Notifications = Notifications;
|
||||
this.LocalStorage = LocalStorage;
|
||||
this.KubernetesNodeService = KubernetesNodeService;
|
||||
this.KubernetesApplicationService = KubernetesApplicationService;
|
||||
this.KubernetesEndpointService = KubernetesEndpointService;
|
||||
this.EndpointService = EndpointService;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.getNodes = this.getNodes.bind(this);
|
||||
this.getNodesAsync = this.getNodesAsync.bind(this);
|
||||
this.getApplicationsAsync = this.getApplicationsAsync.bind(this);
|
||||
this.getEndpointsAsync = this.getEndpointsAsync.bind(this);
|
||||
this.hasResourceUsageAccess = this.hasResourceUsageAccess.bind(this);
|
||||
}
|
||||
|
||||
async getEndpointsAsync() {
|
||||
try {
|
||||
const endpoints = await this.KubernetesEndpointService.get();
|
||||
const systemEndpoints = _.filter(endpoints, { Namespace: 'kube-system' });
|
||||
this.systemEndpoints = _.filter(systemEndpoints, (ep) => ep.HolderIdentity);
|
||||
|
||||
const kubernetesEndpoint = _.find(endpoints, { Name: 'kubernetes' });
|
||||
if (kubernetesEndpoint && kubernetesEndpoint.Subsets) {
|
||||
const ips = _.flatten(_.map(kubernetesEndpoint.Subsets, 'Ips'));
|
||||
_.forEach(this.nodes, (node) => {
|
||||
node.Api = _.includes(ips, node.IPAddress);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve environments');
|
||||
}
|
||||
}
|
||||
|
||||
getEndpoints() {
|
||||
return this.$async(this.getEndpointsAsync);
|
||||
}
|
||||
|
||||
async getNodesAsync() {
|
||||
try {
|
||||
const nodes = await this.KubernetesNodeService.get();
|
||||
_.forEach(nodes, (node) => (node.Memory = filesizeParser(node.Memory)));
|
||||
this.nodes = nodes;
|
||||
this.CPULimit = _.reduce(this.nodes, (acc, node) => node.CPU + acc, 0);
|
||||
this.CPULimit = Math.round(this.CPULimit * 10000) / 10000;
|
||||
this.MemoryLimit = _.reduce(this.nodes, (acc, node) => KubernetesResourceReservationHelper.megaBytesValue(node.Memory) + acc, 0);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve nodes');
|
||||
}
|
||||
}
|
||||
|
||||
getNodes() {
|
||||
return this.$async(this.getNodesAsync);
|
||||
}
|
||||
|
||||
async getApplicationsAsync() {
|
||||
try {
|
||||
this.state.applicationsLoading = true;
|
||||
|
||||
const applicationsResources = await getTotalResourcesForAllApplications(this.endpoint.Id);
|
||||
this.resourceReservation = new KubernetesResourceReservation();
|
||||
|
||||
// Using same rounding method as CPULimit in getNodesAsync for consistency
|
||||
this.resourceReservation.CPU = Math.round(applicationsResources.CpuRequest * 10000) / 10000;
|
||||
this.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(applicationsResources.MemoryRequest);
|
||||
|
||||
if (this.hasResourceUsageAccess()) {
|
||||
await this.getResourceUsage(this.endpoint.Id);
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve applications');
|
||||
} finally {
|
||||
this.state.applicationsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
getApplications() {
|
||||
return this.$async(this.getApplicationsAsync);
|
||||
}
|
||||
|
||||
async getResourceUsage(endpointId) {
|
||||
try {
|
||||
const nodeMetrics = await getMetricsForAllNodes(endpointId);
|
||||
const resourceUsageList = nodeMetrics.items.map((i) => i.usage);
|
||||
const clusterResourceUsage = resourceUsageList.reduce((total, u) => {
|
||||
total.CPU += KubernetesResourceReservationHelper.parseCPU(u.cpu);
|
||||
total.Memory += KubernetesResourceReservationHelper.megaBytesValue(u.memory);
|
||||
return total;
|
||||
}, new KubernetesResourceReservation());
|
||||
this.resourceUsage = clusterResourceUsage;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve cluster resource usage');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if resource usage stats can be displayed
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasResourceUsageAccess() {
|
||||
return this.isAdmin && this.state.useServerMetrics;
|
||||
}
|
||||
|
||||
async onInit() {
|
||||
this.endpoint = await this.EndpointService.endpoint(this.endpoint.Id);
|
||||
this.isAdmin = this.Authentication.isAdmin();
|
||||
const useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
|
||||
|
||||
this.state = {
|
||||
applicationsLoading: true,
|
||||
viewReady: false,
|
||||
useServerMetrics,
|
||||
};
|
||||
|
||||
await this.getNodes();
|
||||
if (this.isAdmin) {
|
||||
await Promise.allSettled([this.getEndpoints(), this.getApplicationsAsync()]);
|
||||
}
|
||||
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
return this.$async(this.onInit);
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesClusterController;
|
||||
angular.module('portainer.kubernetes').controller('KubernetesClusterController', KubernetesClusterController);
|
||||
@@ -187,7 +187,13 @@
|
||||
<!-- Helm -->
|
||||
<div ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.HELM">
|
||||
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.selectedHelmChart">Selected Helm chart</div>
|
||||
<helm-templates-view on-select-helm-chart="(ctrl.onSelectHelmChart)" namespace="ctrl.formValues.Namespace" name="ctrl.formValues.Name" />
|
||||
<helm-templates-view
|
||||
on-select-helm-chart="(ctrl.onSelectHelmChart)"
|
||||
endpoint="ctrl.endpoint"
|
||||
namespace="ctrl.formValues.Namespace"
|
||||
stack-name="ctrl.formValues.StackName"
|
||||
name="ctrl.formValues.Name"
|
||||
></helm-templates-view>
|
||||
</div>
|
||||
<!-- !Helm -->
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@ import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
|
||||
|
||||
class KubernetesDeployController {
|
||||
/* @ngInject */
|
||||
constructor($scope, $async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, CustomTemplateService, KubernetesApplicationService) {
|
||||
this.$scope = $scope;
|
||||
constructor($async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, CustomTemplateService, KubernetesApplicationService) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$window = $window;
|
||||
@@ -111,9 +110,6 @@ class KubernetesDeployController {
|
||||
|
||||
onSelectHelmChart(chart) {
|
||||
this.state.selectedHelmChart = chart;
|
||||
|
||||
// Force a digest cycle to ensure the change is reflected in the UI
|
||||
this.$scope.$apply();
|
||||
}
|
||||
|
||||
onChangeTemplateVariables(value) {
|
||||
|
||||
@@ -6,5 +6,4 @@
|
||||
on-change="($ctrl.handleChange)"
|
||||
value="$ctrl.value"
|
||||
height="$ctrl.height || undefined"
|
||||
schema="$ctrl.schema"
|
||||
></react-code-editor>
|
||||
|
||||
@@ -13,6 +13,5 @@ angular.module('portainer.app').component('codeEditor', {
|
||||
onChange: '<',
|
||||
value: '<',
|
||||
height: '@',
|
||||
schema: '<',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ export const webEditorForm = {
|
||||
onChange: '<',
|
||||
hideTitle: '<',
|
||||
height: '@',
|
||||
schema: '<',
|
||||
},
|
||||
|
||||
transclude: {
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
value="$ctrl.value"
|
||||
on-change="($ctrl.onChange)"
|
||||
height="{{ $ctrl.height }}"
|
||||
schema="$ctrl.schema"
|
||||
></code-editor>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { gitFormRefField } from './git-form-ref-field';
|
||||
|
||||
export const gitFormModule = angular
|
||||
.module('portainer.app.components.git-form', [])
|
||||
.component('gitForm', gitForm) // kube deploy + docker stack create
|
||||
.component('gitForm', gitForm)
|
||||
.component('gitFormAuthFieldset', gitFormAuthFieldset)
|
||||
.component('gitFormAutoUpdateFieldset', gitFormAutoUpdate)
|
||||
.component('gitFormRefField', gitFormRefField).name;
|
||||
|
||||
@@ -29,7 +29,6 @@ export const gitFormModule = angular
|
||||
'webhookId',
|
||||
'webhooksDocs',
|
||||
'createdFromCustomTemplateId',
|
||||
'isAutoUpdateVisible',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
||||
@@ -232,7 +232,6 @@ export const ngModule = angular
|
||||
'data-cy',
|
||||
'versions',
|
||||
'onVersionChange',
|
||||
'schema',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user