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>.
Find more detailed steps at <https://documentation.portainer.io/contributing/instructions/>.
Find more detailed steps at <https://docs.portainer.io/contribute/build>.
### 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_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
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"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"time"
@ -42,7 +43,9 @@ func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (port
if err != nil {
return 0, "", err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
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 {
panic(err)
}
*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.
func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
displayDeprecationWarnings(flags)
err := validateEndpointURL(*flags.EndpointURL)
@ -111,31 +111,38 @@ func displayDeprecationWarnings(flags *portainer.CLIFlags) {
}
func validateEndpointURL(endpointURL string) error {
if endpointURL != "" {
if !strings.HasPrefix(endpointURL, "unix://") && !strings.HasPrefix(endpointURL, "tcp://") && !strings.HasPrefix(endpointURL, "npipe://") {
return errInvalidEndpointProtocol
}
if endpointURL == "" {
return nil
}
if strings.HasPrefix(endpointURL, "unix://") || strings.HasPrefix(endpointURL, "npipe://") {
socketPath := strings.TrimPrefix(endpointURL, "unix://")
socketPath = strings.TrimPrefix(socketPath, "npipe://")
if _, err := os.Stat(socketPath); err != nil {
if os.IsNotExist(err) {
return errSocketOrNamedPipeNotFound
}
return err
if !strings.HasPrefix(endpointURL, "unix://") && !strings.HasPrefix(endpointURL, "tcp://") && !strings.HasPrefix(endpointURL, "npipe://") {
return errInvalidEndpointProtocol
}
if strings.HasPrefix(endpointURL, "unix://") || strings.HasPrefix(endpointURL, "npipe://") {
socketPath := strings.TrimPrefix(endpointURL, "unix://")
socketPath = strings.TrimPrefix(socketPath, "npipe://")
if _, err := os.Stat(socketPath); err != nil {
if os.IsNotExist(err) {
return errSocketOrNamedPipeNotFound
}
return err
}
}
return nil
}
func validateSnapshotInterval(snapshotInterval string) error {
if snapshotInterval != "" {
_, err := time.ParseDuration(snapshotInterval)
if err != nil {
return errInvalidSnapshotInterval
}
if snapshotInterval == "" {
return nil
}
_, err := time.ParseDuration(snapshotInterval)
if err != nil {
return errInvalidSnapshotInterval
}
return nil
}

View File

@ -12,13 +12,14 @@ func Confirm(message string) (bool, error) {
fmt.Printf("%s [y/N]", message)
reader := bufio.NewReader(os.Stdin)
answer, err := reader.ReadString('\n')
if err != nil {
return false, err
}
answer = strings.Replace(answer, "\n", "", -1)
answer = strings.ReplaceAll(answer, "\n", "")
answer = strings.ToLower(answer)
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")
}
upgradeService, err := upgrade.NewService(*flags.Assets, composeDeployer)
upgradeService, err := upgrade.NewService(*flags.Assets, composeDeployer, kubernetesClientFactory)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing upgrade service")
}
// FIXME: In 2.16 we changed the way ingress controller permissions are
// stored. Instead of being stored as annotation on an ingress rule, we keep
// them in our database. However, in order to run the migration we need an
// admin kube client to run lookup the old ingress rules and compare them
// with the current existing ingress classes.
//
// Unfortunately, our migrations run as part of the database initialization
// and our kubeclients require an initialized database. So it is not
// possible to do this migration as part of our normal flow. We DO have a
// migration which toggles a boolean in kubernetes configuration that
// 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 {
// Our normal migrations run as part of the database initialization
// but some more complex migrations require access to a kubernetes or docker
// client. Therefore we run a separate migration process just before
// starting the server.
postInitMigrator := datastore.NewPostInitMigrator(
kubernetesClientFactory,
dockerClientFactory,
dataStore,
)
if err := postInitMigrator.PostInitMigrate(); err != nil {
log.Fatal().Err(err).Msg("failure during post init migrations")
}

View File

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

View File

@ -129,7 +129,7 @@ func Test_UnMarshalObjectUnencrypted(t *testing.T) {
var object string
err := conn.UnmarshalObject(test.object, &object)
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 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 {

View File

@ -9,8 +9,7 @@ import (
// 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) {
switch storeType {
case "boltdb":
if storeType == "boltdb" {
return &boltdb.DbConnection{
Path: storePath,
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 (
"github.com/rs/zerolog/log"
portainer "github.com/portainer/portainer/api"
portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors"
)
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
}
@ -39,7 +44,7 @@ func (m *Migrator) updateEdgeStackStatusForDB90() error {
return nil
}
func (m *Migrator) updateUserThemForDB90() error {
func (m *Migrator) updateUserThemeForDB90() error {
log.Info().Msg("updating existing user theme settings")
users, err := m.userService.Users()
@ -60,3 +65,28 @@ func (m *Migrator) updateUserThemForDB90() error {
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,
"EdgeKey": "",
"EnableGPUManagement": false,
"Gpus": [],
"GroupId": 1,
"Id": 1,
@ -63,6 +64,7 @@
"UseServerMetrics": false
},
"Flags": {
"IsServerIngressClassDetected": false,
"IsServerMetricsDetected": false,
"IsServerStorageDetected": false
},
@ -71,6 +73,7 @@
"LastCheckInDate": 0,
"Name": "local",
"PostInitMigrations": {
"MigrateGPUs": true,
"MigrateIngresses": true
},
"PublicURL": "",
@ -903,8 +906,7 @@
},
"Role": 1,
"ThemeSettings": {
"color": "",
"subtleUpgradeButton": false
"color": ""
},
"TokenIssueAt": 0,
"UserTheme": "",
@ -934,8 +936,7 @@
},
"Role": 1,
"ThemeSettings": {
"color": "",
"subtleUpgradeButton": false
"color": ""
},
"TokenIssueAt": 0,
"UserTheme": "",
@ -943,6 +944,6 @@
}
],
"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.
// 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) {
if endpoint.Type == portainer.AzureEnvironment {
switch endpoint.Type {
case portainer.AzureEnvironment:
return nil, errUnsupportedEnvironmentType
} else if endpoint.Type == portainer.AgentOnDockerEnvironment {
case portainer.AgentOnDockerEnvironment:
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)
}
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
return createLocalClient(endpoint)
}
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{
WorkingDir: stack.ProjectPath,
EnvFilePath: envFilePath,
Host: url,
})
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 {
return err
}
for _, registry := range registries {
if registry.Authentication {
err = registryutils.EnsureRegTokenValid(manager.dataStore, &registry)
@ -75,6 +76,7 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
runCommandAndCaptureStdErr(command, registryArgs, nil, "")
}
}
return nil
}
@ -84,7 +86,9 @@ func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error {
if err != nil {
return err
}
args = append(args, "logout")
return runCommandAndCaptureStdErr(command, args, nil, "")
}
@ -101,6 +105,7 @@ func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, pul
} else {
args = append(args, "stack", "deploy", "--with-registry-auth")
}
if !pullImage {
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 {
env = append(env, envvar.Name+"="+envvar.Value)
}
return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath)
}
@ -121,7 +127,9 @@ func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *porta
if err != nil {
return err
}
args = append(args, "stack", "rm", stack.Name)
return runCommandAndCaptureStdErr(command, args, nil, "")
}
@ -198,6 +206,7 @@ func (manager *SwarmStackManager) updateDockerCLIConfiguration(configPath string
if config["HttpHeaders"] == nil {
config["HttpHeaders"] = make(map[string]interface{})
}
headersObject := config["HttpHeaders"].(map[string]interface{})
headersObject["X-PortainerAgent-ManagerOperation"] = "1"
headersObject["X-PortainerAgent-Signature"] = signature
@ -230,5 +239,6 @@ func configureFilePaths(args []string, filePaths []string) []string {
for _, path := range filePaths {
args = append(args, "--compose-file", path)
}
return args
}

View File

@ -75,6 +75,7 @@ func newHttpClientForAzure() *http.Client {
}
client.InstallProtocol("https", githttp.NewClient(httpsCli))
return httpsCli
}
@ -98,10 +99,12 @@ func (a *azureClient) downloadZipFromAzureDevOps(ctx context.Context, opt cloneO
if err != nil {
return "", errors.WithMessage(err, "failed to parse url")
}
downloadUrl, err := a.buildDownloadUrl(config, opt.referenceName)
if err != nil {
return "", errors.WithMessage(err, "failed to build download url")
}
zipFile, err := os.CreateTemp("", "azure-git-repo-*.zip")
if err != nil {
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 {
return "", errors.WithMessage(err, "failed to save HTTP response to a file")
}
return zipFile.Name(), nil
}
@ -141,6 +145,7 @@ func (a *azureClient) latestCommitID(ctx context.Context, opt fetchOption) (stri
if err != nil {
return "", err
}
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 == "" {
return nil, errors.Errorf("failed to get latest commitID in the repository")
}
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)
}
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) {
path := strings.Split(rawUrl, "/")
@ -343,6 +349,7 @@ func (a *azureClient) buildTreeUrl(config *azureOptions, rootObjectHash string)
if err != nil {
return "", errors.Wrapf(err, "failed to parse list tree url path %s", rawUrl)
}
q := u.Query()
// projectId={projectId}&recursive=true&fileName={fileName}&$format={$format}&api-version=6.0
q.Set("recursive", "true")
@ -361,9 +368,11 @@ func formatReferenceName(name string) string {
if strings.HasPrefix(name, branchPrefix) {
return strings.TrimPrefix(name, branchPrefix)
}
if strings.HasPrefix(name, tagPrefix) {
return strings.TrimPrefix(name, tagPrefix)
}
return name
}
@ -371,9 +380,11 @@ func getVersionType(name string) string {
if strings.HasPrefix(name, branchPrefix) {
return "branch"
}
if strings.HasPrefix(name, tagPrefix) {
return "tag"
}
return "commit"
}
@ -490,5 +501,6 @@ func checkAzureStatusCode(err error, code int) error {
} else if code == http.StatusUnauthorized || code == http.StatusNonAuthoritativeInfo {
return gittypes.ErrAuthenticationFailure
}
return err
}

View File

@ -8,14 +8,13 @@ import (
"testing"
"time"
_ "github.com/joho/godotenv/autoload"
gittypes "github.com/portainer/portainer/api/git/types"
_ "github.com/joho/godotenv/autoload"
"github.com/stretchr/testify/assert"
)
var (
privateAzureRepoURL = "https://portainer.visualstudio.com/gitops-test/_git/gitops-test"
)
const privateAzureRepoURL = "https://portainer.visualstudio.com/gitops-test/_git/gitops-test"
func TestService_ClonePublicRepository_Azure(t *testing.T) {
ensureIntegrationTest(t)
@ -107,7 +106,7 @@ func TestService_ListRefs_Azure_Concurrently(t *testing.T) {
accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT")
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)
service.ListRefs(privateAzureRepoURL, username, accessToken, false)
@ -269,7 +268,7 @@ func TestService_ListFiles_Azure_Concurrently(t *testing.T) {
accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT")
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{})
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")
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
go service.ListRefs(repositoryUrl, username, accessToken, false)
@ -224,7 +224,7 @@ func TestService_ListFiles_Github_Concurrently(t *testing.T) {
repositoryUrl := privateGitRepoURL
accessToken := getRequiredValue(t, "GITHUB_PAT")
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{})
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 {
t.Fatalf("can't open a git repo at %s with error %v", dir, err)
}
iter, err := repo.Log(&git.LogOptions{All: true})
if err != nil {
t.Fatalf("can't get a commit history iterator with error %v", err)
}
count := 0
err = iter.ForEach(func(_ *object.Commit) error {
count++
@ -107,6 +109,7 @@ func getCommitHistoryLength(t *testing.T, err error, dir string) int {
if err != nil {
t.Fatalf("can't iterate over the commit history with error %v", err)
}
return count
}

View File

@ -10,9 +10,9 @@ import (
"github.com/rs/zerolog/log"
)
var (
REPOSITORY_CACHE_SIZE = 4
REPOSITORY_CACHE_TTL = 5 * time.Minute
const (
repositoryCacheSize = 4
repositoryCacheTTL = 5 * time.Minute
)
// 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.
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 {

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)
}
hashChanged := !strings.EqualFold(newHash, string(gitConfig.ConfigHash))
hashChanged := !strings.EqualFold(newHash, gitConfig.ConfigHash)
forceUpdate := autoUpdateConfig != nil && autoUpdateConfig.ForceUpdate
if !hashChanged && !forceUpdate {
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww=
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 {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.New(http.StatusText(resp.StatusCode))
@ -132,7 +134,9 @@ func (c FDOOwnerClient) PutDeviceSVIRaw(info url.Values, body []byte) error {
if err != nil {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.New(http.StatusText(resp.StatusCode))
@ -151,7 +155,9 @@ func (c FDOOwnerClient) GetVouchers() ([]string, error) {
if err != nil {
return nil, err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New(http.StatusText(resp.StatusCode))
@ -182,7 +188,9 @@ func (c FDOOwnerClient) DeleteVoucher(guid string) error {
if err != nil {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.New(http.StatusText(resp.StatusCode))
@ -201,7 +209,9 @@ func (c FDOOwnerClient) GetDeviceSVI(guid string) (string, error) {
if err != nil {
return "", err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
@ -225,7 +235,9 @@ func (c FDOOwnerClient) DeleteDeviceSVI(id string) error {
if err != nil {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.New(http.StatusText(resp.StatusCode))

View File

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

View File

@ -128,6 +128,7 @@ func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfig
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code %s", response.Status)
@ -137,6 +138,8 @@ func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfig
if err != nil {
return "", err
}
block, _ := pem.Decode(certificate)
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 {
return nil, err
}
defer response.Body.Close()
responseBody, readErr := io.ReadAll(response.Body)
if readErr != nil {
return nil, readErr
@ -132,6 +134,8 @@ func (service *Service) executeGetRequest(url string, token string) ([]byte, err
if err != nil {
return nil, err
}
defer response.Body.Close()
responseBody, readErr := io.ReadAll(response.Body)
if readErr != nil {
return nil, readErr
@ -141,10 +145,12 @@ func (service *Service) executeGetRequest(url string, token string) ([]byte, err
if response.StatusCode == http.StatusNotFound {
return nil, nil
}
errorResponse := parseError(responseBody)
if errorResponse != nil {
return nil, errorResponse
}
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()
body, _ := io.ReadAll(response.Body)
response.Body.Close()
tmpdir := t.TempDir()
@ -89,6 +90,7 @@ func Test_backupHandlerWithPassword_shouldCreateEncryptedATarballArchive(t *test
response := w.Result()
body, _ := io.ReadAll(response.Body)
response.Body.Close()
tmpdir := t.TempDir()

View File

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

View File

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

View File

@ -6,8 +6,8 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/slices"
@ -27,12 +27,15 @@ func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Name) {
return errors.New("invalid Edge group name")
}
if payload.Dynamic && len(payload.TagIDs) == 0 {
return errors.New("tagIDs is mandatory for a dynamic Edge group")
}
if !payload.Dynamic && len(payload.Endpoints) == 0 {
return errors.New("environments is mandatory for a static Edge group")
}
return nil
}
@ -62,128 +65,135 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Invalid request payload", err)
}
edgeGroup, err := handler.DataStore.EdgeGroup().EdgeGroup(portainer.EdgeGroupID(edgeGroupID))
if handler.DataStore.IsErrObjectNotFound(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"))
}
var edgeGroup *portainer.EdgeGroup
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
edgeGroup, err = tx.EdgeGroup().EdgeGroup(portainer.EdgeGroupID(edgeGroupID))
if handler.DataStore.IsErrObjectNotFound(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)
}
edgeGroup.Name = payload.Name
}
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 payload.Name != "" {
edgeGroups, err := tx.EdgeGroup().EdgeGroups()
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) {
endpointIDs = append(endpointIDs, endpoint.ID)
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
}
edgeGroup.Endpoints = endpointIDs
}
if payload.PartialMatch != nil {
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)
endpoints, err := tx.Endpoint().Endpoints()
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 {
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) {
continue
}
oldRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups)
var operation string
if slices.Contains(newRelatedEndpoints, endpointID) {
operation = "add"
} else if slices.Contains(oldRelatedEndpoints, endpointID) {
operation = "remove"
edgeGroup.Dynamic = payload.Dynamic
if edgeGroup.Dynamic {
edgeGroup.TagIDs = payload.TagIDs
} 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 {
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 {
relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID)
func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) error {
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
if err != nil {
return err
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID)
endpoint, err := tx.Endpoint().Endpoint(endpointID)
if err != nil {
return err
}
endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(endpoint.GroupID)
endpointGroup, err := tx.EndpointGroup().EndpointGroup(endpoint.GroupID)
if err != nil {
return err
}
edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups()
edgeGroups, err := tx.EdgeGroup().EdgeGroups()
if err != nil {
return err
}
edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks()
edgeStacks, err := tx.EdgeStack().EdgeStacks()
if err != nil {
return err
}
@ -197,7 +207,7 @@ func (handler *Handler) updateEndpointStacks(endpointID portainer.EndpointID) er
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 {

View File

@ -3,11 +3,13 @@ package edgegroups
import (
"net/http"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/gorilla/mux"
)
// 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)
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 {
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))
if err != nil {
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
}
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
// EE-5910
return httperror.Forbidden("Permission denied to access environment", errors.New("the device has not been trusted yet"))
}
err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint)

View File

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

View File

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

View File

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

View File

@ -28,6 +28,10 @@ type endpointSettingsUpdatePayload struct {
AllowSysctlSettingForRegularUsers *bool `json:"allowSysctlSettingForRegularUsers" example:"true"`
// Whether host management features are enabled
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 {
@ -107,6 +111,14 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re
securitySettings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures
}
if payload.EnableGPUManagement != nil {
endpoint.EnableGPUManagement = *payload.EnableGPUManagement
}
if payload.Gpus != nil {
endpoint.Gpus = payload.Gpus
}
endpoint.SecuritySettings = securitySettings
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)

View File

@ -82,7 +82,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.18.0
// @version 2.19.0
// @description.markdown api-description.md
// @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)
}
switch method {
case "editor":
if method == "editor" {
return handler.createFDOProfileFromFileContent(w, r)
}
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 {
var payload createProfileFromFileContentPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
@ -66,6 +67,7 @@ func (handler *Handler) createFDOProfileFromFileContent(w http.ResponseWriter, r
if err != nil {
return httperror.InternalServerError(err.Error(), err)
}
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")}
}
@ -80,6 +82,7 @@ func (handler *Handler) createFDOProfileFromFileContent(w http.ResponseWriter, r
if err != nil {
return httperror.InternalServerError("Unable to persist profile file on disk", err)
}
profile.FilePath = filePath
profile.DateCreated = time.Now().Unix()

View File

@ -1,7 +1,6 @@
package stacks
import (
"fmt"
"net/http"
"time"
@ -10,7 +9,6 @@ import (
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/git"
httperrors "github.com/portainer/portainer/api/http/errors"
"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 := ""
repositoryPassword := ""
if payload.RepositoryAuthentication {

View File

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

View File

@ -8,6 +8,8 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/platform"
)
type systemUpgradePayload struct {
@ -28,13 +30,19 @@ func (payload *systemUpgradePayload) Validate(r *http.Request) error {
return nil
}
var platformToEndpointType = map[platform.ContainerPlatform]portainer.EndpointType{
platform.PlatformDockerStandalone: portainer.DockerEnvironment,
platform.PlatformDockerSwarm: portainer.DockerEnvironment,
platform.PlatformKubernetes: portainer.KubernetesLocalEnvironment,
}
// @id systemUpgrade
// @summary Upgrade Portainer to BE
// @description Upgrade Portainer to BE
// @description **Access policy**: administrator
// @tags system
// @produce json
// @success 200 {object} status "Success"
// @success 204 {object} status "Success"
// @router /system/upgrade [post]
func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
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)
}
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 {
return httperror.InternalServerError("Failed to upgrade Portainer", err)
}
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 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]
func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload filePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
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)
if err != nil {
log.Debug().Err(err).Msg("HTTP error: unable to cleanup stack creation")
}
return nil
}

View File

@ -18,8 +18,6 @@ import (
type themePayload struct {
// Color represents the color theme of the UI
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 {
@ -33,11 +31,11 @@ type userUpdatePayload struct {
func (payload *userUpdatePayload) Validate(r *http.Request) error {
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 {
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
}
@ -120,10 +118,6 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
if payload.Theme.Color != nil {
user.ThemeSettings.Color = *payload.Theme.Color
}
if payload.Theme.SubtleUpgradeButton != nil {
user.ThemeSettings.SubtleUpgradeButton = *payload.Theme.SubtleUpgradeButton
}
}
if payload.Role != 0 {

View File

@ -14,48 +14,57 @@ func streamFromWebsocketToWriter(websocketConn *websocket.Conn, writer io.Writer
_, in, err := websocketConn.ReadMessage()
if err != nil {
errorChan <- err
break
}
_, err = writer.Write(in)
if err != nil {
errorChan <- err
break
}
}
}
func streamFromReaderToWebsocket(websocketConn *websocket.Conn, reader io.Reader, errorChan chan error) {
out := make([]byte, readerBufferSize)
for {
out := make([]byte, readerBufferSize)
_, err := reader.Read(out)
n, err := reader.Read(out)
if err != nil {
errorChan <- err
break
}
processedOutput := validString(string(out[:]))
processedOutput := validString(string(out[:n]))
err = websocketConn.WriteMessage(websocket.TextMessage, []byte(processedOutput))
if err != nil {
errorChan <- err
break
}
}
}
func validString(s string) string {
if !utf8.ValidString(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)
}
s = string(v)
if utf8.ValidString(s) {
return s
}
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)
decoratedResourceData = append(decoratedResourceData, responseObject)
}
return decoratedResourceData, nil
}
@ -34,8 +35,10 @@ func (transport *Transport) applyPortainerContainer(resourceObject map[string]in
if !ok {
return resourceObject, nil
}
if len(resourceId) >= 12 && resourceId[0:12] == portainerContainerId {
resourceObject["IsPortainer"] = true
}
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 {
requestPath := strings.TrimPrefix(r.URL.Path, "/v2")
switch {
case strings.HasPrefix(requestPath, "/dockerhub"):
if strings.HasPrefix(requestPath, "/dockerhub") {
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) {
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)
if err != nil {
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) {
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)
if err != nil {
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
}
endpoint.Kubernetes.Configuration.UseServerMetrics = true
endpoint.Kubernetes.Flags.IsServerMetricsDetected = true
err = endpointService.UpdateEndpoint(
portainer.EndpointID(endpoint.ID),
endpoint,
)
if err != nil {
log.Debug().Err(err).Msg("unable to enable UseServerMetrics inside the database")
return
@ -158,6 +173,16 @@ func storageDetect(endpoint *portainer.Endpoint, endpointService dataservices.En
}
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")
err := storageDetect(endpoint, endpointService, factory)
if err == nil {

View File

@ -1,23 +1,13 @@
package upgrade
import (
"bytes"
"context"
"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"
libstack "github.com/portainer/docker-compose-wrapper"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/docker"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/platform"
"github.com/rs/zerolog/log"
)
const (
@ -36,19 +26,23 @@ const (
)
type Service interface {
Upgrade(licenseKey string) error
Upgrade(environment *portainer.Endpoint, licenseKey string) error
}
type service struct {
composeDeployer libstack.Deployer
isUpdating bool
platform platform.ContainerPlatform
assetsPath string
composeDeployer libstack.Deployer
kubernetesClientFactory *cli.ClientFactory
isUpdating bool
platform platform.ContainerPlatform
assetsPath string
}
func NewService(
assetsPath string,
composeDeployer libstack.Deployer,
kubernetesClientFactory *cli.ClientFactory,
) (Service, error) {
platform, err := platform.DetermineContainerPlatform()
if err != nil {
@ -56,13 +50,14 @@ func NewService(
}
return &service{
assetsPath: assetsPath,
composeDeployer: composeDeployer,
platform: platform,
assetsPath: assetsPath,
composeDeployer: composeDeployer,
kubernetesClientFactory: kubernetesClientFactory,
platform: platform,
}, nil
}
func (service *service) Upgrade(licenseKey string) error {
func (service *service) Upgrade(environment *portainer.Endpoint, licenseKey string) error {
service.isUpdating = true
switch service.platform {
@ -70,113 +65,9 @@ func (service *service) Upgrade(licenseKey string) error {
return service.upgradeDocker(licenseKey, portainer.APIVersion, "standalone")
case platform.PlatformDockerSwarm:
return service.upgradeDocker(licenseKey, portainer.APIVersion, "swarm")
// case platform.PlatformKubernetes:
// case platform.PlatformPodman:
// case platform.PlatformNomad:
// default:
case platform.PlatformKubernetes:
return service.upgradeKubernetes(environment, licenseKey, portainer.APIVersion)
}
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
func (kcl *KubeClient) NamespaceAccessPoliciesDeleteNamespace(ns string) error {
kcl.lock.Lock()
defer kcl.lock.Unlock()
kcl.mu.Lock()
defer kcl.mu.Unlock()
policies, err := kcl.GetNamespaceAccessPolicies()
if err != nil {
@ -42,6 +42,7 @@ func (kcl *KubeClient) GetNamespaceAccessPolicies() (map[string]portainer.K8sNam
if err != nil {
return nil, err
}
return policies, nil
}

View File

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

View File

@ -7,17 +7,20 @@ import (
"sync"
"time"
cmap "github.com/orcaman/concurrent-map"
"github.com/patrickmn/go-cache"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
const (
DefaultKubeClientQPS = 30
DefaultKubeClientBurst = 100
)
type (
// ClientFactory is used to create Kubernetes clients
ClientFactory struct {
@ -25,16 +28,17 @@ type (
reverseTunnelService portainer.ReverseTunnelService
signatureService portainer.DigitalSignatureService
instanceID string
endpointClients cmap.ConcurrentMap
endpointClients map[string]*KubeClient
endpointProxyClients *cache.Cache
AddrHTTPS string
mu sync.Mutex
}
// KubeClient represent a service used to execute Kubernetes operations
KubeClient struct {
cli kubernetes.Interface
instanceID string
lock *sync.Mutex
mu sync.Mutex
}
)
@ -53,7 +57,7 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService, revers
signatureService: signatureService,
reverseTunnelService: reverseTunnelService,
instanceID: instanceID,
endpointClients: cmap.New(),
endpointClients: make(map[string]*KubeClient),
endpointProxyClients: cache.New(timeout, timeout),
AddrHTTPS: addrHTTPS,
}, nil
@ -65,82 +69,87 @@ func (factory *ClientFactory) GetInstanceID() (instanceID string) {
// Remove the cached kube client so a new one can be created
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.
// 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))
client, ok := factory.endpointClients.Get(key)
client, ok := factory.endpointClients[key]
if !ok {
client, err := factory.createCachedAdminKubeClient(endpoint)
var err error
client, err = factory.createCachedAdminKubeClient(endpoint)
if err != nil {
return nil, err
}
factory.endpointClients.Set(key, client)
return client, nil
factory.endpointClients[key] = client
}
return client.(portainer.KubeClient), nil
return client, nil
}
// GetProxyKubeClient retrieves a KubeClient from the cache. You should be
// calling SetProxyKubeClient before first. It is normally, called the
// 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)
if !ok {
return nil, false
}
return client.(portainer.KubeClient), true
return client.(*KubeClient), true
}
// 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)
}
// CreateKubeClientFromKubeConfig creates a KubeClient from a clusterID, and
// 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))
if err != nil {
return nil, err
}
cliConfig, err := config.ClientConfig()
if err != nil {
return nil, err
}
cliConfig.QPS = DefaultKubeClientQPS
cliConfig.Burst = DefaultKubeClientBurst
cli, err := kubernetes.NewForConfig(cliConfig)
if err != nil {
return nil, err
}
kubecli := &KubeClient{
return &KubeClient{
cli: cli,
instanceID: factory.instanceID,
lock: &sync.Mutex{},
}
return kubecli, nil
}, 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)
if err != nil {
return nil, err
}
kubecli := &KubeClient{
return &KubeClient{
cli: cli,
instanceID: factory.instanceID,
lock: &sync.Mutex{},
}
return kubecli, nil
}, nil
}
// CreateClient returns a pointer to a new Clientset instance
@ -199,7 +208,10 @@ func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernete
if err != nil {
return nil, err
}
config.Insecure = true
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
return &agentHeaderRoundTripper{
@ -218,30 +230,13 @@ func buildLocalClient() (*kubernetes.Clientset, error) {
return nil, err
}
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
return kubernetes.NewForConfig(config)
}
func (factory *ClientFactory) PostInitMigrateIngresses() 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 {
func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint) error {
// 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
// blocked in specific namespaces which they were not previously allowed in.

View File

@ -3,7 +3,6 @@ package cli
import (
"context"
"strconv"
"sync"
"testing"
portainer "github.com/portainer/portainer/api"
@ -19,7 +18,6 @@ func Test_ToggleSystemState(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}),
instanceID: "instance",
lock: &sync.Mutex{},
}
err := kcl.ToggleSystemState(nsName, true)
@ -37,12 +35,10 @@ func Test_ToggleSystemState(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "instance",
lock: &sync.Mutex{},
}
err := kcl.ToggleSystemState(nsName, true)
assert.Error(t, err)
})
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),
}}}),
instanceID: "instance",
lock: &sync.Mutex{},
}
err := kcl.ToggleSystemState(nsName, test.isSystem)
@ -81,7 +76,6 @@ func Test_ToggleSystemState(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}),
instanceID: "instance",
lock: &sync.Mutex{},
}
err := kcl.ToggleSystemState(nsName, true)
@ -102,7 +96,6 @@ func Test_ToggleSystemState(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}),
instanceID: "instance",
lock: &sync.Mutex{},
}
err := kcl.ToggleSystemState(nsName, false)
@ -125,7 +118,6 @@ func Test_ToggleSystemState(t *testing.T) {
systemNamespaceLabel: "true",
}}}),
instanceID: "instance",
lock: &sync.Mutex{},
}
err := kcl.ToggleSystemState(nsName, false)
@ -159,7 +151,6 @@ func Test_ToggleSystemState(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(namespace, config),
instanceID: "instance",
lock: &sync.Mutex{},
}
err := kcl.ToggleSystemState(nsName, true)
@ -178,6 +169,5 @@ func Test_ToggleSystemState(t *testing.T) {
actualPolicies, err := kcl.GetNamespaceAccessPolicies()
assert.NoError(t, err, "failed to fetch policies")
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.
if hostURL == "localhost" {
hostURL = hostURL + service.httpsBindAddr
hostURL += service.httpsBindAddr
baseURL = "/"
}

View File

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

View File

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

View File

@ -225,7 +225,7 @@ type (
// It contains some information of Docker's ContainerJSON struct
DockerContainerSnapshot struct {
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
@ -388,8 +388,7 @@ type (
LastCheckInDate int64
// QueryDate of each query with the endpoints list
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
UserTrusted bool
@ -402,6 +401,8 @@ type (
Version string `example:"1.0.0"`
}
EnableGPUManagement bool `json:"EnableGPUManagement"`
// Deprecated fields
// Deprecated in DBVersion == 4
TLS bool `json:"TLS,omitempty"`
@ -415,6 +416,9 @@ type (
// Deprecated in DBVersion == 22
Tags []string `json:"Tags"`
// Deprecated v2.18
IsEdgeDevice bool
}
EnvironmentEdgeSettings struct {
@ -502,6 +506,7 @@ type (
// EndpointPostInitMigrations
EndpointPostInitMigrations struct {
MigrateIngresses bool `json:"MigrateIngresses"`
MigrateGPUs bool `json:"MigrateGPUs"`
}
// Extension represents a deprecated Portainer extension
@ -585,9 +590,12 @@ type (
Flags KubernetesFlags `json:"Flags"`
}
// KubernetesFlags are used to detect if we need to run initial cluster
// detection again.
KubernetesFlags struct {
IsServerMetricsDetected bool `json:"IsServerMetricsDetected"`
IsServerStorageDetected bool `json:"IsServerStorageDetected"`
IsServerMetricsDetected bool `json:"IsServerMetricsDetected"`
IsServerIngressClassDetected bool `json:"IsServerIngressClassDetected"`
IsServerStorageDetected bool `json:"IsServerStorageDetected"`
}
// KubernetesSnapshot represents a snapshot of a specific Kubernetes environment(endpoint) at a specific time
@ -1283,8 +1291,6 @@ type (
UserThemeSettings struct {
// Color represents the color theme of the UI
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
@ -1507,7 +1513,7 @@ type (
const (
// 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 = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
@ -1554,7 +1560,9 @@ const (
)
// List of supported features
var SupportedFeatureFlags = []featureflags.Feature{}
var SupportedFeatureFlags = []featureflags.Feature{
"fdo",
}
const (
_ 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...) {
filePaths = append(filePaths, filesystem.JoinPaths(stack.ProjectPath, file))
}
return filePaths
}

View File

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

View File

@ -36,6 +36,10 @@
border: 1px solid var(--border-input-group-addon-color);
}
.input-group .form-control {
z-index: unset;
}
.text-danger {
color: var(--ui-error-9);
}
@ -150,50 +154,6 @@ code {
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 {
background: var(--bg-dropdown-menu-color);
border-radius: 8px;
@ -358,11 +318,6 @@ input:-webkit-autofill {
}
/* Overide Vendor CSS */
.btn-link:hover {
color: var(--text-link-hover-color) !important;
}
.multiSelect.inlineBlock button {
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 { PortainerEndpointTypes } from 'Portainer/models/endpoint/models';
import { EnvironmentStatus } from '@/react/portainer/environments/types';
import { reactModule } from './react';
@ -16,14 +18,17 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
abstract: true,
onEnter: /* @ngInject */ function onEnter(endpoint, $async, $state, EndpointService, Notifications, StateManager, SystemService) {
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');
return;
}
try {
const status = await checkEndpointStatus(endpoint);
if (endpoint.Type !== 4) {
if (endpoint.Type !== PortainerEndpointTypes.EdgeAgentOnDockerEnvironment) {
await updateEndpointStatus(endpoint, status);
}
endpoint.Status = status;
@ -34,16 +39,22 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
await StateManager.updateEndpointState(endpoint);
} catch (e) {
Notifications.error('Failed loading environment', e);
$state.go('portainer.home', {}, { reload: true });
let params = {};
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) {
try {
await SystemService.ping(endpoint.Id);
return 1;
return EnvironmentStatus.Up;
} catch (e) {
return 2;
return EnvironmentStatus.Down;
}
}

View File

@ -1,7 +1,7 @@
<!-- use registry -->
<div class="row">
<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">
<select
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"
target="_blank"
>
<pr-icon icon="'svg-docker'" size="'lg'"></pr-icon> Search
<pr-icon icon="'svg-docker'" size="'md'"></pr-icon> Search
</a>
</span>
</div>

View File

@ -1,6 +1,7 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withControlledInput } from '@/react-tools/withControlledInput';
import { StackContainersDatatable } from '@/react/docker/stacks/ItemView/StackContainersDatatable';
import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions';
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 { DockerfileDetails } from '@/react/docker/images/ItemView/DockerfileDetails';
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
.module('portainer.docker.react.components', [])
@ -37,5 +41,28 @@ export const componentsModule = angular
)
.component(
'gpu',
r2a(Gpu, ['values', 'onChange', 'gpus', 'usedGpus', 'usedAllGpus'])
).name;
r2a(Gpu, [
'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',
'$transition$',
'$filter',
'$analytics',
'Container',
'ContainerHelper',
'Image',
@ -35,6 +36,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
'FormValidator',
'RegistryService',
'SystemService',
'SettingsService',
'PluginService',
'HttpRequestHelper',
'endpoint',
@ -46,6 +48,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
$timeout,
$transition$,
$filter,
$analytics,
Container,
ContainerHelper,
Image,
@ -60,6 +63,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
FormValidator,
RegistryService,
SystemService,
SettingsService,
PluginService,
HttpRequestHelper,
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) {
const userId = Authentication.getUserDetails().ID;
const resourceControl = newContainer.Portainer.ResourceControl;
@ -1101,7 +1117,8 @@ angular.module('portainer.docker').controller('CreateContainerController', [
return validateForm(accessControlData, $scope.isAdmin);
}
function onSuccess() {
async function onSuccess() {
await sendAnalytics();
Notifications.success('Success', 'Container successfully created');
$state.go('docker.containers', {}, { reload: true });
}

View File

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

View File

@ -78,7 +78,7 @@
</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">
<dashboard-item icon="'layers'" type="'Stack'" value="stackCount"></dashboard-item>
</a>
@ -106,7 +106,12 @@
</a>
<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>

View File

@ -2,10 +2,13 @@ import { FeatureId } from '@/react/portainer/feature-flags/enums';
export default class DockerFeaturesConfigurationController {
/* @ngInject */
constructor($async, $scope, EndpointService, Notifications, StateManager) {
constructor($async, $scope, $state, $analytics, EndpointService, SettingsService, Notifications, StateManager) {
this.$async = $async;
this.$scope = $scope;
this.$state = $state;
this.$analytics = $analytics;
this.EndpointService = EndpointService;
this.SettingsService = SettingsService;
this.Notifications = Notifications;
this.StateManager = StateManager;
@ -35,6 +38,8 @@ export default class DockerFeaturesConfigurationController {
this.save = this.save.bind(this);
this.onChangeField = this.onChangeField.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.onChangeAllowVolumeBrowserForRegularUsers = this.onChangeField('allowVolumeBrowserForRegularUsers');
this.onChangeDisableBindMountsForRegularUsers = this.onChangeField('disableBindMountsForRegularUsers');
@ -52,6 +57,12 @@ export default class DockerFeaturesConfigurationController {
});
}
onToggleGPUManagement(checked) {
this.$scope.$evalAsync(() => {
this.state.enableGPUManagement = checked;
});
}
onChange(values) {
return this.$scope.$evalAsync(() => {
this.formValues = {
@ -69,6 +80,12 @@ export default class DockerFeaturesConfigurationController {
};
}
onGpusChange(value) {
return this.$async(async () => {
this.endpoint.Gpus = value;
});
}
isContainerEditDisabled() {
const {
disableBindMountsForRegularUsers,
@ -92,7 +109,11 @@ export default class DockerFeaturesConfigurationController {
return this.$async(async () => {
try {
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,
allowBindMountsForRegularUsers: !this.formValues.disableBindMountsForRegularUsers,
allowPrivilegedModeForRegularUsers: !this.formValues.disablePrivilegedModeForRegularUsers,
@ -102,33 +123,53 @@ export default class DockerFeaturesConfigurationController {
allowStackManagementForRegularUsers: !this.formValues.disableStackManagementForRegularUsers,
allowContainerCapabilitiesForRegularUsers: !this.formValues.disableContainerCapabilitiesForRegularUsers,
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');
} catch (e) {
this.Notifications.error('Failure', e, 'Failed saving settings');
}
this.state.actionInProgress = false;
this.$state.reload();
});
}
checkAgent() {
const applicationState = this.StateManager.getState();
return applicationState.endpoint.mode.agentProxy;
}
$onInit() {
const securitySettings = this.endpoint.SecuritySettings;
const isAgent = this.checkAgent();
this.isAgent = isAgent;
const applicationState = this.StateManager.getState();
this.isAgent = applicationState.endpoint.mode.agentProxy;
this.isDockerStandaloneEnv = applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE';
this.formValues = {
enableHostManagementFeatures: isAgent && securitySettings.enableHostManagementFeatures,
allowVolumeBrowserForRegularUsers: isAgent && securitySettings.allowVolumeBrowserForRegularUsers,
enableHostManagementFeatures: this.isAgent && securitySettings.enableHostManagementFeatures,
allowVolumeBrowserForRegularUsers: this.isAgent && securitySettings.allowVolumeBrowserForRegularUsers,
disableBindMountsForRegularUsers: !securitySettings.allowBindMountsForRegularUsers,
disablePrivilegedModeForRegularUsers: !securitySettings.allowPrivilegedModeForRegularUsers,
disableHostNamespaceForRegularUsers: !securitySettings.allowHostNamespaceForRegularUsers,
@ -137,5 +178,11 @@ export default class DockerFeaturesConfigurationController {
disableContainerCapabilitiesForRegularUsers: !securitySettings.allowContainerCapabilitiesForRegularUsers,
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 -->
<div class="col-sm-12 form-section-title"> Other </div>
<div class="form-group">
<div class="col-sm-12 pb-3">
<gpus-insights></gpus-insights>
</div>
<div class="col-sm-12">
<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"
name="'outOfDateImageToggle'"
label-class="'col-sm-7 col-lg-4'"
@ -166,7 +185,13 @@
<div class="col-sm-12 form-section-title"> Actions </div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" 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-show="$ctrl.state.actionInProgress">Saving...</span>
</button>

View File

@ -167,6 +167,6 @@ function confirmImageForceRemoval() {
title: 'Are you sure?',
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.',
confirmButton: buildConfirmButton('Remote the image', 'danger'),
confirmButton: buildConfirmButton('Remove the image', 'danger'),
});
}

View File

@ -1,19 +1,5 @@
<form class="form-horizontal">
<div class="col-sm-12 form-section-title"> Edge Groups </div>
<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-groups-selector value="$ctrl.model.EdgeGroups" items="$ctrl.edgeGroups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
<edge-stack-deployment-type-selector
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">
<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">

View File

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

View File

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

View File

@ -21,7 +21,6 @@ export class CreateEdgeGroupController {
};
this.createGroup = this.createGroup.bind(this);
this.createGroupAsync = this.createGroupAsync.bind(this);
}
async $onInit() {
@ -31,20 +30,18 @@ export class CreateEdgeGroupController {
this.state.loaded = true;
}
createGroup() {
return this.$async(this.createGroupAsync);
}
async createGroupAsync() {
this.state.actionInProgress = true;
try {
await this.EdgeGroupService.create(this.model);
this.Notifications.success('Success', 'Edge group successfully created');
this.$state.go('edge.groups');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to create edge group');
} finally {
this.state.actionInProgress = false;
}
async createGroup(model) {
return this.$async(async () => {
this.state.actionInProgress = true;
try {
await this.EdgeGroupService.create(model);
this.Notifications.success('Success', 'Edge group successfully created');
this.$state.go('edge.groups');
} catch (err) {
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.updateGroupAsync = this.updateGroupAsync.bind(this);
}
async $onInit() {
@ -28,20 +27,18 @@ export class EditEdgeGroupController {
this.state.loaded = true;
}
updateGroup() {
return this.$async(this.updateGroupAsync);
}
async updateGroupAsync() {
this.state.actionInProgress = true;
try {
await this.EdgeGroupService.update(this.model);
this.Notifications.success('Success', 'Edge group successfully updated');
this.$state.go('edge.groups');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to update edge group');
} finally {
this.state.actionInProgress = false;
}
updateGroup(group) {
return this.$async(async () => {
this.state.actionInProgress = true;
try {
await this.EdgeGroupService.update(group);
this.Notifications.success('Success', 'Edge group successfully updated');
this.$state.go('edge.groups');
} catch (err) {
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() {
try {
this.edgeGroups = await this.EdgeGroupService.groups();
this.noGroups = this.edgeGroups.length === 0;
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
}

View File

@ -39,19 +39,7 @@
</div>
<!-- !name-input -->
<div class="col-sm-12 form-section-title"> Edge Groups </div>
<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-groups-selector value="$ctrl.formValues.Groups" on-change="($ctrl.onChangeGroups)" items="$ctrl.edgeGroups"></edge-groups-selector>
<edge-stack-deployment-type-selector
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 customTemplateModule from './custom-templates';
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) {
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');
return;
}
try {
if (endpoint.Type === 7) {
if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
//edge
try {
await KubernetesHealthService.ping(endpoint.Id);
endpoint.Status = 1;
endpoint.Status = EnvironmentStatus.Up;
} catch (e) {
endpoint.Status = 2;
endpoint.Status = EnvironmentStatus.Down;
}
}
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.');
}
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) {
Notifications.error('Failed loading environment', e);
$state.go('portainer.home', {}, { reload: true });
let params = {};
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">
<!-- toolbar header actions and settings -->
<div ng-if="$ctrl.isPrimary" class="toolBar !flex-col gap-1">
<div class="toolBar vertical-center w-full flex-wrap !gap-x-5 !gap-y-1 !p-0">
<div ng-if="$ctrl.isPrimary" class="toolBar !flex-col !gap-0">
<div class="toolBar w-full !items-start !gap-x-5 !p-0">
<div class="toolBarTitle vertical-center">
<div class="widget-icon space-right">
<pr-icon icon="'box'"></pr-icon>
</div>
Applications
</div>
<div class="searchBar vertical-center !mr-0 min-w-[280px]">
<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 for an application..."
auto-focus
ng-model-options="{ debounce: 300 }"
data-cy="k8sApp-searchApplicationsInput"
/>
</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"
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="settings" data-cy="k8sApp-tableSettings">
<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>
<!-- use row reverse to make the left most items wrap first to the right side in the next line -->
<div class="inline-flex flex-row-reverse flex-wrap !gap-x-5 gap-y-3">
<div class="settings" data-cy="k8sApp-tableSettings">
<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>
<a type="button" class="btn btn-sm btn-default btn-sm" ng-click="$ctrl.settings.open = false;" data-cy="k8sApp-tableSettingsCloseButton">Close</a>
<div>
<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>
</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>
</span>
</div>
</div>
</div>
<div class="flex w-full flex-row" ng-if="$ctrl.isAdmin && !$ctrl.settings.showSystem">
<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.
</span>
</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>
<!-- data table content -->
<div ng-class="{ 'table-responsive': $ctrl.isPrimary, 'inner-datatable': !$ctrl.isPrimary }">

View File

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

View File

@ -21,6 +21,8 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
this.state = Object.assign(this.state, {
expandAll: false,
expandedItems: [],
namespace: '',
namespaces: [],
});
this.filters = {
@ -70,6 +72,8 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
};
this.onSettingsShowSystemChange = function () {
this.updateNamespace();
this.setSystemResources(this.settings.showSystem);
DatatableService.setDataTableSettings(this.tableKey, this.settings);
};
@ -135,6 +139,45 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
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.isAdmin = Authentication.isAdmin();
this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
@ -172,7 +215,16 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
if (storedSettings !== null) {
this.settings = storedSettings;
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();
};
},

View File

@ -1,99 +1,122 @@
<div class="datatable">
<!-- table title and action menu -->
<div class="toolBar !flex-col gap-1">
<div class="toolBar vertical-center w-full flex-wrap !gap-x-5 !gap-y-1 !p-0">
<!-- title -->
<div class="toolBar !flex-col !gap-0">
<div class="toolBar w-full !items-start !gap-x-5 !p-0">
<div class="toolBarTitle vertical-center">
<div class="widget-icon space-right">
<pr-icon icon="'list'"></pr-icon>
</div>
Stacks
</div>
<!-- actions -->
<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 for a stack..."
auto-focus
ng-model-options="{ debounce: 300 }"
/>
</div>
<div class="actionBar !mr-0 !gap-3">
<button
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-removeStackButton"
>
<pr-icon icon="'trash-2'"></pr-icon>
Remove
</button>
<div class="settings" data-cy="k8sApp-StackTableSettings">
<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>
<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-stack"
/>
<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-stack"
>
<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>
<!-- use row reverse to make the left most items wrap first to the right side in the next line -->
<div class="inline-flex flex-row-reverse flex-wrap !gap-x-5 gap-y-3">
<div class="actionBar !mr-0 !gap-3">
<button
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-removeStackButton"
>
<pr-icon icon="'trash-2'"></pr-icon>
Remove
</button>
<div class="settings" data-cy="k8sApp-StackTableSettings">
<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>
<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-stack"
/>
<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-stack"
>
<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>
<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>
<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>
</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>
<!-- info text -->
<div class="flex w-full flex-row">
<span class="small text-muted vertical-center mt-1" 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">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
System resources are hidden, this can be changed in the table settings.
</span>
</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 class="table-responsive">
<table class="table-hover nowrap-cells table">

View File

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

View File

@ -13,6 +13,8 @@ angular.module('portainer.kubernetes').controller('KubernetesApplicationsStacksD
this.state = Object.assign(this.state, {
expandedItems: [],
expandAll: false,
namespace: '',
namespaces: [],
});
var ctrl = this;
@ -22,6 +24,8 @@ angular.module('portainer.kubernetes').controller('KubernetesApplicationsStacksD
});
this.onSettingsShowSystemChange = function () {
this.updateNamespace();
this.setSystemResources(this.settings.showSystem);
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.isAdmin = Authentication.isAdmin();
this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
@ -103,11 +145,20 @@ angular.module('portainer.kubernetes').controller('KubernetesApplicationsStacksD
this.filters.state.open = false;
}
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
var storedSettings = DatatableService.getDataTableSettings(this.settingsKey);
if (storedSettings !== null) {
this.settings = storedSettings;
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();
};
},

View File

@ -141,7 +141,9 @@ export default class HelmTemplatesController {
try {
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.resourcePool = this.state.resourcePools[0];
} catch (err) {

View File

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

View File

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

View File

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

View File

@ -7,9 +7,10 @@ import $allSettled from 'Portainer/services/allSettled';
class KubernetesNamespaceService {
/* @ngInject */
constructor($async, KubernetesNamespaces) {
constructor($async, KubernetesNamespaces, LocalStorage) {
this.$async = $async;
this.KubernetesNamespaces = KubernetesNamespaces;
this.LocalStorage = LocalStorage;
this.getAsync = this.getAsync.bind(this);
this.getAllAsync = this.getAllAsync.bind(this);
@ -17,6 +18,7 @@ class KubernetesNamespaceService {
this.deleteAsync = this.deleteAsync.bind(this);
this.getJSONAsync = this.getJSONAsync.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) {
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 params = {};
const data = await this.KubernetesNamespaces().create(params, payload).$promise;
await this.refreshCacheAsync();
return data;
} catch (err) {
throw new PortainerError('Unable to create namespace', err);
@ -104,6 +116,14 @@ class KubernetesNamespaceService {
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
*/

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

View File

@ -19,6 +19,11 @@
refresh-callback="ctrl.getApplications"
on-publishing-mode-click="(ctrl.onPublishingModeClick)"
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>
</uib-tab>
@ -30,6 +35,11 @@
order-by="Name"
refresh-callback="ctrl.getApplications"
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>
</uib-tab>

View File

@ -9,9 +9,23 @@ import { confirmDelete } from '@@/modals/confirm';
class KubernetesApplicationsController {
/* @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.$state = $state;
this.$scope = $scope;
this.Authentication = Authentication;
this.Notifications = Notifications;
this.KubernetesApplicationService = KubernetesApplicationService;
this.HelmService = HelmService;
@ -19,6 +33,7 @@ class KubernetesApplicationsController {
this.Authentication = Authentication;
this.LocalStorage = LocalStorage;
this.StackService = StackService;
this.KubernetesNamespaceService = KubernetesNamespaceService;
this.onInit = this.onInit.bind(this);
this.getApplications = this.getApplications.bind(this);
@ -28,6 +43,8 @@ class KubernetesApplicationsController {
this.removeStacksAction = this.removeStacksAction.bind(this);
this.removeStacksActionAsync = this.removeStacksActionAsync.bind(this);
this.onPublishingModeClick = this.onPublishingModeClick.bind(this);
this.onChangeNamespaceDropdown = this.onChangeNamespaceDropdown.bind(this);
this.setSystemResources = this.setSystemResources.bind(this);
}
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() {
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 { helmApplications, nonHelmApplications } = KubernetesApplicationHelper.getNestedApplications(configuredApplications);
this.state.applications = [...helmApplications, ...nonHelmApplications];
this.state.stacks = KubernetesStackHelper.stacksFromApplications(applications);
this.state.ports = KubernetesApplicationHelper.portMappingsFromApplications(applications);
this.$scope.$apply();
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve applications');
}
}
setSystemResources(flag) {
this.state.isSystemResources = flag;
}
getApplications() {
return this.$async(this.getApplicationsAsync);
}
@ -153,7 +186,27 @@ class KubernetesApplicationsController {
applications: [],
stacks: [],
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();
this.state.viewReady = true;

View File

@ -212,7 +212,7 @@
</div>
<!-- #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>
</div>
@ -336,7 +336,7 @@
</div>
</div>
<div class="col-sm-12 mt-4">
<div class="col-sm-12 mt-2">
<span
ng-if="ctrl.formValues.Containers.length <= 1"
class="btn btn-primary btn-sm btn btn-sm btn-light mb-2 !ml-0"
@ -503,7 +503,7 @@
</div>
<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>
</div>

View File

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

View File

@ -15,7 +15,7 @@
<form class="form-horizontal" name="kubernetesConfigurationCreationForm" autocomplete="off">
<!-- resource-pool -->
<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">
<select
class="form-control"

View File

@ -196,7 +196,10 @@ class KubernetesCreateConfigurationController {
try {
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];
await this.getConfigurations();

View File

@ -165,7 +165,10 @@ class KubernetesConfigureController {
const allResourcePools = await this.KubernetesResourcePoolService.get();
const resourcePools = _.filter(
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) => {

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