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 |
|
@ -95,6 +95,8 @@ body:
|
||||||
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
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
|
multiple: false
|
||||||
options:
|
options:
|
||||||
|
- '2.27.1'
|
||||||
|
- '2.27.0'
|
||||||
- '2.26.1'
|
- '2.26.1'
|
||||||
- '2.26.0'
|
- '2.26.0'
|
||||||
- '2.25.1'
|
- '2.25.1'
|
||||||
|
|
|
@ -60,6 +60,7 @@ func CLIFlags() *portainer.CLIFlags {
|
||||||
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
|
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
|
||||||
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
|
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
|
||||||
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
|
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
|
||||||
|
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,20 +4,21 @@
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultBindAddress = ":9000"
|
defaultBindAddress = ":9000"
|
||||||
defaultHTTPSBindAddress = ":9443"
|
defaultHTTPSBindAddress = ":9443"
|
||||||
defaultTunnelServerAddress = "0.0.0.0"
|
defaultTunnelServerAddress = "0.0.0.0"
|
||||||
defaultTunnelServerPort = "8000"
|
defaultTunnelServerPort = "8000"
|
||||||
defaultDataDirectory = "/data"
|
defaultDataDirectory = "/data"
|
||||||
defaultAssetsDirectory = "./"
|
defaultAssetsDirectory = "./"
|
||||||
defaultTLS = "false"
|
defaultTLS = "false"
|
||||||
defaultTLSSkipVerify = "false"
|
defaultTLSSkipVerify = "false"
|
||||||
defaultTLSCACertPath = "/certs/ca.pem"
|
defaultTLSCACertPath = "/certs/ca.pem"
|
||||||
defaultTLSCertPath = "/certs/cert.pem"
|
defaultTLSCertPath = "/certs/cert.pem"
|
||||||
defaultTLSKeyPath = "/certs/key.pem"
|
defaultTLSKeyPath = "/certs/key.pem"
|
||||||
defaultHTTPDisabled = "false"
|
defaultHTTPDisabled = "false"
|
||||||
defaultHTTPEnabled = "false"
|
defaultHTTPEnabled = "false"
|
||||||
defaultSSL = "false"
|
defaultSSL = "false"
|
||||||
defaultBaseURL = "/"
|
defaultBaseURL = "/"
|
||||||
defaultSecretKeyName = "portainer"
|
defaultSecretKeyName = "portainer"
|
||||||
|
defaultPullLimitCheckDisabled = "false"
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,21 +1,22 @@
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultBindAddress = ":9000"
|
defaultBindAddress = ":9000"
|
||||||
defaultHTTPSBindAddress = ":9443"
|
defaultHTTPSBindAddress = ":9443"
|
||||||
defaultTunnelServerAddress = "0.0.0.0"
|
defaultTunnelServerAddress = "0.0.0.0"
|
||||||
defaultTunnelServerPort = "8000"
|
defaultTunnelServerPort = "8000"
|
||||||
defaultDataDirectory = "C:\\data"
|
defaultDataDirectory = "C:\\data"
|
||||||
defaultAssetsDirectory = "./"
|
defaultAssetsDirectory = "./"
|
||||||
defaultTLS = "false"
|
defaultTLS = "false"
|
||||||
defaultTLSSkipVerify = "false"
|
defaultTLSSkipVerify = "false"
|
||||||
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
||||||
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
||||||
defaultTLSKeyPath = "C:\\certs\\key.pem"
|
defaultTLSKeyPath = "C:\\certs\\key.pem"
|
||||||
defaultHTTPDisabled = "false"
|
defaultHTTPDisabled = "false"
|
||||||
defaultHTTPEnabled = "false"
|
defaultHTTPEnabled = "false"
|
||||||
defaultSSL = "false"
|
defaultSSL = "false"
|
||||||
defaultSnapshotInterval = "5m"
|
defaultSnapshotInterval = "5m"
|
||||||
defaultBaseURL = "/"
|
defaultBaseURL = "/"
|
||||||
defaultSecretKeyName = "portainer"
|
defaultSecretKeyName = "portainer"
|
||||||
|
defaultPullLimitCheckDisabled = "false"
|
||||||
)
|
)
|
||||||
|
|
|
@ -238,10 +238,10 @@ func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.SnapshotInterval = *cmp.Or(flags.SnapshotInterval, &settings.SnapshotInterval)
|
settings.SnapshotInterval = cmp.Or(*flags.SnapshotInterval, settings.SnapshotInterval)
|
||||||
settings.LogoURL = *cmp.Or(flags.Logo, &settings.LogoURL)
|
settings.LogoURL = cmp.Or(*flags.Logo, settings.LogoURL)
|
||||||
settings.EnableEdgeComputeFeatures = *cmp.Or(flags.EnableEdgeComputeFeatures, &settings.EnableEdgeComputeFeatures)
|
settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
|
||||||
settings.TemplatesURL = *cmp.Or(flags.Templates, &settings.TemplatesURL)
|
settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
|
||||||
|
|
||||||
if *flags.Labels != nil {
|
if *flags.Labels != nil {
|
||||||
settings.BlackListedLabels = *flags.Labels
|
settings.BlackListedLabels = *flags.Labels
|
||||||
|
@ -575,6 +575,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||||
AdminCreationDone: adminCreationDone,
|
AdminCreationDone: adminCreationDone,
|
||||||
PendingActionsService: pendingActionsService,
|
PendingActionsService: pendingActionsService,
|
||||||
PlatformService: platformService,
|
PlatformService: platformService,
|
||||||
|
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,8 +6,10 @@ import (
|
||||||
|
|
||||||
type ReadTransaction interface {
|
type ReadTransaction interface {
|
||||||
GetObject(bucketName string, key []byte, object any) error
|
GetObject(bucketName string, key []byte, object any) error
|
||||||
|
GetRawBytes(bucketName string, key []byte) ([]byte, error)
|
||||||
GetAll(bucketName string, obj any, append func(o any) (any, error)) error
|
GetAll(bucketName string, obj any, append func(o any) (any, error)) error
|
||||||
GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, append func(o any) (any, error)) error
|
GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, append func(o any) (any, error)) error
|
||||||
|
KeyExists(bucketName string, key []byte) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Transaction interface {
|
type Transaction interface {
|
||||||
|
|
|
@ -244,6 +244,32 @@ func (connection *DbConnection) GetObject(bucketName string, key []byte, object
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (connection *DbConnection) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
|
||||||
|
var value []byte
|
||||||
|
|
||||||
|
err := connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
var err error
|
||||||
|
value, err = tx.GetRawBytes(bucketName, key)
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (connection *DbConnection) KeyExists(bucketName string, key []byte) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
|
||||||
|
err := connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
var err error
|
||||||
|
exists, err = tx.KeyExists(bucketName, key)
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
func (connection *DbConnection) getEncryptionKey() []byte {
|
func (connection *DbConnection) getEncryptionKey() []byte {
|
||||||
if !connection.isEncrypted {
|
if !connection.isEncrypted {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -10,7 +10,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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"
|
passphrase = "my secret key"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
|
|
||||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
@ -31,6 +32,33 @@ func (tx *DbTransaction) GetObject(bucketName string, key []byte, object any) er
|
||||||
return tx.conn.UnmarshalObject(value, object)
|
return tx.conn.UnmarshalObject(value, object)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (tx *DbTransaction) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
|
||||||
|
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||||
|
|
||||||
|
value := bucket.Get(key)
|
||||||
|
if value == nil {
|
||||||
|
return nil, fmt.Errorf("%w (bucket=%s, key=%s)", dserrors.ErrObjectNotFound, bucketName, keyToString(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
if tx.conn.getEncryptionKey() != nil {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if value, err = decrypt(value, tx.conn.getEncryptionKey()); err != nil {
|
||||||
|
return value, errors.Wrap(err, "Failed decrypting object")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tx *DbTransaction) KeyExists(bucketName string, key []byte) (bool, error) {
|
||||||
|
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||||
|
|
||||||
|
value := bucket.Get(key)
|
||||||
|
|
||||||
|
return value != nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (tx *DbTransaction) UpdateObject(bucketName string, key []byte, object any) error {
|
func (tx *DbTransaction) UpdateObject(bucketName string, key []byte, object any) error {
|
||||||
data, err := tx.conn.MarshalObject(object)
|
data, err := tx.conn.MarshalObject(object)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
type BaseCRUD[T any, I constraints.Integer] interface {
|
type BaseCRUD[T any, I constraints.Integer] interface {
|
||||||
Create(element *T) error
|
Create(element *T) error
|
||||||
Read(ID I) (*T, error)
|
Read(ID I) (*T, error)
|
||||||
|
Exists(ID I) (bool, error)
|
||||||
ReadAll() ([]T, error)
|
ReadAll() ([]T, error)
|
||||||
Update(ID I, element *T) error
|
Update(ID I, element *T) error
|
||||||
Delete(ID I) error
|
Delete(ID I) error
|
||||||
|
@ -42,6 +43,19 @@ func (service BaseDataService[T, I]) Read(ID I) (*T, error) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service BaseDataService[T, I]) Exists(ID I) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
|
||||||
|
err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
var err error
|
||||||
|
exists, err = service.Tx(tx).Exists(ID)
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
func (service BaseDataService[T, I]) ReadAll() ([]T, error) {
|
func (service BaseDataService[T, I]) ReadAll() ([]T, error) {
|
||||||
var collection = make([]T, 0)
|
var collection = make([]T, 0)
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,12 @@ func (service BaseDataServiceTx[T, I]) Read(ID I) (*T, error) {
|
||||||
return &element, nil
|
return &element, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) {
|
||||||
|
identifier := service.Connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
|
return service.Tx.KeyExists(service.Bucket, identifier)
|
||||||
|
}
|
||||||
|
|
||||||
func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) {
|
func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) {
|
||||||
var collection = make([]T, 0)
|
var collection = make([]T, 0)
|
||||||
|
|
||||||
|
|
|
@ -605,12 +605,12 @@
|
||||||
"GlobalDeploymentOptions": {
|
"GlobalDeploymentOptions": {
|
||||||
"hideStacksFunctionality": false
|
"hideStacksFunctionality": false
|
||||||
},
|
},
|
||||||
"HelmRepositoryURL": "https://charts.bitnami.com/bitnami",
|
"HelmRepositoryURL": "",
|
||||||
"InternalAuthSettings": {
|
"InternalAuthSettings": {
|
||||||
"RequiredPasswordLength": 12
|
"RequiredPasswordLength": 12
|
||||||
},
|
},
|
||||||
"KubeconfigExpiry": "0",
|
"KubeconfigExpiry": "0",
|
||||||
"KubectlShellImage": "portainer/kubectl-shell:2.27.0-rc1",
|
"KubectlShellImage": "portainer/kubectl-shell:2.27.6",
|
||||||
"LDAPSettings": {
|
"LDAPSettings": {
|
||||||
"AnonymousMode": true,
|
"AnonymousMode": true,
|
||||||
"AutoCreateUsers": true,
|
"AutoCreateUsers": true,
|
||||||
|
@ -943,7 +943,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": {
|
"version": {
|
||||||
"VERSION": "{\"SchemaVersion\":\"2.27.0-rc1\",\"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
|
"webhooks": null
|
||||||
}
|
}
|
|
@ -3,8 +3,8 @@ package client
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"maps"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -141,7 +141,6 @@ func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatu
|
||||||
|
|
||||||
type NodeNameTransport struct {
|
type NodeNameTransport struct {
|
||||||
*http.Transport
|
*http.Transport
|
||||||
nodeNames map[string]string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
@ -176,18 +175,19 @@ func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error)
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
t.nodeNames = make(map[string]string)
|
nodeNames, ok := req.Context().Value("nodeNames").(map[string]string)
|
||||||
for _, r := range rs {
|
if ok {
|
||||||
t.nodeNames[r.ID] = r.Portainer.Agent.NodeName
|
for idx, r := range rs {
|
||||||
|
// as there is no way to differentiate the same image available in multiple nodes only by their ID
|
||||||
|
// we append the index of the image in the payload response to match the node name later
|
||||||
|
// from the image.Summary[] list returned by docker's client.ImageList()
|
||||||
|
nodeNames[fmt.Sprintf("%s-%d", r.ID, idx)] = r.Portainer.Agent.NodeName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *NodeNameTransport) NodeNames() map[string]string {
|
|
||||||
return maps.Clone(t.nodeNames)
|
|
||||||
}
|
|
||||||
|
|
||||||
func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) {
|
func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) {
|
||||||
transport := &NodeNameTransport{
|
transport := &NodeNameTransport{
|
||||||
Transport: &http.Transport{},
|
Transport: &http.Transport{},
|
||||||
|
|
|
@ -68,7 +68,7 @@ func copyFile(src, dst string) error {
|
||||||
defer from.Close()
|
defer from.Close()
|
||||||
|
|
||||||
// has to include 'execute' bit, otherwise fails. MkdirAll follows `mkdir -m` restrictions
|
// has to include 'execute' bit, otherwise fails. MkdirAll follows `mkdir -m` restrictions
|
||||||
if err := os.MkdirAll(filepath.Dir(dst), 0744); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
to, err := os.Create(dst)
|
to, err := os.Create(dst)
|
||||||
|
|
|
@ -841,11 +841,11 @@ func (service *Service) GetDefaultSSLCertsPath() (string, string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultMTLSCertPathUnderFileStore() (string, string, string) {
|
func defaultMTLSCertPathUnderFileStore() (string, string, string) {
|
||||||
certPath := JoinPaths(SSLCertPath, MTLSCertFilename)
|
|
||||||
caCertPath := JoinPaths(SSLCertPath, MTLSCACertFilename)
|
caCertPath := JoinPaths(SSLCertPath, MTLSCACertFilename)
|
||||||
|
certPath := JoinPaths(SSLCertPath, MTLSCertFilename)
|
||||||
keyPath := JoinPaths(SSLCertPath, MTLSKeyFilename)
|
keyPath := JoinPaths(SSLCertPath, MTLSKeyFilename)
|
||||||
|
|
||||||
return certPath, caCertPath, keyPath
|
return caCertPath, certPath, keyPath
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultChiselPrivateKeyPath returns the chisle private key path
|
// GetDefaultChiselPrivateKeyPath returns the chisle private key path
|
||||||
|
@ -1014,26 +1014,45 @@ func CreateFile(path string, r io.Reader) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) StoreMTLSCertificates(cert, caCert, key []byte) (string, string, string, error) {
|
func (service *Service) StoreMTLSCertificates(caCert, cert, key []byte) (string, string, string, error) {
|
||||||
certPath, caCertPath, keyPath := defaultMTLSCertPathUnderFileStore()
|
caCertPath, certPath, keyPath := defaultMTLSCertPathUnderFileStore()
|
||||||
|
|
||||||
r := bytes.NewReader(cert)
|
r := bytes.NewReader(caCert)
|
||||||
err := service.createFileInStore(certPath, r)
|
if err := service.createFileInStore(caCertPath, r); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
r = bytes.NewReader(caCert)
|
r = bytes.NewReader(cert)
|
||||||
err = service.createFileInStore(caCertPath, r)
|
if err := service.createFileInStore(certPath, r); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
r = bytes.NewReader(key)
|
r = bytes.NewReader(key)
|
||||||
err = service.createFileInStore(keyPath, r)
|
if err := service.createFileInStore(keyPath, r); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return service.wrapFileStore(certPath), service.wrapFileStore(caCertPath), service.wrapFileStore(keyPath), nil
|
return service.wrapFileStore(caCertPath), service.wrapFileStore(certPath), service.wrapFileStore(keyPath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) GetMTLSCertificates() (string, string, string, error) {
|
||||||
|
caCertPath, certPath, keyPath := defaultMTLSCertPathUnderFileStore()
|
||||||
|
|
||||||
|
caCertPath = service.wrapFileStore(caCertPath)
|
||||||
|
certPath = service.wrapFileStore(certPath)
|
||||||
|
keyPath = service.wrapFileStore(keyPath)
|
||||||
|
|
||||||
|
paths := [...]string{caCertPath, certPath, keyPath}
|
||||||
|
for _, path := range paths {
|
||||||
|
exists, err := service.FileExists(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return "", "", "", fmt.Errorf("file %s does not exist", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return caCertPath, certPath, keyPath, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -482,28 +482,3 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po
|
||||||
|
|
||||||
return customTemplate, nil
|
return customTemplate, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// @id CustomTemplateCreate
|
|
||||||
// @summary Create a custom template
|
|
||||||
// @description Create a custom template.
|
|
||||||
// @description **Access policy**: authenticated
|
|
||||||
// @tags custom_templates
|
|
||||||
// @security ApiKeyAuth
|
|
||||||
// @security jwt
|
|
||||||
// @accept json,multipart/form-data
|
|
||||||
// @produce json
|
|
||||||
// @param method query string true "method for creating template" Enums(string, file, repository)
|
|
||||||
// @param body body object true "for body documentation see the relevant /custom_templates/{method} endpoint"
|
|
||||||
// @success 200 {object} portainer.CustomTemplate
|
|
||||||
// @failure 400 "Invalid request"
|
|
||||||
// @failure 500 "Server error"
|
|
||||||
// @deprecated
|
|
||||||
// @router /custom_templates [post]
|
|
||||||
func deprecatedCustomTemplateCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
|
||||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
|
||||||
if err != nil {
|
|
||||||
return "", httperror.BadRequest("Invalid query parameter: method", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return "/custom_templates/create/" + method, nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/http/middlewares"
|
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
)
|
)
|
||||||
|
@ -33,7 +32,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||||
|
|
||||||
h.Handle("/custom_templates/create/{method}",
|
h.Handle("/custom_templates/create/{method}",
|
||||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateCreate))).Methods(http.MethodPost)
|
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateCreate))).Methods(http.MethodPost)
|
||||||
h.Handle("/custom_templates", middlewares.Deprecated(h, deprecatedCustomTemplateCreateUrlParser)).Methods(http.MethodPost) // Deprecated
|
|
||||||
h.Handle("/custom_templates",
|
h.Handle("/custom_templates",
|
||||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateList))).Methods(http.MethodGet)
|
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateList))).Methods(http.MethodGet)
|
||||||
h.Handle("/custom_templates/{id}",
|
h.Handle("/custom_templates/{id}",
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
package images
|
package images
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/docker/client"
|
|
||||||
"github.com/portainer/portainer/api/http/handler/docker/utils"
|
"github.com/portainer/portainer/api/http/handler/docker/utils"
|
||||||
"github.com/portainer/portainer/api/set"
|
"github.com/portainer/portainer/api/set"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
@ -46,17 +47,16 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
|
||||||
return httpErr
|
return httpErr
|
||||||
}
|
}
|
||||||
|
|
||||||
images, err := cli.ImageList(r.Context(), image.ListOptions{})
|
nodeNames := make(map[string]string)
|
||||||
|
|
||||||
|
// Pass the node names map to the context so the custom NodeNameTransport can use it
|
||||||
|
ctx := context.WithValue(r.Context(), "nodeNames", nodeNames)
|
||||||
|
|
||||||
|
images, err := cli.ImageList(ctx, image.ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to retrieve Docker images", err)
|
return httperror.InternalServerError("Unable to retrieve Docker images", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the node name from the custom transport
|
|
||||||
nodeNames := make(map[string]string)
|
|
||||||
if t, ok := cli.HTTPClient().Transport.(*client.NodeNameTransport); ok {
|
|
||||||
nodeNames = t.NodeNames()
|
|
||||||
}
|
|
||||||
|
|
||||||
withUsage, err := request.RetrieveBooleanQueryParameter(r, "withUsage", true)
|
withUsage, err := request.RetrieveBooleanQueryParameter(r, "withUsage", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.BadRequest("Invalid query parameter: withUsage", err)
|
return httperror.BadRequest("Invalid query parameter: withUsage", err)
|
||||||
|
@ -85,8 +85,12 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
|
||||||
}
|
}
|
||||||
|
|
||||||
imagesList[i] = ImageResponse{
|
imagesList[i] = ImageResponse{
|
||||||
Created: image.Created,
|
Created: image.Created,
|
||||||
NodeName: nodeNames[image.ID],
|
// Only works if the order of `images` is not changed between unmarshaling the agent's response
|
||||||
|
// in NodeNameTransport.RoundTrip() (api/docker/client/client.go)
|
||||||
|
// and docker's cli.ImageList()
|
||||||
|
// As both functions unmarshal the same response body, the resulting array will be ordered the same way.
|
||||||
|
NodeName: nodeNames[fmt.Sprintf("%s-%d", image.ID, i)],
|
||||||
ID: image.ID,
|
ID: image.ID,
|
||||||
Size: image.Size,
|
Size: image.Size,
|
||||||
Tags: image.RepoTags,
|
Tags: image.RepoTags,
|
||||||
|
|
|
@ -167,7 +167,7 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
|
||||||
|
|
||||||
func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
|
func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
|
||||||
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
||||||
if err != nil {
|
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -183,6 +183,12 @@ func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoi
|
||||||
edgeStackSet[edgeStackID] = true
|
edgeStackSet[edgeStackID] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if relation == nil {
|
||||||
|
relation = &portainer.EndpointRelation{
|
||||||
|
EndpointID: endpoint.ID,
|
||||||
|
EdgeStacks: make(map[portainer.EdgeStackID]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
relation.EdgeStacks = edgeStackSet
|
relation.EdgeStacks = edgeStackSet
|
||||||
|
|
||||||
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)
|
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)
|
||||||
|
|
|
@ -271,26 +271,3 @@ func (handler *Handler) addAndPersistEdgeJob(tx dataservices.DataStoreTx, edgeJo
|
||||||
|
|
||||||
return tx.EdgeJob().CreateWithID(edgeJob.ID, edgeJob)
|
return tx.EdgeJob().CreateWithID(edgeJob.ID, edgeJob)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @id EdgeJobCreate
|
|
||||||
// @summary Create an EdgeJob
|
|
||||||
// @description **Access policy**: administrator
|
|
||||||
// @tags edge_jobs
|
|
||||||
// @security ApiKeyAuth
|
|
||||||
// @security jwt
|
|
||||||
// @produce json
|
|
||||||
// @param method query string true "Creation Method" Enums(file, string)
|
|
||||||
// @param body body object true "for body documentation see the relevant /edge_jobs/create/{method} endpoint"
|
|
||||||
// @success 200 {object} portainer.EdgeGroup
|
|
||||||
// @failure 503 "Edge compute features are disabled"
|
|
||||||
// @failure 500
|
|
||||||
// @deprecated
|
|
||||||
// @router /edge_jobs [post]
|
|
||||||
func deprecatedEdgeJobCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
|
||||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
|
||||||
if err != nil {
|
|
||||||
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return "/edge_jobs/create/" + method, nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -6,7 +6,6 @@ import (
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/http/middlewares"
|
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
|
@ -30,8 +29,6 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||||
|
|
||||||
h.Handle("/edge_jobs",
|
h.Handle("/edge_jobs",
|
||||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobList)))).Methods(http.MethodGet)
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobList)))).Methods(http.MethodGet)
|
||||||
h.Handle("/edge_jobs",
|
|
||||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeJobCreateUrlParser)))).Methods(http.MethodPost)
|
|
||||||
h.Handle("/edge_jobs/create/{method}",
|
h.Handle("/edge_jobs/create/{method}",
|
||||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobCreate)))).Methods(http.MethodPost)
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobCreate)))).Methods(http.MethodPost)
|
||||||
h.Handle("/edge_jobs/{id}",
|
h.Handle("/edge_jobs/{id}",
|
||||||
|
|
|
@ -55,26 +55,3 @@ func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method str
|
||||||
|
|
||||||
return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file")
|
return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// @id EdgeStackCreate
|
|
||||||
// @summary Create an EdgeStack
|
|
||||||
// @description **Access policy**: administrator
|
|
||||||
// @tags edge_stacks
|
|
||||||
// @security ApiKeyAuth
|
|
||||||
// @security jwt
|
|
||||||
// @produce json
|
|
||||||
// @param method query string true "Creation Method" Enums(file,string,repository)
|
|
||||||
// @param body body object true "for body documentation see the relevant /edge_stacks/create/{method} endpoint"
|
|
||||||
// @success 200 {object} portainer.EdgeStack
|
|
||||||
// @failure 500
|
|
||||||
// @failure 503 "Edge compute features are disabled"
|
|
||||||
// @deprecated
|
|
||||||
// @router /edge_stacks [post]
|
|
||||||
func deprecatedEdgeStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
|
||||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
|
||||||
if err != nil {
|
|
||||||
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return "/edge_stacks/create/" + method, nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,87 +0,0 @@
|
||||||
package edgestacks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
|
||||||
"github.com/portainer/portainer/api/http/middlewares"
|
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
|
||||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
|
||||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
// @id EdgeStackStatusDelete
|
|
||||||
// @summary Delete an EdgeStack status
|
|
||||||
// @description Authorized only if the request is done by an Edge Environment(Endpoint)
|
|
||||||
// @tags edge_stacks
|
|
||||||
// @produce json
|
|
||||||
// @param id path int true "EdgeStack Id"
|
|
||||||
// @param environmentId path int true "Environment identifier"
|
|
||||||
// @success 200 {object} portainer.EdgeStack
|
|
||||||
// @failure 500
|
|
||||||
// @failure 400
|
|
||||||
// @failure 404
|
|
||||||
// @failure 403
|
|
||||||
// @deprecated
|
|
||||||
// @router /edge_stacks/{id}/status/{environmentId} [delete]
|
|
||||||
func (handler *Handler) edgeStackStatusDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
|
||||||
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
|
||||||
if err != nil {
|
|
||||||
return httperror.BadRequest("Invalid stack identifier route variable", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint, err := middlewares.FetchEndpoint(r)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to retrieve a valid endpoint from the handler context", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.Forbidden("Permission denied to access environment", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var stack *portainer.EdgeStack
|
|
||||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
|
||||||
stack, err = handler.deleteEdgeStackStatus(tx, portainer.EdgeStackID(stackID), endpoint)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
var httpErr *httperror.HandlerError
|
|
||||||
if errors.As(err, &httpErr) {
|
|
||||||
return httpErr
|
|
||||||
}
|
|
||||||
|
|
||||||
return httperror.InternalServerError("Unexpected error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.JSON(w, stack)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler *Handler) deleteEdgeStackStatus(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID, endpoint *portainer.Endpoint) (*portainer.EdgeStack, error) {
|
|
||||||
stack, err := tx.EdgeStack().EdgeStack(stackID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
|
|
||||||
}
|
|
||||||
|
|
||||||
environmentStatus, ok := stack.Status[endpoint.ID]
|
|
||||||
if !ok {
|
|
||||||
environmentStatus = portainer.EdgeStackStatus{}
|
|
||||||
}
|
|
||||||
|
|
||||||
environmentStatus.Status = append(environmentStatus.Status, portainer.EdgeStackDeploymentStatus{
|
|
||||||
Time: time.Now().Unix(),
|
|
||||||
Type: portainer.EdgeStackStatusRemoved,
|
|
||||||
})
|
|
||||||
|
|
||||||
stack.Status[endpoint.ID] = environmentStatus
|
|
||||||
|
|
||||||
err = tx.EdgeStack().UpdateEdgeStack(stack.ID, stack)
|
|
||||||
if err != nil {
|
|
||||||
return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return stack, nil
|
|
||||||
}
|
|
|
@ -1,30 +0,0 @@
|
||||||
package edgestacks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDeleteStatus(t *testing.T) {
|
|
||||||
handler, _ := setupHandler(t)
|
|
||||||
|
|
||||||
endpoint := createEndpoint(t, handler.DataStore)
|
|
||||||
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d/status/%d", edgeStack.ID, endpoint.ID), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -79,7 +79,7 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFn := func(stack *portainer.EdgeStack) (*portainer.EdgeStack, error) {
|
updateFn := func(stack *portainer.EdgeStack) (*portainer.EdgeStack, error) {
|
||||||
return handler.updateEdgeStackStatus(stack, endpoint, r, stack.ID, payload)
|
return handler.updateEdgeStackStatus(stack, stack.ID, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
stack, err := handler.stackCoordinator.UpdateStatus(r, portainer.EdgeStackID(stackID), updateFn)
|
stack, err := handler.stackCoordinator.UpdateStatus(r, portainer.EdgeStackID(stackID), updateFn)
|
||||||
|
@ -99,7 +99,7 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
||||||
return response.JSON(w, stack)
|
return response.JSON(w, stack)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) updateEdgeStackStatus(stack *portainer.EdgeStack, endpoint *portainer.Endpoint, r *http.Request, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
|
func (handler *Handler) updateEdgeStackStatus(stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
|
||||||
if payload.Version > 0 && payload.Version < stack.Version {
|
if payload.Version > 0 && payload.Version < stack.Version {
|
||||||
return stack, nil
|
return stack, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,6 +60,11 @@ func (c *EdgeStackStatusUpdateCoordinator) loop() {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return early when the agent tries to update the status on a deleted stack
|
||||||
|
if stack == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Mutate the edge stack opportunistically until there are no more pending updates
|
// 2. Mutate the edge stack opportunistically until there are no more pending updates
|
||||||
for {
|
for {
|
||||||
stack, err = u.updateFn(stack)
|
stack, err = u.updateFn(stack)
|
||||||
|
|
|
@ -107,7 +107,7 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
|
||||||
|
|
||||||
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, payload.DeploymentType)
|
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, payload.DeploymentType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, httperror.BadRequest("unable to check for existence of non fitting environments: %w", err)
|
return nil, httperror.InternalServerError("unable to check for existence of non fitting environments: %w", err)
|
||||||
}
|
}
|
||||||
if hasWrongType {
|
if hasWrongType {
|
||||||
return nil, httperror.BadRequest("edge stack with config do not match the environment type", nil)
|
return nil, httperror.BadRequest("edge stack with config do not match the environment type", nil)
|
||||||
|
@ -151,6 +151,9 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
|
||||||
for endpointID := range endpointsToRemove {
|
for endpointID := range endpointsToRemove {
|
||||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if tx.IsErrObjectNotFound(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
|
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,10 +173,16 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
|
||||||
|
|
||||||
for endpointID := range endpointsToAdd {
|
for endpointID := range endpointsToAdd {
|
||||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
||||||
if err != nil {
|
if err != nil && !tx.IsErrObjectNotFound(err) {
|
||||||
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
|
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
|
relation.EdgeStacks[edgeStackID] = true
|
||||||
|
|
||||||
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
|
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
|
||||||
|
|
|
@ -37,8 +37,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||||
|
|
||||||
h.Handle("/edge_stacks/create/{method}",
|
h.Handle("/edge_stacks/create/{method}",
|
||||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
|
||||||
h.Handle("/edge_stacks",
|
|
||||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeStackCreateUrlParser)))).Methods(http.MethodPost) // Deprecated
|
|
||||||
h.Handle("/edge_stacks",
|
h.Handle("/edge_stacks",
|
||||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet)
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet)
|
||||||
h.Handle("/edge_stacks/{id}",
|
h.Handle("/edge_stacks/{id}",
|
||||||
|
@ -55,8 +53,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||||
edgeStackStatusRouter := h.NewRoute().Subrouter()
|
edgeStackStatusRouter := h.NewRoute().Subrouter()
|
||||||
edgeStackStatusRouter.Use(middlewares.WithEndpoint(h.DataStore.Endpoint(), "endpoint_id"))
|
edgeStackStatusRouter.Use(middlewares.WithEndpoint(h.DataStore.Endpoint(), "endpoint_id"))
|
||||||
|
|
||||||
edgeStackStatusRouter.PathPrefix("/edge_stacks/{id}/status/{endpoint_id}").Handler(bouncer.PublicAccess(httperror.LoggerHandler(h.edgeStackStatusDelete))).Methods(http.MethodDelete)
|
|
||||||
|
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,71 +0,0 @@
|
||||||
package edgetemplates
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"slices"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/http/client"
|
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
|
||||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
|
||||||
|
|
||||||
"github.com/segmentio/encoding/json"
|
|
||||||
)
|
|
||||||
|
|
||||||
type templateFileFormat struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
Templates []portainer.Template `json:"templates"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// @id EdgeTemplateList
|
|
||||||
// @deprecated
|
|
||||||
// @summary Fetches the list of Edge Templates
|
|
||||||
// @description **Access policy**: administrator
|
|
||||||
// @tags edge_templates
|
|
||||||
// @security ApiKeyAuth
|
|
||||||
// @security jwt
|
|
||||||
// @accept json
|
|
||||||
// @produce json
|
|
||||||
// @success 200 {array} portainer.Template
|
|
||||||
// @failure 500
|
|
||||||
// @router /edge_templates [get]
|
|
||||||
func (handler *Handler) edgeTemplateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
|
||||||
settings, err := handler.DataStore.Settings().Settings()
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
url := portainer.DefaultTemplatesURL
|
|
||||||
if settings.TemplatesURL != "" {
|
|
||||||
url = settings.TemplatesURL
|
|
||||||
}
|
|
||||||
|
|
||||||
var templateData []byte
|
|
||||||
templateData, err = client.Get(url, 10)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to retrieve external templates", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var templateFile templateFileFormat
|
|
||||||
|
|
||||||
err = json.Unmarshal(templateData, &templateFile)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to parse template file", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// We only support version 3 of the template format
|
|
||||||
// this is only a temporary fix until we have custom edge templates
|
|
||||||
if templateFile.Version != "3" {
|
|
||||||
return httperror.InternalServerError("Unsupported template version", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredTemplates := make([]portainer.Template, 0)
|
|
||||||
|
|
||||||
for _, template := range templateFile.Templates {
|
|
||||||
if slices.Contains(template.Categories, "edge") && slices.Contains([]portainer.TemplateType{portainer.ComposeStackTemplate, portainer.SwarmStackTemplate}, template.Type) {
|
|
||||||
filteredTemplates = append(filteredTemplates, template)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.JSON(w, filteredTemplates)
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
package edgetemplates
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
|
||||||
"github.com/portainer/portainer/api/http/middlewares"
|
|
||||||
"github.com/portainer/portainer/api/http/security"
|
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handler is the HTTP handler used to handle edge environment(endpoint) operations.
|
|
||||||
type Handler struct {
|
|
||||||
*mux.Router
|
|
||||||
requestBouncer security.BouncerService
|
|
||||||
DataStore dataservices.DataStore
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHandler creates a handler to manage environment(endpoint) operations.
|
|
||||||
func NewHandler(bouncer security.BouncerService) *Handler {
|
|
||||||
h := &Handler{
|
|
||||||
Router: mux.NewRouter(),
|
|
||||||
requestBouncer: bouncer,
|
|
||||||
}
|
|
||||||
|
|
||||||
h.Handle("/edge_templates",
|
|
||||||
bouncer.AdminAccess(middlewares.Deprecated(httperror.LoggerHandler(h.edgeTemplateList), func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { return "", nil }))).Methods(http.MethodGet)
|
|
||||||
|
|
||||||
return h
|
|
||||||
}
|
|
|
@ -264,6 +264,9 @@ func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpointID p
|
||||||
func (handler *Handler) buildEdgeStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) ([]stackStatusResponse, *httperror.HandlerError) {
|
func (handler *Handler) buildEdgeStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) ([]stackStatusResponse, *httperror.HandlerError) {
|
||||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if tx.IsErrObjectNotFound(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
return nil, httperror.InternalServerError("Unable to retrieve relation object from the database", err)
|
return nil, httperror.InternalServerError("Unable to retrieve relation object from the database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,10 +21,17 @@ func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, end
|
||||||
}
|
}
|
||||||
|
|
||||||
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
||||||
if err != nil {
|
if err != nil && !tx.IsErrObjectNotFound(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if endpointRelation == nil {
|
||||||
|
endpointRelation = &portainer.EndpointRelation{
|
||||||
|
EndpointID: endpoint.ID,
|
||||||
|
EdgeStacks: make(map[portainer.EdgeStackID]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
edgeGroups, err := tx.EdgeGroup().ReadAll()
|
edgeGroups, err := tx.EdgeGroup().ReadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -32,6 +39,9 @@ func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, end
|
||||||
|
|
||||||
edgeStacks, err := tx.EdgeStack().EdgeStacks()
|
edgeStacks, err := tx.EdgeStack().EdgeStacks()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if tx.IsErrObjectNotFound(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -91,7 +91,7 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
|
||||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||||
// @failure 403 "Unauthorized access or operation not allowed."
|
// @failure 403 "Unauthorized access or operation not allowed."
|
||||||
// @failure 500 "Server error occurred while attempting to delete the specified environments."
|
// @failure 500 "Server error occurred while attempting to delete the specified environments."
|
||||||
// @router /endpoints [delete]
|
// @router /endpoints/delete [post]
|
||||||
func (handler *Handler) endpointDeleteBatch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) endpointDeleteBatch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
var p endpointDeleteBatchPayload
|
var p endpointDeleteBatchPayload
|
||||||
if err := request.DecodeAndValidateJSONPayload(r, &p); err != nil {
|
if err := request.DecodeAndValidateJSONPayload(r, &p); err != nil {
|
||||||
|
@ -127,6 +127,27 @@ func (handler *Handler) endpointDeleteBatch(w http.ResponseWriter, r *http.Reque
|
||||||
return response.Empty(w)
|
return response.Empty(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @id EndpointDeleteBatchDeprecated
|
||||||
|
// @summary Remove multiple environments
|
||||||
|
// @deprecated
|
||||||
|
// @description Deprecated: use the `POST` endpoint instead.
|
||||||
|
// @description Remove multiple environments and optionally clean-up associated resources.
|
||||||
|
// @description **Access policy**: Administrator only.
|
||||||
|
// @tags endpoints
|
||||||
|
// @security ApiKeyAuth || jwt
|
||||||
|
// @accept json
|
||||||
|
// @produce json
|
||||||
|
// @param body body endpointDeleteBatchPayload true "List of environments to delete, with optional deleteCluster flag to clean-up associated resources (cloud environments only)"
|
||||||
|
// @success 204 "Environment(s) successfully deleted."
|
||||||
|
// @failure 207 {object} endpointDeleteBatchPartialResponse "Partial success. Some environments were deleted successfully, while others failed."
|
||||||
|
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||||
|
// @failure 403 "Unauthorized access or operation not allowed."
|
||||||
|
// @failure 500 "Server error occurred while attempting to delete the specified environments."
|
||||||
|
// @router /endpoints [delete]
|
||||||
|
func (handler *Handler) endpointDeleteBatchDeprecated(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
return handler.endpointDeleteBatch(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, deleteCluster bool) error {
|
func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, deleteCluster bool) error {
|
||||||
endpoint, err := tx.Endpoint().Endpoint(endpointID)
|
endpoint, err := tx.Endpoint().Endpoint(endpointID)
|
||||||
if tx.IsErrObjectNotFound(err) {
|
if tx.IsErrObjectNotFound(err) {
|
||||||
|
|
|
@ -80,6 +80,13 @@ func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.R
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if handler.PullLimitCheckDisabled {
|
||||||
|
return response.JSON(w, &dockerhubStatusResponse{
|
||||||
|
Limit: 10,
|
||||||
|
Remaining: 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
httpClient := client.NewHTTPClient()
|
httpClient := client.NewHTTPClient()
|
||||||
token, err := getDockerHubToken(httpClient, registry)
|
token, err := getDockerHubToken(httpClient, registry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -26,19 +26,20 @@ func hideFields(endpoint *portainer.Endpoint) {
|
||||||
// Handler is the HTTP handler used to handle environment(endpoint) operations.
|
// Handler is the HTTP handler used to handle environment(endpoint) operations.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
*mux.Router
|
*mux.Router
|
||||||
requestBouncer security.BouncerService
|
requestBouncer security.BouncerService
|
||||||
DataStore dataservices.DataStore
|
DataStore dataservices.DataStore
|
||||||
FileService portainer.FileService
|
FileService portainer.FileService
|
||||||
ProxyManager *proxy.Manager
|
ProxyManager *proxy.Manager
|
||||||
ReverseTunnelService portainer.ReverseTunnelService
|
ReverseTunnelService portainer.ReverseTunnelService
|
||||||
SnapshotService portainer.SnapshotService
|
SnapshotService portainer.SnapshotService
|
||||||
K8sClientFactory *cli.ClientFactory
|
K8sClientFactory *cli.ClientFactory
|
||||||
ComposeStackManager portainer.ComposeStackManager
|
ComposeStackManager portainer.ComposeStackManager
|
||||||
AuthorizationService *authorization.Service
|
AuthorizationService *authorization.Service
|
||||||
DockerClientFactory *dockerclient.ClientFactory
|
DockerClientFactory *dockerclient.ClientFactory
|
||||||
BindAddress string
|
BindAddress string
|
||||||
BindAddressHTTPS string
|
BindAddressHTTPS string
|
||||||
PendingActionsService *pendingactions.PendingActionsService
|
PendingActionsService *pendingactions.PendingActionsService
|
||||||
|
PullLimitCheckDisabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a handler to manage environment(endpoint) operations.
|
// NewHandler creates a handler to manage environment(endpoint) operations.
|
||||||
|
@ -68,8 +69,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut)
|
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut)
|
||||||
h.Handle("/endpoints/{id}",
|
h.Handle("/endpoints/{id}",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete)
|
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete)
|
||||||
h.Handle("/endpoints",
|
h.Handle("/endpoints/delete",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatch))).Methods(http.MethodDelete)
|
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatch))).Methods(http.MethodPost)
|
||||||
h.Handle("/endpoints/{id}/dockerhub/{registryId}",
|
h.Handle("/endpoints/{id}/dockerhub/{registryId}",
|
||||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet)
|
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet)
|
||||||
h.Handle("/endpoints/{id}/snapshot",
|
h.Handle("/endpoints/{id}/snapshot",
|
||||||
|
@ -85,6 +86,7 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||||
|
|
||||||
// DEPRECATED
|
// DEPRECATED
|
||||||
h.Handle("/endpoints/{id}/status", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet)
|
h.Handle("/endpoints/{id}/status", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/endpoints", bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatchDeprecated))).Methods(http.MethodDelete)
|
||||||
|
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ func (handler *Handler) updateEdgeRelations(tx dataservices.DataStoreTx, endpoin
|
||||||
|
|
||||||
relation = &portainer.EndpointRelation{
|
relation = &portainer.EndpointRelation{
|
||||||
EndpointID: endpoint.ID,
|
EndpointID: endpoint.ID,
|
||||||
|
EdgeStacks: map[portainer.EdgeStackID]bool{},
|
||||||
}
|
}
|
||||||
if err := tx.EndpointRelation().Create(relation); err != nil {
|
if err := tx.EndpointRelation().Create(relation); err != nil {
|
||||||
return errors.WithMessage(err, "Unable to create environment relation inside the database")
|
return errors.WithMessage(err, "Unable to create environment relation inside the database")
|
||||||
|
|
|
@ -11,7 +11,6 @@ import (
|
||||||
"github.com/portainer/portainer/api/http/handler/edgegroups"
|
"github.com/portainer/portainer/api/http/handler/edgegroups"
|
||||||
"github.com/portainer/portainer/api/http/handler/edgejobs"
|
"github.com/portainer/portainer/api/http/handler/edgejobs"
|
||||||
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
||||||
"github.com/portainer/portainer/api/http/handler/edgetemplates"
|
|
||||||
"github.com/portainer/portainer/api/http/handler/endpointedge"
|
"github.com/portainer/portainer/api/http/handler/endpointedge"
|
||||||
"github.com/portainer/portainer/api/http/handler/endpointgroups"
|
"github.com/portainer/portainer/api/http/handler/endpointgroups"
|
||||||
"github.com/portainer/portainer/api/http/handler/endpointproxy"
|
"github.com/portainer/portainer/api/http/handler/endpointproxy"
|
||||||
|
@ -50,7 +49,6 @@ type Handler struct {
|
||||||
EdgeGroupsHandler *edgegroups.Handler
|
EdgeGroupsHandler *edgegroups.Handler
|
||||||
EdgeJobsHandler *edgejobs.Handler
|
EdgeJobsHandler *edgejobs.Handler
|
||||||
EdgeStacksHandler *edgestacks.Handler
|
EdgeStacksHandler *edgestacks.Handler
|
||||||
EdgeTemplatesHandler *edgetemplates.Handler
|
|
||||||
EndpointEdgeHandler *endpointedge.Handler
|
EndpointEdgeHandler *endpointedge.Handler
|
||||||
EndpointGroupHandler *endpointgroups.Handler
|
EndpointGroupHandler *endpointgroups.Handler
|
||||||
EndpointHandler *endpoints.Handler
|
EndpointHandler *endpoints.Handler
|
||||||
|
@ -83,7 +81,7 @@ type Handler struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// @title PortainerCE API
|
// @title PortainerCE API
|
||||||
// @version 2.26.0
|
// @version 2.27.6
|
||||||
// @description.markdown api-description.md
|
// @description.markdown api-description.md
|
||||||
// @termsOfService
|
// @termsOfService
|
||||||
|
|
||||||
|
@ -190,8 +188,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
http.StripPrefix("/api", h.EdgeGroupsHandler).ServeHTTP(w, r)
|
http.StripPrefix("/api", h.EdgeGroupsHandler).ServeHTTP(w, r)
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/edge_jobs"):
|
case strings.HasPrefix(r.URL.Path, "/api/edge_jobs"):
|
||||||
http.StripPrefix("/api", h.EdgeJobsHandler).ServeHTTP(w, r)
|
http.StripPrefix("/api", h.EdgeJobsHandler).ServeHTTP(w, r)
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/edge_templates"):
|
|
||||||
http.StripPrefix("/api", h.EdgeTemplatesHandler).ServeHTTP(w, r)
|
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"):
|
case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"):
|
||||||
http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r)
|
http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r)
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/kubernetes"):
|
case strings.HasPrefix(r.URL.Path, "/api/kubernetes"):
|
||||||
|
|
|
@ -53,12 +53,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||||
h.Handle("/{id}/kubernetes/helm",
|
h.Handle("/{id}/kubernetes/helm",
|
||||||
httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost)
|
httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost)
|
||||||
|
|
||||||
// Deprecated
|
|
||||||
h.Handle("/{id}/kubernetes/helm/repositories",
|
|
||||||
httperror.LoggerHandler(h.userGetHelmRepos)).Methods(http.MethodGet)
|
|
||||||
h.Handle("/{id}/kubernetes/helm/repositories",
|
|
||||||
httperror.LoggerHandler(h.userCreateHelmRepo)).Methods(http.MethodPost)
|
|
||||||
|
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -45,7 +45,7 @@ func Test_helmInstall(t *testing.T) {
|
||||||
is.NotNil(h, "Handler should not fail")
|
is.NotNil(h, "Handler should not fail")
|
||||||
|
|
||||||
// Install a single chart. We expect to get these values back
|
// 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)
|
optdata, err := json.Marshal(options)
|
||||||
is.NoError(err)
|
is.NoError(err)
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ func Test_helmRepoSearch(t *testing.T) {
|
||||||
|
|
||||||
assert.NotNil(t, h, "Handler should not fail")
|
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 {
|
for _, repo := range repos {
|
||||||
t.Run(repo, func(t *testing.T) {
|
t.Run(repo, func(t *testing.T) {
|
||||||
|
|
|
@ -31,7 +31,7 @@ func Test_helmShow(t *testing.T) {
|
||||||
t.Run(cmd, func(t *testing.T) {
|
t.Run(cmd, func(t *testing.T) {
|
||||||
is.NotNil(h, "Handler should not fail")
|
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"
|
chart := "nginx"
|
||||||
req := httptest.NewRequest("GET", fmt.Sprintf("/templates/helm/%s?repo=%s&chart=%s", cmd, repoUrlEncoded, chart), nil)
|
req := httptest.NewRequest("GET", fmt.Sprintf("/templates/helm/%s?repo=%s&chart=%s", cmd, repoUrlEncoded, chart), nil)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
|
|
|
@ -1,127 +0,0 @@
|
||||||
package helm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/http/security"
|
|
||||||
"github.com/portainer/portainer/pkg/libhelm"
|
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
|
||||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
|
||||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
type helmUserRepositoryResponse struct {
|
|
||||||
GlobalRepository string `json:"GlobalRepository"`
|
|
||||||
UserRepositories []portainer.HelmUserRepository `json:"UserRepositories"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type addHelmRepoUrlPayload struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error {
|
|
||||||
return libhelm.ValidateHelmRepositoryURL(p.URL, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// @id HelmUserRepositoryCreateDeprecated
|
|
||||||
// @summary Create a user helm repository
|
|
||||||
// @description Create a user helm repository.
|
|
||||||
// @description **Access policy**: authenticated
|
|
||||||
// @tags helm
|
|
||||||
// @security ApiKeyAuth
|
|
||||||
// @security jwt
|
|
||||||
// @accept json
|
|
||||||
// @produce json
|
|
||||||
// @param id path int true "Environment(Endpoint) identifier"
|
|
||||||
// @param payload body addHelmRepoUrlPayload true "Helm Repository"
|
|
||||||
// @success 200 {object} portainer.HelmUserRepository "Success"
|
|
||||||
// @failure 400 "Invalid request"
|
|
||||||
// @failure 403 "Permission denied"
|
|
||||||
// @failure 500 "Server error"
|
|
||||||
// @deprecated
|
|
||||||
// @router /endpoints/{id}/kubernetes/helm/repositories [post]
|
|
||||||
func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
|
||||||
tokenData, err := security.RetrieveTokenData(r)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
|
||||||
}
|
|
||||||
userID := tokenData.ID
|
|
||||||
|
|
||||||
p := new(addHelmRepoUrlPayload)
|
|
||||||
err = request.DecodeAndValidateJSONPayload(r, p)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.BadRequest("Invalid Helm repository URL", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// lowercase, remove trailing slash
|
|
||||||
p.URL = strings.TrimSuffix(strings.ToLower(p.URL), "/")
|
|
||||||
|
|
||||||
records, err := handler.dataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to access the DataStore", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if repo already exists - by doing case insensitive comparison
|
|
||||||
for _, record := range records {
|
|
||||||
if strings.EqualFold(record.URL, p.URL) {
|
|
||||||
errMsg := "Helm repo already registered for user"
|
|
||||||
return httperror.BadRequest(errMsg, errors.New(errMsg))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
record := portainer.HelmUserRepository{
|
|
||||||
UserID: userID,
|
|
||||||
URL: p.URL,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = handler.dataStore.HelmUserRepository().Create(&record)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to save a user Helm repository URL", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.JSON(w, record)
|
|
||||||
}
|
|
||||||
|
|
||||||
// @id HelmUserRepositoriesListDeprecated
|
|
||||||
// @summary List a users helm repositories
|
|
||||||
// @description Inspect a user helm repositories.
|
|
||||||
// @description **Access policy**: authenticated
|
|
||||||
// @tags helm
|
|
||||||
// @security ApiKeyAuth
|
|
||||||
// @security jwt
|
|
||||||
// @produce json
|
|
||||||
// @param id path int true "User identifier"
|
|
||||||
// @success 200 {object} helmUserRepositoryResponse "Success"
|
|
||||||
// @failure 400 "Invalid request"
|
|
||||||
// @failure 403 "Permission denied"
|
|
||||||
// @failure 500 "Server error"
|
|
||||||
// @deprecated
|
|
||||||
// @router /endpoints/{id}/kubernetes/helm/repositories [get]
|
|
||||||
func (handler *Handler) userGetHelmRepos(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
|
||||||
tokenData, err := security.RetrieveTokenData(r)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
|
||||||
}
|
|
||||||
userID := tokenData.ID
|
|
||||||
|
|
||||||
settings, err := handler.dataStore.Settings().Settings()
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
userRepos, err := handler.dataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to get user Helm repositories", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := helmUserRepositoryResponse{
|
|
||||||
GlobalRepository: settings.HelmRepositoryURL,
|
|
||||||
UserRepositories: userRepos,
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.JSON(w, resp)
|
|
||||||
}
|
|
|
@ -69,7 +69,6 @@ func (handler *Handler) getApplicationsResources(w http.ResponseWriter, r *http.
|
||||||
// @param id path int true "Environment(Endpoint) identifier"
|
// @param id path int true "Environment(Endpoint) identifier"
|
||||||
// @param namespace query string true "Namespace name"
|
// @param namespace query string true "Namespace name"
|
||||||
// @param nodeName query string true "Node name"
|
// @param nodeName query string true "Node name"
|
||||||
// @param withDependencies query boolean false "Include dependencies in the response"
|
|
||||||
// @success 200 {array} models.K8sApplication "Success"
|
// @success 200 {array} models.K8sApplication "Success"
|
||||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
// @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 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
||||||
|
@ -117,12 +116,6 @@ func (handler *Handler) getAllKubernetesApplications(r *http.Request) ([]models.
|
||||||
return nil, httperror.BadRequest("Unable to parse the namespace query parameter", err)
|
return nil, httperror.BadRequest("Unable to parse the namespace query parameter", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
withDependencies, err := request.RetrieveBooleanQueryParameter(r, "withDependencies", true)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the withDependencies query parameter")
|
|
||||||
return nil, httperror.BadRequest("Unable to parse the withDependencies query parameter", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeName, err := request.RetrieveQueryParameter(r, "nodeName", true)
|
nodeName, err := request.RetrieveQueryParameter(r, "nodeName", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the nodeName query parameter")
|
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the nodeName query parameter")
|
||||||
|
@ -135,7 +128,7 @@ func (handler *Handler) getAllKubernetesApplications(r *http.Request) ([]models.
|
||||||
return nil, httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
|
return nil, httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
applications, err := cli.GetApplications(namespace, nodeName, withDependencies)
|
applications, err := cli.GetApplications(namespace, nodeName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if k8serrors.IsUnauthorized(err) {
|
if k8serrors.IsUnauthorized(err) {
|
||||||
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Str("namespace", namespace).Str("nodeName", nodeName).Msg("Unable to get the list of applications")
|
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Str("namespace", namespace).Str("nodeName", nodeName).Msg("Unable to get the list of applications")
|
||||||
|
|
|
@ -46,7 +46,7 @@ type settingsUpdatePayload struct {
|
||||||
// Whether telemetry is enabled
|
// Whether telemetry is enabled
|
||||||
EnableTelemetry *bool `example:"false"`
|
EnableTelemetry *bool `example:"false"`
|
||||||
// Helm repository URL
|
// Helm repository URL
|
||||||
HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
|
HelmRepositoryURL *string `example:"https://kubernetes.github.io/ingress-nginx"`
|
||||||
// Kubectl Shell Image
|
// Kubectl Shell Image
|
||||||
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
|
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
|
||||||
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
|
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
|
||||||
|
|
|
@ -11,7 +11,6 @@ import (
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||||
"github.com/portainer/portainer/api/docker/consts"
|
"github.com/portainer/portainer/api/docker/consts"
|
||||||
"github.com/portainer/portainer/api/http/middlewares"
|
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
"github.com/portainer/portainer/api/internal/authorization"
|
"github.com/portainer/portainer/api/internal/authorization"
|
||||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
|
@ -62,8 +61,6 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||||
|
|
||||||
h.Handle("/stacks/create/{type}/{method}",
|
h.Handle("/stacks/create/{type}/{method}",
|
||||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost)
|
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost)
|
||||||
h.Handle("/stacks",
|
|
||||||
bouncer.AuthenticatedAccess(middlewares.Deprecated(h, deprecatedStackCreateUrlParser))).Methods(http.MethodPost) // Deprecated
|
|
||||||
h.Handle("/stacks",
|
h.Handle("/stacks",
|
||||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet)
|
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet)
|
||||||
h.Handle("/stacks/{id}",
|
h.Handle("/stacks/{id}",
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package stacks
|
package stacks
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
@ -141,53 +140,3 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
|
||||||
|
|
||||||
return response.JSON(w, stack)
|
return response.JSON(w, stack)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStackTypeFromQueryParameter(r *http.Request) (string, error) {
|
|
||||||
stackType, err := request.RetrieveNumericQueryParameter(r, "type", false)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch stackType {
|
|
||||||
case 1:
|
|
||||||
return "swarm", nil
|
|
||||||
case 2:
|
|
||||||
return "standalone", nil
|
|
||||||
case 3:
|
|
||||||
return "kubernetes", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", errors.New(request.ErrInvalidQueryParameter)
|
|
||||||
}
|
|
||||||
|
|
||||||
// @id StackCreate
|
|
||||||
// @summary Deploy a new stack
|
|
||||||
// @description Deploy a new stack into a Docker environment(endpoint) specified via the environment(endpoint) identifier.
|
|
||||||
// @description **Access policy**: authenticated
|
|
||||||
// @tags stacks
|
|
||||||
// @security ApiKeyAuth
|
|
||||||
// @security jwt
|
|
||||||
// @accept json,multipart/form-data
|
|
||||||
// @produce json
|
|
||||||
// @param type query int true "Stack deployment type. Possible values: 1 (Swarm stack), 2 (Compose stack) or 3 (Kubernetes stack)." Enums(1,2,3)
|
|
||||||
// @param method query string true "Stack deployment method. Possible values: file, string, repository or url." Enums(string, file, repository, url)
|
|
||||||
// @param endpointId query int true "Identifier of the environment(endpoint) that will be used to deploy the stack"
|
|
||||||
// @param body body object true "for body documentation see the relevant /stacks/create/{type}/{method} endpoint"
|
|
||||||
// @success 200 {object} portainer.Stack
|
|
||||||
// @failure 400 "Invalid request"
|
|
||||||
// @failure 500 "Server error"
|
|
||||||
// @deprecated
|
|
||||||
// @router /stacks [post]
|
|
||||||
func deprecatedStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
|
||||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
|
||||||
if err != nil {
|
|
||||||
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stackType, err := getStackTypeFromQueryParameter(r)
|
|
||||||
if err != nil {
|
|
||||||
return "", httperror.BadRequest("Invalid query parameter: type", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("/stacks/create/%s/%s", stackType, method), nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -59,10 +59,6 @@ func NewHandler(bouncer security.BouncerService,
|
||||||
// Deprecated /status endpoint, will be removed in the future.
|
// Deprecated /status endpoint, will be removed in the future.
|
||||||
h.Handle("/status",
|
h.Handle("/status",
|
||||||
bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspectDeprecated))).Methods(http.MethodGet)
|
bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspectDeprecated))).Methods(http.MethodGet)
|
||||||
h.Handle("/status/version",
|
|
||||||
bouncer.AuthenticatedAccess(http.HandlerFunc(h.versionDeprecated))).Methods(http.MethodGet)
|
|
||||||
h.Handle("/status/nodes",
|
|
||||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.statusNodesCountDeprecated))).Methods(http.MethodGet)
|
|
||||||
|
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,8 +8,6 @@ import (
|
||||||
"github.com/portainer/portainer/api/internal/snapshot"
|
"github.com/portainer/portainer/api/internal/snapshot"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type nodesCountResponse struct {
|
type nodesCountResponse struct {
|
||||||
|
@ -44,21 +42,3 @@ func (handler *Handler) systemNodesCount(w http.ResponseWriter, r *http.Request)
|
||||||
|
|
||||||
return response.JSON(w, &nodesCountResponse{Nodes: nodes})
|
return response.JSON(w, &nodesCountResponse{Nodes: nodes})
|
||||||
}
|
}
|
||||||
|
|
||||||
// @id statusNodesCount
|
|
||||||
// @summary Retrieve the count of nodes
|
|
||||||
// @deprecated
|
|
||||||
// @description Deprecated: use the `/system/nodes` endpoint instead.
|
|
||||||
// @description **Access policy**: authenticated
|
|
||||||
// @security ApiKeyAuth
|
|
||||||
// @security jwt
|
|
||||||
// @tags status
|
|
||||||
// @produce json
|
|
||||||
// @success 200 {object} nodesCountResponse "Success"
|
|
||||||
// @failure 500 "Server error"
|
|
||||||
// @router /status/nodes [get]
|
|
||||||
func (handler *Handler) statusNodesCountDeprecated(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
|
||||||
log.Warn().Msg("The /status/nodes endpoint is deprecated, please use the /system/nodes endpoint instead")
|
|
||||||
|
|
||||||
return handler.systemNodesCount(w, r)
|
|
||||||
}
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ package system
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
plf "github.com/portainer/portainer/api/platform"
|
plf "github.com/portainer/portainer/api/platform"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
@ -46,7 +47,12 @@ func (handler *Handler) systemInfo(w http.ResponseWriter, r *http.Request) *http
|
||||||
|
|
||||||
platform, err := handler.platformService.GetPlatform()
|
platform, err := handler.platformService.GetPlatform()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Failed to get platform", err)
|
if !errors.Is(err, plf.ErrNoLocalEnvironment) {
|
||||||
|
return httperror.InternalServerError("Failed to get platform", err)
|
||||||
|
}
|
||||||
|
// If no local environment is detected, we assume the platform is Docker
|
||||||
|
// UI will stop showing the upgrade banner
|
||||||
|
platform = plf.PlatformDocker
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.JSON(w, &systemInfoResponse{
|
return response.JSON(w, &systemInfoResponse{
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
|
ceplf "github.com/portainer/portainer/api/platform"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
|
@ -45,6 +46,9 @@ func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *h
|
||||||
|
|
||||||
environment, err := handler.platformService.GetLocalEnvironment()
|
environment, err := handler.platformService.GetLocalEnvironment()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ceplf.ErrNoLocalEnvironment) {
|
||||||
|
return httperror.NotFound("The system upgrade feature is disabled because no local environment was detected.", err)
|
||||||
|
}
|
||||||
return httperror.InternalServerError("Failed to get local environment", err)
|
return httperror.InternalServerError("Failed to get local environment", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,8 +57,7 @@ func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *h
|
||||||
return httperror.InternalServerError("Failed to get platform", err)
|
return httperror.InternalServerError("Failed to get platform", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.upgradeService.Upgrade(platform, environment, payload.License)
|
if err := handler.upgradeService.Upgrade(platform, environment, payload.License); err != nil {
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Failed to upgrade Portainer", err)
|
return httperror.InternalServerError("Failed to upgrade Portainer", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -106,21 +106,3 @@ func HasNewerVersion(currentVersion, latestVersion string) bool {
|
||||||
|
|
||||||
return currentVersionSemver.LessThan(*latestVersionSemver)
|
return currentVersionSemver.LessThan(*latestVersionSemver)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @id Version
|
|
||||||
// @summary Check for portainer updates
|
|
||||||
// @deprecated
|
|
||||||
// @description Deprecated: use the `/system/version` endpoint instead.
|
|
||||||
// @description Check if portainer has an update available
|
|
||||||
// @description **Access policy**: authenticated
|
|
||||||
// @security ApiKeyAuth
|
|
||||||
// @security jwt
|
|
||||||
// @tags status
|
|
||||||
// @produce json
|
|
||||||
// @success 200 {object} versionResponse "Success"
|
|
||||||
// @router /status/version [get]
|
|
||||||
func (handler *Handler) versionDeprecated(w http.ResponseWriter, r *http.Request) {
|
|
||||||
log.Warn().Msg("The /status/version endpoint is deprecated, please use the /system/version endpoint instead")
|
|
||||||
|
|
||||||
handler.version(w, r)
|
|
||||||
}
|
|
||||||
|
|
|
@ -133,10 +133,17 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error {
|
||||||
|
|
||||||
func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
|
func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
|
||||||
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
||||||
if err != nil {
|
if err != nil && !tx.IsErrObjectNotFound(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if endpointRelation == nil {
|
||||||
|
endpointRelation = &portainer.EndpointRelation{
|
||||||
|
EndpointID: endpoint.ID,
|
||||||
|
EdgeStacks: make(map[portainer.EdgeStackID]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID)
|
endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -147,6 +154,7 @@ func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.End
|
||||||
for _, edgeStackID := range endpointStacks {
|
for _, edgeStackID := range endpointStacks {
|
||||||
stacksSet[edgeStackID] = true
|
stacksSet[edgeStackID] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
endpointRelation.EdgeStacks = stacksSet
|
endpointRelation.EdgeStacks = stacksSet
|
||||||
|
|
||||||
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
|
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
|
||||||
|
|
|
@ -29,7 +29,5 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet)
|
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet)
|
||||||
h.Handle("/templates/{id}/file",
|
h.Handle("/templates/{id}/file",
|
||||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateFile))).Methods(http.MethodPost)
|
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateFile))).Methods(http.MethodPost)
|
||||||
h.Handle("/templates/file",
|
|
||||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateFileOld))).Methods(http.MethodPost)
|
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,93 +0,0 @@
|
||||||
package templates
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"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/rs/zerolog/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
type filePayload struct {
|
|
||||||
// URL of a git repository where the file is stored
|
|
||||||
RepositoryURL string `example:"https://github.com/portainer/portainer-compose" validate:"required"`
|
|
||||||
// Path to the file inside the git repository
|
|
||||||
ComposeFilePathInRepository string `example:"./subfolder/docker-compose.yml" validate:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (payload *filePayload) Validate(r *http.Request) error {
|
|
||||||
if len(payload.RepositoryURL) == 0 {
|
|
||||||
return errors.New("Invalid repository url")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(payload.ComposeFilePathInRepository) == 0 {
|
|
||||||
return errors.New("Invalid file path")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler *Handler) ifRequestedTemplateExists(payload *filePayload) *httperror.HandlerError {
|
|
||||||
response, httpErr := handler.fetchTemplates()
|
|
||||||
if httpErr != nil {
|
|
||||||
return httpErr
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, t := range response.Templates {
|
|
||||||
if t.Repository.URL == payload.RepositoryURL && t.Repository.StackFile == payload.ComposeFilePathInRepository {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return httperror.InternalServerError("Invalid template", errors.New("requested template does not exist"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// @id TemplateFileOld
|
|
||||||
// @summary Get a template's file
|
|
||||||
// @deprecated
|
|
||||||
// @description Get a template's file
|
|
||||||
// @description **Access policy**: authenticated
|
|
||||||
// @tags templates
|
|
||||||
// @security ApiKeyAuth
|
|
||||||
// @security jwt
|
|
||||||
// @accept json
|
|
||||||
// @produce json
|
|
||||||
// @param body body filePayload true "File details"
|
|
||||||
// @success 200 {object} fileResponse "Success"
|
|
||||||
// @failure 400 "Invalid request"
|
|
||||||
// @failure 500 "Server error"
|
|
||||||
// @router /templates/file [post]
|
|
||||||
func (handler *Handler) templateFileOld(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
|
||||||
log.Warn().Msg("This api is deprecated. Please use /templates/{id}/file instead")
|
|
||||||
|
|
||||||
var payload filePayload
|
|
||||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.BadRequest("Invalid request payload", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := handler.ifRequestedTemplateExists(&payload); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
projectPath, err := handler.FileService.GetTemporaryPath()
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to create temporary folder", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer handler.cleanUp(projectPath)
|
|
||||||
|
|
||||||
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, "", "", "", false)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to clone git repository", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fileContent, err := handler.FileService.GetFileContent(projectPath, payload.ComposeFilePathInRepository)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Failed loading file content", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.JSON(w, fileResponse{FileContent: string(fileContent)})
|
|
||||||
|
|
||||||
}
|
|
|
@ -243,8 +243,7 @@ func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler,
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = bouncer.dataStore.User().Read(tokenData.ID)
|
if ok, err := bouncer.dataStore.User().Exists(tokenData.ID); !ok {
|
||||||
if bouncer.dataStore.IsErrObjectNotFound(err) {
|
|
||||||
httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized)
|
httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized)
|
||||||
return
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
|
@ -322,9 +321,8 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, _ := bouncer.dataStore.User().Read(token.ID)
|
if ok, _ := bouncer.dataStore.User().Exists(token.ID); !ok {
|
||||||
if user == nil {
|
httperror.WriteError(w, http.StatusUnauthorized, "The authorization token is invalid", httperrors.ErrUnauthorized)
|
||||||
httperror.WriteError(w, http.StatusUnauthorized, "An authorization token is invalid", httperrors.ErrUnauthorized)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,6 @@ import (
|
||||||
"github.com/portainer/portainer/api/http/handler/edgegroups"
|
"github.com/portainer/portainer/api/http/handler/edgegroups"
|
||||||
"github.com/portainer/portainer/api/http/handler/edgejobs"
|
"github.com/portainer/portainer/api/http/handler/edgejobs"
|
||||||
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
||||||
"github.com/portainer/portainer/api/http/handler/edgetemplates"
|
|
||||||
"github.com/portainer/portainer/api/http/handler/endpointedge"
|
"github.com/portainer/portainer/api/http/handler/endpointedge"
|
||||||
"github.com/portainer/portainer/api/http/handler/endpointgroups"
|
"github.com/portainer/portainer/api/http/handler/endpointgroups"
|
||||||
"github.com/portainer/portainer/api/http/handler/endpointproxy"
|
"github.com/portainer/portainer/api/http/handler/endpointproxy"
|
||||||
|
@ -113,6 +112,7 @@ type Server struct {
|
||||||
AdminCreationDone chan struct{}
|
AdminCreationDone chan struct{}
|
||||||
PendingActionsService *pendingactions.PendingActionsService
|
PendingActionsService *pendingactions.PendingActionsService
|
||||||
PlatformService platform.Service
|
PlatformService platform.Service
|
||||||
|
PullLimitCheckDisabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start starts the HTTP server
|
// Start starts the HTTP server
|
||||||
|
@ -169,9 +169,6 @@ func (server *Server) Start() error {
|
||||||
edgeStacksHandler.GitService = server.GitService
|
edgeStacksHandler.GitService = server.GitService
|
||||||
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer
|
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer
|
||||||
|
|
||||||
var edgeTemplatesHandler = edgetemplates.NewHandler(requestBouncer)
|
|
||||||
edgeTemplatesHandler.DataStore = server.DataStore
|
|
||||||
|
|
||||||
var endpointHandler = endpoints.NewHandler(requestBouncer)
|
var endpointHandler = endpoints.NewHandler(requestBouncer)
|
||||||
endpointHandler.DataStore = server.DataStore
|
endpointHandler.DataStore = server.DataStore
|
||||||
endpointHandler.FileService = server.FileService
|
endpointHandler.FileService = server.FileService
|
||||||
|
@ -185,6 +182,7 @@ func (server *Server) Start() error {
|
||||||
endpointHandler.BindAddress = server.BindAddress
|
endpointHandler.BindAddress = server.BindAddress
|
||||||
endpointHandler.BindAddressHTTPS = server.BindAddressHTTPS
|
endpointHandler.BindAddressHTTPS = server.BindAddressHTTPS
|
||||||
endpointHandler.PendingActionsService = server.PendingActionsService
|
endpointHandler.PendingActionsService = server.PendingActionsService
|
||||||
|
endpointHandler.PullLimitCheckDisabled = server.PullLimitCheckDisabled
|
||||||
|
|
||||||
var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer, server.DataStore, server.FileService, server.ReverseTunnelService)
|
var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer, server.DataStore, server.FileService, server.ReverseTunnelService)
|
||||||
|
|
||||||
|
@ -306,7 +304,6 @@ func (server *Server) Start() error {
|
||||||
EdgeGroupsHandler: edgeGroupsHandler,
|
EdgeGroupsHandler: edgeGroupsHandler,
|
||||||
EdgeJobsHandler: edgeJobsHandler,
|
EdgeJobsHandler: edgeJobsHandler,
|
||||||
EdgeStacksHandler: edgeStacksHandler,
|
EdgeStacksHandler: edgeStacksHandler,
|
||||||
EdgeTemplatesHandler: edgeTemplatesHandler,
|
|
||||||
EndpointGroupHandler: endpointGroupHandler,
|
EndpointGroupHandler: endpointGroupHandler,
|
||||||
EndpointHandler: endpointHandler,
|
EndpointHandler: endpointHandler,
|
||||||
EndpointHelmHandler: endpointHelmHandler,
|
EndpointHelmHandler: endpointHelmHandler,
|
||||||
|
|
|
@ -11,6 +11,7 @@ import (
|
||||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||||
"github.com/portainer/portainer/api/internal/edge"
|
"github.com/portainer/portainer/api/internal/edge"
|
||||||
edgetypes "github.com/portainer/portainer/api/internal/edge/types"
|
edgetypes "github.com/portainer/portainer/api/internal/edge/types"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
@ -119,6 +120,9 @@ func (service *Service) updateEndpointRelations(tx dataservices.DataStoreTx, edg
|
||||||
for _, endpointID := range relatedEndpointIds {
|
for _, endpointID := range relatedEndpointIds {
|
||||||
relation, err := endpointRelationService.EndpointRelation(endpointID)
|
relation, err := endpointRelationService.EndpointRelation(endpointID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if tx.IsErrObjectNotFound(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
return fmt.Errorf("unable to find endpoint relation in database: %w", err)
|
return fmt.Errorf("unable to find endpoint relation in database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -147,6 +151,14 @@ func (service *Service) DeleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
|
||||||
for _, endpointID := range relatedEndpointIds {
|
for _, endpointID := range relatedEndpointIds {
|
||||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
||||||
if err != nil {
|
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")
|
return errors.WithMessage(err, "Unable to find environment relation in database")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -151,6 +151,7 @@ func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User
|
||||||
func (s *stubUserService) Create(user *portainer.User) error { return 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) Update(ID portainer.UserID, user *portainer.User) error { return nil }
|
||||||
func (s *stubUserService) Delete(ID portainer.UserID) 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
|
// WithUsers testDatastore option that will instruct testDatastore to return provided users
|
||||||
func WithUsers(us []portainer.User) datastoreOption {
|
func WithUsers(us []portainer.User) datastoreOption {
|
||||||
|
@ -186,6 +187,9 @@ func (s *stubEdgeJobService) UpdateEdgeJobFunc(ID portainer.EdgeJobID, updateFun
|
||||||
}
|
}
|
||||||
func (s *stubEdgeJobService) Delete(ID portainer.EdgeJobID) error { return nil }
|
func (s *stubEdgeJobService) Delete(ID portainer.EdgeJobID) error { return nil }
|
||||||
func (s *stubEdgeJobService) GetNextIdentifier() int { return 0 }
|
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
|
// WithEdgeJobs option will instruct testDatastore to return provided jobs
|
||||||
func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
|
func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
|
||||||
|
@ -426,6 +430,10 @@ func (s *stubStacksService) GetNextIdentifier() int {
|
||||||
return len(s.stacks)
|
return len(s.stacks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubStacksService) Exists(ID portainer.StackID) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
// WithStacks option will instruct testDatastore to return provided stacks
|
// WithStacks option will instruct testDatastore to return provided stacks
|
||||||
func WithStacks(stacks []portainer.Stack) datastoreOption {
|
func WithStacks(stacks []portainer.Stack) datastoreOption {
|
||||||
return func(d *testDatastore) {
|
return func(d *testDatastore) {
|
||||||
|
|
|
@ -12,45 +12,58 @@ import (
|
||||||
labels "k8s.io/apimachinery/pkg/labels"
|
labels "k8s.io/apimachinery/pkg/labels"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PortainerApplicationResources contains collections of various Kubernetes resources
|
||||||
|
// associated with a Portainer application.
|
||||||
|
type PortainerApplicationResources struct {
|
||||||
|
Pods []corev1.Pod
|
||||||
|
ReplicaSets []appsv1.ReplicaSet
|
||||||
|
Deployments []appsv1.Deployment
|
||||||
|
StatefulSets []appsv1.StatefulSet
|
||||||
|
DaemonSets []appsv1.DaemonSet
|
||||||
|
Services []corev1.Service
|
||||||
|
HorizontalPodAutoscalers []autoscalingv2.HorizontalPodAutoscaler
|
||||||
|
}
|
||||||
|
|
||||||
// GetAllKubernetesApplications gets a list of kubernetes workloads (or applications) across all namespaces in the cluster
|
// GetAllKubernetesApplications gets a list of kubernetes workloads (or applications) across all namespaces in the cluster
|
||||||
// if the user is an admin, all namespaces in the current k8s environment(endpoint) are fetched using the fetchApplications function.
|
// if the user is an admin, all namespaces in the current k8s environment(endpoint) are fetched using the fetchApplications function.
|
||||||
// otherwise, namespaces the non-admin user has access to will be used to filter the applications based on the allowed namespaces.
|
// otherwise, namespaces the non-admin user has access to will be used to filter the applications based on the allowed namespaces.
|
||||||
func (kcl *KubeClient) GetApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
|
func (kcl *KubeClient) GetApplications(namespace, nodeName string) ([]models.K8sApplication, error) {
|
||||||
if kcl.IsKubeAdmin {
|
if kcl.IsKubeAdmin {
|
||||||
return kcl.fetchApplications(namespace, nodeName, withDependencies)
|
return kcl.fetchApplications(namespace, nodeName)
|
||||||
}
|
}
|
||||||
|
|
||||||
return kcl.fetchApplicationsForNonAdmin(namespace, nodeName, withDependencies)
|
return kcl.fetchApplicationsForNonAdmin(namespace, nodeName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchApplications fetches the applications in the namespaces the user has access to.
|
// fetchApplications fetches the applications in the namespaces the user has access to.
|
||||||
// This function is called when the user is an admin.
|
// This function is called when the user is an admin.
|
||||||
func (kcl *KubeClient) fetchApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
|
func (kcl *KubeClient) fetchApplications(namespace, nodeName string) ([]models.K8sApplication, error) {
|
||||||
podListOptions := metav1.ListOptions{}
|
podListOptions := metav1.ListOptions{}
|
||||||
if nodeName != "" {
|
if nodeName != "" {
|
||||||
podListOptions.FieldSelector = "spec.nodeName=" + nodeName
|
podListOptions.FieldSelector = "spec.nodeName=" + nodeName
|
||||||
}
|
}
|
||||||
if !withDependencies {
|
|
||||||
// TODO: make sure not to fetch services in fetchAllApplicationsListResources from this call
|
|
||||||
pods, replicaSets, deployments, statefulSets, daemonSets, _, _, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, nil, nil)
|
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
||||||
}
|
|
||||||
|
|
||||||
pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
|
applications, err := kcl.convertPodsToApplications(portainerApplicationResources)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(applications, unhealthyApplications...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchApplicationsForNonAdmin fetches the applications in the namespaces the user has access to.
|
// fetchApplicationsForNonAdmin fetches the applications in the namespaces the user has access to.
|
||||||
// This function is called when the user is not an admin.
|
// This function is called when the user is not an admin.
|
||||||
func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
|
func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string) ([]models.K8sApplication, error) {
|
||||||
log.Debug().Msgf("Fetching applications for non-admin user: %v", kcl.NonAdminNamespaces)
|
log.Debug().Msgf("Fetching applications for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||||
|
|
||||||
if len(kcl.NonAdminNamespaces) == 0 {
|
if len(kcl.NonAdminNamespaces) == 0 {
|
||||||
|
@ -62,28 +75,24 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string,
|
||||||
podListOptions.FieldSelector = "spec.nodeName=" + nodeName
|
podListOptions.FieldSelector = "spec.nodeName=" + nodeName
|
||||||
}
|
}
|
||||||
|
|
||||||
if !withDependencies {
|
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
||||||
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets(namespace, podListOptions)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return kcl.convertPodsToApplications(pods, replicaSets, nil, nil, nil, nil, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
applications, err := kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
|
applications, err := kcl.convertPodsToApplications(portainerApplicationResources)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
|
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
|
||||||
results := make([]models.K8sApplication, 0)
|
results := make([]models.K8sApplication, 0)
|
||||||
for _, application := range applications {
|
for _, application := range append(applications, unhealthyApplications...) {
|
||||||
if _, ok := nonAdminNamespaceSet[application.ResourcePool]; ok {
|
if _, ok := nonAdminNamespaceSet[application.ResourcePool]; ok {
|
||||||
results = append(results, application)
|
results = append(results, application)
|
||||||
}
|
}
|
||||||
|
@ -93,11 +102,11 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string,
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertPodsToApplications processes pods and converts them to applications, ensuring uniqueness by owner reference.
|
// convertPodsToApplications processes pods and converts them to applications, ensuring uniqueness by owner reference.
|
||||||
func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) ([]models.K8sApplication, error) {
|
func (kcl *KubeClient) convertPodsToApplications(portainerApplicationResources PortainerApplicationResources) ([]models.K8sApplication, error) {
|
||||||
applications := []models.K8sApplication{}
|
applications := []models.K8sApplication{}
|
||||||
processedOwners := make(map[string]struct{})
|
processedOwners := make(map[string]struct{})
|
||||||
|
|
||||||
for _, pod := range pods {
|
for _, pod := range portainerApplicationResources.Pods {
|
||||||
if len(pod.OwnerReferences) > 0 {
|
if len(pod.OwnerReferences) > 0 {
|
||||||
ownerUID := string(pod.OwnerReferences[0].UID)
|
ownerUID := string(pod.OwnerReferences[0].UID)
|
||||||
if _, exists := processedOwners[ownerUID]; exists {
|
if _, exists := processedOwners[ownerUID]; exists {
|
||||||
|
@ -106,7 +115,7 @@ func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets
|
||||||
processedOwners[ownerUID] = struct{}{}
|
processedOwners[ownerUID] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, deployments, statefulSets, daemonSets, services, hpas, true)
|
application, err := kcl.ConvertPodToApplication(pod, portainerApplicationResources, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -151,7 +160,9 @@ func (kcl *KubeClient) GetApplicationNamesFromConfigMap(configMap models.K8sConf
|
||||||
for _, pod := range pods {
|
for _, pod := range pods {
|
||||||
if pod.Namespace == configMap.Namespace {
|
if pod.Namespace == configMap.Namespace {
|
||||||
if isPodUsingConfigMap(&pod, configMap.Name) {
|
if isPodUsingConfigMap(&pod, configMap.Name) {
|
||||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
|
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||||
|
ReplicaSets: replicaSets,
|
||||||
|
}, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -168,7 +179,9 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
|
||||||
for _, pod := range pods {
|
for _, pod := range pods {
|
||||||
if pod.Namespace == secret.Namespace {
|
if pod.Namespace == secret.Namespace {
|
||||||
if isPodUsingSecret(&pod, secret.Name) {
|
if isPodUsingSecret(&pod, secret.Name) {
|
||||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
|
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||||
|
ReplicaSets: replicaSets,
|
||||||
|
}, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -181,12 +194,12 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertPodToApplication converts a pod to an application, updating owner references if necessary
|
// ConvertPodToApplication converts a pod to an application, updating owner references if necessary
|
||||||
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler, withResource bool) (*models.K8sApplication, error) {
|
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, portainerApplicationResources PortainerApplicationResources, withResource bool) (*models.K8sApplication, error) {
|
||||||
if isReplicaSetOwner(pod) {
|
if isReplicaSetOwner(pod) {
|
||||||
updateOwnerReferenceToDeployment(&pod, replicaSets)
|
updateOwnerReferenceToDeployment(&pod, portainerApplicationResources.ReplicaSets)
|
||||||
}
|
}
|
||||||
|
|
||||||
application := createApplication(&pod, deployments, statefulSets, daemonSets, services, hpas)
|
application := createApplicationFromPod(&pod, portainerApplicationResources)
|
||||||
if application.ID == "" && application.Name == "" {
|
if application.ID == "" && application.Name == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
@ -203,9 +216,9 @@ func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []app
|
||||||
return &application, nil
|
return &application, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// createApplication creates a K8sApplication object from a pod
|
// createApplicationFromPod creates a K8sApplication object from a pod
|
||||||
// it sets the application name, namespace, kind, image, stack id, stack name, and labels
|
// it sets the application name, namespace, kind, image, stack id, stack name, and labels
|
||||||
func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) models.K8sApplication {
|
func createApplicationFromPod(pod *corev1.Pod, portainerApplicationResources PortainerApplicationResources) models.K8sApplication {
|
||||||
kind := "Pod"
|
kind := "Pod"
|
||||||
name := pod.Name
|
name := pod.Name
|
||||||
|
|
||||||
|
@ -221,120 +234,172 @@ func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefu
|
||||||
|
|
||||||
switch kind {
|
switch kind {
|
||||||
case "Deployment":
|
case "Deployment":
|
||||||
for _, deployment := range deployments {
|
for _, deployment := range portainerApplicationResources.Deployments {
|
||||||
if deployment.Name == name && deployment.Namespace == pod.Namespace {
|
if deployment.Name == name && deployment.Namespace == pod.Namespace {
|
||||||
application.ApplicationType = "Deployment"
|
populateApplicationFromDeployment(&application, deployment)
|
||||||
application.Kind = "Deployment"
|
|
||||||
application.ID = string(deployment.UID)
|
|
||||||
application.ResourcePool = deployment.Namespace
|
|
||||||
application.Name = name
|
|
||||||
application.Image = deployment.Spec.Template.Spec.Containers[0].Image
|
|
||||||
application.ApplicationOwner = deployment.Labels["io.portainer.kubernetes.application.owner"]
|
|
||||||
application.StackID = deployment.Labels["io.portainer.kubernetes.application.stackid"]
|
|
||||||
application.StackName = deployment.Labels["io.portainer.kubernetes.application.stack"]
|
|
||||||
application.Labels = deployment.Labels
|
|
||||||
application.MatchLabels = deployment.Spec.Selector.MatchLabels
|
|
||||||
application.CreationDate = deployment.CreationTimestamp.Time
|
|
||||||
application.TotalPodsCount = int(deployment.Status.Replicas)
|
|
||||||
application.RunningPodsCount = int(deployment.Status.ReadyReplicas)
|
|
||||||
application.DeploymentType = "Replicated"
|
|
||||||
application.Metadata = &models.Metadata{
|
|
||||||
Labels: deployment.Labels,
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "StatefulSet":
|
case "StatefulSet":
|
||||||
for _, statefulSet := range statefulSets {
|
for _, statefulSet := range portainerApplicationResources.StatefulSets {
|
||||||
if statefulSet.Name == name && statefulSet.Namespace == pod.Namespace {
|
if statefulSet.Name == name && statefulSet.Namespace == pod.Namespace {
|
||||||
application.Kind = "StatefulSet"
|
populateApplicationFromStatefulSet(&application, statefulSet)
|
||||||
application.ApplicationType = "StatefulSet"
|
|
||||||
application.ID = string(statefulSet.UID)
|
|
||||||
application.ResourcePool = statefulSet.Namespace
|
|
||||||
application.Name = name
|
|
||||||
application.Image = statefulSet.Spec.Template.Spec.Containers[0].Image
|
|
||||||
application.ApplicationOwner = statefulSet.Labels["io.portainer.kubernetes.application.owner"]
|
|
||||||
application.StackID = statefulSet.Labels["io.portainer.kubernetes.application.stackid"]
|
|
||||||
application.StackName = statefulSet.Labels["io.portainer.kubernetes.application.stack"]
|
|
||||||
application.Labels = statefulSet.Labels
|
|
||||||
application.MatchLabels = statefulSet.Spec.Selector.MatchLabels
|
|
||||||
application.CreationDate = statefulSet.CreationTimestamp.Time
|
|
||||||
application.TotalPodsCount = int(statefulSet.Status.Replicas)
|
|
||||||
application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas)
|
|
||||||
application.DeploymentType = "Replicated"
|
|
||||||
application.Metadata = &models.Metadata{
|
|
||||||
Labels: statefulSet.Labels,
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "DaemonSet":
|
case "DaemonSet":
|
||||||
for _, daemonSet := range daemonSets {
|
for _, daemonSet := range portainerApplicationResources.DaemonSets {
|
||||||
if daemonSet.Name == name && daemonSet.Namespace == pod.Namespace {
|
if daemonSet.Name == name && daemonSet.Namespace == pod.Namespace {
|
||||||
application.Kind = "DaemonSet"
|
populateApplicationFromDaemonSet(&application, daemonSet)
|
||||||
application.ApplicationType = "DaemonSet"
|
|
||||||
application.ID = string(daemonSet.UID)
|
|
||||||
application.ResourcePool = daemonSet.Namespace
|
|
||||||
application.Name = name
|
|
||||||
application.Image = daemonSet.Spec.Template.Spec.Containers[0].Image
|
|
||||||
application.ApplicationOwner = daemonSet.Labels["io.portainer.kubernetes.application.owner"]
|
|
||||||
application.StackID = daemonSet.Labels["io.portainer.kubernetes.application.stackid"]
|
|
||||||
application.StackName = daemonSet.Labels["io.portainer.kubernetes.application.stack"]
|
|
||||||
application.Labels = daemonSet.Labels
|
|
||||||
application.MatchLabels = daemonSet.Spec.Selector.MatchLabels
|
|
||||||
application.CreationDate = daemonSet.CreationTimestamp.Time
|
|
||||||
application.TotalPodsCount = int(daemonSet.Status.DesiredNumberScheduled)
|
|
||||||
application.RunningPodsCount = int(daemonSet.Status.NumberReady)
|
|
||||||
application.DeploymentType = "Global"
|
|
||||||
application.Metadata = &models.Metadata{
|
|
||||||
Labels: daemonSet.Labels,
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "Pod":
|
case "Pod":
|
||||||
runningPodsCount := 1
|
populateApplicationFromPod(&application, *pod)
|
||||||
if pod.Status.Phase != corev1.PodRunning {
|
|
||||||
runningPodsCount = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
application.ApplicationType = "Pod"
|
|
||||||
application.Kind = "Pod"
|
|
||||||
application.ID = string(pod.UID)
|
|
||||||
application.ResourcePool = pod.Namespace
|
|
||||||
application.Name = pod.Name
|
|
||||||
application.Image = pod.Spec.Containers[0].Image
|
|
||||||
application.ApplicationOwner = pod.Labels["io.portainer.kubernetes.application.owner"]
|
|
||||||
application.StackID = pod.Labels["io.portainer.kubernetes.application.stackid"]
|
|
||||||
application.StackName = pod.Labels["io.portainer.kubernetes.application.stack"]
|
|
||||||
application.Labels = pod.Labels
|
|
||||||
application.MatchLabels = pod.Labels
|
|
||||||
application.CreationDate = pod.CreationTimestamp.Time
|
|
||||||
application.TotalPodsCount = 1
|
|
||||||
application.RunningPodsCount = runningPodsCount
|
|
||||||
application.DeploymentType = string(pod.Status.Phase)
|
|
||||||
application.Metadata = &models.Metadata{
|
|
||||||
Labels: pod.Labels,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if application.ID != "" && application.Name != "" && len(services) > 0 {
|
if application.ID != "" && application.Name != "" && len(portainerApplicationResources.Services) > 0 {
|
||||||
updateApplicationWithService(&application, services)
|
updateApplicationWithService(&application, portainerApplicationResources.Services)
|
||||||
}
|
}
|
||||||
|
|
||||||
if application.ID != "" && application.Name != "" && len(hpas) > 0 {
|
if application.ID != "" && application.Name != "" && len(portainerApplicationResources.HorizontalPodAutoscalers) > 0 {
|
||||||
updateApplicationWithHorizontalPodAutoscaler(&application, hpas)
|
updateApplicationWithHorizontalPodAutoscaler(&application, portainerApplicationResources.HorizontalPodAutoscalers)
|
||||||
}
|
}
|
||||||
|
|
||||||
return application
|
return application
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createApplicationFromDeployment creates a K8sApplication from a Deployment
|
||||||
|
func createApplicationFromDeployment(deployment appsv1.Deployment) models.K8sApplication {
|
||||||
|
var app models.K8sApplication
|
||||||
|
populateApplicationFromDeployment(&app, deployment)
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
// createApplicationFromStatefulSet creates a K8sApplication from a StatefulSet
|
||||||
|
func createApplicationFromStatefulSet(statefulSet appsv1.StatefulSet) models.K8sApplication {
|
||||||
|
var app models.K8sApplication
|
||||||
|
populateApplicationFromStatefulSet(&app, statefulSet)
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
// createApplicationFromDaemonSet creates a K8sApplication from a DaemonSet
|
||||||
|
func createApplicationFromDaemonSet(daemonSet appsv1.DaemonSet) models.K8sApplication {
|
||||||
|
var app models.K8sApplication
|
||||||
|
populateApplicationFromDaemonSet(&app, daemonSet)
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
func populateApplicationFromDeployment(application *models.K8sApplication, deployment appsv1.Deployment) {
|
||||||
|
application.ApplicationType = "Deployment"
|
||||||
|
application.Kind = "Deployment"
|
||||||
|
application.ID = string(deployment.UID)
|
||||||
|
application.ResourcePool = deployment.Namespace
|
||||||
|
application.Name = deployment.Name
|
||||||
|
application.ApplicationOwner = deployment.Labels["io.portainer.kubernetes.application.owner"]
|
||||||
|
application.StackID = deployment.Labels["io.portainer.kubernetes.application.stackid"]
|
||||||
|
application.StackName = deployment.Labels["io.portainer.kubernetes.application.stack"]
|
||||||
|
application.Labels = deployment.Labels
|
||||||
|
application.MatchLabels = deployment.Spec.Selector.MatchLabels
|
||||||
|
application.CreationDate = deployment.CreationTimestamp.Time
|
||||||
|
application.TotalPodsCount = 0
|
||||||
|
if deployment.Spec.Replicas != nil {
|
||||||
|
application.TotalPodsCount = int(*deployment.Spec.Replicas)
|
||||||
|
}
|
||||||
|
application.RunningPodsCount = int(deployment.Status.ReadyReplicas)
|
||||||
|
application.DeploymentType = "Replicated"
|
||||||
|
application.Metadata = &models.Metadata{
|
||||||
|
Labels: deployment.Labels,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the deployment has containers, use the first container's image
|
||||||
|
if len(deployment.Spec.Template.Spec.Containers) > 0 {
|
||||||
|
application.Image = deployment.Spec.Template.Spec.Containers[0].Image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func populateApplicationFromStatefulSet(application *models.K8sApplication, statefulSet appsv1.StatefulSet) {
|
||||||
|
application.Kind = "StatefulSet"
|
||||||
|
application.ApplicationType = "StatefulSet"
|
||||||
|
application.ID = string(statefulSet.UID)
|
||||||
|
application.ResourcePool = statefulSet.Namespace
|
||||||
|
application.Name = statefulSet.Name
|
||||||
|
application.ApplicationOwner = statefulSet.Labels["io.portainer.kubernetes.application.owner"]
|
||||||
|
application.StackID = statefulSet.Labels["io.portainer.kubernetes.application.stackid"]
|
||||||
|
application.StackName = statefulSet.Labels["io.portainer.kubernetes.application.stack"]
|
||||||
|
application.Labels = statefulSet.Labels
|
||||||
|
application.MatchLabels = statefulSet.Spec.Selector.MatchLabels
|
||||||
|
application.CreationDate = statefulSet.CreationTimestamp.Time
|
||||||
|
application.TotalPodsCount = 0
|
||||||
|
if statefulSet.Spec.Replicas != nil {
|
||||||
|
application.TotalPodsCount = int(*statefulSet.Spec.Replicas)
|
||||||
|
}
|
||||||
|
application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas)
|
||||||
|
application.DeploymentType = "Replicated"
|
||||||
|
application.Metadata = &models.Metadata{
|
||||||
|
Labels: statefulSet.Labels,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the statefulSet has containers, use the first container's image
|
||||||
|
if len(statefulSet.Spec.Template.Spec.Containers) > 0 {
|
||||||
|
application.Image = statefulSet.Spec.Template.Spec.Containers[0].Image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func populateApplicationFromDaemonSet(application *models.K8sApplication, daemonSet appsv1.DaemonSet) {
|
||||||
|
application.Kind = "DaemonSet"
|
||||||
|
application.ApplicationType = "DaemonSet"
|
||||||
|
application.ID = string(daemonSet.UID)
|
||||||
|
application.ResourcePool = daemonSet.Namespace
|
||||||
|
application.Name = daemonSet.Name
|
||||||
|
application.ApplicationOwner = daemonSet.Labels["io.portainer.kubernetes.application.owner"]
|
||||||
|
application.StackID = daemonSet.Labels["io.portainer.kubernetes.application.stackid"]
|
||||||
|
application.StackName = daemonSet.Labels["io.portainer.kubernetes.application.stack"]
|
||||||
|
application.Labels = daemonSet.Labels
|
||||||
|
application.MatchLabels = daemonSet.Spec.Selector.MatchLabels
|
||||||
|
application.CreationDate = daemonSet.CreationTimestamp.Time
|
||||||
|
application.TotalPodsCount = int(daemonSet.Status.DesiredNumberScheduled)
|
||||||
|
application.RunningPodsCount = int(daemonSet.Status.NumberReady)
|
||||||
|
application.DeploymentType = "Global"
|
||||||
|
application.Metadata = &models.Metadata{
|
||||||
|
Labels: daemonSet.Labels,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(daemonSet.Spec.Template.Spec.Containers) > 0 {
|
||||||
|
application.Image = daemonSet.Spec.Template.Spec.Containers[0].Image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func populateApplicationFromPod(application *models.K8sApplication, pod corev1.Pod) {
|
||||||
|
runningPodsCount := 1
|
||||||
|
if pod.Status.Phase != corev1.PodRunning {
|
||||||
|
runningPodsCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
application.ApplicationType = "Pod"
|
||||||
|
application.Kind = "Pod"
|
||||||
|
application.ID = string(pod.UID)
|
||||||
|
application.ResourcePool = pod.Namespace
|
||||||
|
application.Name = pod.Name
|
||||||
|
application.ApplicationOwner = pod.Labels["io.portainer.kubernetes.application.owner"]
|
||||||
|
application.StackID = pod.Labels["io.portainer.kubernetes.application.stackid"]
|
||||||
|
application.StackName = pod.Labels["io.portainer.kubernetes.application.stack"]
|
||||||
|
application.Labels = pod.Labels
|
||||||
|
application.MatchLabels = pod.Labels
|
||||||
|
application.CreationDate = pod.CreationTimestamp.Time
|
||||||
|
application.TotalPodsCount = 1
|
||||||
|
application.RunningPodsCount = runningPodsCount
|
||||||
|
application.DeploymentType = string(pod.Status.Phase)
|
||||||
|
application.Metadata = &models.Metadata{
|
||||||
|
Labels: pod.Labels,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the pod has containers, use the first container's image
|
||||||
|
if len(pod.Spec.Containers) > 0 {
|
||||||
|
application.Image = pod.Spec.Containers[0].Image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// updateApplicationWithService updates the application with the services that match the application's selector match labels
|
// updateApplicationWithService updates the application with the services that match the application's selector match labels
|
||||||
// and are in the same namespace as the application
|
// and are in the same namespace as the application
|
||||||
func updateApplicationWithService(application *models.K8sApplication, services []corev1.Service) {
|
func updateApplicationWithService(application *models.K8sApplication, services []corev1.Service) {
|
||||||
|
@ -410,7 +475,9 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap
|
||||||
for _, pod := range pods {
|
for _, pod := range pods {
|
||||||
if pod.Namespace == configMap.Namespace {
|
if pod.Namespace == configMap.Namespace {
|
||||||
if isPodUsingConfigMap(&pod, configMap.Name) {
|
if isPodUsingConfigMap(&pod, configMap.Name) {
|
||||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
|
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||||
|
ReplicaSets: replicaSets,
|
||||||
|
}, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -436,7 +503,9 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
|
||||||
for _, pod := range pods {
|
for _, pod := range pods {
|
||||||
if pod.Namespace == secret.Namespace {
|
if pod.Namespace == secret.Namespace {
|
||||||
if isPodUsingSecret(&pod, secret.Name) {
|
if isPodUsingSecret(&pod, secret.Name) {
|
||||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
|
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||||
|
ReplicaSets: replicaSets,
|
||||||
|
}, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -454,3 +523,84 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
|
||||||
|
|
||||||
return configurationOwners, nil
|
return configurationOwners, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchUnhealthyApplications fetches applications that failed to schedule any pods
|
||||||
|
// due to issues like missing resource limits or other scheduling constraints
|
||||||
|
func fetchUnhealthyApplications(resources PortainerApplicationResources) ([]models.K8sApplication, error) {
|
||||||
|
var unhealthyApplications []models.K8sApplication
|
||||||
|
|
||||||
|
// Process Deployments
|
||||||
|
for _, deployment := range resources.Deployments {
|
||||||
|
if hasNoScheduledPods(deployment) {
|
||||||
|
app := createApplicationFromDeployment(deployment)
|
||||||
|
addRelatedResourcesToApplication(&app, resources)
|
||||||
|
unhealthyApplications = append(unhealthyApplications, app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process StatefulSets
|
||||||
|
for _, statefulSet := range resources.StatefulSets {
|
||||||
|
if hasNoScheduledPods(statefulSet) {
|
||||||
|
app := createApplicationFromStatefulSet(statefulSet)
|
||||||
|
addRelatedResourcesToApplication(&app, resources)
|
||||||
|
unhealthyApplications = append(unhealthyApplications, app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process DaemonSets
|
||||||
|
for _, daemonSet := range resources.DaemonSets {
|
||||||
|
if hasNoScheduledPods(daemonSet) {
|
||||||
|
app := createApplicationFromDaemonSet(daemonSet)
|
||||||
|
addRelatedResourcesToApplication(&app, resources)
|
||||||
|
unhealthyApplications = append(unhealthyApplications, app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unhealthyApplications, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addRelatedResourcesToApplication adds Services and HPA information to the application
|
||||||
|
func addRelatedResourcesToApplication(app *models.K8sApplication, resources PortainerApplicationResources) {
|
||||||
|
if app.ID == "" || app.Name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resources.Services) > 0 {
|
||||||
|
updateApplicationWithService(app, resources.Services)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resources.HorizontalPodAutoscalers) > 0 {
|
||||||
|
updateApplicationWithHorizontalPodAutoscaler(app, resources.HorizontalPodAutoscalers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasNoScheduledPods checks if a workload has completely failed to schedule any pods
|
||||||
|
// it checks for no replicas desired, i.e. nothing to schedule and see if any pods are running
|
||||||
|
// if any pods exist at all (even if not ready), it returns false
|
||||||
|
func hasNoScheduledPods(obj interface{}) bool {
|
||||||
|
switch resource := obj.(type) {
|
||||||
|
case appsv1.Deployment:
|
||||||
|
if resource.Status.Replicas > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return resource.Status.ReadyReplicas == 0 && resource.Status.AvailableReplicas == 0
|
||||||
|
|
||||||
|
case appsv1.StatefulSet:
|
||||||
|
if resource.Status.Replicas > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return resource.Status.ReadyReplicas == 0 && resource.Status.CurrentReplicas == 0
|
||||||
|
|
||||||
|
case appsv1.DaemonSet:
|
||||||
|
if resource.Status.CurrentNumberScheduled > 0 || resource.Status.NumberMisscheduled > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return resource.Status.NumberReady == 0 && resource.Status.DesiredNumberScheduled > 0
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,461 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
"k8s.io/client-go/kubernetes/fake"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper functions to create test resources
|
||||||
|
func createTestDeployment(name, namespace string, replicas int32) *appsv1.Deployment {
|
||||||
|
return &appsv1.Deployment{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: name,
|
||||||
|
Namespace: namespace,
|
||||||
|
UID: types.UID("deploy-" + name),
|
||||||
|
Labels: map[string]string{
|
||||||
|
"app": name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: appsv1.DeploymentSpec{
|
||||||
|
Replicas: &replicas,
|
||||||
|
Selector: &metav1.LabelSelector{
|
||||||
|
MatchLabels: map[string]string{
|
||||||
|
"app": name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Template: corev1.PodTemplateSpec{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Labels: map[string]string{
|
||||||
|
"app": name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
Containers: []corev1.Container{
|
||||||
|
{
|
||||||
|
Name: name,
|
||||||
|
Image: "nginx:latest",
|
||||||
|
Resources: corev1.ResourceRequirements{
|
||||||
|
Limits: corev1.ResourceList{},
|
||||||
|
Requests: corev1.ResourceList{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: appsv1.DeploymentStatus{
|
||||||
|
Replicas: replicas,
|
||||||
|
ReadyReplicas: replicas,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestReplicaSet(name, namespace, deploymentName string) *appsv1.ReplicaSet {
|
||||||
|
return &appsv1.ReplicaSet{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: name,
|
||||||
|
Namespace: namespace,
|
||||||
|
UID: types.UID("rs-" + name),
|
||||||
|
OwnerReferences: []metav1.OwnerReference{
|
||||||
|
{
|
||||||
|
Kind: "Deployment",
|
||||||
|
Name: deploymentName,
|
||||||
|
UID: types.UID("deploy-" + deploymentName),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: appsv1.ReplicaSetSpec{
|
||||||
|
Selector: &metav1.LabelSelector{
|
||||||
|
MatchLabels: map[string]string{
|
||||||
|
"app": deploymentName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestStatefulSet(name, namespace string, replicas int32) *appsv1.StatefulSet {
|
||||||
|
return &appsv1.StatefulSet{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: name,
|
||||||
|
Namespace: namespace,
|
||||||
|
UID: types.UID("sts-" + name),
|
||||||
|
Labels: map[string]string{
|
||||||
|
"app": name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: appsv1.StatefulSetSpec{
|
||||||
|
Replicas: &replicas,
|
||||||
|
Selector: &metav1.LabelSelector{
|
||||||
|
MatchLabels: map[string]string{
|
||||||
|
"app": name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Template: corev1.PodTemplateSpec{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Labels: map[string]string{
|
||||||
|
"app": name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
Containers: []corev1.Container{
|
||||||
|
{
|
||||||
|
Name: name,
|
||||||
|
Image: "redis:latest",
|
||||||
|
Resources: corev1.ResourceRequirements{
|
||||||
|
Limits: corev1.ResourceList{},
|
||||||
|
Requests: corev1.ResourceList{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: appsv1.StatefulSetStatus{
|
||||||
|
Replicas: replicas,
|
||||||
|
ReadyReplicas: replicas,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestDaemonSet(name, namespace string) *appsv1.DaemonSet {
|
||||||
|
return &appsv1.DaemonSet{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: name,
|
||||||
|
Namespace: namespace,
|
||||||
|
UID: types.UID("ds-" + name),
|
||||||
|
Labels: map[string]string{
|
||||||
|
"app": name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: appsv1.DaemonSetSpec{
|
||||||
|
Selector: &metav1.LabelSelector{
|
||||||
|
MatchLabels: map[string]string{
|
||||||
|
"app": name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Template: corev1.PodTemplateSpec{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Labels: map[string]string{
|
||||||
|
"app": name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
Containers: []corev1.Container{
|
||||||
|
{
|
||||||
|
Name: name,
|
||||||
|
Image: "fluentd:latest",
|
||||||
|
Resources: corev1.ResourceRequirements{
|
||||||
|
Limits: corev1.ResourceList{},
|
||||||
|
Requests: corev1.ResourceList{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: appsv1.DaemonSetStatus{
|
||||||
|
DesiredNumberScheduled: 2,
|
||||||
|
NumberReady: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestPod(name, namespace, ownerKind, ownerName string, isRunning bool) *corev1.Pod {
|
||||||
|
phase := corev1.PodPending
|
||||||
|
if isRunning {
|
||||||
|
phase = corev1.PodRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
var ownerReferences []metav1.OwnerReference
|
||||||
|
if ownerKind != "" && ownerName != "" {
|
||||||
|
ownerReferences = []metav1.OwnerReference{
|
||||||
|
{
|
||||||
|
Kind: ownerKind,
|
||||||
|
Name: ownerName,
|
||||||
|
UID: types.UID(ownerKind + "-" + ownerName),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &corev1.Pod{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: name,
|
||||||
|
Namespace: namespace,
|
||||||
|
UID: types.UID("pod-" + name),
|
||||||
|
OwnerReferences: ownerReferences,
|
||||||
|
Labels: map[string]string{
|
||||||
|
"app": ownerName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
Containers: []corev1.Container{
|
||||||
|
{
|
||||||
|
Name: "container-" + name,
|
||||||
|
Image: "busybox:latest",
|
||||||
|
Resources: corev1.ResourceRequirements{
|
||||||
|
Limits: corev1.ResourceList{},
|
||||||
|
Requests: corev1.ResourceList{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: corev1.PodStatus{
|
||||||
|
Phase: phase,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestService(name, namespace string, selector map[string]string) *corev1.Service {
|
||||||
|
return &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: name,
|
||||||
|
Namespace: namespace,
|
||||||
|
UID: types.UID("svc-" + name),
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Selector: selector,
|
||||||
|
Type: corev1.ServiceTypeClusterIP,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetApplications(t *testing.T) {
|
||||||
|
t.Run("Admin user - Mix of deployments, statefulsets and daemonsets with and without pods", func(t *testing.T) {
|
||||||
|
// Create a fake K8s client
|
||||||
|
fakeClient := fake.NewSimpleClientset()
|
||||||
|
|
||||||
|
// Setup the test namespace
|
||||||
|
namespace := "test-namespace"
|
||||||
|
defaultNamespace := "default"
|
||||||
|
|
||||||
|
// Create resources in the test namespace
|
||||||
|
// 1. Deployment with pods
|
||||||
|
deployWithPods := createTestDeployment("deploy-with-pods", namespace, 2)
|
||||||
|
_, err := fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployWithPods, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
replicaSet := createTestReplicaSet("rs-deploy-with-pods", namespace, "deploy-with-pods")
|
||||||
|
_, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(context.TODO(), replicaSet, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pod1 := createTestPod("pod1-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true)
|
||||||
|
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod1, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pod2 := createTestPod("pod2-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true)
|
||||||
|
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod2, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// 2. Deployment without pods (scaled to 0)
|
||||||
|
deployNoPods := createTestDeployment("deploy-no-pods", namespace, 0)
|
||||||
|
_, err = fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployNoPods, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// 3. StatefulSet with pods
|
||||||
|
stsWithPods := createTestStatefulSet("sts-with-pods", namespace, 1)
|
||||||
|
_, err = fakeClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), stsWithPods, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pod3 := createTestPod("pod1-sts", namespace, "StatefulSet", "sts-with-pods", true)
|
||||||
|
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod3, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// 4. StatefulSet without pods
|
||||||
|
stsNoPods := createTestStatefulSet("sts-no-pods", namespace, 0)
|
||||||
|
_, err = fakeClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), stsNoPods, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// 5. DaemonSet with pods
|
||||||
|
dsWithPods := createTestDaemonSet("ds-with-pods", namespace)
|
||||||
|
_, err = fakeClient.AppsV1().DaemonSets(namespace).Create(context.TODO(), dsWithPods, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pod4 := createTestPod("pod1-ds", namespace, "DaemonSet", "ds-with-pods", true)
|
||||||
|
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod4, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pod5 := createTestPod("pod2-ds", namespace, "DaemonSet", "ds-with-pods", true)
|
||||||
|
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod5, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// 6. Naked Pod (no owner reference)
|
||||||
|
nakedPod := createTestPod("naked-pod", namespace, "", "", true)
|
||||||
|
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), nakedPod, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// 7. Resources in another namespace
|
||||||
|
deployOtherNs := createTestDeployment("deploy-other-ns", defaultNamespace, 1)
|
||||||
|
_, err = fakeClient.AppsV1().Deployments(defaultNamespace).Create(context.TODO(), deployOtherNs, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
podOtherNs := createTestPod("pod-other-ns", defaultNamespace, "Deployment", "deploy-other-ns", true)
|
||||||
|
_, err = fakeClient.CoreV1().Pods(defaultNamespace).Create(context.TODO(), podOtherNs, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// 8. Add a service (dependency)
|
||||||
|
service := createTestService("svc-deploy", namespace, map[string]string{"app": "deploy-with-pods"})
|
||||||
|
_, err = fakeClient.CoreV1().Services(namespace).Create(context.TODO(), service, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create the KubeClient with admin privileges
|
||||||
|
kubeClient := &KubeClient{
|
||||||
|
cli: fakeClient,
|
||||||
|
instanceID: "test-instance",
|
||||||
|
IsKubeAdmin: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test cases
|
||||||
|
|
||||||
|
// 1. All resources, no filtering
|
||||||
|
t.Run("All resources with dependencies", func(t *testing.T) {
|
||||||
|
apps, err := kubeClient.GetApplications("", "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// We expect 7 resources: 2 deployments + 2 statefulsets + 1 daemonset + 1 naked pod + 1 deployment in other namespace
|
||||||
|
// Note: Each controller with pods should count once, not per pod
|
||||||
|
assert.Equal(t, 7, len(apps))
|
||||||
|
|
||||||
|
// Verify one of the deployments has services attached
|
||||||
|
appsWithServices := []models.K8sApplication{}
|
||||||
|
for _, app := range apps {
|
||||||
|
if len(app.Services) > 0 {
|
||||||
|
appsWithServices = append(appsWithServices, app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, len(appsWithServices))
|
||||||
|
assert.Equal(t, "deploy-with-pods", appsWithServices[0].Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Filter by namespace
|
||||||
|
t.Run("Filter by namespace", func(t *testing.T) {
|
||||||
|
apps, err := kubeClient.GetApplications(namespace, "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// We expect 6 resources in the test namespace
|
||||||
|
assert.Equal(t, 6, len(apps))
|
||||||
|
|
||||||
|
// Verify resources from other namespaces are not included
|
||||||
|
for _, app := range apps {
|
||||||
|
assert.Equal(t, namespace, app.ResourcePool)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Non-admin user - Resources filtered by accessible namespaces", func(t *testing.T) {
|
||||||
|
// Create a fake K8s client
|
||||||
|
fakeClient := fake.NewSimpleClientset()
|
||||||
|
|
||||||
|
// Setup the test namespaces
|
||||||
|
namespace1 := "allowed-ns"
|
||||||
|
namespace2 := "restricted-ns"
|
||||||
|
|
||||||
|
// Create resources in the allowed namespace
|
||||||
|
sts1 := createTestStatefulSet("sts-allowed", namespace1, 1)
|
||||||
|
_, err := fakeClient.AppsV1().StatefulSets(namespace1).Create(context.TODO(), sts1, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pod1 := createTestPod("pod-allowed", namespace1, "StatefulSet", "sts-allowed", true)
|
||||||
|
_, err = fakeClient.CoreV1().Pods(namespace1).Create(context.TODO(), pod1, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Add a StatefulSet without pods in the allowed namespace
|
||||||
|
stsNoPods := createTestStatefulSet("sts-no-pods-allowed", namespace1, 0)
|
||||||
|
_, err = fakeClient.AppsV1().StatefulSets(namespace1).Create(context.TODO(), stsNoPods, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create resources in the restricted namespace
|
||||||
|
sts2 := createTestStatefulSet("sts-restricted", namespace2, 1)
|
||||||
|
_, err = fakeClient.AppsV1().StatefulSets(namespace2).Create(context.TODO(), sts2, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pod2 := createTestPod("pod-restricted", namespace2, "StatefulSet", "sts-restricted", true)
|
||||||
|
_, err = fakeClient.CoreV1().Pods(namespace2).Create(context.TODO(), pod2, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create the KubeClient with non-admin privileges (only allowed namespace1)
|
||||||
|
kubeClient := &KubeClient{
|
||||||
|
cli: fakeClient,
|
||||||
|
instanceID: "test-instance",
|
||||||
|
IsKubeAdmin: false,
|
||||||
|
NonAdminNamespaces: []string{namespace1},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that only resources from allowed namespace are returned
|
||||||
|
apps, err := kubeClient.GetApplications("", "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// We expect 2 resources from the allowed namespace (1 sts with pod + 1 sts without pod)
|
||||||
|
assert.Equal(t, 2, len(apps))
|
||||||
|
|
||||||
|
// Verify resources are from the allowed namespace
|
||||||
|
for _, app := range apps {
|
||||||
|
assert.Equal(t, namespace1, app.ResourcePool)
|
||||||
|
assert.Equal(t, "StatefulSet", app.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify names of returned resources
|
||||||
|
stsNames := make(map[string]bool)
|
||||||
|
for _, app := range apps {
|
||||||
|
stsNames[app.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, stsNames["sts-allowed"], "Expected StatefulSet 'sts-allowed' was not found")
|
||||||
|
assert.True(t, stsNames["sts-no-pods-allowed"], "Expected StatefulSet 'sts-no-pods-allowed' was not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Filter by node name", func(t *testing.T) {
|
||||||
|
// Create a fake K8s client
|
||||||
|
fakeClient := fake.NewSimpleClientset()
|
||||||
|
|
||||||
|
// Setup test namespace
|
||||||
|
namespace := "node-filter-ns"
|
||||||
|
nodeName := "worker-node-1"
|
||||||
|
|
||||||
|
// Create a deployment with pods on specific node
|
||||||
|
deploy := createTestDeployment("node-deploy", namespace, 2)
|
||||||
|
_, err := fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deploy, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create ReplicaSet for the deployment
|
||||||
|
rs := createTestReplicaSet("rs-node-deploy", namespace, "node-deploy")
|
||||||
|
_, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(context.TODO(), rs, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create 2 pods, one on the specified node, one on a different node
|
||||||
|
pod1 := createTestPod("pod-on-node", namespace, "ReplicaSet", "rs-node-deploy", true)
|
||||||
|
pod1.Spec.NodeName = nodeName
|
||||||
|
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod1, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pod2 := createTestPod("pod-other-node", namespace, "ReplicaSet", "rs-node-deploy", true)
|
||||||
|
pod2.Spec.NodeName = "worker-node-2"
|
||||||
|
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod2, metav1.CreateOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create the KubeClient
|
||||||
|
kubeClient := &KubeClient{
|
||||||
|
cli: fakeClient,
|
||||||
|
instanceID: "test-instance",
|
||||||
|
IsKubeAdmin: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test filtering by node name
|
||||||
|
apps, err := kubeClient.GetApplications(namespace, nodeName)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// We expect to find only the pod on the specified node
|
||||||
|
assert.Equal(t, 1, len(apps))
|
||||||
|
if len(apps) > 0 {
|
||||||
|
assert.Equal(t, "node-deploy", apps[0].Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -24,7 +24,7 @@ func (kcl *KubeClient) GetConfigMaps(namespace string) ([]models.K8sConfigMap, e
|
||||||
// fetchConfigMapsForNonAdmin fetches the configMaps in the namespaces the user has access to.
|
// fetchConfigMapsForNonAdmin fetches the configMaps in the namespaces the user has access to.
|
||||||
// This function is called when the user is not an admin.
|
// This function is called when the user is not an admin.
|
||||||
func (kcl *KubeClient) fetchConfigMapsForNonAdmin(namespace string) ([]models.K8sConfigMap, error) {
|
func (kcl *KubeClient) fetchConfigMapsForNonAdmin(namespace string) ([]models.K8sConfigMap, error) {
|
||||||
log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces)
|
log.Debug().Msgf("Fetching configMaps for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||||
|
|
||||||
if len(kcl.NonAdminNamespaces) == 0 {
|
if len(kcl.NonAdminNamespaces) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
@ -102,7 +102,7 @@ func parseConfigMap(configMap *corev1.ConfigMap, withData bool) models.K8sConfig
|
||||||
func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8sConfigMap) ([]models.K8sConfigMap, error) {
|
func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8sConfigMap) ([]models.K8sConfigMap, error) {
|
||||||
updatedConfigMaps := make([]models.K8sConfigMap, len(configMaps))
|
updatedConfigMaps := make([]models.K8sConfigMap, len(configMaps))
|
||||||
|
|
||||||
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
|
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
|
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -110,7 +110,7 @@ func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8s
|
||||||
for index, configMap := range configMaps {
|
for index, configMap := range configMaps {
|
||||||
updatedConfigMap := configMap
|
updatedConfigMap := configMap
|
||||||
|
|
||||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, pods, replicaSets)
|
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to get applications from config map. Error: %w", err)
|
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to get applications from config map. Error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,6 @@ import (
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
appsv1 "k8s.io/api/apps/v1"
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
autoscalingv2 "k8s.io/api/autoscaling/v2"
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
@ -110,7 +109,7 @@ func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountNam
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(ctx, podSpec, metav1.CreateOptions{})
|
shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(context.TODO(), podSpec, metav1.CreateOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "error creating shell pod")
|
return nil, errors.Wrap(err, "error creating shell pod")
|
||||||
}
|
}
|
||||||
|
@ -158,7 +157,7 @@ func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPha
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
default:
|
default:
|
||||||
pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(ctx, pod.Name, metav1.GetOptions{})
|
pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(context.TODO(), pod.Name, metav1.GetOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -172,70 +171,67 @@ func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPha
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchAllPodsAndReplicaSets fetches all pods and replica sets across the cluster, i.e. all namespaces
|
|
||||||
func (kcl *KubeClient) fetchAllPodsAndReplicaSets(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
|
|
||||||
return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, false, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchAllApplicationsListResources fetches all pods, replica sets, stateful sets, and daemon sets across the cluster, i.e. all namespaces
|
// fetchAllApplicationsListResources fetches all pods, replica sets, stateful sets, and daemon sets across the cluster, i.e. all namespaces
|
||||||
// this is required for the applications list view
|
// this is required for the applications list view
|
||||||
func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
|
func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) (PortainerApplicationResources, error) {
|
||||||
return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, true, true)
|
return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, true, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchResourcesWithOwnerReferences fetches pods and other resources based on owner references
|
// fetchResourcesWithOwnerReferences fetches pods and other resources based on owner references
|
||||||
func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
|
func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) (PortainerApplicationResources, error) {
|
||||||
pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.Background(), podListOptions)
|
pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.Background(), podListOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if k8serrors.IsNotFound(err) {
|
if k8serrors.IsNotFound(err) {
|
||||||
return nil, nil, nil, nil, nil, nil, nil, nil
|
return PortainerApplicationResources{}, nil
|
||||||
}
|
}
|
||||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list pods across the cluster: %w", err)
|
return PortainerApplicationResources{}, fmt.Errorf("unable to list pods across the cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if replicaSet owner reference exists, fetch the replica sets
|
portainerApplicationResources := PortainerApplicationResources{
|
||||||
// this also means that the deployments will be fetched because deployments own replica sets
|
Pods: pods.Items,
|
||||||
replicaSets := &appsv1.ReplicaSetList{}
|
|
||||||
deployments := &appsv1.DeploymentList{}
|
|
||||||
if containsReplicaSetOwnerReference(pods) {
|
|
||||||
replicaSets, err = kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{})
|
|
||||||
if err != nil && !k8serrors.IsNotFound(err) {
|
|
||||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list replica sets across the cluster: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
deployments, err = kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{})
|
|
||||||
if err != nil && !k8serrors.IsNotFound(err) {
|
|
||||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list deployments across the cluster: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
statefulSets := &appsv1.StatefulSetList{}
|
replicaSets, err := kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||||
if includeStatefulSets && containsStatefulSetOwnerReference(pods) {
|
if err != nil && !k8serrors.IsNotFound(err) {
|
||||||
statefulSets, err = kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{})
|
return PortainerApplicationResources{}, fmt.Errorf("unable to list replica sets across the cluster: %w", err)
|
||||||
|
}
|
||||||
|
portainerApplicationResources.ReplicaSets = replicaSets.Items
|
||||||
|
|
||||||
|
deployments, err := kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{})
|
||||||
|
if err != nil && !k8serrors.IsNotFound(err) {
|
||||||
|
return PortainerApplicationResources{}, fmt.Errorf("unable to list deployments across the cluster: %w", err)
|
||||||
|
}
|
||||||
|
portainerApplicationResources.Deployments = deployments.Items
|
||||||
|
|
||||||
|
if includeStatefulSets {
|
||||||
|
statefulSets, err := kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||||
if err != nil && !k8serrors.IsNotFound(err) {
|
if err != nil && !k8serrors.IsNotFound(err) {
|
||||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list stateful sets across the cluster: %w", err)
|
return PortainerApplicationResources{}, fmt.Errorf("unable to list stateful sets across the cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
portainerApplicationResources.StatefulSets = statefulSets.Items
|
||||||
}
|
}
|
||||||
|
|
||||||
daemonSets := &appsv1.DaemonSetList{}
|
if includeDaemonSets {
|
||||||
if includeDaemonSets && containsDaemonSetOwnerReference(pods) {
|
daemonSets, err := kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||||
daemonSets, err = kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{})
|
|
||||||
if err != nil && !k8serrors.IsNotFound(err) {
|
if err != nil && !k8serrors.IsNotFound(err) {
|
||||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
|
return PortainerApplicationResources{}, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
portainerApplicationResources.DaemonSets = daemonSets.Items
|
||||||
}
|
}
|
||||||
|
|
||||||
services, err := kcl.cli.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{})
|
services, err := kcl.cli.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{})
|
||||||
if err != nil && !k8serrors.IsNotFound(err) {
|
if err != nil && !k8serrors.IsNotFound(err) {
|
||||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list services across the cluster: %w", err)
|
return PortainerApplicationResources{}, fmt.Errorf("unable to list services across the cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
portainerApplicationResources.Services = services.Items
|
||||||
|
|
||||||
hpas, err := kcl.cli.AutoscalingV2().HorizontalPodAutoscalers(namespace).List(context.Background(), metav1.ListOptions{})
|
hpas, err := kcl.cli.AutoscalingV2().HorizontalPodAutoscalers(namespace).List(context.Background(), metav1.ListOptions{})
|
||||||
if err != nil && !k8serrors.IsNotFound(err) {
|
if err != nil && !k8serrors.IsNotFound(err) {
|
||||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
|
return PortainerApplicationResources{}, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
portainerApplicationResources.HorizontalPodAutoscalers = hpas.Items
|
||||||
|
|
||||||
return pods.Items, replicaSets.Items, deployments.Items, statefulSets.Items, daemonSets.Items, services.Items, hpas.Items, nil
|
return portainerApplicationResources, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isPodUsingConfigMap checks if a pod is using a specific ConfigMap
|
// isPodUsingConfigMap checks if a pod is using a specific ConfigMap
|
||||||
|
|
|
@ -10,7 +10,6 @@ import (
|
||||||
rbacv1 "k8s.io/api/rbac/v1"
|
rbacv1 "k8s.io/api/rbac/v1"
|
||||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetRoles gets all the roles for either at the cluster level or a given namespace in a k8s endpoint.
|
// GetRoles gets all the roles for either at the cluster level or a given namespace in a k8s endpoint.
|
||||||
|
@ -137,7 +136,7 @@ func (kcl *KubeClient) DeleteRoles(reqs models.K8sRoleDeleteRequests) error {
|
||||||
for _, name := range reqs[namespace] {
|
for _, name := range reqs[namespace] {
|
||||||
client := kcl.cli.RbacV1().Roles(namespace)
|
client := kcl.cli.RbacV1().Roles(namespace)
|
||||||
|
|
||||||
role, err := client.Get(context.Background(), name, v1.GetOptions{})
|
role, err := client.Get(context.Background(), name, metav1.GetOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if k8serrors.IsNotFound(err) {
|
if k8serrors.IsNotFound(err) {
|
||||||
continue
|
continue
|
||||||
|
|
|
@ -7,11 +7,9 @@ import (
|
||||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||||
"github.com/portainer/portainer/api/internal/errorlist"
|
"github.com/portainer/portainer/api/internal/errorlist"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
corev1 "k8s.io/api/rbac/v1"
|
|
||||||
rbacv1 "k8s.io/api/rbac/v1"
|
rbacv1 "k8s.io/api/rbac/v1"
|
||||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetRoleBindings gets all the roleBindings for either at the cluster level or a given namespace in a k8s endpoint.
|
// GetRoleBindings gets all the roleBindings for either at the cluster level or a given namespace in a k8s endpoint.
|
||||||
|
@ -98,7 +96,7 @@ func (kcl *KubeClient) isSystemRoleBinding(rb *rbacv1.RoleBinding) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (kcl *KubeClient) getRole(namespace, name string) (*corev1.Role, error) {
|
func (kcl *KubeClient) getRole(namespace, name string) (*rbacv1.Role, error) {
|
||||||
client := kcl.cli.RbacV1().Roles(namespace)
|
client := kcl.cli.RbacV1().Roles(namespace)
|
||||||
return client.Get(context.Background(), name, metav1.GetOptions{})
|
return client.Get(context.Background(), name, metav1.GetOptions{})
|
||||||
}
|
}
|
||||||
|
@ -111,7 +109,7 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
|
||||||
for _, name := range reqs[namespace] {
|
for _, name := range reqs[namespace] {
|
||||||
client := kcl.cli.RbacV1().RoleBindings(namespace)
|
client := kcl.cli.RbacV1().RoleBindings(namespace)
|
||||||
|
|
||||||
roleBinding, err := client.Get(context.Background(), name, v1.GetOptions{})
|
roleBinding, err := client.Get(context.Background(), name, metav1.GetOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if k8serrors.IsNotFound(err) {
|
if k8serrors.IsNotFound(err) {
|
||||||
continue
|
continue
|
||||||
|
@ -125,7 +123,7 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
|
||||||
log.Error().Str("role_name", name).Msg("ignoring delete of 'system' role binding, not allowed")
|
log.Error().Str("role_name", name).Msg("ignoring delete of 'system' role binding, not allowed")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := client.Delete(context.Background(), name, v1.DeleteOptions{}); err != nil {
|
if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
|
||||||
errors = append(errors, err)
|
errors = append(errors, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ func (kcl *KubeClient) GetSecrets(namespace string) ([]models.K8sSecret, error)
|
||||||
// getSecretsForNonAdmin fetches the secrets in the namespaces the user has access to.
|
// getSecretsForNonAdmin fetches the secrets in the namespaces the user has access to.
|
||||||
// This function is called when the user is not an admin.
|
// This function is called when the user is not an admin.
|
||||||
func (kcl *KubeClient) getSecretsForNonAdmin(namespace string) ([]models.K8sSecret, error) {
|
func (kcl *KubeClient) getSecretsForNonAdmin(namespace string) ([]models.K8sSecret, error) {
|
||||||
log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces)
|
log.Debug().Msgf("Fetching secrets for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||||
|
|
||||||
if len(kcl.NonAdminNamespaces) == 0 {
|
if len(kcl.NonAdminNamespaces) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
@ -118,7 +118,7 @@ func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret {
|
||||||
func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret) ([]models.K8sSecret, error) {
|
func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret) ([]models.K8sSecret, error) {
|
||||||
updatedSecrets := make([]models.K8sSecret, len(secrets))
|
updatedSecrets := make([]models.K8sSecret, len(secrets))
|
||||||
|
|
||||||
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
|
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
|
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -126,7 +126,7 @@ func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret
|
||||||
for index, secret := range secrets {
|
for index, secret := range secrets {
|
||||||
updatedSecret := secret
|
updatedSecret := secret
|
||||||
|
|
||||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, pods, replicaSets)
|
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to get applications from secret. Error: %w", err)
|
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to get applications from secret. Error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -174,7 +174,7 @@ func (kcl *KubeClient) UpdateService(namespace string, info models.K8sServiceInf
|
||||||
func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error) {
|
func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error) {
|
||||||
if containsServiceWithSelector(services) {
|
if containsServiceWithSelector(services) {
|
||||||
updatedServices := make([]models.K8sServiceInfo, len(services))
|
updatedServices := make([]models.K8sServiceInfo, len(services))
|
||||||
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
|
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
|
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -182,7 +182,7 @@ func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServ
|
||||||
for index, service := range services {
|
for index, service := range services {
|
||||||
updatedService := service
|
updatedService := service
|
||||||
|
|
||||||
application, err := kcl.GetApplicationFromServiceSelector(pods, service, replicaSets)
|
application, err := kcl.GetApplicationFromServiceSelector(portainerApplicationResources.Pods, service, portainerApplicationResources.ReplicaSets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return services, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to get application from service. Error: %w", err)
|
return services, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to get application from service. Error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/http/models/kubernetes"
|
|
||||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||||
"github.com/portainer/portainer/api/internal/errorlist"
|
"github.com/portainer/portainer/api/internal/errorlist"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
@ -92,7 +91,7 @@ func (kcl *KubeClient) isSystemServiceAccount(namespace string) bool {
|
||||||
|
|
||||||
// DeleteServices processes a K8sServiceDeleteRequest by deleting each service
|
// DeleteServices processes a K8sServiceDeleteRequest by deleting each service
|
||||||
// in its given namespace.
|
// in its given namespace.
|
||||||
func (kcl *KubeClient) DeleteServiceAccounts(reqs kubernetes.K8sServiceAccountDeleteRequests) error {
|
func (kcl *KubeClient) DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error {
|
||||||
var errors []error
|
var errors []error
|
||||||
for namespace := range reqs {
|
for namespace := range reqs {
|
||||||
for _, serviceName := range reqs[namespace] {
|
for _, serviceName := range reqs[namespace] {
|
||||||
|
|
|
@ -7,7 +7,6 @@ import (
|
||||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
appsv1 "k8s.io/api/apps/v1"
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
autoscalingv2 "k8s.io/api/autoscaling/v2"
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
storagev1 "k8s.io/api/storage/v1"
|
storagev1 "k8s.io/api/storage/v1"
|
||||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
@ -265,7 +264,12 @@ func (kcl *KubeClient) updateVolumesWithOwningApplications(volumes *[]models.K8s
|
||||||
if pod.Spec.Volumes != nil {
|
if pod.Spec.Volumes != nil {
|
||||||
for _, podVolume := range pod.Spec.Volumes {
|
for _, podVolume := range pod.Spec.Volumes {
|
||||||
if podVolume.VolumeSource.PersistentVolumeClaim != nil && podVolume.VolumeSource.PersistentVolumeClaim.ClaimName == volume.PersistentVolumeClaim.Name && pod.Namespace == volume.PersistentVolumeClaim.Namespace {
|
if podVolume.VolumeSource.PersistentVolumeClaim != nil && podVolume.VolumeSource.PersistentVolumeClaim.ClaimName == volume.PersistentVolumeClaim.Name && pod.Namespace == volume.PersistentVolumeClaim.Namespace {
|
||||||
application, err := kcl.ConvertPodToApplication(pod, replicaSetItems, deploymentItems, statefulSetItems, daemonSetItems, []corev1.Service{}, []autoscalingv2.HorizontalPodAutoscaler{}, false)
|
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||||
|
ReplicaSets: replicaSetItems,
|
||||||
|
Deployments: deploymentItems,
|
||||||
|
StatefulSets: statefulSetItems,
|
||||||
|
DaemonSets: daemonSetItems,
|
||||||
|
}, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to convert pod to application")
|
log.Error().Err(err).Msg("Failed to convert pod to application")
|
||||||
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to convert pod to application. Error: %w", err)
|
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to convert pod to application. Error: %w", err)
|
||||||
|
|
|
@ -14,6 +14,10 @@ import (
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNoLocalEnvironment = errors.New("No local environment was detected")
|
||||||
|
)
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
GetLocalEnvironment() (*portainer.Endpoint, error)
|
GetLocalEnvironment() (*portainer.Endpoint, error)
|
||||||
GetPlatform() (ContainerPlatform, error)
|
GetPlatform() (ContainerPlatform, error)
|
||||||
|
@ -35,7 +39,7 @@ func (service *service) loadEnvAndPlatform() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
environment, platform, err := guessLocalEnvironment(service.dataStore)
|
environment, platform, err := detectLocalEnvironment(service.dataStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -73,7 +77,7 @@ var platformToEndpointType = map[ContainerPlatform][]portainer.EndpointType{
|
||||||
PlatformKubernetes: {portainer.KubernetesLocalEnvironment},
|
PlatformKubernetes: {portainer.KubernetesLocalEnvironment},
|
||||||
}
|
}
|
||||||
|
|
||||||
func guessLocalEnvironment(dataStore dataservices.DataStore) (*portainer.Endpoint, ContainerPlatform, error) {
|
func detectLocalEnvironment(dataStore dataservices.DataStore) (*portainer.Endpoint, ContainerPlatform, error) {
|
||||||
platform := DetermineContainerPlatform()
|
platform := DetermineContainerPlatform()
|
||||||
|
|
||||||
if !slices.Contains([]ContainerPlatform{PlatformDocker, PlatformKubernetes}, platform) {
|
if !slices.Contains([]ContainerPlatform{PlatformDocker, PlatformKubernetes}, platform) {
|
||||||
|
@ -113,7 +117,7 @@ func guessLocalEnvironment(dataStore dataservices.DataStore) (*portainer.Endpoin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, "", errors.New("failed to find local environment")
|
return nil, "", ErrNoLocalEnvironment
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkDockerEnvTypeForUpgrade(environment *portainer.Endpoint) ContainerPlatform {
|
func checkDockerEnvTypeForUpgrade(environment *portainer.Endpoint) ContainerPlatform {
|
||||||
|
|
|
@ -134,6 +134,7 @@ type (
|
||||||
LogLevel *string
|
LogLevel *string
|
||||||
LogMode *string
|
LogMode *string
|
||||||
KubectlShellImage *string
|
KubectlShellImage *string
|
||||||
|
PullLimitCheckDisabled *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CustomTemplateVariableDefinition
|
// CustomTemplateVariableDefinition
|
||||||
|
@ -588,7 +589,7 @@ type (
|
||||||
// User identifier
|
// User identifier
|
||||||
UserID UserID `json:"UserId" example:"1"`
|
UserID UserID `json:"UserId" example:"1"`
|
||||||
// Helm repository URL
|
// 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
|
// QuayRegistryData represents data required for Quay registry to work
|
||||||
|
@ -984,8 +985,8 @@ type (
|
||||||
KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"`
|
KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"`
|
||||||
// Whether telemetry is enabled
|
// Whether telemetry is enabled
|
||||||
EnableTelemetry bool `json:"EnableTelemetry" example:"false"`
|
EnableTelemetry bool `json:"EnableTelemetry" example:"false"`
|
||||||
// Helm repository URL, defaults to "https://charts.bitnami.com/bitnami"
|
// Helm repository URL, defaults to ""
|
||||||
HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"`
|
HelmRepositoryURL string `json:"HelmRepositoryURL"`
|
||||||
// KubectlImage, defaults to portainer/kubectl-shell
|
// KubectlImage, defaults to portainer/kubectl-shell
|
||||||
KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"`
|
KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"`
|
||||||
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
|
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
|
||||||
|
@ -1491,7 +1492,8 @@ type (
|
||||||
StoreSSLCertPair(cert, key []byte) (string, string, error)
|
StoreSSLCertPair(cert, key []byte) (string, string, error)
|
||||||
CopySSLCertPair(certPath, keyPath string) (string, string, error)
|
CopySSLCertPair(certPath, keyPath string) (string, string, error)
|
||||||
CopySSLCACert(caCertPath string) (string, error)
|
CopySSLCACert(caCertPath string) (string, error)
|
||||||
StoreMTLSCertificates(cert, caCert, key []byte) (string, string, string, error)
|
StoreMTLSCertificates(caCert, cert, key []byte) (string, string, string, error)
|
||||||
|
GetMTLSCertificates() (string, string, string, error)
|
||||||
GetDefaultChiselPrivateKeyPath() string
|
GetDefaultChiselPrivateKeyPath() string
|
||||||
StoreChiselPrivateKey(privateKey []byte) error
|
StoreChiselPrivateKey(privateKey []byte) error
|
||||||
}
|
}
|
||||||
|
@ -1543,7 +1545,7 @@ type (
|
||||||
GetConfigMaps(namespace string) ([]models.K8sConfigMap, error)
|
GetConfigMaps(namespace string) ([]models.K8sConfigMap, error)
|
||||||
GetSecrets(namespace string) ([]models.K8sSecret, error)
|
GetSecrets(namespace string) ([]models.K8sSecret, error)
|
||||||
GetIngressControllers() (models.K8sIngressControllers, error)
|
GetIngressControllers() (models.K8sIngressControllers, error)
|
||||||
GetApplications(namespace, nodename string, withDependencies bool) ([]models.K8sApplication, error)
|
GetApplications(namespace, nodename string) ([]models.K8sApplication, error)
|
||||||
GetMetrics() (models.K8sMetrics, error)
|
GetMetrics() (models.K8sMetrics, error)
|
||||||
GetStorage() ([]KubernetesStorageClassConfig, error)
|
GetStorage() ([]KubernetesStorageClassConfig, error)
|
||||||
CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error
|
CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error
|
||||||
|
@ -1636,7 +1638,7 @@ type (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// APIVersion is the version number of the Portainer API
|
// APIVersion is the version number of the Portainer API
|
||||||
APIVersion = "2.27.0-rc1"
|
APIVersion = "2.27.6"
|
||||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||||
APIVersionSupport = "LTS"
|
APIVersionSupport = "LTS"
|
||||||
// Edition is what this edition of Portainer is called
|
// Edition is what this edition of Portainer is called
|
||||||
|
@ -1672,8 +1674,8 @@ const (
|
||||||
DefaultEdgeAgentCheckinIntervalInSeconds = 5
|
DefaultEdgeAgentCheckinIntervalInSeconds = 5
|
||||||
// DefaultTemplatesURL represents the URL to the official templates supported by Portainer
|
// DefaultTemplatesURL represents the URL to the official templates supported by Portainer
|
||||||
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/v3/templates.json"
|
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/v3/templates.json"
|
||||||
// DefaultHelmrepositoryURL represents the URL to the official templates supported by Bitnami
|
// DefaultHelmrepositoryURL set to empty string until oci support is added
|
||||||
DefaultHelmRepositoryURL = "https://charts.bitnami.com/bitnami"
|
DefaultHelmRepositoryURL = ""
|
||||||
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
|
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
|
||||||
DefaultUserSessionTimeout = "8h"
|
DefaultUserSessionTimeout = "8h"
|
||||||
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
|
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
|
||||||
|
@ -1688,6 +1690,8 @@ const (
|
||||||
PortainerCacheHeader = "X-Portainer-Cache"
|
PortainerCacheHeader = "X-Portainer-Cache"
|
||||||
// KubectlShellImageEnvVar is the environment variable used to override the default kubectl shell image
|
// KubectlShellImageEnvVar is the environment variable used to override the default kubectl shell image
|
||||||
KubectlShellImageEnvVar = "KUBECTL_SHELL_IMAGE"
|
KubectlShellImageEnvVar = "KUBECTL_SHELL_IMAGE"
|
||||||
|
// PullLimitCheckDisabledEnvVar is the environment variable used to disable the pull limit check
|
||||||
|
PullLimitCheckDisabledEnvVar = "PULL_LIMIT_CHECK_DISABLED"
|
||||||
)
|
)
|
||||||
|
|
||||||
// List of supported features
|
// List of supported features
|
||||||
|
|
|
@ -5,7 +5,6 @@ export const API_ENDPOINT_CUSTOM_TEMPLATES = 'api/custom_templates';
|
||||||
export const API_ENDPOINT_EDGE_GROUPS = 'api/edge_groups';
|
export const API_ENDPOINT_EDGE_GROUPS = 'api/edge_groups';
|
||||||
export const API_ENDPOINT_EDGE_JOBS = 'api/edge_jobs';
|
export const API_ENDPOINT_EDGE_JOBS = 'api/edge_jobs';
|
||||||
export const API_ENDPOINT_EDGE_STACKS = 'api/edge_stacks';
|
export const API_ENDPOINT_EDGE_STACKS = 'api/edge_stacks';
|
||||||
export const API_ENDPOINT_EDGE_TEMPLATES = 'api/edge_templates';
|
|
||||||
export const API_ENDPOINT_ENDPOINTS = 'api/endpoints';
|
export const API_ENDPOINT_ENDPOINTS = 'api/endpoints';
|
||||||
export const API_ENDPOINT_ENDPOINT_GROUPS = 'api/endpoint_groups';
|
export const API_ENDPOINT_ENDPOINT_GROUPS = 'api/endpoint_groups';
|
||||||
export const API_ENDPOINT_KUBERNETES = 'api/kubernetes';
|
export const API_ENDPOINT_KUBERNETES = 'api/kubernetes';
|
||||||
|
|
|
@ -31,10 +31,40 @@
|
||||||
>Select the Helm chart to use. Bring further Helm charts into your selection list via
|
>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
|
<a ui-sref="portainer.account({'#': 'helm-repositories'})">User settings - Helm repositories</a>.</div
|
||||||
>
|
>
|
||||||
<beta-alert
|
<div class="w-full">
|
||||||
is-html="true"
|
<div class="small text-muted mb-2"
|
||||||
message="'Beta feature - so far, this functionality has been tested in limited scenarios. For more information, see this <a href=\'https://www.portainer.io/blog/portainer-now-with-helm-support\' target=\'_blank\' class=\'hyperlink\'>blog post on Portainer Helm support</a>.'"
|
>Select the Helm chart to use. Bring further Helm charts into your selection list via
|
||||||
></beta-alert>
|
<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>
|
||||||
|
|
||||||
<div class="blocklist !px-0" role="list">
|
<div class="blocklist !px-0" role="list">
|
||||||
|
|
|
@ -3,6 +3,7 @@ import _ from 'lodash-es';
|
||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
import KubernetesResourcePoolConverter from 'Kubernetes/converters/resourcePool';
|
import KubernetesResourcePoolConverter from 'Kubernetes/converters/resourcePool';
|
||||||
import KubernetesResourceQuotaHelper from 'Kubernetes/helpers/resourceQuotaHelper';
|
import KubernetesResourceQuotaHelper from 'Kubernetes/helpers/resourceQuotaHelper';
|
||||||
|
import { getNamespaces } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
|
||||||
|
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
export function KubernetesResourcePoolService(
|
export function KubernetesResourcePoolService(
|
||||||
|
@ -11,7 +12,8 @@ export function KubernetesResourcePoolService(
|
||||||
KubernetesNamespaceService,
|
KubernetesNamespaceService,
|
||||||
KubernetesResourceQuotaService,
|
KubernetesResourceQuotaService,
|
||||||
KubernetesIngressService,
|
KubernetesIngressService,
|
||||||
KubernetesPortainerNamespaces
|
KubernetesPortainerNamespaces,
|
||||||
|
EndpointProvider
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
get,
|
get,
|
||||||
|
@ -37,9 +39,14 @@ export function KubernetesResourcePoolService(
|
||||||
|
|
||||||
// getting the quota for all namespaces is costly by default, so disable getting it by default
|
// getting the quota for all namespaces is costly by default, so disable getting it by default
|
||||||
async function getAll({ getQuota = false }) {
|
async function getAll({ getQuota = false }) {
|
||||||
const namespaces = await KubernetesNamespaceService.get();
|
const namespaces = await getNamespaces(EndpointProvider.endpointID());
|
||||||
|
// there is a lot of downstream logic using the angular namespace type with a '.Status' field (not '.Status.phase'), so format the status here to match this logic
|
||||||
|
const namespacesFormattedStatus = namespaces.map((namespace) => ({
|
||||||
|
...namespace,
|
||||||
|
Status: namespace.Status.phase,
|
||||||
|
}));
|
||||||
const pools = await Promise.all(
|
const pools = await Promise.all(
|
||||||
_.map(namespaces, async (namespace) => {
|
_.map(namespacesFormattedStatus, async (namespace) => {
|
||||||
const name = namespace.Name;
|
const name = namespace.Name;
|
||||||
const pool = KubernetesResourcePoolConverter.apiToResourcePool(namespace);
|
const pool = KubernetesResourcePoolConverter.apiToResourcePool(namespace);
|
||||||
if (getQuota) {
|
if (getQuota) {
|
||||||
|
|
|
@ -71,7 +71,9 @@ class KubernetesClusterController {
|
||||||
|
|
||||||
const applicationsResources = await getTotalResourcesForAllApplications(this.endpoint.Id);
|
const applicationsResources = await getTotalResourcesForAllApplications(this.endpoint.Id);
|
||||||
this.resourceReservation = new KubernetesResourceReservation();
|
this.resourceReservation = new KubernetesResourceReservation();
|
||||||
this.resourceReservation.CPU = Math.round(applicationsResources.CpuRequest / 1000);
|
|
||||||
|
// 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);
|
this.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(applicationsResources.MemoryRequest);
|
||||||
|
|
||||||
if (this.hasResourceUsageAccess()) {
|
if (this.hasResourceUsageAccess()) {
|
||||||
|
|
|
@ -6,13 +6,13 @@ import PortainerError from '@/portainer/error';
|
||||||
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
|
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
|
||||||
import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||||
import { getDeploymentOptions } from '@/react/portainer/environments/environment.service';
|
import { getDeploymentOptions } from '@/react/portainer/environments/environment.service';
|
||||||
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
|
|
||||||
import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods';
|
|
||||||
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
|
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
|
||||||
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
|
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
|
||||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
|
||||||
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||||
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
|
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
|
||||||
|
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||||
|
import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods';
|
||||||
|
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
|
||||||
|
|
||||||
class KubernetesDeployController {
|
class KubernetesDeployController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
|
|
|
@ -7,7 +7,6 @@ import {
|
||||||
API_ENDPOINT_EDGE_GROUPS,
|
API_ENDPOINT_EDGE_GROUPS,
|
||||||
API_ENDPOINT_EDGE_JOBS,
|
API_ENDPOINT_EDGE_JOBS,
|
||||||
API_ENDPOINT_EDGE_STACKS,
|
API_ENDPOINT_EDGE_STACKS,
|
||||||
API_ENDPOINT_EDGE_TEMPLATES,
|
|
||||||
API_ENDPOINT_ENDPOINTS,
|
API_ENDPOINT_ENDPOINTS,
|
||||||
API_ENDPOINT_ENDPOINT_GROUPS,
|
API_ENDPOINT_ENDPOINT_GROUPS,
|
||||||
API_ENDPOINT_KUBERNETES,
|
API_ENDPOINT_KUBERNETES,
|
||||||
|
@ -42,7 +41,6 @@ export const constantsModule = angular
|
||||||
.constant('API_ENDPOINT_EDGE_GROUPS', API_ENDPOINT_EDGE_GROUPS)
|
.constant('API_ENDPOINT_EDGE_GROUPS', API_ENDPOINT_EDGE_GROUPS)
|
||||||
.constant('API_ENDPOINT_EDGE_JOBS', API_ENDPOINT_EDGE_JOBS)
|
.constant('API_ENDPOINT_EDGE_JOBS', API_ENDPOINT_EDGE_JOBS)
|
||||||
.constant('API_ENDPOINT_EDGE_STACKS', API_ENDPOINT_EDGE_STACKS)
|
.constant('API_ENDPOINT_EDGE_STACKS', API_ENDPOINT_EDGE_STACKS)
|
||||||
.constant('API_ENDPOINT_EDGE_TEMPLATES', API_ENDPOINT_EDGE_TEMPLATES)
|
|
||||||
.constant('API_ENDPOINT_ENDPOINTS', API_ENDPOINT_ENDPOINTS)
|
.constant('API_ENDPOINT_ENDPOINTS', API_ENDPOINT_ENDPOINTS)
|
||||||
.constant('API_ENDPOINT_ENDPOINT_GROUPS', API_ENDPOINT_ENDPOINT_GROUPS)
|
.constant('API_ENDPOINT_ENDPOINT_GROUPS', API_ENDPOINT_ENDPOINT_GROUPS)
|
||||||
.constant('API_ENDPOINT_KUBERNETES', API_ENDPOINT_KUBERNETES)
|
.constant('API_ENDPOINT_KUBERNETES', API_ENDPOINT_KUBERNETES)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
export function pluralize(val: number, word: string, plural = `${word}s`) {
|
// Re-exporting so we don't have to update one meeeeellion files that are already importing these
|
||||||
return [1, -1].includes(Number(val)) ? word : plural;
|
// functions from here.
|
||||||
}
|
export {
|
||||||
|
pluralize,
|
||||||
export function addPlural(value: number, word: string, plural = `${word}s`) {
|
addPlural,
|
||||||
return `${value} ${pluralize(value, word, plural)}`;
|
grammaticallyJoin,
|
||||||
}
|
} from '@/react/common/string-utils';
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
export function capitalize(s: string) {
|
||||||
|
return s.slice(0, 1).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluralize(val: number, word: string, plural = `${word}s`) {
|
||||||
|
return [1, -1].includes(Number(val)) ? word : plural;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addPlural(value: number, word: string, plural = `${word}s`) {
|
||||||
|
return `${value} ${pluralize(value, word, plural)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins an array of strings into a grammatically correct sentence.
|
||||||
|
*/
|
||||||
|
export function grammaticallyJoin(
|
||||||
|
values: string[],
|
||||||
|
separator = ', ',
|
||||||
|
lastSeparator = ' and '
|
||||||
|
) {
|
||||||
|
if (values.length === 0) return '';
|
||||||
|
if (values.length === 1) return values[0];
|
||||||
|
|
||||||
|
const allButLast = values.slice(0, -1);
|
||||||
|
const last = values[values.length - 1];
|
||||||
|
return `${allButLast.join(separator)}${lastSeparator}${last}`;
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
createColumnHelper,
|
createColumnHelper,
|
||||||
|
@ -154,6 +155,84 @@ describe('Datatable', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('No data available')).toBeInTheDocument();
|
expect(screen.getByText('No data available')).toBeInTheDocument();
|
||||||
|
const selectAllCheckbox: HTMLInputElement =
|
||||||
|
screen.getByLabelText('Select all rows');
|
||||||
|
expect(selectAllCheckbox.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects/deselects only page rows when select all is clicked', () => {
|
||||||
|
render(
|
||||||
|
<Datatable
|
||||||
|
dataset={mockData}
|
||||||
|
columns={mockColumns}
|
||||||
|
settingsManager={{ ...mockSettingsManager, pageSize: 2 }}
|
||||||
|
data-cy="test-table"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectAllCheckbox = screen.getByLabelText('Select all rows');
|
||||||
|
fireEvent.click(selectAllCheckbox);
|
||||||
|
|
||||||
|
// Check if all rows on the page are selected
|
||||||
|
expect(screen.getByText('2 items selected')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Deselect
|
||||||
|
fireEvent.click(selectAllCheckbox);
|
||||||
|
const checkboxes: HTMLInputElement[] = screen.queryAllByRole('checkbox');
|
||||||
|
expect(checkboxes.filter((checkbox) => checkbox.checked).length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects/deselects all rows including other pages when select all is clicked with shift key', () => {
|
||||||
|
render(
|
||||||
|
<Datatable
|
||||||
|
dataset={mockData}
|
||||||
|
columns={mockColumns}
|
||||||
|
settingsManager={{ ...mockSettingsManager, pageSize: 2 }}
|
||||||
|
data-cy="test-table"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectAllCheckbox = screen.getByLabelText('Select all rows');
|
||||||
|
fireEvent.click(selectAllCheckbox, { shiftKey: true });
|
||||||
|
|
||||||
|
// Check if all rows on the page are selected
|
||||||
|
expect(screen.getByText('3 items selected')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Deselect
|
||||||
|
fireEvent.click(selectAllCheckbox, { shiftKey: true });
|
||||||
|
const checkboxes: HTMLInputElement[] = screen.queryAllByRole('checkbox');
|
||||||
|
expect(checkboxes.filter((checkbox) => checkbox.checked).length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows indeterminate state and correct footer text when hidden rows are selected', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<DatatableWithStore
|
||||||
|
dataset={mockData}
|
||||||
|
columns={mockColumns}
|
||||||
|
data-cy="test-table"
|
||||||
|
title="Test table with search"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Select Jane
|
||||||
|
const checkboxes = screen.getAllByRole('checkbox');
|
||||||
|
await user.click(checkboxes[2]); // Select the second row
|
||||||
|
|
||||||
|
// Search for John (will hide selected Jane)
|
||||||
|
const searchInput = screen.getByPlaceholderText('Search...');
|
||||||
|
await user.type(searchInput, 'John');
|
||||||
|
|
||||||
|
// Check if the footer text is correct
|
||||||
|
expect(
|
||||||
|
await screen.findByText('1 item selected (1 hidden by filters)')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check if the checkbox is indeterminate
|
||||||
|
const selectAllCheckbox: HTMLInputElement =
|
||||||
|
screen.getByLabelText('Select all rows');
|
||||||
|
expect(selectAllCheckbox.indeterminate).toBe(true);
|
||||||
|
expect(selectAllCheckbox.checked).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -171,6 +171,14 @@ export function Datatable<D extends DefaultType>({
|
||||||
|
|
||||||
const selectedRowModel = tableInstance.getSelectedRowModel();
|
const selectedRowModel = tableInstance.getSelectedRowModel();
|
||||||
const selectedItems = selectedRowModel.rows.map((row) => row.original);
|
const selectedItems = selectedRowModel.rows.map((row) => row.original);
|
||||||
|
const filteredItems = tableInstance
|
||||||
|
.getFilteredRowModel()
|
||||||
|
.rows.map((row) => row.original);
|
||||||
|
|
||||||
|
const hiddenSelectedItems = useMemo(
|
||||||
|
() => _.difference(selectedItems, filteredItems),
|
||||||
|
[selectedItems, filteredItems]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Container noWidget={noWidget} aria-label={title}>
|
<Table.Container noWidget={noWidget} aria-label={title}>
|
||||||
|
@ -203,6 +211,7 @@ export function Datatable<D extends DefaultType>({
|
||||||
pageSize={tableState.pagination.pageSize}
|
pageSize={tableState.pagination.pageSize}
|
||||||
pageCount={tableInstance.getPageCount()}
|
pageCount={tableInstance.getPageCount()}
|
||||||
totalSelected={selectedItems.length}
|
totalSelected={selectedItems.length}
|
||||||
|
totalHiddenSelected={hiddenSelectedItems.length}
|
||||||
/>
|
/>
|
||||||
</Table.Container>
|
</Table.Container>
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { SelectedRowsCount } from './SelectedRowsCount';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
totalSelected: number;
|
totalSelected: number;
|
||||||
|
totalHiddenSelected: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
page: number;
|
page: number;
|
||||||
onPageChange(page: number): void;
|
onPageChange(page: number): void;
|
||||||
|
@ -14,6 +15,7 @@ interface Props {
|
||||||
|
|
||||||
export function DatatableFooter({
|
export function DatatableFooter({
|
||||||
totalSelected,
|
totalSelected,
|
||||||
|
totalHiddenSelected,
|
||||||
pageSize,
|
pageSize,
|
||||||
page,
|
page,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
@ -22,7 +24,7 @@ export function DatatableFooter({
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Table.Footer>
|
<Table.Footer>
|
||||||
<SelectedRowsCount value={totalSelected} />
|
<SelectedRowsCount value={totalSelected} hidden={totalHiddenSelected} />
|
||||||
<PaginationControls
|
<PaginationControls
|
||||||
showAll
|
showAll
|
||||||
pageLimit={pageSize}
|
pageLimit={pageSize}
|
||||||
|
|
|
@ -1,9 +1,15 @@
|
||||||
|
import { addPlural } from '@/react/common/string-utils';
|
||||||
|
|
||||||
interface SelectedRowsCountProps {
|
interface SelectedRowsCountProps {
|
||||||
value: number;
|
value: number;
|
||||||
|
hidden: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectedRowsCount({ value }: SelectedRowsCountProps) {
|
export function SelectedRowsCount({ value, hidden }: SelectedRowsCountProps) {
|
||||||
return value !== 0 ? (
|
return value !== 0 ? (
|
||||||
<div className="infoBar">{value} item(s) selected</div>
|
<div className="infoBar">
|
||||||
|
{addPlural(value, 'item')} selected
|
||||||
|
{hidden !== 0 && ` (${hidden} hidden by filters)`}
|
||||||
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,20 @@
|
||||||
import { ColumnDef, Row } from '@tanstack/react-table';
|
import { ColumnDef, Row, Table } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Checkbox } from '@@/form-components/Checkbox';
|
import { Checkbox } from '@@/form-components/Checkbox';
|
||||||
|
|
||||||
|
function allRowsSelected<T>(table: Table<T>) {
|
||||||
|
const { rows } = table.getCoreRowModel();
|
||||||
|
return rows.length > 0 && rows.every((row) => row.getIsSelected());
|
||||||
|
}
|
||||||
|
|
||||||
|
function someRowsSelected<T>(table: Table<T>) {
|
||||||
|
return table.getCoreRowModel().rows.some((row) => row.getIsSelected());
|
||||||
|
}
|
||||||
|
|
||||||
|
function somePageRowsSelected<T>(table: Table<T>) {
|
||||||
|
return table.getRowModel().rows.some((row) => row.getIsSelected());
|
||||||
|
}
|
||||||
|
|
||||||
export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
|
export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
|
||||||
let lastSelectedId = '';
|
let lastSelectedId = '';
|
||||||
|
|
||||||
|
@ -11,15 +24,22 @@ export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="select-all"
|
id="select-all"
|
||||||
data-cy={`select-all-checkbox-${dataCy}`}
|
data-cy={`select-all-checkbox-${dataCy}`}
|
||||||
checked={table.getIsAllRowsSelected()}
|
checked={allRowsSelected(table)}
|
||||||
indeterminate={table.getIsSomeRowsSelected()}
|
indeterminate={!allRowsSelected(table) && someRowsSelected(table)}
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
onChange={(e) => {
|
||||||
|
// Select all rows if shift key is held down, otherwise only page rows
|
||||||
|
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey) {
|
||||||
|
table.toggleAllRowsSelected();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
table.toggleAllPageRowsSelected(!somePageRowsSelected(table));
|
||||||
|
}}
|
||||||
disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())}
|
disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
aria-label="Select all rows"
|
aria-label="Select all rows"
|
||||||
title="Select all rows"
|
title="Select all rows. Hold shift key to select across all pages."
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row, table }) => (
|
cell: ({ row, table }) => (
|
||||||
|
|
|
@ -42,6 +42,8 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
|
||||||
resolvedRef = defaultRef;
|
resolvedRef = defaultRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Need to check this on every render as the browser will always set the element's
|
||||||
|
// indeterminate state to false when the checkbox is clicked, even if the indeterminate prop hasn't changed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (resolvedRef === null || resolvedRef.current === null) {
|
if (resolvedRef === null || resolvedRef.current === null) {
|
||||||
return;
|
return;
|
||||||
|
@ -50,7 +52,7 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
|
||||||
if (typeof indeterminate !== 'undefined') {
|
if (typeof indeterminate !== 'undefined') {
|
||||||
resolvedRef.current.indeterminate = indeterminate;
|
resolvedRef.current.indeterminate = indeterminate;
|
||||||
}
|
}
|
||||||
}, [resolvedRef, indeterminate]);
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md-checkbox flex items-center" title={title || label}>
|
<div className="md-checkbox flex items-center" title={title || label}>
|
||||||
|
|
|
@ -143,7 +143,7 @@ export function NonGitStackForm({ edgeStack }: { edgeStack: EdgeStack }) {
|
||||||
updateVersion,
|
updateVersion,
|
||||||
webhook: values.webhookEnabled
|
webhook: values.webhookEnabled
|
||||||
? edgeStack.Webhook || createWebhookId()
|
? edgeStack.Webhook || createWebhookId()
|
||||||
: undefined,
|
: '',
|
||||||
envVars: values.envVars,
|
envVars: values.envVars,
|
||||||
rollbackTo: values.rollbackTo,
|
rollbackTo: values.rollbackTo,
|
||||||
staggerConfig: values.staggerConfig,
|
staggerConfig: values.staggerConfig,
|
||||||
|
|
|
@ -57,7 +57,6 @@ export function ApplicationsDatatable({
|
||||||
const applicationsQuery = useApplications(environmentId, {
|
const applicationsQuery = useApplications(environmentId, {
|
||||||
refetchInterval: tableState.autoRefreshRate * 1000,
|
refetchInterval: tableState.autoRefreshRate * 1000,
|
||||||
namespace: tableState.namespace,
|
namespace: tableState.namespace,
|
||||||
withDependencies: true,
|
|
||||||
});
|
});
|
||||||
const ingressesQuery = useIngresses(environmentId);
|
const ingressesQuery = useIngresses(environmentId);
|
||||||
const ingresses = ingressesQuery.data ?? [];
|
const ingresses = ingressesQuery.data ?? [];
|
||||||
|
|
|
@ -38,7 +38,6 @@ export function ApplicationsStacksDatatable({
|
||||||
const applicationsQuery = useApplications(environmentId, {
|
const applicationsQuery = useApplications(environmentId, {
|
||||||
refetchInterval: tableState.autoRefreshRate * 1000,
|
refetchInterval: tableState.autoRefreshRate * 1000,
|
||||||
namespace: tableState.namespace,
|
namespace: tableState.namespace,
|
||||||
withDependencies: true,
|
|
||||||
});
|
});
|
||||||
const ingressesQuery = useIngresses(environmentId);
|
const ingressesQuery = useIngresses(environmentId);
|
||||||
const ingresses = ingressesQuery.data ?? [];
|
const ingresses = ingressesQuery.data ?? [];
|
||||||
|
|
|
@ -3,7 +3,6 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
export type GetAppsParams = {
|
export type GetAppsParams = {
|
||||||
namespace?: string;
|
namespace?: string;
|
||||||
nodeName?: string;
|
nodeName?: string;
|
||||||
withDependencies?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
|
|
|
@ -11,7 +11,6 @@ import { queryKeys } from './query-keys';
|
||||||
type GetAppsParams = {
|
type GetAppsParams = {
|
||||||
namespace?: string;
|
namespace?: string;
|
||||||
nodeName?: string;
|
nodeName?: string;
|
||||||
withDependencies?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetAppsQueryOptions = {
|
type GetAppsQueryOptions = {
|
||||||
|
|
|
@ -33,7 +33,7 @@ export function isExternalApplication(application: Application) {
|
||||||
|
|
||||||
function getDeploymentRunningPods(deployment: Deployment): number {
|
function getDeploymentRunningPods(deployment: Deployment): number {
|
||||||
const availableReplicas = deployment.status?.availableReplicas ?? 0;
|
const availableReplicas = deployment.status?.availableReplicas ?? 0;
|
||||||
const totalReplicas = deployment.status?.replicas ?? 0;
|
const totalReplicas = deployment.spec?.replicas ?? 0;
|
||||||
const unavailableReplicas = deployment.status?.unavailableReplicas ?? 0;
|
const unavailableReplicas = deployment.status?.unavailableReplicas ?? 0;
|
||||||
return availableReplicas || totalReplicas - unavailableReplicas;
|
return availableReplicas || totalReplicas - unavailableReplicas;
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,6 @@ export function NamespaceAppsDatatable({ namespace }: { namespace: string }) {
|
||||||
const applicationsQuery = useApplications(environmentId, {
|
const applicationsQuery = useApplications(environmentId, {
|
||||||
refetchInterval: tableState.autoRefreshRate * 1000,
|
refetchInterval: tableState.autoRefreshRate * 1000,
|
||||||
namespace,
|
namespace,
|
||||||
withDependencies: true,
|
|
||||||
});
|
});
|
||||||
const applications = applicationsQuery.data ?? [];
|
const applications = applicationsQuery.data ?? [];
|
||||||
|
|
||||||
|
|
|
@ -109,6 +109,7 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
|
||||||
agentVersions,
|
agentVersions,
|
||||||
updateInformation: isBE,
|
updateInformation: isBE,
|
||||||
edgeAsync: getEdgeAsyncValue(connectionTypes),
|
edgeAsync: getEdgeAsyncValue(connectionTypes),
|
||||||
|
platformTypes,
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryWithSort = {
|
const queryWithSort = {
|
||||||
|
|
|
@ -209,6 +209,7 @@ function getPlatformTypeOptions(connectionTypes: ConnectionType[]) {
|
||||||
{ value: PlatformType.Docker, label: 'Docker' },
|
{ value: PlatformType.Docker, label: 'Docker' },
|
||||||
{ value: PlatformType.Azure, label: 'Azure' },
|
{ value: PlatformType.Azure, label: 'Azure' },
|
||||||
{ value: PlatformType.Kubernetes, label: 'Kubernetes' },
|
{ value: PlatformType.Kubernetes, label: 'Kubernetes' },
|
||||||
|
{ value: PlatformType.Podman, label: 'Podman' },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (connectionTypes.length === 0) {
|
if (connectionTypes.length === 0) {
|
||||||
|
@ -216,15 +217,25 @@ function getPlatformTypeOptions(connectionTypes: ConnectionType[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionTypePlatformType = {
|
const connectionTypePlatformType = {
|
||||||
[ConnectionType.API]: [PlatformType.Docker, PlatformType.Azure],
|
[ConnectionType.API]: [
|
||||||
[ConnectionType.Agent]: [PlatformType.Docker, PlatformType.Kubernetes],
|
PlatformType.Docker,
|
||||||
|
PlatformType.Azure,
|
||||||
|
PlatformType.Podman,
|
||||||
|
],
|
||||||
|
[ConnectionType.Agent]: [
|
||||||
|
PlatformType.Docker,
|
||||||
|
PlatformType.Kubernetes,
|
||||||
|
PlatformType.Podman,
|
||||||
|
],
|
||||||
[ConnectionType.EdgeAgentStandard]: [
|
[ConnectionType.EdgeAgentStandard]: [
|
||||||
PlatformType.Kubernetes,
|
PlatformType.Kubernetes,
|
||||||
PlatformType.Docker,
|
PlatformType.Docker,
|
||||||
|
PlatformType.Podman,
|
||||||
],
|
],
|
||||||
[ConnectionType.EdgeAgentAsync]: [
|
[ConnectionType.EdgeAgentAsync]: [
|
||||||
PlatformType.Docker,
|
PlatformType.Docker,
|
||||||
PlatformType.Kubernetes,
|
PlatformType.Kubernetes,
|
||||||
|
PlatformType.Podman,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -55,11 +55,11 @@ async function deleteEnvironments(
|
||||||
environments: { id: EnvironmentId; deleteCluster?: boolean }[]
|
environments: { id: EnvironmentId; deleteCluster?: boolean }[]
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.delete<{
|
const { data } = await axios.post<{
|
||||||
deleted: EnvironmentId[];
|
deleted: EnvironmentId[];
|
||||||
errors: EnvironmentId[];
|
errors: EnvironmentId[];
|
||||||
} | null>(buildUrl(), {
|
} | null>(buildUrl(undefined, 'delete'), {
|
||||||
data: { endpoints: environments },
|
endpoints: environments,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
EnvironmentSecuritySettings,
|
EnvironmentSecuritySettings,
|
||||||
EnvironmentStatus,
|
EnvironmentStatus,
|
||||||
EnvironmentGroupId,
|
EnvironmentGroupId,
|
||||||
|
PlatformType,
|
||||||
} from '@/react/portainer/environments/types';
|
} from '@/react/portainer/environments/types';
|
||||||
import { type TagId } from '@/portainer/tags/types';
|
import { type TagId } from '@/portainer/tags/types';
|
||||||
import { UserId } from '@/portainer/users/types';
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
@ -45,6 +46,7 @@ export interface BaseEnvironmentsQueryParams {
|
||||||
agentVersions?: string[];
|
agentVersions?: string[];
|
||||||
updateInformation?: boolean;
|
updateInformation?: boolean;
|
||||||
edgeCheckInPassedSeconds?: number;
|
edgeCheckInPassedSeconds?: number;
|
||||||
|
platformTypes?: PlatformType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EnvironmentsQueryParams = BaseEnvironmentsQueryParams &
|
export type EnvironmentsQueryParams = BaseEnvironmentsQueryParams &
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { withError } from '@/react-tools/react-query';
|
import { withError } from '@/react-tools/react-query';
|
||||||
|
import {
|
||||||
|
PlatformType,
|
||||||
|
EnvironmentStatus,
|
||||||
|
} from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
import { EnvironmentStatus } from '../types';
|
|
||||||
import {
|
import {
|
||||||
EnvironmentsQueryParams,
|
EnvironmentsQueryParams,
|
||||||
getEnvironments,
|
getEnvironments,
|
||||||
|
@ -98,6 +101,30 @@ export function useEnvironmentList(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (data?.value && query && query.platformTypes) {
|
||||||
|
const platforms = Array.from(query.platformTypes);
|
||||||
|
|
||||||
|
if (
|
||||||
|
platforms.includes(PlatformType.Podman) !==
|
||||||
|
platforms.includes(PlatformType.Docker)
|
||||||
|
) {
|
||||||
|
const isPodmanSelected = platforms.includes(PlatformType.Podman);
|
||||||
|
const containerEngineToExclude = isPodmanSelected ? 'docker' : 'podman';
|
||||||
|
|
||||||
|
const filteredList = data?.value.filter(
|
||||||
|
(env) => env.ContainerEngine !== containerEngineToExclude
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading,
|
||||||
|
environments: filteredList,
|
||||||
|
totalCount: data ? data.totalCount : 0,
|
||||||
|
totalAvailable: data ? data.totalAvailable : 0,
|
||||||
|
updateAvailable: data ? data.updateAvailable : false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoading,
|
isLoading,
|
||||||
environments: data ? data.value : [],
|
environments: data ? data.value : [],
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { TextTip } from '@@/Tip/TextTip';
|
||||||
import { FormControl } from '@@/form-components/FormControl';
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
import { FormSection } from '@@/form-components/FormSection';
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
import { Input } from '@@/form-components/Input';
|
import { Input } from '@@/form-components/Input';
|
||||||
|
import { InsightsBox } from '@@/InsightsBox';
|
||||||
|
|
||||||
export function HelmSection() {
|
export function HelmSection() {
|
||||||
const [{ name }, { error }] = useField<string>('helmRepositoryUrl');
|
const [{ name }, { error }] = useField<string>('helmRepositoryUrl');
|
||||||
|
@ -24,13 +25,34 @@ export function HelmSection() {
|
||||||
</TextTip>
|
</TextTip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<InsightsBox
|
||||||
|
header="Disclaimer"
|
||||||
|
content={
|
||||||
|
<>
|
||||||
|
At present Portainer does not support OCI format Helm charts.
|
||||||
|
Support for OCI charts will be available in a future release. 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>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
className="block w-fit mt-2 mb-1"
|
||||||
|
/>
|
||||||
|
|
||||||
<FormControl label="URL" errors={error} inputId="helm-repo-url">
|
<FormControl label="URL" errors={error} inputId="helm-repo-url">
|
||||||
<Field
|
<Field
|
||||||
as={Input}
|
as={Input}
|
||||||
id="helm-repo-url"
|
id="helm-repo-url"
|
||||||
data-cy="helm-repo-url-input"
|
data-cy="helm-repo-url-input"
|
||||||
name={name}
|
name={name}
|
||||||
placeholder="https://charts.bitnami.com/bitnami"
|
placeholder="https://kubernetes.github.io/ingress-nginx"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
|
@ -110,6 +110,7 @@ export function AppTemplatesList({
|
||||||
pageSize={listState.pageSize}
|
pageSize={listState.pageSize}
|
||||||
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
|
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
|
||||||
totalSelected={0}
|
totalSelected={0}
|
||||||
|
totalHiddenSelected={0}
|
||||||
/>
|
/>
|
||||||
</Table.Container>
|
</Table.Container>
|
||||||
);
|
);
|
||||||
|
|
|
@ -86,6 +86,7 @@ export function CustomTemplatesList({
|
||||||
pageSize={listState.pageSize}
|
pageSize={listState.pageSize}
|
||||||
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
|
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
|
||||||
totalSelected={0}
|
totalSelected={0}
|
||||||
|
totalHiddenSelected={0}
|
||||||
/>
|
/>
|
||||||
</Table.Container>
|
</Table.Container>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"docker": "v27.1.2",
|
"docker": "v27.5.1",
|
||||||
"helm": "v3.16.4",
|
"helm": "v3.17.3",
|
||||||
"kubectl": "v1.31.4",
|
"kubectl": "v1.32.2",
|
||||||
"mingit": "2.46.0.1"
|
"mingit": "2.49.0.1"
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue