Merge branch 'develop' into feat/EE-5028/security_teaser

pull/8592/head
testa113 2023-03-30 16:32:56 +13:00
commit 659402c3ec
273 changed files with 3733 additions and 1808 deletions

View File

@ -93,7 +93,7 @@ $ yarn start
Portainer can now be accessed at <https://localhost:9443>. Portainer can now be accessed at <https://localhost:9443>.
Find more detailed steps at <https://documentation.portainer.io/contributing/instructions/>. Find more detailed steps at <https://docs.portainer.io/contribute/build>.
### Build customisation ### Build customisation
@ -103,6 +103,10 @@ You can customise the following settings:
- `PORTAINER_PROJECT`: The root dir of the repository - `${portainerRoot}/dist/` is imported into the container to get the build artifacts and external tools (defaults to `your current dir`). - `PORTAINER_PROJECT`: The root dir of the repository - `${portainerRoot}/dist/` is imported into the container to get the build artifacts and external tools (defaults to `your current dir`).
- `PORTAINER_FLAGS`: a list of flags to be used on the portainer commandline, in the form `--admin-password=<pwd hash> --feat fdo=false --feat open-amt` (default: `""`). - `PORTAINER_FLAGS`: a list of flags to be used on the portainer commandline, in the form `--admin-password=<pwd hash> --feat fdo=false --feat open-amt` (default: `""`).
## Testing your build
The `--log-level=DEBUG` flag can be passed to the Portainer container in order to provide additional debug output which may be useful when troubleshooting your builds. Please note that this flag was originally intended for internal use and as such the format, functionality and output may change between releases without warning.
## Adding api docs ## Adding api docs
When adding a new resource (or a route handler), we should add a new tag to api/http/handler/handler.go#L136 like this: When adding a new resource (or a route handler), we should add a new tag to api/http/handler/handler.go#L136 like this:

View File

@ -4,6 +4,7 @@ import (
"crypto/tls" "crypto/tls"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@ -42,7 +43,9 @@ func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (port
if err != nil { if err != nil {
return 0, "", err return 0, "", err
} }
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent { if resp.StatusCode != http.StatusNoContent {
return 0, "", fmt.Errorf("Failed request with status %d", resp.StatusCode) return 0, "", fmt.Errorf("Failed request with status %d", resp.StatusCode)

View File

@ -72,6 +72,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
if err != nil { if err != nil {
panic(err) panic(err)
} }
*flags.Assets = filepath.Join(filepath.Dir(ex), *flags.Assets) *flags.Assets = filepath.Join(filepath.Dir(ex), *flags.Assets)
} }
@ -80,7 +81,6 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
// ValidateFlags validates the values of the flags. // ValidateFlags validates the values of the flags.
func (*Service) ValidateFlags(flags *portainer.CLIFlags) error { func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
displayDeprecationWarnings(flags) displayDeprecationWarnings(flags)
err := validateEndpointURL(*flags.EndpointURL) err := validateEndpointURL(*flags.EndpointURL)
@ -111,31 +111,38 @@ func displayDeprecationWarnings(flags *portainer.CLIFlags) {
} }
func validateEndpointURL(endpointURL string) error { func validateEndpointURL(endpointURL string) error {
if endpointURL != "" { if endpointURL == "" {
if !strings.HasPrefix(endpointURL, "unix://") && !strings.HasPrefix(endpointURL, "tcp://") && !strings.HasPrefix(endpointURL, "npipe://") { return nil
return errInvalidEndpointProtocol }
}
if strings.HasPrefix(endpointURL, "unix://") || strings.HasPrefix(endpointURL, "npipe://") { if !strings.HasPrefix(endpointURL, "unix://") && !strings.HasPrefix(endpointURL, "tcp://") && !strings.HasPrefix(endpointURL, "npipe://") {
socketPath := strings.TrimPrefix(endpointURL, "unix://") return errInvalidEndpointProtocol
socketPath = strings.TrimPrefix(socketPath, "npipe://") }
if _, err := os.Stat(socketPath); err != nil {
if os.IsNotExist(err) { if strings.HasPrefix(endpointURL, "unix://") || strings.HasPrefix(endpointURL, "npipe://") {
return errSocketOrNamedPipeNotFound socketPath := strings.TrimPrefix(endpointURL, "unix://")
} socketPath = strings.TrimPrefix(socketPath, "npipe://")
return err if _, err := os.Stat(socketPath); err != nil {
if os.IsNotExist(err) {
return errSocketOrNamedPipeNotFound
} }
return err
} }
} }
return nil return nil
} }
func validateSnapshotInterval(snapshotInterval string) error { func validateSnapshotInterval(snapshotInterval string) error {
if snapshotInterval != "" { if snapshotInterval == "" {
_, err := time.ParseDuration(snapshotInterval) return nil
if err != nil {
return errInvalidSnapshotInterval
}
} }
_, err := time.ParseDuration(snapshotInterval)
if err != nil {
return errInvalidSnapshotInterval
}
return nil return nil
} }

View File

@ -12,13 +12,14 @@ func Confirm(message string) (bool, error) {
fmt.Printf("%s [y/N]", message) fmt.Printf("%s [y/N]", message)
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
answer, err := reader.ReadString('\n') answer, err := reader.ReadString('\n')
if err != nil { if err != nil {
return false, err return false, err
} }
answer = strings.Replace(answer, "\n", "", -1)
answer = strings.ReplaceAll(answer, "\n", "")
answer = strings.ToLower(answer) answer = strings.ToLower(answer)
return answer == "y" || answer == "yes", nil return answer == "y" || answer == "yes", nil
} }

View File

@ -684,25 +684,21 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
log.Fatal().Msg("failed to fetch SSL settings from DB") log.Fatal().Msg("failed to fetch SSL settings from DB")
} }
upgradeService, err := upgrade.NewService(*flags.Assets, composeDeployer) upgradeService, err := upgrade.NewService(*flags.Assets, composeDeployer, kubernetesClientFactory)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("failed initializing upgrade service") log.Fatal().Err(err).Msg("failed initializing upgrade service")
} }
// FIXME: In 2.16 we changed the way ingress controller permissions are // Our normal migrations run as part of the database initialization
// stored. Instead of being stored as annotation on an ingress rule, we keep // but some more complex migrations require access to a kubernetes or docker
// them in our database. However, in order to run the migration we need an // client. Therefore we run a separate migration process just before
// admin kube client to run lookup the old ingress rules and compare them // starting the server.
// with the current existing ingress classes. postInitMigrator := datastore.NewPostInitMigrator(
// kubernetesClientFactory,
// Unfortunately, our migrations run as part of the database initialization dockerClientFactory,
// and our kubeclients require an initialized database. So it is not dataStore,
// possible to do this migration as part of our normal flow. We DO have a )
// migration which toggles a boolean in kubernetes configuration that if err := postInitMigrator.PostInitMigrate(); err != nil {
// indicated that this "post init" migration should be run. If/when this is
// resolved we can remove this function.
err = kubernetesClientFactory.PostInitMigrateIngresses()
if err != nil {
log.Fatal().Err(err).Msg("failure during post init migrations") log.Fatal().Err(err).Msg("failure during post init migrations")
} }

View File

@ -7,7 +7,6 @@ import (
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"math/big"
"github.com/portainer/libcrypto" "github.com/portainer/libcrypto"
) )
@ -115,9 +114,6 @@ func (service *ECDSAService) CreateSignature(message string) (string, error) {
hash := libcrypto.HashFromBytes([]byte(message)) hash := libcrypto.HashFromBytes([]byte(message))
r := big.NewInt(0)
s := big.NewInt(0)
r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash) r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash)
if err != nil { if err != nil {
return "", err return "", err

View File

@ -129,7 +129,7 @@ func Test_UnMarshalObjectUnencrypted(t *testing.T) {
var object string var object string
err := conn.UnmarshalObject(test.object, &object) err := conn.UnmarshalObject(test.object, &object)
is.NoError(err) is.NoError(err)
is.Equal(test.expected, string(object)) is.Equal(test.expected, object)
}) })
} }
} }

View File

@ -92,7 +92,7 @@ func (tx *DbTransaction) CreateObject(bucketName string, fn func(uint64) (int, i
return err return err
} }
return bucket.Put(tx.conn.ConvertToKey(int(id)), data) return bucket.Put(tx.conn.ConvertToKey(id), data)
} }
func (tx *DbTransaction) CreateObjectWithId(bucketName string, id int, obj interface{}) error { func (tx *DbTransaction) CreateObjectWithId(bucketName string, id int, obj interface{}) error {

View File

@ -9,8 +9,7 @@ import (
// NewDatabase should use config options to return a connection to the requested database // NewDatabase should use config options to return a connection to the requested database
func NewDatabase(storeType, storePath string, encryptionKey []byte) (connection portainer.Connection, err error) { func NewDatabase(storeType, storePath string, encryptionKey []byte) (connection portainer.Connection, err error) {
switch storeType { if storeType == "boltdb" {
case "boltdb":
return &boltdb.DbConnection{ return &boltdb.DbConnection{
Path: storePath, Path: storePath,
EncryptionKey: encryptionKey, EncryptionKey: encryptionKey,

View File

@ -0,0 +1,117 @@
package datastore
import (
"context"
"github.com/docker/docker/api/types"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/docker"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/rs/zerolog/log"
)
type PostInitMigrator struct {
kubeFactory *cli.ClientFactory
dockerFactory *docker.ClientFactory
dataStore dataservices.DataStore
}
func NewPostInitMigrator(kubeFactory *cli.ClientFactory, dockerFactory *docker.ClientFactory, dataStore dataservices.DataStore) *PostInitMigrator {
return &PostInitMigrator{
kubeFactory: kubeFactory,
dockerFactory: dockerFactory,
dataStore: dataStore,
}
}
func (migrator *PostInitMigrator) PostInitMigrate() error {
if err := migrator.PostInitMigrateIngresses(); err != nil {
return err
}
migrator.PostInitMigrateGPUs()
return nil
}
func (migrator *PostInitMigrator) PostInitMigrateIngresses() error {
endpoints, err := migrator.dataStore.Endpoint().Endpoints()
if err != nil {
return err
}
for i := range endpoints {
// Early exit if we do not need to migrate!
if !endpoints[i].PostInitMigrations.MigrateIngresses {
return nil
}
err := migrator.kubeFactory.MigrateEndpointIngresses(&endpoints[i])
if err != nil {
log.Debug().Err(err).Msg("failure migrating endpoint ingresses")
}
}
return nil
}
// PostInitMigrateGPUs will check all docker endpoints for containers with GPUs and set EnableGPUManagement to true if any are found
// If there's an error getting the containers, we'll log it and move on
func (migrator *PostInitMigrator) PostInitMigrateGPUs() {
environments, err := migrator.dataStore.Endpoint().Endpoints()
if err != nil {
log.Err(err).Msg("failure getting endpoints")
return
}
for i := range environments {
if environments[i].Type == portainer.DockerEnvironment {
// // Early exit if we do not need to migrate!
if !environments[i].PostInitMigrations.MigrateGPUs {
return
}
// set the MigrateGPUs flag to false so we don't run this again
environments[i].PostInitMigrations.MigrateGPUs = false
migrator.dataStore.Endpoint().UpdateEndpoint(environments[i].ID, &environments[i])
// create a docker client
dockerClient, err := migrator.dockerFactory.CreateClient(&environments[i], "", nil)
if err != nil {
log.Err(err).Msg("failure creating docker client for environment: " + environments[i].Name)
return
}
defer dockerClient.Close()
// get all containers
containers, err := dockerClient.ContainerList(context.Background(), types.ContainerListOptions{All: true})
if err != nil {
log.Err(err).Msg("failed to list containers")
return
}
// check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole endpoint
containersLoop:
for _, container := range containers {
// https://www.sobyte.net/post/2022-10/go-docker/ has nice documentation on the docker client with GPUs
containerDetails, err := dockerClient.ContainerInspect(context.Background(), container.ID)
if err != nil {
log.Err(err).Msg("failed to inspect container")
return
}
deviceRequests := containerDetails.HostConfig.Resources.DeviceRequests
for _, deviceRequest := range deviceRequests {
if deviceRequest.Driver == "nvidia" {
environments[i].EnableGPUManagement = true
migrator.dataStore.Endpoint().UpdateEndpoint(environments[i].ID, &environments[i])
break containersLoop
}
}
}
}
}
}

View File

@ -3,11 +3,16 @@ package migrator
import ( import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
portainer "github.com/portainer/portainer/api"
portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors" portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors"
) )
func (m *Migrator) migrateDBVersionToDB90() error { func (m *Migrator) migrateDBVersionToDB90() error {
if err := m.updateUserThemForDB90(); err != nil { if err := m.updateUserThemeForDB90(); err != nil {
return err
}
if err := m.updateEnableGpuManagementFeatures(); err != nil {
return err return err
} }
@ -39,7 +44,7 @@ func (m *Migrator) updateEdgeStackStatusForDB90() error {
return nil return nil
} }
func (m *Migrator) updateUserThemForDB90() error { func (m *Migrator) updateUserThemeForDB90() error {
log.Info().Msg("updating existing user theme settings") log.Info().Msg("updating existing user theme settings")
users, err := m.userService.Users() users, err := m.userService.Users()
@ -60,3 +65,28 @@ func (m *Migrator) updateUserThemForDB90() error {
return nil return nil
} }
func (m *Migrator) updateEnableGpuManagementFeatures() error {
// get all environments
environments, err := m.endpointService.Endpoints()
if err != nil {
return err
}
for _, environment := range environments {
if environment.Type == portainer.DockerEnvironment {
// set the PostInitMigrations.MigrateGPUs to true on this environment to run the migration only on the 2.18 upgrade
environment.PostInitMigrations.MigrateGPUs = true
// if there's one or more gpu, set the EnableGpuManagement setting to true
gpuList := environment.Gpus
if len(gpuList) > 0 {
environment.EnableGPUManagement = true
}
// update the environment
if err := m.endpointService.UpdateEndpoint(environment.ID, &environment); err != nil {
return err
}
}
}
return nil
}

View File

@ -46,6 +46,7 @@
}, },
"EdgeCheckinInterval": 0, "EdgeCheckinInterval": 0,
"EdgeKey": "", "EdgeKey": "",
"EnableGPUManagement": false,
"Gpus": [], "Gpus": [],
"GroupId": 1, "GroupId": 1,
"Id": 1, "Id": 1,
@ -63,6 +64,7 @@
"UseServerMetrics": false "UseServerMetrics": false
}, },
"Flags": { "Flags": {
"IsServerIngressClassDetected": false,
"IsServerMetricsDetected": false, "IsServerMetricsDetected": false,
"IsServerStorageDetected": false "IsServerStorageDetected": false
}, },
@ -71,6 +73,7 @@
"LastCheckInDate": 0, "LastCheckInDate": 0,
"Name": "local", "Name": "local",
"PostInitMigrations": { "PostInitMigrations": {
"MigrateGPUs": true,
"MigrateIngresses": true "MigrateIngresses": true
}, },
"PublicURL": "", "PublicURL": "",
@ -903,8 +906,7 @@
}, },
"Role": 1, "Role": 1,
"ThemeSettings": { "ThemeSettings": {
"color": "", "color": ""
"subtleUpgradeButton": false
}, },
"TokenIssueAt": 0, "TokenIssueAt": 0,
"UserTheme": "", "UserTheme": "",
@ -934,8 +936,7 @@
}, },
"Role": 1, "Role": 1,
"ThemeSettings": { "ThemeSettings": {
"color": "", "color": ""
"subtleUpgradeButton": false
}, },
"TokenIssueAt": 0, "TokenIssueAt": 0,
"UserTheme": "", "UserTheme": "",
@ -943,6 +944,6 @@
} }
], ],
"version": { "version": {
"VERSION": "{\"SchemaVersion\":\"2.18.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" "VERSION": "{\"SchemaVersion\":\"2.19.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
} }
} }

View File

@ -38,17 +38,19 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService, revers
// with an agent enabled environment(endpoint) to target a specific node in an agent cluster. // with an agent enabled environment(endpoint) to target a specific node in an agent cluster.
// The underlying http client timeout may be specified, a default value is used otherwise. // The underlying http client timeout may be specified, a default value is used otherwise.
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string, timeout *time.Duration) (*client.Client, error) { func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string, timeout *time.Duration) (*client.Client, error) {
if endpoint.Type == portainer.AzureEnvironment { switch endpoint.Type {
case portainer.AzureEnvironment:
return nil, errUnsupportedEnvironmentType return nil, errUnsupportedEnvironmentType
} else if endpoint.Type == portainer.AgentOnDockerEnvironment { case portainer.AgentOnDockerEnvironment:
return createAgentClient(endpoint, factory.signatureService, nodeName, timeout) return createAgentClient(endpoint, factory.signatureService, nodeName, timeout)
} else if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { case portainer.EdgeAgentOnDockerEnvironment:
return createEdgeClient(endpoint, factory.signatureService, factory.reverseTunnelService, nodeName, timeout) return createEdgeClient(endpoint, factory.signatureService, factory.reverseTunnelService, nodeName, timeout)
} }
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") { if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
return createLocalClient(endpoint) return createLocalClient(endpoint)
} }
return createTCPClient(endpoint, timeout) return createTCPClient(endpoint, timeout)
} }

View File

@ -82,9 +82,11 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S
} }
err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.Options{ err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.Options{
WorkingDir: stack.ProjectPath,
EnvFilePath: envFilePath, EnvFilePath: envFilePath,
Host: url, Host: url,
}) })
return errors.Wrap(err, "failed to remove a stack") return errors.Wrap(err, "failed to remove a stack")
} }

View File

@ -59,6 +59,7 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
if err != nil { if err != nil {
return err return err
} }
for _, registry := range registries { for _, registry := range registries {
if registry.Authentication { if registry.Authentication {
err = registryutils.EnsureRegTokenValid(manager.dataStore, &registry) err = registryutils.EnsureRegTokenValid(manager.dataStore, &registry)
@ -75,6 +76,7 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
runCommandAndCaptureStdErr(command, registryArgs, nil, "") runCommandAndCaptureStdErr(command, registryArgs, nil, "")
} }
} }
return nil return nil
} }
@ -84,7 +86,9 @@ func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error {
if err != nil { if err != nil {
return err return err
} }
args = append(args, "logout") args = append(args, "logout")
return runCommandAndCaptureStdErr(command, args, nil, "") return runCommandAndCaptureStdErr(command, args, nil, "")
} }
@ -101,6 +105,7 @@ func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, pul
} else { } else {
args = append(args, "stack", "deploy", "--with-registry-auth") args = append(args, "stack", "deploy", "--with-registry-auth")
} }
if !pullImage { if !pullImage {
args = append(args, "--resolve-image=never") args = append(args, "--resolve-image=never")
} }
@ -112,6 +117,7 @@ func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, pul
for _, envvar := range stack.Env { for _, envvar := range stack.Env {
env = append(env, envvar.Name+"="+envvar.Value) env = append(env, envvar.Name+"="+envvar.Value)
} }
return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath) return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath)
} }
@ -121,7 +127,9 @@ func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *porta
if err != nil { if err != nil {
return err return err
} }
args = append(args, "stack", "rm", stack.Name) args = append(args, "stack", "rm", stack.Name)
return runCommandAndCaptureStdErr(command, args, nil, "") return runCommandAndCaptureStdErr(command, args, nil, "")
} }
@ -198,6 +206,7 @@ func (manager *SwarmStackManager) updateDockerCLIConfiguration(configPath string
if config["HttpHeaders"] == nil { if config["HttpHeaders"] == nil {
config["HttpHeaders"] = make(map[string]interface{}) config["HttpHeaders"] = make(map[string]interface{})
} }
headersObject := config["HttpHeaders"].(map[string]interface{}) headersObject := config["HttpHeaders"].(map[string]interface{})
headersObject["X-PortainerAgent-ManagerOperation"] = "1" headersObject["X-PortainerAgent-ManagerOperation"] = "1"
headersObject["X-PortainerAgent-Signature"] = signature headersObject["X-PortainerAgent-Signature"] = signature
@ -230,5 +239,6 @@ func configureFilePaths(args []string, filePaths []string) []string {
for _, path := range filePaths { for _, path := range filePaths {
args = append(args, "--compose-file", path) args = append(args, "--compose-file", path)
} }
return args return args
} }

View File

@ -75,6 +75,7 @@ func newHttpClientForAzure() *http.Client {
} }
client.InstallProtocol("https", githttp.NewClient(httpsCli)) client.InstallProtocol("https", githttp.NewClient(httpsCli))
return httpsCli return httpsCli
} }
@ -98,10 +99,12 @@ func (a *azureClient) downloadZipFromAzureDevOps(ctx context.Context, opt cloneO
if err != nil { if err != nil {
return "", errors.WithMessage(err, "failed to parse url") return "", errors.WithMessage(err, "failed to parse url")
} }
downloadUrl, err := a.buildDownloadUrl(config, opt.referenceName) downloadUrl, err := a.buildDownloadUrl(config, opt.referenceName)
if err != nil { if err != nil {
return "", errors.WithMessage(err, "failed to build download url") return "", errors.WithMessage(err, "failed to build download url")
} }
zipFile, err := os.CreateTemp("", "azure-git-repo-*.zip") zipFile, err := os.CreateTemp("", "azure-git-repo-*.zip")
if err != nil { if err != nil {
return "", errors.WithMessage(err, "failed to create temp file") return "", errors.WithMessage(err, "failed to create temp file")
@ -133,6 +136,7 @@ func (a *azureClient) downloadZipFromAzureDevOps(ctx context.Context, opt cloneO
if err != nil { if err != nil {
return "", errors.WithMessage(err, "failed to save HTTP response to a file") return "", errors.WithMessage(err, "failed to save HTTP response to a file")
} }
return zipFile.Name(), nil return zipFile.Name(), nil
} }
@ -141,6 +145,7 @@ func (a *azureClient) latestCommitID(ctx context.Context, opt fetchOption) (stri
if err != nil { if err != nil {
return "", err return "", err
} }
return rootItem.CommitId, nil return rootItem.CommitId, nil
} }
@ -187,6 +192,7 @@ func (a *azureClient) getRootItem(ctx context.Context, opt fetchOption) (*azureI
if len(items.Value) == 0 || items.Value[0].CommitId == "" { if len(items.Value) == 0 || items.Value[0].CommitId == "" {
return nil, errors.Errorf("failed to get latest commitID in the repository") return nil, errors.Errorf("failed to get latest commitID in the repository")
} }
return &items.Value[0], nil return &items.Value[0], nil
} }
@ -205,7 +211,7 @@ func parseUrl(rawUrl string) (*azureOptions, error) {
return nil, errors.Errorf("supported url schemes are https and ssh; recevied URL %s rawUrl", rawUrl) return nil, errors.Errorf("supported url schemes are https and ssh; recevied URL %s rawUrl", rawUrl)
} }
var expectedSshUrl = "git@ssh.dev.azure.com:v3/Organisation/Project/Repository" const expectedSshUrl = "git@ssh.dev.azure.com:v3/Organisation/Project/Repository"
func parseSshUrl(rawUrl string) (*azureOptions, error) { func parseSshUrl(rawUrl string) (*azureOptions, error) {
path := strings.Split(rawUrl, "/") path := strings.Split(rawUrl, "/")
@ -343,6 +349,7 @@ func (a *azureClient) buildTreeUrl(config *azureOptions, rootObjectHash string)
if err != nil { if err != nil {
return "", errors.Wrapf(err, "failed to parse list tree url path %s", rawUrl) return "", errors.Wrapf(err, "failed to parse list tree url path %s", rawUrl)
} }
q := u.Query() q := u.Query()
// projectId={projectId}&recursive=true&fileName={fileName}&$format={$format}&api-version=6.0 // projectId={projectId}&recursive=true&fileName={fileName}&$format={$format}&api-version=6.0
q.Set("recursive", "true") q.Set("recursive", "true")
@ -361,9 +368,11 @@ func formatReferenceName(name string) string {
if strings.HasPrefix(name, branchPrefix) { if strings.HasPrefix(name, branchPrefix) {
return strings.TrimPrefix(name, branchPrefix) return strings.TrimPrefix(name, branchPrefix)
} }
if strings.HasPrefix(name, tagPrefix) { if strings.HasPrefix(name, tagPrefix) {
return strings.TrimPrefix(name, tagPrefix) return strings.TrimPrefix(name, tagPrefix)
} }
return name return name
} }
@ -371,9 +380,11 @@ func getVersionType(name string) string {
if strings.HasPrefix(name, branchPrefix) { if strings.HasPrefix(name, branchPrefix) {
return "branch" return "branch"
} }
if strings.HasPrefix(name, tagPrefix) { if strings.HasPrefix(name, tagPrefix) {
return "tag" return "tag"
} }
return "commit" return "commit"
} }
@ -490,5 +501,6 @@ func checkAzureStatusCode(err error, code int) error {
} else if code == http.StatusUnauthorized || code == http.StatusNonAuthoritativeInfo { } else if code == http.StatusUnauthorized || code == http.StatusNonAuthoritativeInfo {
return gittypes.ErrAuthenticationFailure return gittypes.ErrAuthenticationFailure
} }
return err return err
} }

View File

@ -8,14 +8,13 @@ import (
"testing" "testing"
"time" "time"
_ "github.com/joho/godotenv/autoload"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
_ "github.com/joho/godotenv/autoload"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
var ( const privateAzureRepoURL = "https://portainer.visualstudio.com/gitops-test/_git/gitops-test"
privateAzureRepoURL = "https://portainer.visualstudio.com/gitops-test/_git/gitops-test"
)
func TestService_ClonePublicRepository_Azure(t *testing.T) { func TestService_ClonePublicRepository_Azure(t *testing.T) {
ensureIntegrationTest(t) ensureIntegrationTest(t)
@ -107,7 +106,7 @@ func TestService_ListRefs_Azure_Concurrently(t *testing.T) {
accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT") accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT")
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
service := newService(context.TODO(), REPOSITORY_CACHE_SIZE, 200*time.Millisecond) service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
go service.ListRefs(privateAzureRepoURL, username, accessToken, false) go service.ListRefs(privateAzureRepoURL, username, accessToken, false)
service.ListRefs(privateAzureRepoURL, username, accessToken, false) service.ListRefs(privateAzureRepoURL, username, accessToken, false)
@ -269,7 +268,7 @@ func TestService_ListFiles_Azure_Concurrently(t *testing.T) {
accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT") accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT")
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
service := newService(context.TODO(), REPOSITORY_CACHE_SIZE, 200*time.Millisecond) service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
go service.ListFiles(privateAzureRepoURL, "refs/heads/main", username, accessToken, false, []string{}) go service.ListFiles(privateAzureRepoURL, "refs/heads/main", username, accessToken, false, []string{})
service.ListFiles(privateAzureRepoURL, "refs/heads/main", username, accessToken, false, []string{}) service.ListFiles(privateAzureRepoURL, "refs/heads/main", username, accessToken, false, []string{})

View File

@ -60,7 +60,7 @@ func TestService_ListRefs_Github_Concurrently(t *testing.T) {
accessToken := getRequiredValue(t, "GITHUB_PAT") accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME") username := getRequiredValue(t, "GITHUB_USERNAME")
service := newService(context.TODO(), REPOSITORY_CACHE_SIZE, 200*time.Millisecond) service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
repositoryUrl := privateGitRepoURL repositoryUrl := privateGitRepoURL
go service.ListRefs(repositoryUrl, username, accessToken, false) go service.ListRefs(repositoryUrl, username, accessToken, false)
@ -224,7 +224,7 @@ func TestService_ListFiles_Github_Concurrently(t *testing.T) {
repositoryUrl := privateGitRepoURL repositoryUrl := privateGitRepoURL
accessToken := getRequiredValue(t, "GITHUB_PAT") accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME") username := getRequiredValue(t, "GITHUB_USERNAME")
service := newService(context.TODO(), REPOSITORY_CACHE_SIZE, 200*time.Millisecond) service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
go service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, []string{}) go service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, []string{})
service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, []string{}) service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, []string{})

View File

@ -95,10 +95,12 @@ func getCommitHistoryLength(t *testing.T, err error, dir string) int {
if err != nil { if err != nil {
t.Fatalf("can't open a git repo at %s with error %v", dir, err) t.Fatalf("can't open a git repo at %s with error %v", dir, err)
} }
iter, err := repo.Log(&git.LogOptions{All: true}) iter, err := repo.Log(&git.LogOptions{All: true})
if err != nil { if err != nil {
t.Fatalf("can't get a commit history iterator with error %v", err) t.Fatalf("can't get a commit history iterator with error %v", err)
} }
count := 0 count := 0
err = iter.ForEach(func(_ *object.Commit) error { err = iter.ForEach(func(_ *object.Commit) error {
count++ count++
@ -107,6 +109,7 @@ func getCommitHistoryLength(t *testing.T, err error, dir string) int {
if err != nil { if err != nil {
t.Fatalf("can't iterate over the commit history with error %v", err) t.Fatalf("can't iterate over the commit history with error %v", err)
} }
return count return count
} }

View File

@ -10,9 +10,9 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
var ( const (
REPOSITORY_CACHE_SIZE = 4 repositoryCacheSize = 4
REPOSITORY_CACHE_TTL = 5 * time.Minute repositoryCacheTTL = 5 * time.Minute
) )
// baseOption provides a minimum group of information to operate a git repository, like git-remote // baseOption provides a minimum group of information to operate a git repository, like git-remote
@ -58,7 +58,7 @@ type Service struct {
// NewService initializes a new service. // NewService initializes a new service.
func NewService(ctx context.Context) *Service { func NewService(ctx context.Context) *Service {
return newService(ctx, REPOSITORY_CACHE_SIZE, REPOSITORY_CACHE_TTL) return newService(ctx, repositoryCacheSize, repositoryCacheTTL)
} }
func newService(ctx context.Context, cacheSize int, cacheTTL time.Duration) *Service { func newService(ctx context.Context, cacheSize int, cacheTTL time.Duration) *Service {

View File

@ -34,7 +34,7 @@ func UpdateGitObject(gitService portainer.GitService, dataStore dataservices.Dat
return false, "", errors.WithMessagef(err, "failed to fetch latest commit id of %v", objId) return false, "", errors.WithMessagef(err, "failed to fetch latest commit id of %v", objId)
} }
hashChanged := !strings.EqualFold(newHash, string(gitConfig.ConfigHash)) hashChanged := !strings.EqualFold(newHash, gitConfig.ConfigHash)
forceUpdate := autoUpdateConfig != nil && autoUpdateConfig.ForceUpdate forceUpdate := autoUpdateConfig != nil && autoUpdateConfig.ForceUpdate
if !hashChanged && !forceUpdate { if !hashChanged && !forceUpdate {
log.Debug(). log.Debug().

View File

@ -342,12 +342,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/portainer/docker-compose-wrapper v0.0.0-20221215210951-2c30d1b17a27 h1:PceCpp86SDYb3lZHT4KpuBCkmcJMW5x1qrdFNEfAdUo=
github.com/portainer/docker-compose-wrapper v0.0.0-20221215210951-2c30d1b17a27/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww=
github.com/portainer/docker-compose-wrapper v0.0.0-20230209071700-ee11af9c546a h1:4VGM1OH15fqm5rgki0eLF6vND/NxHfoPt3CA6/YdA0k=
github.com/portainer/docker-compose-wrapper v0.0.0-20230209071700-ee11af9c546a/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww=
github.com/portainer/docker-compose-wrapper v0.0.0-20230209082344-8a5b52de366f h1:z/lmLhZMMSIwg70Ap1rPluXNe1vQXH9gfK9K/ols4JA=
github.com/portainer/docker-compose-wrapper v0.0.0-20230209082344-8a5b52de366f/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww=
github.com/portainer/docker-compose-wrapper v0.0.0-20230301083819-3dbc6abf1ce7 h1:/i985KPNw0KvVtLhTEPUa86aJMtun5ZPOyFCJzdY+dY= github.com/portainer/docker-compose-wrapper v0.0.0-20230301083819-3dbc6abf1ce7 h1:/i985KPNw0KvVtLhTEPUa86aJMtun5ZPOyFCJzdY+dY=
github.com/portainer/docker-compose-wrapper v0.0.0-20230301083819-3dbc6abf1ce7/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww= github.com/portainer/docker-compose-wrapper v0.0.0-20230301083819-3dbc6abf1ce7/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww=
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a h1:B0z3skIMT+OwVNJPQhKp52X+9OWW6A9n5UWig3lHBJk= github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a h1:B0z3skIMT+OwVNJPQhKp52X+9OWW6A9n5UWig3lHBJk=

View File

@ -113,7 +113,9 @@ func (c FDOOwnerClient) PutDeviceSVI(info ServiceInfo) error {
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return errors.New(http.StatusText(resp.StatusCode)) return errors.New(http.StatusText(resp.StatusCode))
@ -132,7 +134,9 @@ func (c FDOOwnerClient) PutDeviceSVIRaw(info url.Values, body []byte) error {
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return errors.New(http.StatusText(resp.StatusCode)) return errors.New(http.StatusText(resp.StatusCode))
@ -151,7 +155,9 @@ func (c FDOOwnerClient) GetVouchers() ([]string, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, errors.New(http.StatusText(resp.StatusCode)) return nil, errors.New(http.StatusText(resp.StatusCode))
@ -182,7 +188,9 @@ func (c FDOOwnerClient) DeleteVoucher(guid string) error {
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return errors.New(http.StatusText(resp.StatusCode)) return errors.New(http.StatusText(resp.StatusCode))
@ -201,7 +209,9 @@ func (c FDOOwnerClient) GetDeviceSVI(guid string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
@ -225,7 +235,9 @@ func (c FDOOwnerClient) DeleteDeviceSVI(id string) error {
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return errors.New(http.StatusText(resp.StatusCode)) return errors.New(http.StatusText(resp.StatusCode))

View File

@ -33,10 +33,13 @@ func (service *Service) Authorization(configuration portainer.OpenAMTConfigurati
if err != nil { if err != nil {
return "", err return "", err
} }
defer response.Body.Close()
responseBody, readErr := io.ReadAll(response.Body) responseBody, readErr := io.ReadAll(response.Body)
if readErr != nil { if readErr != nil {
return "", readErr return "", readErr
} }
errorResponse := parseError(responseBody) errorResponse := parseError(responseBody)
if errorResponse != nil { if errorResponse != nil {
return "", errorResponse return "", errorResponse

View File

@ -128,6 +128,7 @@ func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfig
if err != nil { if err != nil {
return "", err return "", err
} }
defer response.Body.Close()
if response.StatusCode != http.StatusOK { if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code %s", response.Status) return "", fmt.Errorf("unexpected status code %s", response.Status)
@ -137,6 +138,8 @@ func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfig
if err != nil { if err != nil {
return "", err return "", err
} }
block, _ := pem.Decode(certificate) block, _ := pem.Decode(certificate)
return base64.StdEncoding.EncodeToString(block.Bytes), nil return base64.StdEncoding.EncodeToString(block.Bytes), nil
} }

View File

@ -103,6 +103,8 @@ func (service *Service) executeSaveRequest(method string, url string, token stri
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer response.Body.Close()
responseBody, readErr := io.ReadAll(response.Body) responseBody, readErr := io.ReadAll(response.Body)
if readErr != nil { if readErr != nil {
return nil, readErr return nil, readErr
@ -132,6 +134,8 @@ func (service *Service) executeGetRequest(url string, token string) ([]byte, err
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer response.Body.Close()
responseBody, readErr := io.ReadAll(response.Body) responseBody, readErr := io.ReadAll(response.Body)
if readErr != nil { if readErr != nil {
return nil, readErr return nil, readErr
@ -141,10 +145,12 @@ func (service *Service) executeGetRequest(url string, token string) ([]byte, err
if response.StatusCode == http.StatusNotFound { if response.StatusCode == http.StatusNotFound {
return nil, nil return nil, nil
} }
errorResponse := parseError(responseBody) errorResponse := parseError(responseBody)
if errorResponse != nil { if errorResponse != nil {
return nil, errorResponse return nil, errorResponse
} }
return nil, fmt.Errorf("unexpected status code %s", response.Status) return nil, fmt.Errorf("unexpected status code %s", response.Status)
} }

View File

@ -53,6 +53,7 @@ func Test_backupHandlerWithoutPassword_shouldCreateATarballArchive(t *testing.T)
response := w.Result() response := w.Result()
body, _ := io.ReadAll(response.Body) body, _ := io.ReadAll(response.Body)
response.Body.Close()
tmpdir := t.TempDir() tmpdir := t.TempDir()
@ -89,6 +90,7 @@ func Test_backupHandlerWithPassword_shouldCreateEncryptedATarballArchive(t *test
response := w.Result() response := w.Result()
body, _ := io.ReadAll(response.Body) body, _ := io.ReadAll(response.Body)
response.Body.Close()
tmpdir := t.TempDir() tmpdir := t.TempDir()

View File

@ -99,6 +99,8 @@ func backup(t *testing.T, h *Handler, password string) []byte {
response := w.Result() response := w.Result()
archive, _ := io.ReadAll(response.Body) archive, _ := io.ReadAll(response.Body)
response.Body.Close()
return archive return archive
} }

View File

@ -6,7 +6,6 @@ import (
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
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/internal/endpointutils" "github.com/portainer/portainer/api/internal/endpointutils"
@ -26,12 +25,15 @@ func (payload *edgeGroupCreatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Name) { if govalidator.IsNull(payload.Name) {
return errors.New("invalid Edge group name") return errors.New("invalid Edge group name")
} }
if payload.Dynamic && len(payload.TagIDs) == 0 { if payload.Dynamic && len(payload.TagIDs) == 0 {
return errors.New("tagIDs is mandatory for a dynamic Edge group") return errors.New("tagIDs is mandatory for a dynamic Edge group")
} }
if !payload.Dynamic && len(payload.Endpoints) == 0 { if !payload.Dynamic && len(payload.Endpoints) == 0 {
return errors.New("environment is mandatory for a static Edge group") return errors.New("environment is mandatory for a static Edge group")
} }
return nil return nil
} }
@ -56,7 +58,6 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request)
} }
var edgeGroup *portainer.EdgeGroup var edgeGroup *portainer.EdgeGroup
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
edgeGroups, err := tx.EdgeGroup().EdgeGroups() edgeGroups, err := tx.EdgeGroup().EdgeGroups()
if err != nil { if err != nil {
@ -101,13 +102,6 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request)
return nil return nil
}) })
if err != nil {
if httpErr, ok := err.(*httperror.HandlerError); ok {
return httpErr
}
return httperror.InternalServerError("Unexpected error", err) return txResponse(w, edgeGroup, err)
}
return response.JSON(w, edgeGroup)
} }

View File

@ -6,8 +6,8 @@ import (
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/edge" "github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/endpointutils" "github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/slices" "github.com/portainer/portainer/api/internal/slices"
@ -27,12 +27,15 @@ func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Name) { if govalidator.IsNull(payload.Name) {
return errors.New("invalid Edge group name") return errors.New("invalid Edge group name")
} }
if payload.Dynamic && len(payload.TagIDs) == 0 { if payload.Dynamic && len(payload.TagIDs) == 0 {
return errors.New("tagIDs is mandatory for a dynamic Edge group") return errors.New("tagIDs is mandatory for a dynamic Edge group")
} }
if !payload.Dynamic && len(payload.Endpoints) == 0 { if !payload.Dynamic && len(payload.Endpoints) == 0 {
return errors.New("environments is mandatory for a static Edge group") return errors.New("environments is mandatory for a static Edge group")
} }
return nil return nil
} }
@ -62,128 +65,135 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Invalid request payload", err) return httperror.BadRequest("Invalid request payload", err)
} }
edgeGroup, err := handler.DataStore.EdgeGroup().EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) var edgeGroup *portainer.EdgeGroup
if handler.DataStore.IsErrObjectNotFound(err) { err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err) edgeGroup, err = tx.EdgeGroup().EdgeGroup(portainer.EdgeGroupID(edgeGroupID))
} else if err != nil { if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err) return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err)
} } else if err != nil {
return httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err)
if payload.Name != "" {
edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups()
if err != nil {
return httperror.InternalServerError("Unable to retrieve Edge groups from the database", err)
}
for _, edgeGroup := range edgeGroups {
if edgeGroup.Name == payload.Name && edgeGroup.ID != portainer.EdgeGroupID(edgeGroupID) {
return httperror.BadRequest("Edge group name must be unique", errors.New("edge group name must be unique"))
}
} }
edgeGroup.Name = payload.Name if payload.Name != "" {
} edgeGroups, err := tx.EdgeGroup().EdgeGroups()
endpoints, err := handler.DataStore.Endpoint().Endpoints()
if err != nil {
return httperror.InternalServerError("Unable to retrieve environments from database", err)
}
endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups()
if err != nil {
return httperror.InternalServerError("Unable to retrieve environment groups from database", err)
}
oldRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups)
edgeGroup.Dynamic = payload.Dynamic
if edgeGroup.Dynamic {
edgeGroup.TagIDs = payload.TagIDs
} else {
endpointIDs := []portainer.EndpointID{}
for _, endpointID := range payload.Endpoints {
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve environment from the database", err) return httperror.InternalServerError("Unable to retrieve Edge groups from the database", err)
} }
if endpointutils.IsEdgeEndpoint(endpoint) { for _, edgeGroup := range edgeGroups {
endpointIDs = append(endpointIDs, endpoint.ID) if edgeGroup.Name == payload.Name && edgeGroup.ID != portainer.EdgeGroupID(edgeGroupID) {
return httperror.BadRequest("Edge group name must be unique", errors.New("edge group name must be unique"))
}
} }
edgeGroup.Name = payload.Name
} }
edgeGroup.Endpoints = endpointIDs
}
if payload.PartialMatch != nil { endpoints, err := tx.Endpoint().Endpoints()
edgeGroup.PartialMatch = *payload.PartialMatch
}
err = handler.DataStore.EdgeGroup().UpdateEdgeGroup(edgeGroup.ID, edgeGroup)
if err != nil {
return httperror.InternalServerError("Unable to persist Edge group changes inside the database", err)
}
newRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups)
endpointsToUpdate := append(newRelatedEndpoints, oldRelatedEndpoints...)
edgeJobs, err := handler.DataStore.EdgeJob().EdgeJobs()
if err != nil {
return httperror.InternalServerError("Unable to fetch Edge jobs", err)
}
for _, endpointID := range endpointsToUpdate {
err = handler.updateEndpointStacks(endpointID)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to persist Environment relation changes inside the database", err) return httperror.InternalServerError("Unable to retrieve environments from database", err)
} }
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) endpointGroups, err := tx.EndpointGroup().EndpointGroups()
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to get Environment from database", err) return httperror.InternalServerError("Unable to retrieve environment groups from database", err)
} }
if !endpointutils.IsEdgeEndpoint(endpoint) { oldRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups)
continue
}
var operation string edgeGroup.Dynamic = payload.Dynamic
if slices.Contains(newRelatedEndpoints, endpointID) { if edgeGroup.Dynamic {
operation = "add" edgeGroup.TagIDs = payload.TagIDs
} else if slices.Contains(oldRelatedEndpoints, endpointID) {
operation = "remove"
} else { } else {
continue endpointIDs := []portainer.EndpointID{}
for _, endpointID := range payload.Endpoints {
endpoint, err := tx.Endpoint().Endpoint(endpointID)
if err != nil {
return httperror.InternalServerError("Unable to retrieve environment from the database", err)
}
if endpointutils.IsEdgeEndpoint(endpoint) {
endpointIDs = append(endpointIDs, endpoint.ID)
}
}
edgeGroup.Endpoints = endpointIDs
} }
err = handler.updateEndpointEdgeJobs(edgeGroup.ID, endpoint, edgeJobs, operation) if payload.PartialMatch != nil {
edgeGroup.PartialMatch = *payload.PartialMatch
}
err = tx.EdgeGroup().UpdateEdgeGroup(edgeGroup.ID, edgeGroup)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to persist Environment Edge Jobs changes inside the database", err) return httperror.InternalServerError("Unable to persist Edge group changes inside the database", err)
} }
}
return response.JSON(w, edgeGroup) newRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups)
endpointsToUpdate := append(newRelatedEndpoints, oldRelatedEndpoints...)
edgeJobs, err := tx.EdgeJob().EdgeJobs()
if err != nil {
return httperror.InternalServerError("Unable to fetch Edge jobs", err)
}
for _, endpointID := range endpointsToUpdate {
err = handler.updateEndpointStacks(tx, endpointID)
if err != nil {
return httperror.InternalServerError("Unable to persist Environment relation changes inside the database", err)
}
endpoint, err := tx.Endpoint().Endpoint(endpointID)
if err != nil {
return httperror.InternalServerError("Unable to get Environment from database", err)
}
if !endpointutils.IsEdgeEndpoint(endpoint) {
continue
}
var operation string
if slices.Contains(newRelatedEndpoints, endpointID) {
operation = "add"
} else if slices.Contains(oldRelatedEndpoints, endpointID) {
operation = "remove"
} else {
continue
}
err = handler.updateEndpointEdgeJobs(edgeGroup.ID, endpoint, edgeJobs, operation)
if err != nil {
return httperror.InternalServerError("Unable to persist Environment Edge Jobs changes inside the database", err)
}
}
return nil
})
return txResponse(w, edgeGroup, err)
} }
func (handler *Handler) updateEndpointStacks(endpointID portainer.EndpointID) error { func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) error {
relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID) relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
if err != nil { if err != nil {
return err return err
} }
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) endpoint, err := tx.Endpoint().Endpoint(endpointID)
if err != nil { if err != nil {
return err return err
} }
endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(endpoint.GroupID) endpointGroup, err := tx.EndpointGroup().EndpointGroup(endpoint.GroupID)
if err != nil { if err != nil {
return err return err
} }
edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() edgeGroups, err := tx.EdgeGroup().EdgeGroups()
if err != nil { if err != nil {
return err return err
} }
edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() edgeStacks, err := tx.EdgeStack().EdgeStacks()
if err != nil { if err != nil {
return err return err
} }
@ -197,7 +207,7 @@ func (handler *Handler) updateEndpointStacks(endpointID portainer.EndpointID) er
relation.EdgeStacks = edgeStackSet relation.EdgeStacks = edgeStackSet
return handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation) return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)
} }
func (handler *Handler) updateEndpointEdgeJobs(edgeGroupID portainer.EdgeGroupID, endpoint *portainer.Endpoint, edgeJobs []portainer.EdgeJob, operation string) error { func (handler *Handler) updateEndpointEdgeJobs(edgeGroupID portainer.EdgeGroupID, endpoint *portainer.Endpoint, edgeJobs []portainer.EdgeJob, operation string) error {

View File

@ -3,11 +3,13 @@ package edgegroups
import ( import (
"net/http" "net/http"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
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/security" "github.com/portainer/portainer/api/http/security"
"github.com/gorilla/mux"
) )
// Handler is the HTTP handler used to handle environment(endpoint) group operations. // Handler is the HTTP handler used to handle environment(endpoint) group operations.
@ -34,3 +36,15 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupDelete)))).Methods(http.MethodDelete) bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupDelete)))).Methods(http.MethodDelete)
return h return h
} }
func txResponse(w http.ResponseWriter, r any, err error) *httperror.HandlerError {
if err != nil {
if httpErr, ok := err.(*httperror.HandlerError); ok {
return httpErr
}
return httperror.InternalServerError("Unexpected error", err)
}
return response.JSON(w, r)
}

View File

@ -81,16 +81,14 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
} }
if _, ok := handler.DataStore.Endpoint().Heartbeat(portainer.EndpointID(endpointID)); !ok { if _, ok := handler.DataStore.Endpoint().Heartbeat(portainer.EndpointID(endpointID)); !ok {
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", nil) // EE-5910
return httperror.Forbidden("Permission denied to access environment", errors.New("the device has not been trusted yet"))
} }
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err != nil { if err != nil {
if handler.DataStore.IsErrObjectNotFound(err) { // EE-5910
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) return httperror.Forbidden("Permission denied to access environment", errors.New("the device has not been trusted yet"))
}
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
} }
err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint) err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint)

View File

@ -30,7 +30,7 @@ var endpointTestCases = []endpointTestCase{
{ {
portainer.Endpoint{}, portainer.Endpoint{},
portainer.EndpointRelation{}, portainer.EndpointRelation{},
http.StatusNotFound, http.StatusForbidden,
}, },
{ {
portainer.Endpoint{ portainer.Endpoint{
@ -43,7 +43,7 @@ var endpointTestCases = []endpointTestCase{
portainer.EndpointRelation{ portainer.EndpointRelation{
EndpointID: -1, EndpointID: -1,
}, },
http.StatusNotFound, http.StatusForbidden,
}, },
{ {
portainer.Endpoint{ portainer.Endpoint{

View File

@ -38,7 +38,6 @@ type endpointCreatePayload struct {
AzureAuthenticationKey string AzureAuthenticationKey string
TagIDs []portainer.TagID TagIDs []portainer.TagID
EdgeCheckinInterval int EdgeCheckinInterval int
IsEdgeDevice bool
} }
type endpointCreationEnum int type endpointCreationEnum int
@ -381,7 +380,6 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload)
EdgeKey: edgeKey, EdgeKey: edgeKey,
EdgeCheckinInterval: payload.EdgeCheckinInterval, EdgeCheckinInterval: payload.EdgeCheckinInterval,
Kubernetes: portainer.KubernetesDefault(), Kubernetes: portainer.KubernetesDefault(),
IsEdgeDevice: payload.IsEdgeDevice,
UserTrusted: true, UserTrusted: true,
} }
@ -435,7 +433,6 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload)
Status: portainer.EndpointStatusUp, Status: portainer.EndpointStatusUp,
Snapshots: []portainer.DockerSnapshot{}, Snapshots: []portainer.DockerSnapshot{},
Kubernetes: portainer.KubernetesDefault(), Kubernetes: portainer.KubernetesDefault(),
IsEdgeDevice: payload.IsEdgeDevice,
} }
err := handler.snapshotAndPersistEndpoint(endpoint) err := handler.snapshotAndPersistEndpoint(endpoint)
@ -501,7 +498,6 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload,
Status: portainer.EndpointStatusUp, Status: portainer.EndpointStatusUp,
Snapshots: []portainer.DockerSnapshot{}, Snapshots: []portainer.DockerSnapshot{},
Kubernetes: portainer.KubernetesDefault(), Kubernetes: portainer.KubernetesDefault(),
IsEdgeDevice: payload.IsEdgeDevice,
} }
endpoint.Agent.Version = agentVersion endpoint.Agent.Version = agentVersion

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@ -128,7 +129,6 @@ func getDockerHubToken(httpClient *client.HTTPClient, registry *portainer.Regist
} }
func getDockerHubLimits(httpClient *client.HTTPClient, token string) (*dockerhubStatusResponse, error) { func getDockerHubLimits(httpClient *client.HTTPClient, token string) (*dockerhubStatusResponse, error) {
requestURL := "https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest" requestURL := "https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest"
req, err := http.NewRequest(http.MethodHead, requestURL, nil) req, err := http.NewRequest(http.MethodHead, requestURL, nil)
@ -142,7 +142,9 @@ func getDockerHubLimits(httpClient *client.HTTPClient, token string) (*dockerhub
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, errors.New("failed fetching dockerhub limits") return nil, errors.New("failed fetching dockerhub limits")

View File

@ -28,6 +28,10 @@ type endpointSettingsUpdatePayload struct {
AllowSysctlSettingForRegularUsers *bool `json:"allowSysctlSettingForRegularUsers" example:"true"` AllowSysctlSettingForRegularUsers *bool `json:"allowSysctlSettingForRegularUsers" example:"true"`
// Whether host management features are enabled // Whether host management features are enabled
EnableHostManagementFeatures *bool `json:"enableHostManagementFeatures" example:"true"` EnableHostManagementFeatures *bool `json:"enableHostManagementFeatures" example:"true"`
EnableGPUManagement *bool `json:"enableGPUManagement" example:"false"`
Gpus []portainer.Pair `json:"gpus"`
} }
func (payload *endpointSettingsUpdatePayload) Validate(r *http.Request) error { func (payload *endpointSettingsUpdatePayload) Validate(r *http.Request) error {
@ -107,6 +111,14 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re
securitySettings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures securitySettings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures
} }
if payload.EnableGPUManagement != nil {
endpoint.EnableGPUManagement = *payload.EnableGPUManagement
}
if payload.Gpus != nil {
endpoint.Gpus = payload.Gpus
}
endpoint.SecuritySettings = securitySettings endpoint.SecuritySettings = securitySettings
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint) err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)

View File

@ -82,7 +82,7 @@ type Handler struct {
} }
// @title PortainerCE API // @title PortainerCE API
// @version 2.18.0 // @version 2.19.0
// @description.markdown api-description.md // @description.markdown api-description.md
// @termsOfService // @termsOfService

View File

@ -48,15 +48,16 @@ func (handler *Handler) createProfile(w http.ResponseWriter, r *http.Request) *h
return httperror.BadRequest("Invalid query parameter: method", err) return httperror.BadRequest("Invalid query parameter: method", err)
} }
switch method { if method == "editor" {
case "editor":
return handler.createFDOProfileFromFileContent(w, r) return handler.createFDOProfileFromFileContent(w, r)
} }
return httperror.BadRequest("Invalid method. Value must be one of: editor", errors.New("invalid method")) return httperror.BadRequest("Invalid method. Value must be one of: editor", errors.New("invalid method"))
} }
func (handler *Handler) createFDOProfileFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) createFDOProfileFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload createProfileFromFileContentPayload var payload createProfileFromFileContentPayload
err := request.DecodeAndValidateJSONPayload(r, &payload) err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
return httperror.BadRequest("Invalid request payload", err) return httperror.BadRequest("Invalid request payload", err)
@ -66,6 +67,7 @@ func (handler *Handler) createFDOProfileFromFileContent(w http.ResponseWriter, r
if err != nil { if err != nil {
return httperror.InternalServerError(err.Error(), err) return httperror.InternalServerError(err.Error(), err)
} }
if !isUnique { if !isUnique {
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A profile with the name '%s' already exists", payload.Name), Err: errors.New("a profile already exists with this name")} return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A profile with the name '%s' already exists", payload.Name), Err: errors.New("a profile already exists with this name")}
} }
@ -80,6 +82,7 @@ func (handler *Handler) createFDOProfileFromFileContent(w http.ResponseWriter, r
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to persist profile file on disk", err) return httperror.InternalServerError("Unable to persist profile file on disk", err)
} }
profile.FilePath = filePath profile.FilePath = filePath
profile.DateCreated = time.Now().Unix() profile.DateCreated = time.Now().Unix()

View File

@ -1,7 +1,6 @@
package stacks package stacks
import ( import (
"fmt"
"net/http" "net/http"
"time" "time"
@ -10,7 +9,6 @@ import (
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/git" "github.com/portainer/portainer/api/git"
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
@ -137,12 +135,6 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
} }
} }
backupProjectPath := fmt.Sprintf("%s-old", stack.ProjectPath)
err = filesystem.MoveDirectory(stack.ProjectPath, backupProjectPath)
if err != nil {
return httperror.InternalServerError("Unable to move git repository directory", err)
}
repositoryUsername := "" repositoryUsername := ""
repositoryPassword := "" repositoryPassword := ""
if payload.RepositoryAuthentication { if payload.RepositoryAuthentication {

View File

@ -10,10 +10,9 @@ import (
) )
type systemInfoResponse struct { type systemInfoResponse struct {
Platform plf.ContainerPlatform `json:"platform"` Platform plf.ContainerPlatform `json:"platform"`
EdgeAgents int `json:"edgeAgents"` EdgeAgents int `json:"edgeAgents"`
EdgeDevices int `json:"edgeDevices"` Agents int `json:"agents"`
Agents int `json:"agents"`
} }
// @id systemInfo // @id systemInfo
@ -34,7 +33,6 @@ func (handler *Handler) systemInfo(w http.ResponseWriter, r *http.Request) *http
agents := 0 agents := 0
edgeAgents := 0 edgeAgents := 0
edgeDevices := 0
for _, environment := range environments { for _, environment := range environments {
if endpointutils.IsAgentEndpoint(&environment) { if endpointutils.IsAgentEndpoint(&environment) {
@ -45,9 +43,6 @@ func (handler *Handler) systemInfo(w http.ResponseWriter, r *http.Request) *http
edgeAgents++ edgeAgents++
} }
if environment.IsEdgeDevice {
edgeDevices++
}
} }
platform, err := plf.DetermineContainerPlatform() platform, err := plf.DetermineContainerPlatform()
@ -56,9 +51,8 @@ func (handler *Handler) systemInfo(w http.ResponseWriter, r *http.Request) *http
} }
return response.JSON(w, &systemInfoResponse{ return response.JSON(w, &systemInfoResponse{
EdgeAgents: edgeAgents, EdgeAgents: edgeAgents,
EdgeDevices: edgeDevices, Agents: agents,
Agents: agents, Platform: platform,
Platform: platform,
}) })
} }

View File

@ -8,6 +8,8 @@ import (
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/platform"
) )
type systemUpgradePayload struct { type systemUpgradePayload struct {
@ -28,13 +30,19 @@ func (payload *systemUpgradePayload) Validate(r *http.Request) error {
return nil return nil
} }
var platformToEndpointType = map[platform.ContainerPlatform]portainer.EndpointType{
platform.PlatformDockerStandalone: portainer.DockerEnvironment,
platform.PlatformDockerSwarm: portainer.DockerEnvironment,
platform.PlatformKubernetes: portainer.KubernetesLocalEnvironment,
}
// @id systemUpgrade // @id systemUpgrade
// @summary Upgrade Portainer to BE // @summary Upgrade Portainer to BE
// @description Upgrade Portainer to BE // @description Upgrade Portainer to BE
// @description **Access policy**: administrator // @description **Access policy**: administrator
// @tags system // @tags system
// @produce json // @produce json
// @success 200 {object} status "Success" // @success 204 {object} status "Success"
// @router /system/upgrade [post] // @router /system/upgrade [post]
func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
payload, err := request.GetPayload[systemUpgradePayload](r) payload, err := request.GetPayload[systemUpgradePayload](r)
@ -42,10 +50,40 @@ func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *h
return httperror.BadRequest("Invalid request payload", err) return httperror.BadRequest("Invalid request payload", err)
} }
err = handler.upgradeService.Upgrade(payload.License) environment, err := handler.guessLocalEndpoint()
if err != nil {
return httperror.InternalServerError("Failed to guess local endpoint", err)
}
err = handler.upgradeService.Upgrade(environment, payload.License)
if err != nil { if err != nil {
return httperror.InternalServerError("Failed to upgrade Portainer", err) return httperror.InternalServerError("Failed to upgrade Portainer", err)
} }
return response.Empty(w) return response.Empty(w)
} }
func (handler *Handler) guessLocalEndpoint() (*portainer.Endpoint, error) {
platform, err := platform.DetermineContainerPlatform()
if err != nil {
return nil, errors.Wrap(err, "failed to determine container platform")
}
endpointType, ok := platformToEndpointType[platform]
if !ok {
return nil, errors.New("failed to determine endpoint type")
}
endpoints, err := handler.dataStore.Endpoint().Endpoints()
if err != nil {
return nil, errors.Wrap(err, "failed to retrieve endpoints")
}
for _, endpoint := range endpoints {
if endpoint.Type == endpointType {
return &endpoint, nil
}
}
return nil, errors.New("failed to find local endpoint")
}

View File

@ -63,6 +63,7 @@ func (handler *Handler) ifRequestedTemplateExists(payload *filePayload) *httperr
return nil return nil
} }
} }
return httperror.InternalServerError("Invalid template", errors.New("requested template does not exist")) return httperror.InternalServerError("Invalid template", errors.New("requested template does not exist"))
} }
@ -82,6 +83,7 @@ func (handler *Handler) ifRequestedTemplateExists(payload *filePayload) *httperr
// @router /templates/file [post] // @router /templates/file [post]
func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload filePayload var payload filePayload
err := request.DecodeAndValidateJSONPayload(r, &payload) err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
return httperror.BadRequest("Invalid request payload", err) return httperror.BadRequest("Invalid request payload", err)
@ -112,11 +114,9 @@ func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *ht
} }
func (handler *Handler) cleanUp(projectPath string) error { func (handler *Handler) cleanUp(projectPath string) {
err := handler.FileService.RemoveDirectory(projectPath) err := handler.FileService.RemoveDirectory(projectPath)
if err != nil { if err != nil {
log.Debug().Err(err).Msg("HTTP error: unable to cleanup stack creation") log.Debug().Err(err).Msg("HTTP error: unable to cleanup stack creation")
} }
return nil
} }

View File

@ -18,8 +18,6 @@ import (
type themePayload struct { type themePayload struct {
// Color represents the color theme of the UI // Color represents the color theme of the UI
Color *string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto"` Color *string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto"`
// SubtleUpgradeButton indicates if the upgrade banner should be displayed in a subtle way
SubtleUpgradeButton *bool `json:"subtleUpgradeButton" example:"false"`
} }
type userUpdatePayload struct { type userUpdatePayload struct {
@ -33,11 +31,11 @@ type userUpdatePayload struct {
func (payload *userUpdatePayload) Validate(r *http.Request) error { func (payload *userUpdatePayload) Validate(r *http.Request) error {
if govalidator.Contains(payload.Username, " ") { if govalidator.Contains(payload.Username, " ") {
return errors.New("Invalid username. Must not contain any whitespace") return errors.New("invalid username. Must not contain any whitespace")
} }
if payload.Role != 0 && payload.Role != 1 && payload.Role != 2 { if payload.Role != 0 && payload.Role != 1 && payload.Role != 2 {
return errors.New("Invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") return errors.New("invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)")
} }
return nil return nil
} }
@ -120,10 +118,6 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
if payload.Theme.Color != nil { if payload.Theme.Color != nil {
user.ThemeSettings.Color = *payload.Theme.Color user.ThemeSettings.Color = *payload.Theme.Color
} }
if payload.Theme.SubtleUpgradeButton != nil {
user.ThemeSettings.SubtleUpgradeButton = *payload.Theme.SubtleUpgradeButton
}
} }
if payload.Role != 0 { if payload.Role != 0 {

View File

@ -14,48 +14,57 @@ func streamFromWebsocketToWriter(websocketConn *websocket.Conn, writer io.Writer
_, in, err := websocketConn.ReadMessage() _, in, err := websocketConn.ReadMessage()
if err != nil { if err != nil {
errorChan <- err errorChan <- err
break break
} }
_, err = writer.Write(in) _, err = writer.Write(in)
if err != nil { if err != nil {
errorChan <- err errorChan <- err
break break
} }
} }
} }
func streamFromReaderToWebsocket(websocketConn *websocket.Conn, reader io.Reader, errorChan chan error) { func streamFromReaderToWebsocket(websocketConn *websocket.Conn, reader io.Reader, errorChan chan error) {
out := make([]byte, readerBufferSize)
for { for {
out := make([]byte, readerBufferSize) n, err := reader.Read(out)
_, err := reader.Read(out)
if err != nil { if err != nil {
errorChan <- err errorChan <- err
break break
} }
processedOutput := validString(string(out[:])) processedOutput := validString(string(out[:n]))
err = websocketConn.WriteMessage(websocket.TextMessage, []byte(processedOutput)) err = websocketConn.WriteMessage(websocket.TextMessage, []byte(processedOutput))
if err != nil { if err != nil {
errorChan <- err errorChan <- err
break break
} }
} }
} }
func validString(s string) string { func validString(s string) string {
if !utf8.ValidString(s) { if utf8.ValidString(s) {
v := make([]rune, 0, len(s)) return s
for i, r := range s {
if r == utf8.RuneError {
_, size := utf8.DecodeRuneInString(s[i:])
if size == 1 {
continue
}
}
v = append(v, r)
}
s = string(v)
} }
return s
v := make([]rune, 0, len(s))
for i, r := range s {
if r == utf8.RuneError {
_, size := utf8.DecodeRuneInString(s[i:])
if size == 1 {
continue
}
}
v = append(v, r)
}
return string(v)
} }

View File

@ -26,6 +26,7 @@ func (transport *Transport) applyPortainerContainers(resources []interface{}) ([
responseObject, _ = transport.applyPortainerContainer(responseObject) responseObject, _ = transport.applyPortainerContainer(responseObject)
decoratedResourceData = append(decoratedResourceData, responseObject) decoratedResourceData = append(decoratedResourceData, responseObject)
} }
return decoratedResourceData, nil return decoratedResourceData, nil
} }
@ -34,8 +35,10 @@ func (transport *Transport) applyPortainerContainer(resourceObject map[string]in
if !ok { if !ok {
return resourceObject, nil return resourceObject, nil
} }
if len(resourceId) >= 12 && resourceId[0:12] == portainerContainerId { if len(resourceId) >= 12 && resourceId[0:12] == portainerContainerId {
resourceObject["IsPortainer"] = true resourceObject["IsPortainer"] = true
} }
return resourceObject, nil return resourceObject, nil
} }

View File

@ -150,8 +150,7 @@ func (transport *baseTransport) getRoundTripToken(request *http.Request, tokenMa
func decorateAgentRequest(r *http.Request, dataStore dataservices.DataStore) error { func decorateAgentRequest(r *http.Request, dataStore dataservices.DataStore) error {
requestPath := strings.TrimPrefix(r.URL.Path, "/v2") requestPath := strings.TrimPrefix(r.URL.Path, "/v2")
switch { if strings.HasPrefix(requestPath, "/dockerhub") {
case strings.HasPrefix(requestPath, "/dockerhub"):
return decorateAgentDockerHubRequest(r, dataStore) return decorateAgentDockerHubRequest(r, dataStore)
} }

View File

@ -76,6 +76,16 @@ func EndpointSet(endpointIDs []portainer.EndpointID) map[portainer.EndpointID]bo
} }
func InitialIngressClassDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) { func InitialIngressClassDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) {
if endpoint.Kubernetes.Flags.IsServerIngressClassDetected {
return
}
defer func() {
endpoint.Kubernetes.Flags.IsServerIngressClassDetected = true
endpointService.UpdateEndpoint(
portainer.EndpointID(endpoint.ID),
endpoint,
)
}()
cli, err := factory.GetKubeClient(endpoint) cli, err := factory.GetKubeClient(endpoint)
if err != nil { if err != nil {
log.Debug().Err(err).Msg("unable to create kubernetes client for ingress class detection") log.Debug().Err(err).Msg("unable to create kubernetes client for ingress class detection")
@ -107,6 +117,16 @@ func InitialIngressClassDetection(endpoint *portainer.Endpoint, endpointService
} }
func InitialMetricsDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) { func InitialMetricsDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) {
if endpoint.Kubernetes.Flags.IsServerMetricsDetected {
return
}
defer func() {
endpoint.Kubernetes.Flags.IsServerMetricsDetected = true
endpointService.UpdateEndpoint(
portainer.EndpointID(endpoint.ID),
endpoint,
)
}()
cli, err := factory.GetKubeClient(endpoint) cli, err := factory.GetKubeClient(endpoint)
if err != nil { if err != nil {
log.Debug().Err(err).Msg("unable to create kubernetes client for initial metrics detection") log.Debug().Err(err).Msg("unable to create kubernetes client for initial metrics detection")
@ -118,11 +138,6 @@ func InitialMetricsDetection(endpoint *portainer.Endpoint, endpointService datas
return return
} }
endpoint.Kubernetes.Configuration.UseServerMetrics = true endpoint.Kubernetes.Configuration.UseServerMetrics = true
endpoint.Kubernetes.Flags.IsServerMetricsDetected = true
err = endpointService.UpdateEndpoint(
portainer.EndpointID(endpoint.ID),
endpoint,
)
if err != nil { if err != nil {
log.Debug().Err(err).Msg("unable to enable UseServerMetrics inside the database") log.Debug().Err(err).Msg("unable to enable UseServerMetrics inside the database")
return return
@ -158,6 +173,16 @@ func storageDetect(endpoint *portainer.Endpoint, endpointService dataservices.En
} }
func InitialStorageDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) { func InitialStorageDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) {
if endpoint.Kubernetes.Flags.IsServerStorageDetected {
return
}
defer func() {
endpoint.Kubernetes.Flags.IsServerStorageDetected = true
endpointService.UpdateEndpoint(
portainer.EndpointID(endpoint.ID),
endpoint,
)
}()
log.Info().Msg("attempting to detect storage classes in the cluster") log.Info().Msg("attempting to detect storage classes in the cluster")
err := storageDetect(endpoint, endpointService, factory) err := storageDetect(endpoint, endpointService, factory)
if err == nil { if err == nil {

View File

@ -1,23 +1,13 @@
package upgrade package upgrade
import ( import (
"bytes"
"context"
"fmt" "fmt"
"os"
"strings"
"time"
"github.com/cbroglie/mustache"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/pkg/errors" "github.com/pkg/errors"
libstack "github.com/portainer/docker-compose-wrapper" libstack "github.com/portainer/docker-compose-wrapper"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/platform" "github.com/portainer/portainer/api/platform"
"github.com/rs/zerolog/log"
) )
const ( const (
@ -36,19 +26,23 @@ const (
) )
type Service interface { type Service interface {
Upgrade(licenseKey string) error Upgrade(environment *portainer.Endpoint, licenseKey string) error
} }
type service struct { type service struct {
composeDeployer libstack.Deployer composeDeployer libstack.Deployer
isUpdating bool kubernetesClientFactory *cli.ClientFactory
platform platform.ContainerPlatform
assetsPath string isUpdating bool
platform platform.ContainerPlatform
assetsPath string
} }
func NewService( func NewService(
assetsPath string, assetsPath string,
composeDeployer libstack.Deployer, composeDeployer libstack.Deployer,
kubernetesClientFactory *cli.ClientFactory,
) (Service, error) { ) (Service, error) {
platform, err := platform.DetermineContainerPlatform() platform, err := platform.DetermineContainerPlatform()
if err != nil { if err != nil {
@ -56,13 +50,14 @@ func NewService(
} }
return &service{ return &service{
assetsPath: assetsPath, assetsPath: assetsPath,
composeDeployer: composeDeployer, composeDeployer: composeDeployer,
platform: platform, kubernetesClientFactory: kubernetesClientFactory,
platform: platform,
}, nil }, nil
} }
func (service *service) Upgrade(licenseKey string) error { func (service *service) Upgrade(environment *portainer.Endpoint, licenseKey string) error {
service.isUpdating = true service.isUpdating = true
switch service.platform { switch service.platform {
@ -70,113 +65,9 @@ func (service *service) Upgrade(licenseKey string) error {
return service.upgradeDocker(licenseKey, portainer.APIVersion, "standalone") return service.upgradeDocker(licenseKey, portainer.APIVersion, "standalone")
case platform.PlatformDockerSwarm: case platform.PlatformDockerSwarm:
return service.upgradeDocker(licenseKey, portainer.APIVersion, "swarm") return service.upgradeDocker(licenseKey, portainer.APIVersion, "swarm")
// case platform.PlatformKubernetes: case platform.PlatformKubernetes:
// case platform.PlatformPodman: return service.upgradeKubernetes(environment, licenseKey, portainer.APIVersion)
// case platform.PlatformNomad:
// default:
} }
return fmt.Errorf("unsupported platform %s", service.platform) return fmt.Errorf("unsupported platform %s", service.platform)
} }
func (service *service) upgradeDocker(licenseKey, version, envType string) error {
ctx := context.TODO()
templateName := filesystem.JoinPaths(service.assetsPath, "mustache-templates", mustacheUpgradeDockerTemplateFile)
portainerImagePrefix := os.Getenv(portainerImagePrefixEnvVar)
if portainerImagePrefix == "" {
portainerImagePrefix = "portainer/portainer-ee"
}
image := fmt.Sprintf("%s:%s", portainerImagePrefix, version)
skipPullImage := os.Getenv(skipPullImageEnvVar)
if err := service.checkImage(ctx, image, skipPullImage != ""); err != nil {
return err
}
composeFile, err := mustache.RenderFile(templateName, map[string]string{
"image": image,
"skip_pull_image": skipPullImage,
"updater_image": os.Getenv(updaterImageEnvVar),
"license": licenseKey,
"envType": envType,
})
log.Debug().
Str("composeFile", composeFile).
Msg("Compose file for upgrade")
if err != nil {
return errors.Wrap(err, "failed to render upgrade template")
}
tmpDir := os.TempDir()
timeId := time.Now().Unix()
filePath := filesystem.JoinPaths(tmpDir, fmt.Sprintf("upgrade-%d.yml", timeId))
r := bytes.NewReader([]byte(composeFile))
err = filesystem.CreateFile(filePath, r)
if err != nil {
return errors.Wrap(err, "failed to create upgrade compose file")
}
projectName := fmt.Sprintf(
"portainer-upgrade-%d-%s",
timeId,
strings.Replace(version, ".", "-", -1))
err = service.composeDeployer.Deploy(
ctx,
[]string{filePath},
libstack.DeployOptions{
ForceRecreate: true,
AbortOnContainerExit: true,
Options: libstack.Options{
ProjectName: projectName,
},
},
)
// optimally, server was restarted by the updater, so we should not reach this point
if err != nil {
return errors.Wrap(err, "failed to deploy upgrade stack")
}
return errors.New("upgrade failed: server should have been restarted by the updater")
}
func (service *service) checkImage(ctx context.Context, image string, skipPullImage bool) error {
cli, err := docker.CreateClientFromEnv()
if err != nil {
return errors.Wrap(err, "failed to create docker client")
}
if skipPullImage {
filters := filters.NewArgs()
filters.Add("reference", image)
images, err := cli.ImageList(ctx, types.ImageListOptions{
Filters: filters,
})
if err != nil {
return errors.Wrap(err, "failed to list images")
}
if len(images) == 0 {
return errors.Errorf("image %s not found locally", image)
}
return nil
} else {
// check if available on registry
_, err := cli.DistributionInspect(ctx, image, "")
if err != nil {
return errors.Errorf("image %s not found on registry", image)
}
return nil
}
}

View File

@ -0,0 +1,122 @@
package upgrade
import (
"bytes"
"context"
"fmt"
"os"
"strings"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
libstack "github.com/portainer/docker-compose-wrapper"
"github.com/portainer/portainer/api/filesystem"
"github.com/cbroglie/mustache"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
func (service *service) upgradeDocker(licenseKey, version, envType string) error {
ctx := context.TODO()
templateName := filesystem.JoinPaths(service.assetsPath, "mustache-templates", mustacheUpgradeDockerTemplateFile)
portainerImagePrefix := os.Getenv(portainerImagePrefixEnvVar)
if portainerImagePrefix == "" {
portainerImagePrefix = "portainer/portainer-ee"
}
image := fmt.Sprintf("%s:%s", portainerImagePrefix, version)
skipPullImage := os.Getenv(skipPullImageEnvVar)
if err := service.checkImageForDocker(ctx, image, skipPullImage != ""); err != nil {
return err
}
composeFile, err := mustache.RenderFile(templateName, map[string]string{
"image": image,
"skip_pull_image": skipPullImage,
"updater_image": os.Getenv(updaterImageEnvVar),
"license": licenseKey,
"envType": envType,
})
log.Debug().
Str("composeFile", composeFile).
Msg("Compose file for upgrade")
if err != nil {
return errors.Wrap(err, "failed to render upgrade template")
}
tmpDir := os.TempDir()
timeId := time.Now().Unix()
filePath := filesystem.JoinPaths(tmpDir, fmt.Sprintf("upgrade-%d.yml", timeId))
r := bytes.NewReader([]byte(composeFile))
err = filesystem.CreateFile(filePath, r)
if err != nil {
return errors.Wrap(err, "failed to create upgrade compose file")
}
projectName := fmt.Sprintf(
"portainer-upgrade-%d-%s",
timeId,
strings.ReplaceAll(version, ".", "-"))
err = service.composeDeployer.Deploy(
ctx,
[]string{filePath},
libstack.DeployOptions{
ForceRecreate: true,
AbortOnContainerExit: true,
Options: libstack.Options{
ProjectName: projectName,
},
},
)
// optimally, server was restarted by the updater, so we should not reach this point
if err != nil {
return errors.Wrap(err, "failed to deploy upgrade stack")
}
return errors.New("upgrade failed: server should have been restarted by the updater")
}
func (service *service) checkImageForDocker(ctx context.Context, image string, skipPullImage bool) error {
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return errors.Wrap(err, "failed to create docker client")
}
if skipPullImage {
filters := filters.NewArgs()
filters.Add("reference", image)
images, err := cli.ImageList(ctx, types.ImageListOptions{
Filters: filters,
})
if err != nil {
return errors.Wrap(err, "failed to list images")
}
if len(images) == 0 {
return errors.Errorf("image %s not found locally", image)
}
return nil
} else {
// check if available on registry
_, err := cli.DistributionInspect(ctx, image, "")
if err != nil {
return errors.Errorf("image %s not found on registry", image)
}
return nil
}
}

View File

@ -0,0 +1,201 @@
package upgrade
import (
"context"
"fmt"
"os"
"time"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
func ptr[T any](i T) *T { return &i }
func (service *service) upgradeKubernetes(environment *portainer.Endpoint, licenseKey, version string) error {
ctx := context.TODO()
kubeCLI, err := service.kubernetesClientFactory.CreateClient(environment)
if err != nil {
return errors.WithMessage(err, "failed to get kubernetes client")
}
namespace := "portainer"
taskName := fmt.Sprintf("portainer-upgrade-%d", time.Now().Unix())
jobsCli := kubeCLI.BatchV1().Jobs(namespace)
updaterImage := os.Getenv(updaterImageEnvVar)
if updaterImage == "" {
updaterImage = "portainer/portainer-updater:latest"
}
portainerImagePrefix := os.Getenv(portainerImagePrefixEnvVar)
if portainerImagePrefix == "" {
portainerImagePrefix = "portainer/portainer-ee"
}
image := fmt.Sprintf("%s:%s", portainerImagePrefix, version)
if err := service.checkImageForKubernetes(ctx, kubeCLI, namespace, image); err != nil {
return err
}
job, err := jobsCli.Create(ctx, &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
Name: taskName,
Namespace: namespace,
},
Spec: batchv1.JobSpec{
TTLSecondsAfterFinished: ptr[int32](5 * 60), // cleanup after 5 minutes
BackoffLimit: ptr[int32](0),
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
RestartPolicy: "Never",
ServiceAccountName: "portainer-sa-clusteradmin",
Containers: []corev1.Container{
{
Name: taskName,
Image: updaterImage,
Args: []string{
"--pretty-log",
"--log-level", "DEBUG",
"portainer",
"--env-type", "kubernetes",
"--image", image,
"--license", licenseKey,
},
},
},
},
},
},
}, metav1.CreateOptions{})
if err != nil {
return errors.WithMessage(err, "failed to create upgrade job")
}
watcher, err := jobsCli.Watch(ctx, metav1.ListOptions{
FieldSelector: "metadata.name=" + taskName,
TimeoutSeconds: ptr[int64](60),
})
if err != nil {
return errors.WithMessage(err, "failed to watch upgrade job")
}
for event := range watcher.ResultChan() {
job, ok := event.Object.(*batchv1.Job)
if !ok {
continue
}
for _, c := range job.Status.Conditions {
if c.Type == batchv1.JobComplete {
log.Debug().
Str("job", job.Name).
Msg("Upgrade job completed")
return nil
}
if c.Type == batchv1.JobFailed {
return fmt.Errorf("upgrade failed: %s", c.Message)
}
}
}
log.Debug().
Str("job", job.Name).
Msg("Upgrade job created")
return errors.New("upgrade failed: server should have been restarted by the updater")
}
func (service *service) checkImageForKubernetes(ctx context.Context, kubeCLI *kubernetes.Clientset, namespace, image string) error {
podsCli := kubeCLI.CoreV1().Pods(namespace)
log.Debug().
Str("image", image).
Msg("Checking image")
podName := fmt.Sprintf("portainer-image-check-%d", time.Now().Unix())
_, err := podsCli.Create(ctx, &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: podName,
},
Spec: corev1.PodSpec{
RestartPolicy: "Never",
Containers: []corev1.Container{
{
Name: fmt.Sprint(podName, "-container"),
Image: image,
},
},
},
}, metav1.CreateOptions{})
if err != nil {
log.Warn().Err(err).Msg("failed to create image check pod")
return errors.WithMessage(err, "failed to create image check pod")
}
defer func() {
log.Debug().
Str("pod", podName).
Msg("Deleting image check pod")
if err := podsCli.Delete(ctx, podName, metav1.DeleteOptions{}); err != nil {
log.Warn().Err(err).Msg("failed to delete image check pod")
}
}()
i := 0
for {
time.Sleep(2 * time.Second)
log.Debug().
Str("image", image).
Int("try", i).
Msg("Checking image")
i++
pod, err := podsCli.Get(ctx, podName, metav1.GetOptions{})
if err != nil {
return errors.WithMessage(err, "failed to get image check pod")
}
for _, containerStatus := range pod.Status.ContainerStatuses {
if containerStatus.Ready {
log.Debug().
Str("image", image).
Str("pod", podName).
Msg("Image check container ready, assuming image is available")
return nil
}
if containerStatus.State.Waiting != nil {
if containerStatus.State.Waiting.Reason == "ErrImagePull" || containerStatus.State.Waiting.Reason == "ImagePullBackOff" {
log.Debug().
Str("image", image).
Str("pod", podName).
Str("reason", containerStatus.State.Waiting.Reason).
Str("message", containerStatus.State.Waiting.Message).
Str("container", containerStatus.Name).
Msg("Image check container failed because of missing image")
return fmt.Errorf("image %s not found", image)
}
}
}
}
}

View File

@ -12,8 +12,8 @@ import (
// NamespaceAccessPoliciesDeleteNamespace removes stored policies associated with a given namespace // NamespaceAccessPoliciesDeleteNamespace removes stored policies associated with a given namespace
func (kcl *KubeClient) NamespaceAccessPoliciesDeleteNamespace(ns string) error { func (kcl *KubeClient) NamespaceAccessPoliciesDeleteNamespace(ns string) error {
kcl.lock.Lock() kcl.mu.Lock()
defer kcl.lock.Unlock() defer kcl.mu.Unlock()
policies, err := kcl.GetNamespaceAccessPolicies() policies, err := kcl.GetNamespaceAccessPolicies()
if err != nil { if err != nil {
@ -42,6 +42,7 @@ func (kcl *KubeClient) GetNamespaceAccessPolicies() (map[string]portainer.K8sNam
if err != nil { if err != nil {
return nil, err return nil, err
} }
return policies, nil return policies, nil
} }

View File

@ -2,7 +2,6 @@ package cli
import ( import (
"context" "context"
"sync"
"testing" "testing"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
@ -40,7 +39,6 @@ func Test_NamespaceAccessPoliciesDeleteNamespace_updatesPortainerConfig_whenConf
k := &KubeClient{ k := &KubeClient{
cli: kfake.NewSimpleClientset(), cli: kfake.NewSimpleClientset(),
instanceID: "instance", instanceID: "instance",
lock: &sync.Mutex{},
} }
config := &ktypes.ConfigMap{ config := &ktypes.ConfigMap{

View File

@ -7,17 +7,20 @@ import (
"sync" "sync"
"time" "time"
cmap "github.com/orcaman/concurrent-map"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
"github.com/pkg/errors" "github.com/pkg/errors"
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/rs/zerolog/log"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd"
) )
const (
DefaultKubeClientQPS = 30
DefaultKubeClientBurst = 100
)
type ( type (
// ClientFactory is used to create Kubernetes clients // ClientFactory is used to create Kubernetes clients
ClientFactory struct { ClientFactory struct {
@ -25,16 +28,17 @@ type (
reverseTunnelService portainer.ReverseTunnelService reverseTunnelService portainer.ReverseTunnelService
signatureService portainer.DigitalSignatureService signatureService portainer.DigitalSignatureService
instanceID string instanceID string
endpointClients cmap.ConcurrentMap endpointClients map[string]*KubeClient
endpointProxyClients *cache.Cache endpointProxyClients *cache.Cache
AddrHTTPS string AddrHTTPS string
mu sync.Mutex
} }
// KubeClient represent a service used to execute Kubernetes operations // KubeClient represent a service used to execute Kubernetes operations
KubeClient struct { KubeClient struct {
cli kubernetes.Interface cli kubernetes.Interface
instanceID string instanceID string
lock *sync.Mutex mu sync.Mutex
} }
) )
@ -53,7 +57,7 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService, revers
signatureService: signatureService, signatureService: signatureService,
reverseTunnelService: reverseTunnelService, reverseTunnelService: reverseTunnelService,
instanceID: instanceID, instanceID: instanceID,
endpointClients: cmap.New(), endpointClients: make(map[string]*KubeClient),
endpointProxyClients: cache.New(timeout, timeout), endpointProxyClients: cache.New(timeout, timeout),
AddrHTTPS: addrHTTPS, AddrHTTPS: addrHTTPS,
}, nil }, nil
@ -65,82 +69,87 @@ func (factory *ClientFactory) GetInstanceID() (instanceID string) {
// Remove the cached kube client so a new one can be created // Remove the cached kube client so a new one can be created
func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID) { func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID) {
factory.endpointClients.Remove(strconv.Itoa(int(endpointID))) factory.mu.Lock()
delete(factory.endpointClients, strconv.Itoa(int(endpointID)))
factory.mu.Unlock()
} }
// GetKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found. // GetKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found.
// If no client is registered, it will create a new client, register it, and returns it. // If no client is registered, it will create a new client, register it, and returns it.
func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (portainer.KubeClient, error) { func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) {
factory.mu.Lock()
defer factory.mu.Unlock()
key := strconv.Itoa(int(endpoint.ID)) key := strconv.Itoa(int(endpoint.ID))
client, ok := factory.endpointClients.Get(key) client, ok := factory.endpointClients[key]
if !ok { if !ok {
client, err := factory.createCachedAdminKubeClient(endpoint) var err error
client, err = factory.createCachedAdminKubeClient(endpoint)
if err != nil { if err != nil {
return nil, err return nil, err
} }
factory.endpointClients.Set(key, client) factory.endpointClients[key] = client
return client, nil
} }
return client.(portainer.KubeClient), nil return client, nil
} }
// GetProxyKubeClient retrieves a KubeClient from the cache. You should be // GetProxyKubeClient retrieves a KubeClient from the cache. You should be
// calling SetProxyKubeClient before first. It is normally, called the // calling SetProxyKubeClient before first. It is normally, called the
// kubernetes middleware. // kubernetes middleware.
func (factory *ClientFactory) GetProxyKubeClient(endpointID, token string) (portainer.KubeClient, bool) { func (factory *ClientFactory) GetProxyKubeClient(endpointID, token string) (*KubeClient, bool) {
client, ok := factory.endpointProxyClients.Get(endpointID + "." + token) client, ok := factory.endpointProxyClients.Get(endpointID + "." + token)
if !ok { if !ok {
return nil, false return nil, false
} }
return client.(portainer.KubeClient), true
return client.(*KubeClient), true
} }
// SetProxyKubeClient stores a kubeclient in the cache. // SetProxyKubeClient stores a kubeclient in the cache.
func (factory *ClientFactory) SetProxyKubeClient(endpointID, token string, cli portainer.KubeClient) { func (factory *ClientFactory) SetProxyKubeClient(endpointID, token string, cli *KubeClient) {
factory.endpointProxyClients.Set(endpointID+"."+token, cli, 0) factory.endpointProxyClients.Set(endpointID+"."+token, cli, 0)
} }
// CreateKubeClientFromKubeConfig creates a KubeClient from a clusterID, and // CreateKubeClientFromKubeConfig creates a KubeClient from a clusterID, and
// Kubernetes config. // Kubernetes config.
func (factory *ClientFactory) CreateKubeClientFromKubeConfig(clusterID string, kubeConfig []byte) (portainer.KubeClient, error) { func (factory *ClientFactory) CreateKubeClientFromKubeConfig(clusterID string, kubeConfig []byte) (*KubeClient, error) {
config, err := clientcmd.NewClientConfigFromBytes([]byte(kubeConfig)) config, err := clientcmd.NewClientConfigFromBytes([]byte(kubeConfig))
if err != nil { if err != nil {
return nil, err return nil, err
} }
cliConfig, err := config.ClientConfig() cliConfig, err := config.ClientConfig()
if err != nil { if err != nil {
return nil, err return nil, err
} }
cliConfig.QPS = DefaultKubeClientQPS
cliConfig.Burst = DefaultKubeClientBurst
cli, err := kubernetes.NewForConfig(cliConfig) cli, err := kubernetes.NewForConfig(cliConfig)
if err != nil { if err != nil {
return nil, err return nil, err
} }
kubecli := &KubeClient{ return &KubeClient{
cli: cli, cli: cli,
instanceID: factory.instanceID, instanceID: factory.instanceID,
lock: &sync.Mutex{}, }, nil
}
return kubecli, nil
} }
func (factory *ClientFactory) createCachedAdminKubeClient(endpoint *portainer.Endpoint) (portainer.KubeClient, error) { func (factory *ClientFactory) createCachedAdminKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) {
cli, err := factory.CreateClient(endpoint) cli, err := factory.CreateClient(endpoint)
if err != nil { if err != nil {
return nil, err return nil, err
} }
kubecli := &KubeClient{ return &KubeClient{
cli: cli, cli: cli,
instanceID: factory.instanceID, instanceID: factory.instanceID,
lock: &sync.Mutex{}, }, nil
}
return kubecli, nil
} }
// CreateClient returns a pointer to a new Clientset instance // CreateClient returns a pointer to a new Clientset instance
@ -199,7 +208,10 @@ func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernete
if err != nil { if err != nil {
return nil, err return nil, err
} }
config.Insecure = true config.Insecure = true
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
config.Wrap(func(rt http.RoundTripper) http.RoundTripper { config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
return &agentHeaderRoundTripper{ return &agentHeaderRoundTripper{
@ -218,30 +230,13 @@ func buildLocalClient() (*kubernetes.Clientset, error) {
return nil, err return nil, err
} }
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
return kubernetes.NewForConfig(config) return kubernetes.NewForConfig(config)
} }
func (factory *ClientFactory) PostInitMigrateIngresses() error { func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint) error {
endpoints, err := factory.dataStore.Endpoint().Endpoints()
if err != nil {
return err
}
for i := range endpoints {
// Early exit if we do not need to migrate!
if endpoints[i].PostInitMigrations.MigrateIngresses == false {
return nil
}
err := factory.migrateEndpointIngresses(&endpoints[i])
if err != nil {
log.Debug().Err(err).Msg("failure migrating endpoint ingresses")
}
}
return nil
}
func (factory *ClientFactory) migrateEndpointIngresses(e *portainer.Endpoint) error {
// classes is a list of controllers which have been manually added to the // classes is a list of controllers which have been manually added to the
// cluster setup view. These need to all be allowed globally, but then // cluster setup view. These need to all be allowed globally, but then
// blocked in specific namespaces which they were not previously allowed in. // blocked in specific namespaces which they were not previously allowed in.

View File

@ -3,7 +3,6 @@ package cli
import ( import (
"context" "context"
"strconv" "strconv"
"sync"
"testing" "testing"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
@ -19,7 +18,6 @@ func Test_ToggleSystemState(t *testing.T) {
kcl := &KubeClient{ kcl := &KubeClient{
cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}), cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}),
instanceID: "instance", instanceID: "instance",
lock: &sync.Mutex{},
} }
err := kcl.ToggleSystemState(nsName, true) err := kcl.ToggleSystemState(nsName, true)
@ -37,12 +35,10 @@ func Test_ToggleSystemState(t *testing.T) {
kcl := &KubeClient{ kcl := &KubeClient{
cli: kfake.NewSimpleClientset(), cli: kfake.NewSimpleClientset(),
instanceID: "instance", instanceID: "instance",
lock: &sync.Mutex{},
} }
err := kcl.ToggleSystemState(nsName, true) err := kcl.ToggleSystemState(nsName, true)
assert.Error(t, err) assert.Error(t, err)
}) })
t.Run("if called with the same state, should skip (exit without error)", func(t *testing.T) { t.Run("if called with the same state, should skip (exit without error)", func(t *testing.T) {
@ -61,7 +57,6 @@ func Test_ToggleSystemState(t *testing.T) {
systemNamespaceLabel: strconv.FormatBool(test.isSystem), systemNamespaceLabel: strconv.FormatBool(test.isSystem),
}}}), }}}),
instanceID: "instance", instanceID: "instance",
lock: &sync.Mutex{},
} }
err := kcl.ToggleSystemState(nsName, test.isSystem) err := kcl.ToggleSystemState(nsName, test.isSystem)
@ -81,7 +76,6 @@ func Test_ToggleSystemState(t *testing.T) {
kcl := &KubeClient{ kcl := &KubeClient{
cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}), cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}),
instanceID: "instance", instanceID: "instance",
lock: &sync.Mutex{},
} }
err := kcl.ToggleSystemState(nsName, true) err := kcl.ToggleSystemState(nsName, true)
@ -102,7 +96,6 @@ func Test_ToggleSystemState(t *testing.T) {
kcl := &KubeClient{ kcl := &KubeClient{
cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}), cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}),
instanceID: "instance", instanceID: "instance",
lock: &sync.Mutex{},
} }
err := kcl.ToggleSystemState(nsName, false) err := kcl.ToggleSystemState(nsName, false)
@ -125,7 +118,6 @@ func Test_ToggleSystemState(t *testing.T) {
systemNamespaceLabel: "true", systemNamespaceLabel: "true",
}}}), }}}),
instanceID: "instance", instanceID: "instance",
lock: &sync.Mutex{},
} }
err := kcl.ToggleSystemState(nsName, false) err := kcl.ToggleSystemState(nsName, false)
@ -159,7 +151,6 @@ func Test_ToggleSystemState(t *testing.T) {
kcl := &KubeClient{ kcl := &KubeClient{
cli: kfake.NewSimpleClientset(namespace, config), cli: kfake.NewSimpleClientset(namespace, config),
instanceID: "instance", instanceID: "instance",
lock: &sync.Mutex{},
} }
err := kcl.ToggleSystemState(nsName, true) err := kcl.ToggleSystemState(nsName, true)
@ -178,6 +169,5 @@ func Test_ToggleSystemState(t *testing.T) {
actualPolicies, err := kcl.GetNamespaceAccessPolicies() actualPolicies, err := kcl.GetNamespaceAccessPolicies()
assert.NoError(t, err, "failed to fetch policies") assert.NoError(t, err, "failed to fetch policies")
assert.Equal(t, expectedPolicies, actualPolicies) assert.Equal(t, expectedPolicies, actualPolicies)
}) })
} }

View File

@ -98,7 +98,7 @@ func (service *kubeClusterAccessService) GetData(hostURL string, endpointID port
// When the api call is internal, the baseURL should not be used. // When the api call is internal, the baseURL should not be used.
if hostURL == "localhost" { if hostURL == "localhost" {
hostURL = hostURL + service.httpsBindAddr hostURL += service.httpsBindAddr
baseURL = "/" baseURL = "/"
} }

View File

@ -121,12 +121,13 @@ func getResource(token string, configuration *portainer.OAuthSettings) (map[stri
client := &http.Client{} client := &http.Client{}
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -41,10 +41,12 @@ func DetermineContainerPlatform() (ContainerPlatform, error) {
if podmanModeEnvVar == "1" { if podmanModeEnvVar == "1" {
return PlatformPodman, nil return PlatformPodman, nil
} }
serviceHostKubernetesEnvVar := os.Getenv(KubernetesServiceHost) serviceHostKubernetesEnvVar := os.Getenv(KubernetesServiceHost)
if serviceHostKubernetesEnvVar != "" { if serviceHostKubernetesEnvVar != "" {
return PlatformKubernetes, nil return PlatformKubernetes, nil
} }
nomadJobName := os.Getenv(NomadJobName) nomadJobName := os.Getenv(NomadJobName)
if nomadJobName != "" { if nomadJobName != "" {
return PlatformNomad, nil return PlatformNomad, nil

View File

@ -225,7 +225,7 @@ type (
// It contains some information of Docker's ContainerJSON struct // It contains some information of Docker's ContainerJSON struct
DockerContainerSnapshot struct { DockerContainerSnapshot struct {
types.Container types.Container
Env []string `json:"Env"` Env []string `json:"Env,omitempty"` // EE-5240
} }
// DockerSnapshotRaw represents all the information related to a snapshot as returned by the Docker API // DockerSnapshotRaw represents all the information related to a snapshot as returned by the Docker API
@ -388,8 +388,7 @@ type (
LastCheckInDate int64 LastCheckInDate int64
// QueryDate of each query with the endpoints list // QueryDate of each query with the endpoints list
QueryDate int64 QueryDate int64
// IsEdgeDevice marks if the environment was created as an EdgeDevice
IsEdgeDevice bool
// Whether the device has been trusted or not by the user // Whether the device has been trusted or not by the user
UserTrusted bool UserTrusted bool
@ -402,6 +401,8 @@ type (
Version string `example:"1.0.0"` Version string `example:"1.0.0"`
} }
EnableGPUManagement bool `json:"EnableGPUManagement"`
// Deprecated fields // Deprecated fields
// Deprecated in DBVersion == 4 // Deprecated in DBVersion == 4
TLS bool `json:"TLS,omitempty"` TLS bool `json:"TLS,omitempty"`
@ -415,6 +416,9 @@ type (
// Deprecated in DBVersion == 22 // Deprecated in DBVersion == 22
Tags []string `json:"Tags"` Tags []string `json:"Tags"`
// Deprecated v2.18
IsEdgeDevice bool
} }
EnvironmentEdgeSettings struct { EnvironmentEdgeSettings struct {
@ -502,6 +506,7 @@ type (
// EndpointPostInitMigrations // EndpointPostInitMigrations
EndpointPostInitMigrations struct { EndpointPostInitMigrations struct {
MigrateIngresses bool `json:"MigrateIngresses"` MigrateIngresses bool `json:"MigrateIngresses"`
MigrateGPUs bool `json:"MigrateGPUs"`
} }
// Extension represents a deprecated Portainer extension // Extension represents a deprecated Portainer extension
@ -585,9 +590,12 @@ type (
Flags KubernetesFlags `json:"Flags"` Flags KubernetesFlags `json:"Flags"`
} }
// KubernetesFlags are used to detect if we need to run initial cluster
// detection again.
KubernetesFlags struct { KubernetesFlags struct {
IsServerMetricsDetected bool `json:"IsServerMetricsDetected"` IsServerMetricsDetected bool `json:"IsServerMetricsDetected"`
IsServerStorageDetected bool `json:"IsServerStorageDetected"` IsServerIngressClassDetected bool `json:"IsServerIngressClassDetected"`
IsServerStorageDetected bool `json:"IsServerStorageDetected"`
} }
// KubernetesSnapshot represents a snapshot of a specific Kubernetes environment(endpoint) at a specific time // KubernetesSnapshot represents a snapshot of a specific Kubernetes environment(endpoint) at a specific time
@ -1283,8 +1291,6 @@ type (
UserThemeSettings struct { UserThemeSettings struct {
// Color represents the color theme of the UI // Color represents the color theme of the UI
Color string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto"` Color string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto"`
// SubtleUpgradeButton indicates if the upgrade banner should be displayed in a subtle way
SubtleUpgradeButton bool `json:"subtleUpgradeButton"`
} }
// Webhook represents a url webhook that can be used to update a service // Webhook represents a url webhook that can be used to update a service
@ -1507,7 +1513,7 @@ type (
const ( const (
// APIVersion is the version number of the Portainer API // APIVersion is the version number of the Portainer API
APIVersion = "2.18.0" APIVersion = "2.19.0"
// Edition is what this edition of Portainer is called // Edition is what this edition of Portainer is called
Edition = PortainerCE Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax // ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
@ -1554,7 +1560,9 @@ const (
) )
// List of supported features // List of supported features
var SupportedFeatureFlags = []featureflags.Feature{} var SupportedFeatureFlags = []featureflags.Feature{
"fdo",
}
const ( const (
_ AuthenticationMethod = iota _ AuthenticationMethod = iota

View File

@ -24,6 +24,7 @@ func GetStackFilePaths(stack *portainer.Stack, absolute bool) []string {
for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) { for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) {
filePaths = append(filePaths, filesystem.JoinPaths(stack.ProjectPath, file)) filePaths = append(filePaths, filesystem.JoinPaths(stack.ProjectPath, file))
} }
return filePaths return filePaths
} }

View File

@ -120,8 +120,6 @@
--bg-navtabs-hover-color: var(--grey-16); --bg-navtabs-hover-color: var(--grey-16);
--bg-nav-tab-active-color: var(--ui-gray-4); --bg-nav-tab-active-color: var(--ui-gray-4);
--bg-table-selected-color: var(--grey-14); --bg-table-selected-color: var(--grey-14);
--bg-codemirror-color: var(--white-color);
--bg-codemirror-gutters-color: var(--grey-17);
--bg-dropdown-menu-color: var(--white-color); --bg-dropdown-menu-color: var(--white-color);
--bg-log-viewer-color: var(--white-color); --bg-log-viewer-color: var(--white-color);
--bg-log-line-selected-color: var(--grey-18); --bg-log-line-selected-color: var(--grey-18);
@ -136,7 +134,6 @@
--bg-item-highlighted-color: var(--grey-21); --bg-item-highlighted-color: var(--grey-21);
--bg-item-highlighted-null-color: var(--grey-14); --bg-item-highlighted-null-color: var(--grey-14);
--bg-panel-body-color: var(--white-color); --bg-panel-body-color: var(--white-color);
--bg-codemirror-selected-color: var(--grey-22);
--bg-tooltip-color: var(--ui-gray-11); --bg-tooltip-color: var(--ui-gray-11);
--bg-input-sm-color: var(--white-color); --bg-input-sm-color: var(--white-color);
--bg-app-datatable-thead: var(--grey-23); --bg-app-datatable-thead: var(--grey-23);
@ -182,11 +179,7 @@
--text-navtabs-color: var(--grey-7); --text-navtabs-color: var(--grey-7);
--text-navtabs-hover-color: var(--grey-6); --text-navtabs-hover-color: var(--grey-6);
--text-nav-tab-active-color: var(--grey-25); --text-nav-tab-active-color: var(--grey-25);
--text-cm-default-color: var(--blue-1);
--text-cm-meta-color: var(--black-color);
--text-cm-string-color: var(--red-3);
--text-cm-number-color: var(--green-1);
--text-codemirror-color: var(--black-color);
--text-dropdown-menu-color: var(--grey-6); --text-dropdown-menu-color: var(--grey-6);
--text-log-viewer-color: var(--black-color); --text-log-viewer-color: var(--black-color);
--text-json-tree-color: var(--blue-3); --text-json-tree-color: var(--blue-3);
@ -224,7 +217,6 @@
--border-md-checkbox-color: var(--grey-19); --border-md-checkbox-color: var(--grey-19);
--border-modal-header-color: var(--grey-45); --border-modal-header-color: var(--grey-45);
--border-navtabs-color: var(--ui-white); --border-navtabs-color: var(--ui-white);
--border-codemirror-cursor-color: var(--black-color);
--border-pre-color: var(--grey-43); --border-pre-color: var(--grey-43);
--border-pagination-span-color: var(--ui-white); --border-pagination-span-color: var(--ui-white);
--border-pagination-hover-color: var(--ui-white); --border-pagination-hover-color: var(--ui-white);
@ -281,9 +273,6 @@
--bg-card-color: var(--grey-1); --bg-card-color: var(--grey-1);
--bg-checkbox-border-color: var(--grey-8); --bg-checkbox-border-color: var(--grey-8);
--bg-code-color: var(--grey-2); --bg-code-color: var(--grey-2);
--bg-codemirror-color: var(--grey-2);
--bg-codemirror-gutters-color: var(--grey-3);
--bg-codemirror-selected-color: var(--grey-3);
--bg-dropdown-menu-color: var(--ui-gray-warm-8); --bg-dropdown-menu-color: var(--ui-gray-warm-8);
--bg-main-color: var(--grey-2); --bg-main-color: var(--grey-2);
--bg-sidebar-color: var(--grey-1); --bg-sidebar-color: var(--grey-1);
@ -361,11 +350,7 @@
--text-navtabs-color: var(--grey-8); --text-navtabs-color: var(--grey-8);
--text-navtabs-hover-color: var(--grey-9); --text-navtabs-hover-color: var(--grey-9);
--text-nav-tab-active-color: var(--white-color); --text-nav-tab-active-color: var(--white-color);
--text-cm-default-color: var(--blue-10);
--text-cm-meta-color: var(--white-color);
--text-cm-string-color: var(--red-5);
--text-cm-number-color: var(--green-2);
--text-codemirror-color: var(--white-color);
--text-dropdown-menu-color: var(--white-color); --text-dropdown-menu-color: var(--white-color);
--text-log-viewer-color: var(--white-color); --text-log-viewer-color: var(--white-color);
--text-json-tree-color: var(--grey-40); --text-json-tree-color: var(--grey-40);
@ -403,7 +388,6 @@
--border-md-checkbox-color: var(--grey-41); --border-md-checkbox-color: var(--grey-41);
--border-modal-header-color: var(--grey-1); --border-modal-header-color: var(--grey-1);
--border-navtabs-color: var(--grey-38); --border-navtabs-color: var(--grey-38);
--border-codemirror-cursor-color: var(--white-color);
--border-pre-color: var(--grey-3); --border-pre-color: var(--grey-3);
--border-blocklist: var(--ui-gray-9); --border-blocklist: var(--ui-gray-9);
--border-blocklist-item-selected-color: var(--grey-38); --border-blocklist-item-selected-color: var(--grey-38);
@ -468,15 +452,12 @@
--bg-switch-box-color: var(--grey-53); --bg-switch-box-color: var(--grey-53);
--bg-panel-body-color: var(--black-color); --bg-panel-body-color: var(--black-color);
--bg-dropdown-menu-color: var(--ui-gray-warm-8); --bg-dropdown-menu-color: var(--ui-gray-warm-8);
--bg-codemirror-selected-color: var(--grey-3);
--bg-motd-body-color: var(--black-color); --bg-motd-body-color: var(--black-color);
--bg-blocklist-hover-color: var(--black-color); --bg-blocklist-hover-color: var(--black-color);
--bg-blocklist-item-selected-color: var(--black-color); --bg-blocklist-item-selected-color: var(--black-color);
--bg-input-group-addon-color: var(--grey-3); --bg-input-group-addon-color: var(--grey-3);
--bg-table-color: var(--black-color); --bg-table-color: var(--black-color);
--bg-codemirror-gutters-color: var(--ui-gray-warm-11);
--bg-codemirror-color: var(--black-color);
--bg-codemirror-selected-color: var(--grey-3);
--bg-log-viewer-color: var(--black-color); --bg-log-viewer-color: var(--black-color);
--bg-log-line-selected-color: var(--grey-3); --bg-log-line-selected-color: var(--grey-3);
--bg-modal-content-color: var(--black-color); --bg-modal-content-color: var(--black-color);
@ -536,7 +517,6 @@
--text-tooltip-color: var(--white-color); --text-tooltip-color: var(--white-color);
--text-blocklist-item-selected-color: var(--blue-9); --text-blocklist-item-selected-color: var(--blue-9);
--text-input-group-addon-color: var(--white-color); --text-input-group-addon-color: var(--white-color);
--text-codemirror-color: var(--white-color);
--text-dropdown-menu-color: var(--white-color); --text-dropdown-menu-color: var(--white-color);
--text-log-viewer-color: var(--white-color); --text-log-viewer-color: var(--white-color);
--text-summary-color: var(--white-color); --text-summary-color: var(--white-color);
@ -582,7 +562,6 @@
--border-pre-next-month: var(--white-color); --border-pre-next-month: var(--white-color);
--border-daterangepicker-after: var(--black-color); --border-daterangepicker-after: var(--black-color);
--border-pre-color: var(--grey-3); --border-pre-color: var(--grey-3);
--border-codemirror-cursor-color: var(--white-color);
--border-modal: 1px solid var(--white-color); --border-modal: 1px solid var(--white-color);
--border-sortbutton: var(--black-color); --border-sortbutton: var(--black-color);
--border-bootbox: var(--black-color); --border-bootbox: var(--black-color);
@ -596,9 +575,7 @@
--text-input-textarea: var(--black-color); --text-input-textarea: var(--black-color);
--bg-item-highlighted-null-color: var(--grey-2); --bg-item-highlighted-null-color: var(--grey-2);
--text-cm-default-color: var(--blue-9);
--text-cm-meta-color: var(--white-color);
--text-cm-string-color: var(--red-7);
--text-progress-bar-color: var(--black-color); --text-progress-bar-color: var(--black-color);
--user-menu-icon-color: var(--white-color); --user-menu-icon-color: var(--white-color);

View File

@ -36,6 +36,10 @@
border: 1px solid var(--border-input-group-addon-color); border: 1px solid var(--border-input-group-addon-color);
} }
.input-group .form-control {
z-index: unset;
}
.text-danger { .text-danger {
color: var(--ui-error-9); color: var(--ui-error-9);
} }
@ -150,50 +154,6 @@ code {
background-color: var(--bg-table-selected-color); background-color: var(--bg-table-selected-color);
} }
.CodeMirror-gutters {
background: var(--bg-codemirror-gutters-color);
border-right: 0px;
}
.CodeMirror-linenumber {
text-align: left;
}
.CodeMirror pre.CodeMirror-line,
.CodeMirror pre.CodeMirror-line-like {
padding: 0 20px;
}
.CodeMirror {
background: var(--bg-codemirror-color);
color: var(--text-codemirror-color);
border-radius: 8px;
}
.CodeMirror-selected {
background: var(--bg-codemirror-selected-color) !important;
}
.CodeMirror-cursor {
border-left: 1px solid var(--border-codemirror-cursor-color);
}
.cm-s-default .cm-atom {
color: var(--text-cm-default-color);
}
.cm-s-default .cm-meta {
color: var(--text-cm-meta-color);
}
.cm-s-default .cm-string {
color: var(--text-cm-string-color);
}
.cm-s-default .cm-number {
color: var(--text-cm-number-color);
}
.dropdown-menu { .dropdown-menu {
background: var(--bg-dropdown-menu-color); background: var(--bg-dropdown-menu-color);
border-radius: 8px; border-radius: 8px;
@ -358,11 +318,6 @@ input:-webkit-autofill {
} }
/* Overide Vendor CSS */ /* Overide Vendor CSS */
.btn-link:hover {
color: var(--text-link-hover-color) !important;
}
.multiSelect.inlineBlock button { .multiSelect.inlineBlock button {
margin: 0; margin: 0;
} }

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.7 KiB

4
app/assets/ico/vendor/kaas-icon.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@ -1,5 +1,7 @@
import angular from 'angular'; import angular from 'angular';
import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models';
import { EnvironmentStatus } from '@/react/portainer/environments/types'; import { EnvironmentStatus } from '@/react/portainer/environments/types';
import { reactModule } from './react'; import { reactModule } from './react';
@ -16,14 +18,17 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
abstract: true, abstract: true,
onEnter: /* @ngInject */ function onEnter(endpoint, $async, $state, EndpointService, Notifications, StateManager, SystemService) { onEnter: /* @ngInject */ function onEnter(endpoint, $async, $state, EndpointService, Notifications, StateManager, SystemService) {
return $async(async () => { return $async(async () => {
if (![1, 2, 4].includes(endpoint.Type)) { const dockerTypes = [PortainerEndpointTypes.DockerEnvironment, PortainerEndpointTypes.AgentOnDockerEnvironment, PortainerEndpointTypes.EdgeAgentOnDockerEnvironment];
if (!dockerTypes.includes(endpoint.Type)) {
$state.go('portainer.home'); $state.go('portainer.home');
return; return;
} }
try { try {
const status = await checkEndpointStatus(endpoint); const status = await checkEndpointStatus(endpoint);
if (endpoint.Type !== 4) { if (endpoint.Type !== PortainerEndpointTypes.EdgeAgentOnDockerEnvironment) {
await updateEndpointStatus(endpoint, status); await updateEndpointStatus(endpoint, status);
} }
endpoint.Status = status; endpoint.Status = status;
@ -34,16 +39,22 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
await StateManager.updateEndpointState(endpoint); await StateManager.updateEndpointState(endpoint);
} catch (e) { } catch (e) {
Notifications.error('Failed loading environment', e); let params = {};
$state.go('portainer.home', {}, { reload: true });
if (endpoint.Type == PortainerEndpointTypes.EdgeAgentOnDockerEnvironment) {
params = { redirect: true, environmentId: endpoint.Id, environmentName: endpoint.Name, route: 'docker.dashboard' };
} else {
Notifications.error('Failed loading environment', e);
}
$state.go('portainer.home', params, { reload: true, inherit: false });
} }
async function checkEndpointStatus(endpoint) { async function checkEndpointStatus(endpoint) {
try { try {
await SystemService.ping(endpoint.Id); await SystemService.ping(endpoint.Id);
return 1; return EnvironmentStatus.Up;
} catch (e) { } catch (e) {
return 2; return EnvironmentStatus.Down;
} }
} }

View File

@ -1,7 +1,7 @@
<!-- use registry --> <!-- use registry -->
<div class="row"> <div class="row">
<div class="form-group" ng-if="$ctrl.model.UseRegistry"> <div class="form-group" ng-if="$ctrl.model.UseRegistry">
<label for="image_registry" class="control-label col-sm-3 col-lg-2 required text-left" ng-class="$ctrl.labelClass"> Registry </label> <label for="image_registry" class="control-label col-sm-3 col-lg-2 text-left" ng-class="$ctrl.labelClass"> Registry </label>
<div ng-class="$ctrl.inputClass" class="col-sm-8"> <div ng-class="$ctrl.inputClass" class="col-sm-8">
<select <select
ng-options="registry as registry.Name for registry in $ctrl.registries track by registry.Id" ng-options="registry as registry.Name for registry in $ctrl.registries track by registry.Id"
@ -36,7 +36,7 @@
title="Search image on Docker Hub" title="Search image on Docker Hub"
target="_blank" target="_blank"
> >
<pr-icon icon="'svg-docker'" size="'lg'"></pr-icon> Search <pr-icon icon="'svg-docker'" size="'md'"></pr-icon> Search
</a> </a>
</span> </span>
</div> </div>

View File

@ -1,6 +1,7 @@
import angular from 'angular'; import angular from 'angular';
import { r2a } from '@/react-tools/react2angular'; import { r2a } from '@/react-tools/react2angular';
import { withControlledInput } from '@/react-tools/withControlledInput';
import { StackContainersDatatable } from '@/react/docker/stacks/ItemView/StackContainersDatatable'; import { StackContainersDatatable } from '@/react/docker/stacks/ItemView/StackContainersDatatable';
import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions'; import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions';
import { TemplateListDropdownAngular } from '@/react/docker/app-templates/TemplateListDropdown'; import { TemplateListDropdownAngular } from '@/react/docker/app-templates/TemplateListDropdown';
@ -11,6 +12,9 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter'; import { withUIRouter } from '@/react-tools/withUIRouter';
import { DockerfileDetails } from '@/react/docker/images/ItemView/DockerfileDetails'; import { DockerfileDetails } from '@/react/docker/images/ItemView/DockerfileDetails';
import { HealthStatus } from '@/react/docker/containers/ItemView/HealthStatus'; import { HealthStatus } from '@/react/docker/containers/ItemView/HealthStatus';
import { GpusList } from '@/react/docker/host/SetupView/GpusList';
import { GpusInsights } from '@/react/docker/host/SetupView/GpusInsights';
import { InsightsBox } from '@/react/components/InsightsBox';
export const componentsModule = angular export const componentsModule = angular
.module('portainer.docker.react.components', []) .module('portainer.docker.react.components', [])
@ -37,5 +41,28 @@ export const componentsModule = angular
) )
.component( .component(
'gpu', 'gpu',
r2a(Gpu, ['values', 'onChange', 'gpus', 'usedGpus', 'usedAllGpus']) r2a(Gpu, [
).name; 'values',
'onChange',
'gpus',
'usedGpus',
'usedAllGpus',
'enableGpuManagement',
])
)
.component(
'gpusList',
r2a(withControlledInput(GpusList), ['value', 'onChange'])
)
.component(
'insightsBox',
r2a(InsightsBox, [
'header',
'content',
'setHtmlContent',
'insightCloseId',
'type',
'className',
])
)
.component('gpusInsights', r2a(GpusInsights, [])).name;

View File

@ -21,6 +21,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
'$timeout', '$timeout',
'$transition$', '$transition$',
'$filter', '$filter',
'$analytics',
'Container', 'Container',
'ContainerHelper', 'ContainerHelper',
'Image', 'Image',
@ -35,6 +36,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
'FormValidator', 'FormValidator',
'RegistryService', 'RegistryService',
'SystemService', 'SystemService',
'SettingsService',
'PluginService', 'PluginService',
'HttpRequestHelper', 'HttpRequestHelper',
'endpoint', 'endpoint',
@ -46,6 +48,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
$timeout, $timeout,
$transition$, $transition$,
$filter, $filter,
$analytics,
Container, Container,
ContainerHelper, ContainerHelper,
Image, Image,
@ -60,6 +63,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
FormValidator, FormValidator,
RegistryService, RegistryService,
SystemService, SystemService,
SettingsService,
PluginService, PluginService,
HttpRequestHelper, HttpRequestHelper,
endpoint endpoint
@ -1042,6 +1046,18 @@ angular.module('portainer.docker').controller('CreateContainerController', [
}); });
} }
async function sendAnalytics() {
const publicSettings = await SettingsService.publicSettings();
const analyticsAllowed = publicSettings.EnableTelemetry;
const image = `${$scope.formValues.RegistryModel.Registry.URL}/${$scope.formValues.RegistryModel.Image}`;
if (analyticsAllowed && $scope.formValues.GPU.enabled) {
$analytics.eventTrack('gpuContainerCreated', {
category: 'docker',
metadata: { gpu: $scope.formValues.GPU, containerImage: image },
});
}
}
function applyResourceControl(newContainer) { function applyResourceControl(newContainer) {
const userId = Authentication.getUserDetails().ID; const userId = Authentication.getUserDetails().ID;
const resourceControl = newContainer.Portainer.ResourceControl; const resourceControl = newContainer.Portainer.ResourceControl;
@ -1101,7 +1117,8 @@ angular.module('portainer.docker').controller('CreateContainerController', [
return validateForm(accessControlData, $scope.isAdmin); return validateForm(accessControlData, $scope.isAdmin);
} }
function onSuccess() { async function onSuccess() {
await sendAnalytics();
Notifications.success('Success', 'Container successfully created'); Notifications.success('Success', 'Container successfully created');
$state.go('docker.containers', {}, { reload: true }); $state.go('docker.containers', {}, { reload: true });
} }

View File

@ -17,8 +17,8 @@
<form class="form-horizontal" autocomplete="off"> <form class="form-horizontal" autocomplete="off">
<!-- name-input --> <!-- name-input -->
<div class="form-group"> <div class="form-group">
<label for="container_name" class="col-sm-2 control-label text-left">Name</label> <label for="container_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
<div class="col-sm-10"> <div class="col-sm-8">
<input type="text" class="form-control" ng-model="config.name" id="container_name" placeholder="e.g. myContainer" /> <input type="text" class="form-control" ng-model="config.name" id="container_name" placeholder="e.g. myContainer" />
</div> </div>
</div> </div>
@ -37,8 +37,6 @@
model="formValues.RegistryModel" model="formValues.RegistryModel"
ng-if="formValues.RegistryModel.Registry" ng-if="formValues.RegistryModel.Registry"
auto-complete="true" auto-complete="true"
label-class="col-sm-1"
input-class="col-sm-11"
endpoint="endpoint" endpoint="endpoint"
is-admin="isAdmin" is-admin="isAdmin"
check-rate-limits="formValues.alwaysPull" check-rate-limits="formValues.alwaysPull"
@ -169,7 +167,7 @@
<div class="col-sm-12"> <div class="col-sm-12">
<button <button
type="button" type="button"
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm !ml-0"
ng-disabled="state.actionInProgress || !formValues.RegistryModel.Image || (!formValues.RegistryModel.Registry && fromContainer) ng-disabled="state.actionInProgress || !formValues.RegistryModel.Image || (!formValues.RegistryModel.Registry && fromContainer)
|| (fromContainer.IsPortainer && fromContainer.Name === '/' + config.name)" || (fromContainer.IsPortainer && fromContainer.Name === '/' + config.name)"
ng-click="create()" ng-click="create()"
@ -701,17 +699,20 @@
</div> </div>
<!-- !shm-size-input --> <!-- !shm-size-input -->
<!-- #region GPU --> <!-- #region GPU -->
<div class="col-sm-12 form-section-title"> GPU </div> <div ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'">
<div class="col-sm-12 form-section-title"> GPU </div>
<gpu <gpu
ng-if="applicationState.endpoint.apiVersion >= 1.4" ng-if="applicationState.endpoint.apiVersion >= 1.4"
values="formValues.GPU" values="formValues.GPU"
on-change="(onGpuChange)" on-change="(onGpuChange)"
gpus="endpoint.Gpus" gpus="endpoint.Gpus"
used-gpus="gpuUseList" used-gpus="gpuUseList"
used-all-gpus="gpuUseAll" used-all-gpus="gpuUseAll"
> enable-gpu-management="endpoint.EnableGPUManagement"
</gpu> >
</gpu>
</div>
<!-- #endregion GPU --> <!-- #endregion GPU -->
<div ng-class="{ 'edit-resources': state.mode == 'duplicate' }"> <div ng-class="{ 'edit-resources': state.mode == 'duplicate' }">

View File

@ -78,7 +78,7 @@
</div> </div>
</div> </div>
<div class="dashboard-grid mx-4"> <div class="mx-4 grid grid-cols-2 gap-3">
<a class="no-link" ui-sref="docker.stacks" ng-if="showStacks"> <a class="no-link" ui-sref="docker.stacks" ng-if="showStacks">
<dashboard-item icon="'layers'" type="'Stack'" value="stackCount"></dashboard-item> <dashboard-item icon="'layers'" type="'Stack'" value="stackCount"></dashboard-item>
</a> </a>
@ -106,7 +106,12 @@
</a> </a>
<div> <div>
<dashboard-item icon="'cpu'" type="'GPU'" value="endpoint.Gpus.length"></dashboard-item> <dashboard-item
ng-if="endpoint.EnableGPUManagement && applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'"
icon="'cpu'"
type="'GPU'"
value="endpoint.Gpus.length"
></dashboard-item>
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,10 +2,13 @@ import { FeatureId } from '@/react/portainer/feature-flags/enums';
export default class DockerFeaturesConfigurationController { export default class DockerFeaturesConfigurationController {
/* @ngInject */ /* @ngInject */
constructor($async, $scope, EndpointService, Notifications, StateManager) { constructor($async, $scope, $state, $analytics, EndpointService, SettingsService, Notifications, StateManager) {
this.$async = $async; this.$async = $async;
this.$scope = $scope; this.$scope = $scope;
this.$state = $state;
this.$analytics = $analytics;
this.EndpointService = EndpointService; this.EndpointService = EndpointService;
this.SettingsService = SettingsService;
this.Notifications = Notifications; this.Notifications = Notifications;
this.StateManager = StateManager; this.StateManager = StateManager;
@ -35,6 +38,8 @@ export default class DockerFeaturesConfigurationController {
this.save = this.save.bind(this); this.save = this.save.bind(this);
this.onChangeField = this.onChangeField.bind(this); this.onChangeField = this.onChangeField.bind(this);
this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this); this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this);
this.onToggleGPUManagement = this.onToggleGPUManagement.bind(this);
this.onGpusChange = this.onGpusChange.bind(this);
this.onChangeEnableHostManagementFeatures = this.onChangeField('enableHostManagementFeatures'); this.onChangeEnableHostManagementFeatures = this.onChangeField('enableHostManagementFeatures');
this.onChangeAllowVolumeBrowserForRegularUsers = this.onChangeField('allowVolumeBrowserForRegularUsers'); this.onChangeAllowVolumeBrowserForRegularUsers = this.onChangeField('allowVolumeBrowserForRegularUsers');
this.onChangeDisableBindMountsForRegularUsers = this.onChangeField('disableBindMountsForRegularUsers'); this.onChangeDisableBindMountsForRegularUsers = this.onChangeField('disableBindMountsForRegularUsers');
@ -52,6 +57,12 @@ export default class DockerFeaturesConfigurationController {
}); });
} }
onToggleGPUManagement(checked) {
this.$scope.$evalAsync(() => {
this.state.enableGPUManagement = checked;
});
}
onChange(values) { onChange(values) {
return this.$scope.$evalAsync(() => { return this.$scope.$evalAsync(() => {
this.formValues = { this.formValues = {
@ -69,6 +80,12 @@ export default class DockerFeaturesConfigurationController {
}; };
} }
onGpusChange(value) {
return this.$async(async () => {
this.endpoint.Gpus = value;
});
}
isContainerEditDisabled() { isContainerEditDisabled() {
const { const {
disableBindMountsForRegularUsers, disableBindMountsForRegularUsers,
@ -92,7 +109,11 @@ export default class DockerFeaturesConfigurationController {
return this.$async(async () => { return this.$async(async () => {
try { try {
this.state.actionInProgress = true; this.state.actionInProgress = true;
const securitySettings = {
const validGpus = this.endpoint.Gpus.filter((gpu) => gpu.name && gpu.value);
const gpus = this.state.enableGPUManagement ? validGpus : [];
const settings = {
enableHostManagementFeatures: this.formValues.enableHostManagementFeatures, enableHostManagementFeatures: this.formValues.enableHostManagementFeatures,
allowBindMountsForRegularUsers: !this.formValues.disableBindMountsForRegularUsers, allowBindMountsForRegularUsers: !this.formValues.disableBindMountsForRegularUsers,
allowPrivilegedModeForRegularUsers: !this.formValues.disablePrivilegedModeForRegularUsers, allowPrivilegedModeForRegularUsers: !this.formValues.disablePrivilegedModeForRegularUsers,
@ -102,33 +123,53 @@ export default class DockerFeaturesConfigurationController {
allowStackManagementForRegularUsers: !this.formValues.disableStackManagementForRegularUsers, allowStackManagementForRegularUsers: !this.formValues.disableStackManagementForRegularUsers,
allowContainerCapabilitiesForRegularUsers: !this.formValues.disableContainerCapabilitiesForRegularUsers, allowContainerCapabilitiesForRegularUsers: !this.formValues.disableContainerCapabilitiesForRegularUsers,
allowSysctlSettingForRegularUsers: !this.formValues.disableSysctlSettingForRegularUsers, allowSysctlSettingForRegularUsers: !this.formValues.disableSysctlSettingForRegularUsers,
enableGPUManagement: this.state.enableGPUManagement,
gpus,
}; };
await this.EndpointService.updateSecuritySettings(this.endpoint.Id, securitySettings); const publicSettings = await this.SettingsService.publicSettings();
const analyticsAllowed = publicSettings.EnableTelemetry;
if (analyticsAllowed) {
// send analytics if GPU management is changed (with the new state)
if (this.initialEnableGPUManagement !== this.state.enableGPUManagement) {
this.$analytics.eventTrack('enable-gpu-management-updated', { category: 'portainer', metadata: { enableGPUManagementState: this.state.enableGPUManagement } });
}
// send analytics if the number of GPUs is changed (with a list of the names)
if (gpus.length > this.initialGPUs.length) {
const numberOfGPUSAdded = this.endpoint.Gpus.length - this.initialGPUs.length;
this.$analytics.eventTrack('gpus-added', { category: 'portainer', metadata: { gpus: gpus.map((gpu) => gpu.name), numberOfGPUSAdded } });
}
if (gpus.length < this.initialGPUs.length) {
const numberOfGPUSRemoved = this.initialGPUs.length - this.endpoint.Gpus.length;
this.$analytics.eventTrack('gpus-removed', { category: 'portainer', metadata: { gpus: gpus.map((gpu) => gpu.name), numberOfGPUSRemoved } });
}
this.initialGPUs = gpus;
this.initialEnableGPUManagement = this.state.enableGPUManagement;
}
this.endpoint.SecuritySettings = securitySettings; await this.EndpointService.updateSecuritySettings(this.endpoint.Id, settings);
this.endpoint.SecuritySettings = settings;
this.Notifications.success('Success', 'Saved settings successfully'); this.Notifications.success('Success', 'Saved settings successfully');
} catch (e) { } catch (e) {
this.Notifications.error('Failure', e, 'Failed saving settings'); this.Notifications.error('Failure', e, 'Failed saving settings');
} }
this.state.actionInProgress = false; this.state.actionInProgress = false;
this.$state.reload();
}); });
} }
checkAgent() {
const applicationState = this.StateManager.getState();
return applicationState.endpoint.mode.agentProxy;
}
$onInit() { $onInit() {
const securitySettings = this.endpoint.SecuritySettings; const securitySettings = this.endpoint.SecuritySettings;
const isAgent = this.checkAgent(); const applicationState = this.StateManager.getState();
this.isAgent = isAgent; this.isAgent = applicationState.endpoint.mode.agentProxy;
this.isDockerStandaloneEnv = applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE';
this.formValues = { this.formValues = {
enableHostManagementFeatures: isAgent && securitySettings.enableHostManagementFeatures, enableHostManagementFeatures: this.isAgent && securitySettings.enableHostManagementFeatures,
allowVolumeBrowserForRegularUsers: isAgent && securitySettings.allowVolumeBrowserForRegularUsers, allowVolumeBrowserForRegularUsers: this.isAgent && securitySettings.allowVolumeBrowserForRegularUsers,
disableBindMountsForRegularUsers: !securitySettings.allowBindMountsForRegularUsers, disableBindMountsForRegularUsers: !securitySettings.allowBindMountsForRegularUsers,
disablePrivilegedModeForRegularUsers: !securitySettings.allowPrivilegedModeForRegularUsers, disablePrivilegedModeForRegularUsers: !securitySettings.allowPrivilegedModeForRegularUsers,
disableHostNamespaceForRegularUsers: !securitySettings.allowHostNamespaceForRegularUsers, disableHostNamespaceForRegularUsers: !securitySettings.allowHostNamespaceForRegularUsers,
@ -137,5 +178,11 @@ export default class DockerFeaturesConfigurationController {
disableContainerCapabilitiesForRegularUsers: !securitySettings.allowContainerCapabilitiesForRegularUsers, disableContainerCapabilitiesForRegularUsers: !securitySettings.allowContainerCapabilitiesForRegularUsers,
disableSysctlSettingForRegularUsers: !securitySettings.allowSysctlSettingForRegularUsers, disableSysctlSettingForRegularUsers: !securitySettings.allowSysctlSettingForRegularUsers,
}; };
// this.endpoint.Gpus could be null as it is Gpus: []Pair in the API
this.endpoint.Gpus = this.endpoint.Gpus || [];
this.state.enableGPUManagement = this.isDockerStandaloneEnv && (this.endpoint.EnableGPUManagement || this.endpoint.Gpus.length > 0);
this.initialGPUs = this.endpoint.Gpus;
this.initialEnableGPUManagement = this.endpoint.EnableGPUManagement;
} }
} }

View File

@ -150,9 +150,28 @@
<!-- other --> <!-- other -->
<div class="col-sm-12 form-section-title"> Other </div> <div class="col-sm-12 form-section-title"> Other </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12 pb-3">
<gpus-insights></gpus-insights>
</div>
<div class="col-sm-12"> <div class="col-sm-12">
<por-switch-field <por-switch-field
label="'Show a notification to indicate out-of-date images for Docker environments'" label="'Show GPU in the UI'"
tooltip="'This allows managing of GPUs for container/stack hardware acceleration via the Portainer UI.'"
checked="$ctrl.state.enableGPUManagement"
name="'enableGPUManagement'"
on-change="($ctrl.onToggleGPUManagement)"
label-class="'col-sm-7 col-lg-4'"
disabled="!$ctrl.isDockerStandaloneEnv"
></por-switch-field>
</div>
<div class="col-sm-12">
<div class="pl-4">
<gpus-list ng-if="$ctrl.state.enableGPUManagement && $ctrl.endpoint" value="$ctrl.endpoint.Gpus" on-change="($ctrl.onGpusChange)"></gpus-list>
</div>
</div>
<div class="col-sm-12">
<por-switch-field
label="'Show an image(s) up to date indicator for Stacks, Services and Containers'"
checked="false" checked="false"
name="'outOfDateImageToggle'" name="'outOfDateImageToggle'"
label-class="'col-sm-7 col-lg-4'" label-class="'col-sm-7 col-lg-4'"
@ -166,7 +185,13 @@
<div class="col-sm-12 form-section-title"> Actions </div> <div class="col-sm-12 form-section-title"> Actions </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-click="$ctrl.save()" ng-disabled="$ctrl.state.actionInProgress" button-spinner="$ctrl.state.actionInProgress"> <button
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-click="$ctrl.save()"
ng-disabled="$ctrl.state.actionInProgress"
button-spinner="$ctrl.state.actionInProgress"
>
<span ng-hide="$ctrl.state.actionInProgress">Save configuration</span> <span ng-hide="$ctrl.state.actionInProgress">Save configuration</span>
<span ng-show="$ctrl.state.actionInProgress">Saving...</span> <span ng-show="$ctrl.state.actionInProgress">Saving...</span>
</button> </button>

View File

@ -167,6 +167,6 @@ function confirmImageForceRemoval() {
title: 'Are you sure?', title: 'Are you sure?',
modalType: ModalType.Destructive, modalType: ModalType.Destructive,
message: 'Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.', message: 'Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.',
confirmButton: buildConfirmButton('Remote the image', 'danger'), confirmButton: buildConfirmButton('Remove the image', 'danger'),
}); });
} }

View File

@ -1,19 +1,5 @@
<form class="form-horizontal"> <form class="form-horizontal">
<div class="col-sm-12 form-section-title"> Edge Groups </div> <edge-groups-selector value="$ctrl.model.EdgeGroups" items="$ctrl.edgeGroups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
<div class="form-group">
<div class="col-sm-12">
<edge-groups-selector value="$ctrl.model.EdgeGroups" items="$ctrl.edgeGroups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
</div>
<p class="col-sm-12 vertical-center help-block small text-warning" ng-if="$ctrl.model.DeploymentType === undefined">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> There are no available deployment types when there is more than one type of environment in your edge group
selection (e.g. Kubernetes and Docker environments). Please select edge groups that have environments of the same type.
</p>
<p class="col-sm-12 vertical-center help-block small text-warning" ng-if="$ctrl.model.DeploymentType === $ctrl.EditorType.Compose && $ctrl.hasKubeEndpoint()">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Edge groups with kubernetes environments no longer support compose deployment types in Portainer. Please select
edge groups that only have docker environments when using compose deployment types.
</p>
</div>
<edge-stack-deployment-type-selector <edge-stack-deployment-type-selector
allow-kube-to-select-compose="$ctrl.allowKubeToSelectCompose" allow-kube-to-select-compose="$ctrl.allowKubeToSelectCompose"

View File

@ -1,4 +1,4 @@
<form class="form-horizontal" name="EdgeGroupForm" ng-submit="$ctrl.formAction()"> <form class="form-horizontal" name="EdgeGroupForm" ng-submit="$ctrl.handleSubmit()">
<div class="form-group"> <div class="form-group">
<label for="group_name" class="col-sm-3 col-lg-2 control-label required text-left"> Name </label> <label for="group_name" class="col-sm-3 col-lg-2 control-label required text-left"> Name </label>
<div class="col-sm-9 col-lg-10"> <div class="col-sm-9 col-lg-10">

View File

@ -37,6 +37,7 @@ export class EdgeGroupFormController {
this.onChangeDynamic = this.onChangeDynamic.bind(this); this.onChangeDynamic = this.onChangeDynamic.bind(this);
this.onChangeModel = this.onChangeModel.bind(this); this.onChangeModel = this.onChangeModel.bind(this);
this.onChangePartialMatch = this.onChangePartialMatch.bind(this); this.onChangePartialMatch = this.onChangePartialMatch.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
$scope.$watch( $scope.$watch(
() => this.model, () => this.model,
@ -118,6 +119,10 @@ export class EdgeGroupFormController {
}); });
} }
handleSubmit() {
this.formAction(this.model);
}
$onInit() { $onInit() {
this.getTags(); this.getTags();
} }

View File

@ -7,12 +7,19 @@ import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInInt
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm'; import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm'; import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector'; import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
import { withUIRouter } from '@/react-tools/withUIRouter';
export const componentsModule = angular export const componentsModule = angular
.module('portainer.edge.react.components', []) .module('portainer.edge.react.components', [])
.component( .component(
'edgeGroupsSelector', 'edgeGroupsSelector',
r2a(EdgeGroupsSelector, ['items', 'onChange', 'value']) r2a(withUIRouter(withReactQuery(EdgeGroupsSelector)), [
'onChange',
'value',
'error',
'horizontal',
'isGroupVisible',
])
) )
.component( .component(
'edgeScriptForm', 'edgeScriptForm',
@ -21,6 +28,7 @@ export const componentsModule = angular
'commands', 'commands',
'isNomadTokenVisible', 'isNomadTokenVisible',
'asyncMode', 'asyncMode',
'showMetaFields',
]) ])
) )
.component( .component(

View File

@ -21,7 +21,6 @@ export class CreateEdgeGroupController {
}; };
this.createGroup = this.createGroup.bind(this); this.createGroup = this.createGroup.bind(this);
this.createGroupAsync = this.createGroupAsync.bind(this);
} }
async $onInit() { async $onInit() {
@ -31,20 +30,18 @@ export class CreateEdgeGroupController {
this.state.loaded = true; this.state.loaded = true;
} }
createGroup() { async createGroup(model) {
return this.$async(this.createGroupAsync); return this.$async(async () => {
} this.state.actionInProgress = true;
try {
async createGroupAsync() { await this.EdgeGroupService.create(model);
this.state.actionInProgress = true; this.Notifications.success('Success', 'Edge group successfully created');
try { this.$state.go('edge.groups');
await this.EdgeGroupService.create(this.model); } catch (err) {
this.Notifications.success('Success', 'Edge group successfully created'); this.Notifications.error('Failure', err, 'Unable to create edge group');
this.$state.go('edge.groups'); } finally {
} catch (err) { this.state.actionInProgress = false;
this.Notifications.error('Failure', err, 'Unable to create edge group'); }
} finally { });
this.state.actionInProgress = false;
}
} }
} }

View File

@ -13,7 +13,6 @@ export class EditEdgeGroupController {
}; };
this.updateGroup = this.updateGroup.bind(this); this.updateGroup = this.updateGroup.bind(this);
this.updateGroupAsync = this.updateGroupAsync.bind(this);
} }
async $onInit() { async $onInit() {
@ -28,20 +27,18 @@ export class EditEdgeGroupController {
this.state.loaded = true; this.state.loaded = true;
} }
updateGroup() { updateGroup(group) {
return this.$async(this.updateGroupAsync); return this.$async(async () => {
} this.state.actionInProgress = true;
try {
async updateGroupAsync() { await this.EdgeGroupService.update(group);
this.state.actionInProgress = true; this.Notifications.success('Success', 'Edge group successfully updated');
try { this.$state.go('edge.groups');
await this.EdgeGroupService.update(this.model); } catch (err) {
this.Notifications.success('Success', 'Edge group successfully updated'); this.Notifications.error('Failure', err, 'Unable to update edge group');
this.$state.go('edge.groups'); } finally {
} catch (err) { this.state.actionInProgress = false;
this.Notifications.error('Failure', err, 'Unable to update edge group'); }
} finally { });
this.state.actionInProgress = false;
}
} }
} }

View File

@ -86,7 +86,6 @@ export default class CreateEdgeStackViewController {
async $onInit() { async $onInit() {
try { try {
this.edgeGroups = await this.EdgeGroupService.groups(); this.edgeGroups = await this.EdgeGroupService.groups();
this.noGroups = this.edgeGroups.length === 0;
} catch (err) { } catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups'); this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
} }

View File

@ -39,19 +39,7 @@
</div> </div>
<!-- !name-input --> <!-- !name-input -->
<div class="col-sm-12 form-section-title"> Edge Groups </div> <edge-groups-selector value="$ctrl.formValues.Groups" on-change="($ctrl.onChangeGroups)" items="$ctrl.edgeGroups"></edge-groups-selector>
<div class="form-group" ng-if="$ctrl.edgeGroups">
<div class="col-sm-12">
<edge-groups-selector ng-if="!$ctrl.noGroups" value="$ctrl.formValues.Groups" on-change="($ctrl.onChangeGroups)" items="$ctrl.edgeGroups"></edge-groups-selector>
</div>
<div ng-if="$ctrl.noGroups" class="col-sm-12 small text-muted">
No Edge groups are available. Head over to the <a ui-sref="edge.groups">Edge groups view</a> to create one.
</div>
<p class="col-sm-12 vertical-center help-block small text-warning" ng-if="$ctrl.formValues.DeploymentType === undefined">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> There are no available deployment types when there is more than one type of environment in your edge
group selection (e.g. Kubernetes and Docker environments). Please select edge groups that have environments of the same type.
</p>
</div>
<edge-stack-deployment-type-selector <edge-stack-deployment-type-selector
value="$ctrl.formValues.DeploymentType" value="$ctrl.formValues.DeploymentType"

View File

@ -1,3 +1,8 @@
import { EnvironmentStatus } from '@/react/portainer/environments/types';
import { getSelfSubjectAccessReview } from '@/react/kubernetes/namespaces/service';
import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models';
import registriesModule from './registries'; import registriesModule from './registries';
import customTemplateModule from './custom-templates'; import customTemplateModule from './custom-templates';
import { reactModule } from './react'; import { reactModule } from './react';
@ -16,31 +21,49 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
onEnter: /* @ngInject */ function onEnter($async, $state, endpoint, KubernetesHealthService, KubernetesNamespaceService, Notifications, StateManager) { onEnter: /* @ngInject */ function onEnter($async, $state, endpoint, KubernetesHealthService, KubernetesNamespaceService, Notifications, StateManager) {
return $async(async () => { return $async(async () => {
if (![5, 6, 7].includes(endpoint.Type)) { const kubeTypes = [
PortainerEndpointTypes.KubernetesLocalEnvironment,
PortainerEndpointTypes.AgentOnKubernetesEnvironment,
PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment,
];
if (!kubeTypes.includes(endpoint.Type)) {
$state.go('portainer.home'); $state.go('portainer.home');
return; return;
} }
try { try {
if (endpoint.Type === 7) { if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
//edge //edge
try { try {
await KubernetesHealthService.ping(endpoint.Id); await KubernetesHealthService.ping(endpoint.Id);
endpoint.Status = 1; endpoint.Status = EnvironmentStatus.Up;
} catch (e) { } catch (e) {
endpoint.Status = 2; endpoint.Status = EnvironmentStatus.Down;
} }
} }
await StateManager.updateEndpointState(endpoint); await StateManager.updateEndpointState(endpoint);
if (endpoint.Type === 7 && endpoint.Status === 2) { if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment && endpoint.Status === EnvironmentStatus.Down) {
throw new Error('Unable to contact Edge agent, please ensure that the agent is properly running on the remote environment.'); throw new Error('Unable to contact Edge agent, please ensure that the agent is properly running on the remote environment.');
} }
await KubernetesNamespaceService.get(); // use selfsubject access review to check if we can connect to the kubernetes environment
// because it's gets a fast response, and is accessible to all users
try {
await getSelfSubjectAccessReview(endpoint.Id, 'default');
} catch (e) {
throw new Error('Environment is unreachable.');
}
} catch (e) { } catch (e) {
Notifications.error('Failed loading environment', e); let params = {};
$state.go('portainer.home', {}, { reload: true });
if (endpoint.Type == PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
params = { redirect: true, environmentId: endpoint.Id, environmentName: endpoint.Name, route: 'kubernetes.dashboard' };
} else {
Notifications.error('Failed loading environment', e);
}
$state.go('portainer.home', params, { reload: true, inherit: false });
} }
}); });
}, },

View File

@ -1,116 +1,144 @@
<div class="datatable"> <div class="datatable">
<!-- toolbar header actions and settings --> <!-- toolbar header actions and settings -->
<div ng-if="$ctrl.isPrimary" class="toolBar !flex-col gap-1"> <div ng-if="$ctrl.isPrimary" class="toolBar !flex-col !gap-0">
<div class="toolBar vertical-center w-full flex-wrap !gap-x-5 !gap-y-1 !p-0"> <div class="toolBar w-full !items-start !gap-x-5 !p-0">
<div class="toolBarTitle vertical-center"> <div class="toolBarTitle vertical-center">
<div class="widget-icon space-right"> <div class="widget-icon space-right">
<pr-icon icon="'box'"></pr-icon> <pr-icon icon="'box'"></pr-icon>
</div> </div>
Applications Applications
</div> </div>
<div class="searchBar vertical-center !mr-0 min-w-[280px]"> <!-- use row reverse to make the left most items wrap first to the right side in the next line -->
<pr-icon icon="'search'" class-name="'searchIcon'"></pr-icon> <div class="inline-flex flex-row-reverse flex-wrap !gap-x-5 gap-y-3">
<input <div class="settings" data-cy="k8sApp-tableSettings">
type="text" <span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
class="searchInput" <span uib-dropdown-toggle aria-label="Settings">
ng-model="$ctrl.state.textFilter" <pr-icon icon="'more-vertical'" class-name="'!mr-0 !h-4'"></pr-icon>
ng-change="$ctrl.onTextFilterChange()" </span>
placeholder="Search for an application..." <div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
auto-focus <div class="tableMenu">
ng-model-options="{ debounce: 300 }" <div class="menuHeader"> Table settings </div>
data-cy="k8sApp-searchApplicationsInput" <div class="menuContent">
/> <div>
</div> <div class="md-checkbox" ng-if="$ctrl.isAdmin">
<div class="actionBar !mr-0 !gap-3"> <input id="applications_setting_show_system" type="checkbox" ng-model="$ctrl.settings.showSystem" ng-change="$ctrl.onSettingsShowSystemChange()" />
<button <label for="applications_setting_show_system">Show system resources</label>
ng-if="$ctrl.isPrimary" </div>
type="button" <div class="md-checkbox">
class="btn btn-sm btn-dangerlight vertical-center !ml-0 h-fit" <input
ng-disabled="$ctrl.state.selectedItemCount === 0" id="setting_auto_refresh"
ng-click="$ctrl.removeAction($ctrl.state.selectedItems)" type="checkbox"
data-cy="k8sApp-removeAppButton" ng-model="$ctrl.settings.repeater.autoRefresh"
> ng-change="$ctrl.onSettingsRepeaterChange()"
<pr-icon icon="'trash-2'"></pr-icon> data-cy="k8sApp-autoRefreshCheckbox"
Remove />
</button> <label for="setting_auto_refresh">Auto refresh</label>
<button </div>
ng-if="$ctrl.isPrimary" <div ng-if="$ctrl.settings.repeater.autoRefresh">
type="button" <label for="settings_refresh_rate"> Refresh rate </label>
class="btn btn-sm btn-secondary vertical-center !ml-0 h-fit" <select
ui-sref="kubernetes.applications.new" id="settings_refresh_rate"
data-cy="k8sApp-addApplicationButton" ng-model="$ctrl.settings.repeater.refreshRate"
> ng-change="$ctrl.onSettingsRepeaterChange()"
<pr-icon icon="'plus'" class-name="'!h-3'"></pr-icon>Add with form class="small-select"
</button> data-cy="k8sApp-refreshRateDropdown"
<button >
ng-if="$ctrl.isPrimary" <option value="10">10s</option>
type="button" <option value="30">30s</option>
class="btn btn-sm btn-primary vertical-center !ml-0 h-fit" <option value="60">1min</option>
ui-sref="kubernetes.deploy({ referrer: 'kubernetes.applications' })" <option value="120">2min</option>
data-cy="k8sApp-deployFromManifestButton" <option value="300">5min</option>
> </select>
<pr-icon icon="'plus'" class-name="'!h-3'"></pr-icon>Create from manifest <span>
</button> <pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</div> </span>
<div class="settings" data-cy="k8sApp-tableSettings"> </div>
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
<span uib-dropdown-toggle aria-label="Settings">
<pr-icon icon="'more-vertical'" class-name="'!mr-0 !h-4'"></pr-icon>
</span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader"> Table settings </div>
<div class="menuContent">
<div>
<div class="md-checkbox" ng-if="$ctrl.isAdmin">
<input id="applications_setting_show_system" type="checkbox" ng-model="$ctrl.settings.showSystem" ng-change="$ctrl.onSettingsShowSystemChange()" />
<label for="applications_setting_show_system">Show system resources</label>
</div>
<div class="md-checkbox">
<input
id="setting_auto_refresh"
type="checkbox"
ng-model="$ctrl.settings.repeater.autoRefresh"
ng-change="$ctrl.onSettingsRepeaterChange()"
data-cy="k8sApp-autoRefreshCheckbox"
/>
<label for="setting_auto_refresh">Auto refresh</label>
</div>
<div ng-if="$ctrl.settings.repeater.autoRefresh">
<label for="settings_refresh_rate"> Refresh rate </label>
<select
id="settings_refresh_rate"
ng-model="$ctrl.settings.repeater.refreshRate"
ng-change="$ctrl.onSettingsRepeaterChange()"
class="small-select"
data-cy="k8sApp-refreshRateDropdown"
>
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">1min</option>
<option value="120">2min</option>
<option value="300">5min</option>
</select>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span>
</div> </div>
</div> </div>
</div> <div>
<div> <a type="button" class="btn btn-sm btn-default btn-sm" ng-click="$ctrl.settings.open = false;" data-cy="k8sApp-tableSettingsCloseButton">Close</a>
<a type="button" class="btn btn-sm btn-default btn-sm" ng-click="$ctrl.settings.open = false;" data-cy="k8sApp-tableSettingsCloseButton">Close</a> </div>
</div> </div>
</div> </div>
</span>
</div>
<div class="actionBar !mr-0 !gap-3">
<button
ng-if="$ctrl.isPrimary"
type="button"
class="btn btn-sm btn-dangerlight vertical-center !ml-0 h-fit"
ng-disabled="$ctrl.state.selectedItemCount === 0"
ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"
data-cy="k8sApp-removeAppButton"
>
<pr-icon icon="'trash-2'"></pr-icon>
Remove
</button>
<button
ng-if="$ctrl.isPrimary"
hide-deployment-option="form"
type="button"
class="btn btn-sm btn-secondary vertical-center !ml-0 h-fit"
ui-sref="kubernetes.applications.new"
data-cy="k8sApp-addApplicationButton"
>
<pr-icon icon="'plus'" class-name="'!h-3'"></pr-icon>Add with form
</button>
<button
ng-if="$ctrl.isPrimary"
type="button"
class="btn btn-sm btn-primary vertical-center !ml-0 h-fit"
ui-sref="kubernetes.deploy({ referrer: 'kubernetes.applications' })"
data-cy="k8sApp-deployFromManifestButton"
>
<pr-icon icon="'plus'" class-name="'!h-3'"></pr-icon>Create from manifest
</button>
</div>
<div class="searchBar">
<pr-icon icon="'search'" class-name="'searchIcon'"></pr-icon>
<input
type="text"
class="searchInput"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
placeholder="Search..."
auto-focus
ng-model-options="{ debounce: 300 }"
data-cy="k8sApp-searchApplicationsInput"
/>
</div>
<div class="form-group namespaces !mb-0 !mr-0 !h-[30px] min-w-[140px]">
<div class="input-group">
<span class="input-group-addon">
<pr-icon icon="'filter'" size="'sm'"></pr-icon>
Namespace
</span>
<select
class="form-control !h-[30px] !py-1"
ng-model="$ctrl.state.namespace"
ng-change="$ctrl.onChangeNamespace()"
data-cy="component-namespaceSelect"
ng-options="o.Value as (o.Name + (o.IsSystem ? ' - system' : '')) for o in $ctrl.state.namespaces"
>
</select>
</div> </div>
</span> </div>
</div> </div>
</div> </div>
<div class="flex w-full flex-row" ng-if="$ctrl.isAdmin && !$ctrl.settings.showSystem"> <div class="flex w-full flex-row" ng-if="$ctrl.isAdmin && !$ctrl.settings.showSystem">
<span class="small text-muted vertical-center mt-1"> <span class="small text-muted vertical-center mt-1">
<pr-icon icon="'info'" mode="'primary'" class="vertical-center"></pr-icon> <pr-icon icon="'info'" mode="'primary'"></pr-icon>
System resources are hidden, this can be changed in the table settings. System resources are hidden, this can be changed in the table settings.
</span> </span>
</div> </div>
<div class="w-full">
<div class="w-fit">
<insights-box class-name="'mt-2'" type="'slim'" header="'From 2.18 on, you can filter this view by namespace.'" insight-close-id="'k8s-namespace-filtering'"></insights-box>
</div>
</div>
</div> </div>
<!-- data table content --> <!-- data table content -->
<div ng-class="{ 'table-responsive': $ctrl.isPrimary, 'inner-datatable': !$ctrl.isPrimary }"> <div ng-class="{ 'table-responsive': $ctrl.isPrimary, 'inner-datatable': !$ctrl.isPrimary }">

View File

@ -15,5 +15,10 @@ angular.module('portainer.kubernetes').component('kubernetesApplicationsDatatabl
refreshCallback: '<', refreshCallback: '<',
onPublishingModeClick: '<', onPublishingModeClick: '<',
isPrimary: '<', isPrimary: '<',
namespaces: '<',
namespace: '<',
onChangeNamespaceDropdown: '<',
isSystemResources: '<',
setSystemResources: '<',
}, },
}); });

View File

@ -21,6 +21,8 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
this.state = Object.assign(this.state, { this.state = Object.assign(this.state, {
expandAll: false, expandAll: false,
expandedItems: [], expandedItems: [],
namespace: '',
namespaces: [],
}); });
this.filters = { this.filters = {
@ -70,6 +72,8 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
}; };
this.onSettingsShowSystemChange = function () { this.onSettingsShowSystemChange = function () {
this.updateNamespace();
this.setSystemResources(this.settings.showSystem);
DatatableService.setDataTableSettings(this.tableKey, this.settings); DatatableService.setDataTableSettings(this.tableKey, this.settings);
}; };
@ -135,6 +139,45 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
this.filters.state.values = _.uniqBy(availableTypeFilters, 'type'); this.filters.state.values = _.uniqBy(availableTypeFilters, 'type');
}; };
this.onChangeNamespace = function () {
this.onChangeNamespaceDropdown(this.state.namespace);
};
this.updateNamespace = function () {
if (this.namespaces && this.settingsLoaded) {
const allNamespacesOption = { Name: 'All namespaces', Value: '', IsSystem: false };
const visibleNamespaceOptions = this.namespaces
.filter((ns) => {
if (!this.settings.showSystem && ns.IsSystem) {
return false;
}
return true;
})
.map((ns) => ({ Name: ns.Name, Value: ns.Name, IsSystem: ns.IsSystem }));
this.state.namespaces = [allNamespacesOption, ...visibleNamespaceOptions];
if (this.state.namespace && !this.state.namespaces.find((ns) => ns.Name === this.state.namespace)) {
if (this.state.namespaces.length > 1) {
let defaultNS = this.state.namespaces.find((ns) => ns.Name === 'default');
defaultNS = defaultNS || this.state.namespaces[1];
this.state.namespace = defaultNS.Value;
} else {
this.state.namespace = this.state.namespaces[0].Value;
}
}
}
};
this.$onChanges = function () {
if (typeof this.isSystemResources !== 'undefined') {
this.settings.showSystem = this.isSystemResources;
DatatableService.setDataTableSettings(this.settingsKey, this.settings);
}
this.state.namespace = this.namespace;
this.updateNamespace();
this.prepareTableFromDataset();
};
this.$onInit = function () { this.$onInit = function () {
this.isAdmin = Authentication.isAdmin(); this.isAdmin = Authentication.isAdmin();
this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes; this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
@ -172,7 +215,16 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
if (storedSettings !== null) { if (storedSettings !== null) {
this.settings = storedSettings; this.settings = storedSettings;
this.settings.open = false; this.settings.open = false;
this.setSystemResources && this.setSystemResources(this.settings.showSystem);
} }
this.settingsLoaded = true;
// Set the default selected namespace
if (!this.state.namespace) {
this.state.namespace = this.namespace;
}
this.updateNamespace();
this.onSettingsRepeaterChange(); this.onSettingsRepeaterChange();
}; };
}, },

View File

@ -1,99 +1,122 @@
<div class="datatable"> <div class="datatable">
<!-- table title and action menu --> <!-- table title and action menu -->
<div class="toolBar !flex-col gap-1"> <div class="toolBar !flex-col !gap-0">
<div class="toolBar vertical-center w-full flex-wrap !gap-x-5 !gap-y-1 !p-0"> <div class="toolBar w-full !items-start !gap-x-5 !p-0">
<!-- title -->
<div class="toolBarTitle vertical-center"> <div class="toolBarTitle vertical-center">
<div class="widget-icon space-right"> <div class="widget-icon space-right">
<pr-icon icon="'list'"></pr-icon> <pr-icon icon="'list'"></pr-icon>
</div> </div>
Stacks Stacks
</div> </div>
<!-- actions --> <!-- use row reverse to make the left most items wrap first to the right side in the next line -->
<div class="searchBar vertical-center"> <div class="inline-flex flex-row-reverse flex-wrap !gap-x-5 gap-y-3">
<pr-icon icon="'search'" class-name="'!h-3'"></pr-icon> <div class="actionBar !mr-0 !gap-3">
<input <button
type="text" type="button"
class="searchInput min-w-min self-start" class="btn btn-sm btn-dangerlight vertical-center !ml-0 h-fit"
ng-model="$ctrl.state.textFilter" ng-disabled="$ctrl.state.selectedItemCount === 0"
ng-change="$ctrl.onTextFilterChange()" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"
placeholder="Search for a stack..." data-cy="k8sApp-removeStackButton"
auto-focus >
ng-model-options="{ debounce: 300 }" <pr-icon icon="'trash-2'"></pr-icon>
/> Remove
</div> </button>
<div class="actionBar !mr-0 !gap-3"> <div class="settings" data-cy="k8sApp-StackTableSettings">
<button <span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
type="button" <span uib-dropdown-toggle>
class="btn btn-sm btn-dangerlight vertical-center !ml-0 h-fit" <pr-icon icon="'more-vertical'" class-name="'!mr-0 !h-4'"></pr-icon>
ng-disabled="$ctrl.state.selectedItemCount === 0" </span>
ng-click="$ctrl.removeAction($ctrl.state.selectedItems)" <div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
data-cy="k8sApp-removeStackButton" <div class="tableMenu">
> <div class="menuHeader"> Table settings </div>
<pr-icon icon="'trash-2'"></pr-icon> <div class="menuContent">
Remove <div>
</button> <div class="md-checkbox" ng-if="$ctrl.isAdmin">
<div class="settings" data-cy="k8sApp-StackTableSettings"> <input id="applications_setting_show_system" type="checkbox" ng-model="$ctrl.settings.showSystem" ng-change="$ctrl.onSettingsShowSystemChange()" />
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open"> <label for="applications_setting_show_system">Show system resources</label>
<span uib-dropdown-toggle> </div>
<pr-icon icon="'more-vertical'" class-name="'!mr-0 !h-4'"></pr-icon> <div class="md-checkbox">
</span> <input
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu> id="setting_auto_refresh"
<div class="tableMenu"> type="checkbox"
<div class="menuHeader"> Table settings </div> ng-model="$ctrl.settings.repeater.autoRefresh"
<div class="menuContent"> ng-change="$ctrl.onSettingsRepeaterChange()"
<div> data-cy="k8sApp-autoRefreshCheckbox-stack"
<div class="md-checkbox" ng-if="$ctrl.isAdmin"> />
<input id="applications_setting_show_system" type="checkbox" ng-model="$ctrl.settings.showSystem" ng-change="$ctrl.onSettingsShowSystemChange()" /> <label for="setting_auto_refresh">Auto refresh</label>
<label for="applications_setting_show_system">Show system resources</label> </div>
</div> <div ng-if="$ctrl.settings.repeater.autoRefresh">
<div class="md-checkbox"> <label for="settings_refresh_rate"> Refresh rate </label>
<input <select
id="setting_auto_refresh" id="settings_refresh_rate"
type="checkbox" ng-model="$ctrl.settings.repeater.refreshRate"
ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()"
ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select"
data-cy="k8sApp-autoRefreshCheckbox-stack" data-cy="k8sApp-refreshRateDropdown-stack"
/> >
<label for="setting_auto_refresh">Auto refresh</label> <option value="10">10s</option>
</div> <option value="30">30s</option>
<div ng-if="$ctrl.settings.repeater.autoRefresh"> <option value="60">1min</option>
<label for="settings_refresh_rate"> Refresh rate </label> <option value="120">2min</option>
<select <option value="300">5min</option>
id="settings_refresh_rate" </select>
ng-model="$ctrl.settings.repeater.refreshRate" <span>
ng-change="$ctrl.onSettingsRepeaterChange()" <pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
class="small-select" </span>
data-cy="k8sApp-refreshRateDropdown-stack" </div>
>
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">1min</option>
<option value="120">2min</option>
<option value="300">5min</option>
</select>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span>
</div> </div>
</div> </div>
</div> <div>
<div> <a type="button" class="btn btn-sm btn-default btn-sm" ng-click="$ctrl.settings.open = false;" data-cy="k8sApp-tableSettingsCloseButton-stack">Close</a>
<a type="button" class="btn btn-sm btn-default btn-sm" ng-click="$ctrl.settings.open = false;" data-cy="k8sApp-tableSettingsCloseButton-stack">Close</a> </div>
</div> </div>
</div> </div>
</div> </span>
</span> </div>
</div>
<div class="searchBar vertical-center">
<pr-icon icon="'search'" class-name="'!h-3'"></pr-icon>
<input
type="text"
class="searchInput min-w-min self-start"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
placeholder="Search..."
auto-focus
ng-model-options="{ debounce: 300 }"
/>
</div>
<div class="form-group namespaces !mb-0 !mr-0 !h-[30px] w-fit min-w-[140px]">
<div class="input-group">
<span class="input-group-addon">
<pr-icon icon="'filter'" size="'sm'"></pr-icon>
Namespace
</span>
<select
class="form-control !h-[30px] !py-1"
ng-model="$ctrl.state.namespace"
ng-change="$ctrl.onChangeNamespace()"
data-cy="component-namespaceSelect"
ng-options="o.Value as (o.Name + (o.IsSystem ? ' - system' : '')) for o in $ctrl.state.namespaces"
>
</select>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- info text --> <!-- info text -->
<div class="flex w-full flex-row"> <div class="flex w-full flex-row" ng-if="$ctrl.isAdmin && !$ctrl.settings.showSystem">
<span class="small text-muted vertical-center mt-1" ng-if="$ctrl.isAdmin && !$ctrl.settings.showSystem"> <span class="small text-muted vertical-center mt-1">
<pr-icon icon="'info'" mode="'primary'"></pr-icon> <pr-icon icon="'info'" mode="'primary'"></pr-icon>
System resources are hidden, this can be changed in the table settings. System resources are hidden, this can be changed in the table settings.
</span> </span>
</div> </div>
<div class="w-full">
<div class="w-fit">
<insights-box class-name="'mt-2'" type="'slim'" header="'From 2.18 on, you can filter this view by namespace.'" insight-close-id="'k8s-namespace-filtering'"></insights-box>
</div>
</div>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table-hover nowrap-cells table"> <table class="table-hover nowrap-cells table">

View File

@ -10,5 +10,10 @@ angular.module('portainer.kubernetes').component('kubernetesApplicationsStacksDa
reverseOrder: '<', reverseOrder: '<',
refreshCallback: '<', refreshCallback: '<',
removeAction: '<', removeAction: '<',
namespaces: '<',
namespace: '<',
onChangeNamespaceDropdown: '<',
isSystemResources: '<',
setSystemResources: '<',
}, },
}); });

View File

@ -13,6 +13,8 @@ angular.module('portainer.kubernetes').controller('KubernetesApplicationsStacksD
this.state = Object.assign(this.state, { this.state = Object.assign(this.state, {
expandedItems: [], expandedItems: [],
expandAll: false, expandAll: false,
namespace: '',
namespaces: [],
}); });
var ctrl = this; var ctrl = this;
@ -22,6 +24,8 @@ angular.module('portainer.kubernetes').controller('KubernetesApplicationsStacksD
}); });
this.onSettingsShowSystemChange = function () { this.onSettingsShowSystemChange = function () {
this.updateNamespace();
this.setSystemResources(this.settings.showSystem);
DatatableService.setDataTableSettings(this.tableKey, this.settings); DatatableService.setDataTableSettings(this.tableKey, this.settings);
}; };
@ -76,6 +80,44 @@ angular.module('portainer.kubernetes').controller('KubernetesApplicationsStacksD
}); });
}; };
this.onChangeNamespace = function () {
this.onChangeNamespaceDropdown(this.state.namespace);
};
this.updateNamespace = function () {
if (this.namespaces) {
const namespaces = [{ Name: 'All namespaces', Value: '', IsSystem: false }];
this.namespaces.find((ns) => {
if (!this.settings.showSystem && ns.IsSystem) {
return false;
}
namespaces.push({ Name: ns.Name, Value: ns.Name, IsSystem: ns.IsSystem });
});
this.state.namespaces = namespaces;
if (this.state.namespace && !this.state.namespaces.find((ns) => ns.Name === this.state.namespace)) {
if (this.state.namespaces.length > 1) {
let defaultNS = this.state.namespaces.find((ns) => ns.Name === 'default');
defaultNS = defaultNS || this.state.namespaces[1];
this.state.namespace = defaultNS.Value;
} else {
this.state.namespace = this.state.namespaces[0].Value;
}
this.onChangeNamespaceDropdown(this.state.namespace);
}
}
};
this.$onChanges = function () {
if (typeof this.isSystemResources !== 'undefined') {
this.settings.showSystem = this.isSystemResources;
DatatableService.setDataTableSettings(this.settingsKey, this.settings);
}
this.state.namespace = this.namespace;
this.updateNamespace();
this.prepareTableFromDataset();
};
this.$onInit = function () { this.$onInit = function () {
this.isAdmin = Authentication.isAdmin(); this.isAdmin = Authentication.isAdmin();
this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes; this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
@ -103,11 +145,20 @@ angular.module('portainer.kubernetes').controller('KubernetesApplicationsStacksD
this.filters.state.open = false; this.filters.state.open = false;
} }
var storedSettings = DatatableService.getDataTableSettings(this.tableKey); var storedSettings = DatatableService.getDataTableSettings(this.settingsKey);
if (storedSettings !== null) { if (storedSettings !== null) {
this.settings = storedSettings; this.settings = storedSettings;
this.settings.open = false; this.settings.open = false;
this.setSystemResources && this.setSystemResources(this.settings.showSystem);
} }
// Set the default selected namespace
if (!this.state.namespace) {
this.state.namespace = this.namespace;
}
this.updateNamespace();
this.onSettingsRepeaterChange(); this.onSettingsRepeaterChange();
}; };
}, },

View File

@ -141,7 +141,9 @@ export default class HelmTemplatesController {
try { try {
const resourcePools = await this.KubernetesResourcePoolService.get(); const resourcePools = await this.KubernetesResourcePoolService.get();
const nonSystemNamespaces = resourcePools.filter((resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name)); const nonSystemNamespaces = resourcePools.filter(
(resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && resourcePool.Namespace.Status === 'Active'
);
this.state.resourcePools = _.sortBy(nonSystemNamespaces, ({ Namespace }) => (Namespace.Name === 'default' ? 0 : 1)); this.state.resourcePools = _.sortBy(nonSystemNamespaces, ({ Namespace }) => (Namespace.Name === 'default' ? 0 : 1));
this.state.resourcePool = this.state.resourcePools[0]; this.state.resourcePool = this.state.resourcePools[0];
} catch (err) { } catch (err) {

View File

@ -6,6 +6,7 @@
placeholder="# Define or paste the content of your manifest here" placeholder="# Define or paste the content of your manifest here"
read-only="true" read-only="true"
hide-title="true" hide-title="true"
height="{{ $ctrl.expanded ? '800px' : '500px' }}"
> >
</web-editor-form> </web-editor-form>
<div class="py-5"> <div class="py-5">

View File

@ -33,9 +33,6 @@ class KubernetesYamlInspectorController {
} }
toggleYAMLInspectorExpansion() { toggleYAMLInspectorExpansion() {
let selector = 'kubernetes-yaml-inspector code-editor > div.CodeMirror';
let height = this.expanded ? '500px' : '80vh';
$(selector).css({ height: height });
this.expanded = !this.expanded; this.expanded = !this.expanded;
} }

View File

@ -6,6 +6,7 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter'; import { withUIRouter } from '@/react-tools/withUIRouter';
import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressDatatable'; import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressDatatable';
import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView'; import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView';
import { DashboardView } from '@/react/kubernetes/DashboardView';
import { ServicesView } from '@/react/kubernetes/ServicesView'; import { ServicesView } from '@/react/kubernetes/ServicesView';
export const viewsModule = angular export const viewsModule = angular
@ -24,4 +25,8 @@ export const viewsModule = angular
.component( .component(
'kubernetesIngressesCreateView', 'kubernetesIngressesCreateView',
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateIngressView))), []) r2a(withUIRouter(withReactQuery(withCurrentUser(CreateIngressView))), [])
)
.component(
'kubernetesDashboardView',
r2a(withUIRouter(withReactQuery(withCurrentUser(DashboardView))), [])
).name; ).name;

View File

@ -7,9 +7,10 @@ import $allSettled from 'Portainer/services/allSettled';
class KubernetesNamespaceService { class KubernetesNamespaceService {
/* @ngInject */ /* @ngInject */
constructor($async, KubernetesNamespaces) { constructor($async, KubernetesNamespaces, LocalStorage) {
this.$async = $async; this.$async = $async;
this.KubernetesNamespaces = KubernetesNamespaces; this.KubernetesNamespaces = KubernetesNamespaces;
this.LocalStorage = LocalStorage;
this.getAsync = this.getAsync.bind(this); this.getAsync = this.getAsync.bind(this);
this.getAllAsync = this.getAllAsync.bind(this); this.getAllAsync = this.getAllAsync.bind(this);
@ -17,6 +18,7 @@ class KubernetesNamespaceService {
this.deleteAsync = this.deleteAsync.bind(this); this.deleteAsync = this.deleteAsync.bind(this);
this.getJSONAsync = this.getJSONAsync.bind(this); this.getJSONAsync = this.getJSONAsync.bind(this);
this.updateFinalizeAsync = this.updateFinalizeAsync.bind(this); this.updateFinalizeAsync = this.updateFinalizeAsync.bind(this);
this.refreshCacheAsync = this.refreshCacheAsync.bind(this);
} }
/** /**
@ -79,11 +81,20 @@ class KubernetesNamespaceService {
} }
} }
get(name) { async get(name, refreshCache = false) {
if (name) { if (name) {
return this.$async(this.getAsync, name); return this.$async(this.getAsync, name);
} }
return this.$async(this.getAllAsync); const cachedAllowedNamespaces = this.LocalStorage.getAllowedNamespaces();
if (!cachedAllowedNamespaces || refreshCache) {
const allowedNamespaces = await this.getAllAsync();
this.LocalStorage.storeAllowedNamespaces(allowedNamespaces);
updateNamespaces(allowedNamespaces);
return allowedNamespaces;
} else {
updateNamespaces(cachedAllowedNamespaces);
return cachedAllowedNamespaces;
}
} }
/** /**
@ -94,6 +105,7 @@ class KubernetesNamespaceService {
const payload = KubernetesNamespaceConverter.createPayload(namespace); const payload = KubernetesNamespaceConverter.createPayload(namespace);
const params = {}; const params = {};
const data = await this.KubernetesNamespaces().create(params, payload).$promise; const data = await this.KubernetesNamespaces().create(params, payload).$promise;
await this.refreshCacheAsync();
return data; return data;
} catch (err) { } catch (err) {
throw new PortainerError('Unable to create namespace', err); throw new PortainerError('Unable to create namespace', err);
@ -104,6 +116,14 @@ class KubernetesNamespaceService {
return this.$async(this.createAsync, namespace); return this.$async(this.createAsync, namespace);
} }
async refreshCacheAsync() {
this.LocalStorage.deleteAllowedNamespaces();
const allowedNamespaces = await this.getAllAsync();
this.LocalStorage.storeAllowedNamespaces(allowedNamespaces);
updateNamespaces(allowedNamespaces);
return allowedNamespaces;
}
/** /**
* DELETE * DELETE
*/ */

View File

@ -36,8 +36,8 @@ 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, refreshCache = false }) {
const namespaces = await KubernetesNamespaceService.get(); const namespaces = await KubernetesNamespaceService.get('', refreshCache);
const pools = await Promise.all( const pools = await Promise.all(
_.map(namespaces, async (namespace) => { _.map(namespaces, async (namespace) => {
const name = namespace.Name; const name = namespace.Name;

View File

@ -19,6 +19,11 @@
refresh-callback="ctrl.getApplications" refresh-callback="ctrl.getApplications"
on-publishing-mode-click="(ctrl.onPublishingModeClick)" on-publishing-mode-click="(ctrl.onPublishingModeClick)"
is-primary="true" is-primary="true"
namespaces="ctrl.state.namespaces"
namespace="ctrl.state.namespaceName"
on-change-namespace-dropdown="(ctrl.onChangeNamespaceDropdown)"
is-system-resources="ctrl.state.isSystemResources"
set-system-resources="(ctrl.setSystemResources)"
> >
</kubernetes-applications-datatable> </kubernetes-applications-datatable>
</uib-tab> </uib-tab>
@ -30,6 +35,11 @@
order-by="Name" order-by="Name"
refresh-callback="ctrl.getApplications" refresh-callback="ctrl.getApplications"
remove-action="ctrl.removeStacksAction" remove-action="ctrl.removeStacksAction"
namespaces="ctrl.state.namespaces"
namespace="ctrl.state.namespaceName"
on-change-namespace-dropdown="(ctrl.onChangeNamespaceDropdown)"
is-system-resources="ctrl.state.isSystemResources"
set-system-resources="(ctrl.setSystemResources)"
> >
</kubernetes-applications-stacks-datatable> </kubernetes-applications-stacks-datatable>
</uib-tab> </uib-tab>

View File

@ -9,9 +9,23 @@ import { confirmDelete } from '@@/modals/confirm';
class KubernetesApplicationsController { class KubernetesApplicationsController {
/* @ngInject */ /* @ngInject */
constructor($async, $state, Notifications, KubernetesApplicationService, HelmService, KubernetesConfigurationService, Authentication, LocalStorage, StackService) { constructor(
$async,
$state,
$scope,
Authentication,
Notifications,
KubernetesApplicationService,
HelmService,
KubernetesConfigurationService,
LocalStorage,
StackService,
KubernetesNamespaceService
) {
this.$async = $async; this.$async = $async;
this.$state = $state; this.$state = $state;
this.$scope = $scope;
this.Authentication = Authentication;
this.Notifications = Notifications; this.Notifications = Notifications;
this.KubernetesApplicationService = KubernetesApplicationService; this.KubernetesApplicationService = KubernetesApplicationService;
this.HelmService = HelmService; this.HelmService = HelmService;
@ -19,6 +33,7 @@ class KubernetesApplicationsController {
this.Authentication = Authentication; this.Authentication = Authentication;
this.LocalStorage = LocalStorage; this.LocalStorage = LocalStorage;
this.StackService = StackService; this.StackService = StackService;
this.KubernetesNamespaceService = KubernetesNamespaceService;
this.onInit = this.onInit.bind(this); this.onInit = this.onInit.bind(this);
this.getApplications = this.getApplications.bind(this); this.getApplications = this.getApplications.bind(this);
@ -28,6 +43,8 @@ class KubernetesApplicationsController {
this.removeStacksAction = this.removeStacksAction.bind(this); this.removeStacksAction = this.removeStacksAction.bind(this);
this.removeStacksActionAsync = this.removeStacksActionAsync.bind(this); this.removeStacksActionAsync = this.removeStacksActionAsync.bind(this);
this.onPublishingModeClick = this.onPublishingModeClick.bind(this); this.onPublishingModeClick = this.onPublishingModeClick.bind(this);
this.onChangeNamespaceDropdown = this.onChangeNamespaceDropdown.bind(this);
this.setSystemResources = this.setSystemResources.bind(this);
} }
selectTab(index) { selectTab(index) {
@ -126,20 +143,36 @@ class KubernetesApplicationsController {
}); });
} }
onChangeNamespaceDropdown(namespaceName) {
this.state.namespaceName = namespaceName;
// save the selected namespaceName in local storage with the key 'kubernetes_namespace_filter_${environmentId}_${userID}'
this.LocalStorage.storeNamespaceFilter(this.endpoint.Id, this.user.ID, namespaceName);
this.getApplicationsAsync();
}
async getApplicationsAsync() { async getApplicationsAsync() {
try { try {
const [applications, configurations] = await Promise.all([this.KubernetesApplicationService.get(), this.KubernetesConfigurationService.get()]); const [applications, configurations] = await Promise.all([
this.KubernetesApplicationService.get(this.state.namespaceName),
this.KubernetesConfigurationService.get(this.state.namespaceName),
]);
const configuredApplications = KubernetesConfigurationHelper.getApplicationConfigurations(applications, configurations); const configuredApplications = KubernetesConfigurationHelper.getApplicationConfigurations(applications, configurations);
const { helmApplications, nonHelmApplications } = KubernetesApplicationHelper.getNestedApplications(configuredApplications); const { helmApplications, nonHelmApplications } = KubernetesApplicationHelper.getNestedApplications(configuredApplications);
this.state.applications = [...helmApplications, ...nonHelmApplications]; this.state.applications = [...helmApplications, ...nonHelmApplications];
this.state.stacks = KubernetesStackHelper.stacksFromApplications(applications); this.state.stacks = KubernetesStackHelper.stacksFromApplications(applications);
this.state.ports = KubernetesApplicationHelper.portMappingsFromApplications(applications); this.state.ports = KubernetesApplicationHelper.portMappingsFromApplications(applications);
this.$scope.$apply();
} catch (err) { } catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve applications'); this.Notifications.error('Failure', err, 'Unable to retrieve applications');
} }
} }
setSystemResources(flag) {
this.state.isSystemResources = flag;
}
getApplications() { getApplications() {
return this.$async(this.getApplicationsAsync); return this.$async(this.getApplicationsAsync);
} }
@ -153,7 +186,27 @@ class KubernetesApplicationsController {
applications: [], applications: [],
stacks: [], stacks: [],
ports: [], ports: [],
namespaces: [],
namespaceName: '',
isSystemResources: undefined,
}; };
this.user = this.Authentication.getUserDetails();
this.state.namespaces = await this.KubernetesNamespaceService.get();
const savedNamespace = this.LocalStorage.getNamespaceFilter(this.endpoint.Id, this.user.ID); // could be null if not found, and '' if all namepsaces is selected
const preferredNamespace = savedNamespace === null ? 'default' : savedNamespace;
this.state.namespaces = this.state.namespaces.filter((n) => n.Status === 'Active');
this.state.namespaces = _.sortBy(this.state.namespaces, 'Name');
// set all namespaces ('') if there are no namespaces, or if all namespaces is selected
if (!this.state.namespaces.length || preferredNamespace === '') {
this.state.namespaceName = '';
} else {
// otherwise, set the preferred namespaceName if it exists, otherwise set the first namespaceName
this.state.namespaceName = this.state.namespaces.find((n) => n.Name === preferredNamespace) ? preferredNamespace : this.state.namespaces[0].Name;
}
await this.getApplications(); await this.getApplications();
this.state.viewReady = true; this.state.viewReady = true;

View File

@ -212,7 +212,7 @@
</div> </div>
<!-- #end region IMAGE FIELD --> <!-- #end region IMAGE FIELD -->
<div class="col-sm-12 !p-0"> <div class="col-sm-12 mb-4 !p-0">
<annotations-be-teaser></annotations-be-teaser> <annotations-be-teaser></annotations-be-teaser>
</div> </div>
@ -336,7 +336,7 @@
</div> </div>
</div> </div>
<div class="col-sm-12 mt-4"> <div class="col-sm-12 mt-2">
<span <span
ng-if="ctrl.formValues.Containers.length <= 1" ng-if="ctrl.formValues.Containers.length <= 1"
class="btn btn-primary btn-sm btn btn-sm btn-light mb-2 !ml-0" class="btn btn-primary btn-sm btn btn-sm btn-light mb-2 !ml-0"
@ -503,7 +503,7 @@
</div> </div>
<div class="form-group" ng-if="ctrl.storageClassAvailable()"> <div class="form-group" ng-if="ctrl.storageClassAvailable()">
<div class="col-sm-12 vertical-center pt-2.5" style="margin-top: 5px" ng-if="!ctrl.allQuotasExhaustedAndNoVolumesAvailable()"> <div class="col-sm-12 vertical-center mb-2 pt-2.5" style="margin-top: 5px" ng-if="!ctrl.allQuotasExhaustedAndNoVolumesAvailable()">
<label class="control-label !pt-0 text-left">Persisted folders</label> <label class="control-label !pt-0 text-left">Persisted folders</label>
</div> </div>

View File

@ -1208,22 +1208,31 @@ class KubernetesCreateApplicationController {
]); ]);
this.nodesLimits = nodesLimits; this.nodesLimits = nodesLimits;
const nonSystemNamespaces = _.filter(resourcePools, (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name)); const nonSystemNamespaces = _.filter(
resourcePools,
(resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && resourcePool.Namespace.Status === 'Active'
);
this.allNamespaces = resourcePools.map(({ Namespace }) => Namespace.Name); this.allNamespaces = resourcePools.map(({ Namespace }) => Namespace.Name);
this.resourcePools = _.sortBy(nonSystemNamespaces, ({ Namespace }) => (Namespace.Name === 'default' ? 0 : 1)); this.resourcePools = _.sortBy(nonSystemNamespaces, ({ Namespace }) => (Namespace.Name === 'default' ? 0 : 1));
const namespaceWithQuota = await this.KubernetesResourcePoolService.get(this.resourcePools[0].Namespace.Name); // this.state.nodes.memory and this.state.nodes.cpu are used to calculate the slider limits, so set them before calling updateSliders()
this.formValues.ResourcePool = this.resourcePools[0];
this.formValues.ResourcePool.Quota = namespaceWithQuota.Quota;
if (!this.formValues.ResourcePool) {
return;
}
_.forEach(nodes, (item) => { _.forEach(nodes, (item) => {
this.state.nodes.memory += filesizeParser(item.Memory); this.state.nodes.memory += filesizeParser(item.Memory);
this.state.nodes.cpu += item.CPU; this.state.nodes.cpu += item.CPU;
}); });
if (this.resourcePools.length) {
const namespaceWithQuota = await this.KubernetesResourcePoolService.get(this.resourcePools[0].Namespace.Name);
this.formValues.ResourcePool.Quota = namespaceWithQuota.Quota;
this.updateNamespaceLimits(namespaceWithQuota);
this.updateSliders(namespaceWithQuota);
}
this.formValues.ResourcePool = this.resourcePools[0];
if (!this.formValues.ResourcePool) {
return;
}
this.nodesLabels = KubernetesNodeHelper.generateNodeLabelsFromNodes(nodes); this.nodesLabels = KubernetesNodeHelper.generateNodeLabelsFromNodes(nodes);
this.nodeNumber = nodes.length; this.nodeNumber = nodes.length;
@ -1281,9 +1290,6 @@ class KubernetesCreateApplicationController {
this.formValues.IsPublishingService = this.formValues.PublishedPorts.length > 0; this.formValues.IsPublishingService = this.formValues.PublishedPorts.length > 0;
this.oldFormValues = angular.copy(this.formValues); this.oldFormValues = angular.copy(this.formValues);
this.updateNamespaceLimits(namespaceWithQuota);
this.updateSliders(namespaceWithQuota);
} catch (err) { } catch (err) {
this.Notifications.error('Failure', err, 'Unable to load view data'); this.Notifications.error('Failure', err, 'Unable to load view data');
} finally { } finally {

View File

@ -15,7 +15,7 @@
<form class="form-horizontal" name="kubernetesConfigurationCreationForm" autocomplete="off"> <form class="form-horizontal" name="kubernetesConfigurationCreationForm" autocomplete="off">
<!-- resource-pool --> <!-- resource-pool -->
<div class="form-group" ng-if="ctrl.formValues.ResourcePool"> <div class="form-group" ng-if="ctrl.formValues.ResourcePool">
<label for="resource-pool-selector" class="col-sm-3 col-lg-2 control-label required text-left">Namespace</label> <label for="resource-pool-selector" class="col-sm-3 col-lg-2 control-label text-left">Namespace</label>
<div class="col-sm-8 col-lg-9"> <div class="col-sm-8 col-lg-9">
<select <select
class="form-control" class="form-control"

View File

@ -196,7 +196,10 @@ class KubernetesCreateConfigurationController {
try { try {
const resourcePools = await this.KubernetesResourcePoolService.get(); const resourcePools = await this.KubernetesResourcePoolService.get();
this.resourcePools = _.filter(resourcePools, (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name)); this.resourcePools = _.filter(
resourcePools,
(resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && resourcePool.Namespace.Status === 'Active'
);
this.formValues.ResourcePool = this.resourcePools[0]; this.formValues.ResourcePool = this.resourcePools[0];
await this.getConfigurations(); await this.getConfigurations();

View File

@ -165,7 +165,10 @@ class KubernetesConfigureController {
const allResourcePools = await this.KubernetesResourcePoolService.get(); const allResourcePools = await this.KubernetesResourcePoolService.get();
const resourcePools = _.filter( const resourcePools = _.filter(
allResourcePools, allResourcePools,
(resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && !KubernetesNamespaceHelper.isDefaultNamespace(resourcePool.Namespace.Name) (resourcePool) =>
!KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) &&
!KubernetesNamespaceHelper.isDefaultNamespace(resourcePool.Namespace.Name) &&
resourcePool.Namespace.Status === 'Active'
); );
ingressesToDel.forEach((ingress) => { ingressesToDel.forEach((ingress) => {

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