Compare commits

...

65 Commits

Author SHA1 Message Date
Matt Hook
8f42ba0254 allow libhelm to use forward proxy (#10330) 2023-09-19 18:07:41 +12:00
Chaim Lev-Ari
6f81fcc169 fix(api): restore deleted apis [EE-6090] (#10266) 2023-09-19 13:44:55 +12:00
Oscar Zhou
46949508a4 fix(db/migration): avoid fatal error from being overwritten (#10317) 2023-09-18 14:32:57 +12:00
Matt Hook
034157be9a improved user update validation (#10322) 2023-09-18 12:29:12 +12:00
Dakota Walsh
011a1ce720 fix(kubernetes): add prefix only when needed EE-6068 (#3918) (#10311) 2023-09-15 07:59:37 +12:00
Prabhat Khera
a4922eb693 fix(docker): revert PR #10297 and #10242 [EE-5825] (#10308)
* revert PR #10297 and #10242
2023-09-14 15:51:19 +12:00
cmeng
8c77c5ffbe fix(backup): add chisel key to backup EE-6105 (#10282) 2023-09-13 09:01:31 +12:00
andres-portainer
a062c36ff5 fix(gitops): avoid cancelling the auto updates for any error EE-5604 (#10295) 2023-09-12 17:52:52 -03:00
Oscar Zhou
122fd835dc fix(db/init): check server version and db schema version (#10299) 2023-09-12 15:55:15 +12:00
Prabhat Khera
f7ff07833f fix(security): added restrictions to see user names [EE-5825] (#10297)
* fix(security): added restrictions to see user names [EE-5825]

* use pluralize method
2023-09-12 13:15:29 +12:00
matias-portainer
8010167006 fix(authentication): allow nested whitespaces on AD OU names EE-5206 (#10261) 2023-09-07 11:03:04 -03:00
Matt Hook
4c79e9ef6b prevent regular users changing their username (#10246) 2023-09-06 08:44:24 +12:00
Matt Hook
88ea0cb64f non-admins must supply existing passwd when changing passwd (#10248) 2023-09-06 07:53:31 +12:00
Dakota Walsh
5f50f20a7a fix(security): block user access policies for non admins EE-5826 (#10244) 2023-09-05 09:18:17 +12:00
Dakota Walsh
bbc26682dd fix(security): block non-admins from user info listing EE-5825 (#10242) 2023-09-05 09:17:10 +12:00
Matt Hook
f74704fca4 Bump 2.19.0 release to 2.19.1 (#10237) 2023-09-04 12:06:47 +12:00
Chaim Lev-Ari
9b52bd50d9 fix(ui/switch): reduce label size [EE-3803] (#10018) 2023-09-03 10:26:33 +01:00
Prabhat Khera
04073f0d1f add tls options to the tls dropdown (#10222) 2023-09-01 10:42:26 +12:00
Ali
c035e4a778 fix(k8sconfigure): make ingress restrict be only [EE-6062] (#10217)
Co-authored-by: testa113 <testa113>
2023-09-01 06:11:43 +12:00
Prabhat Khera
7abed624d9 fix showing default ns for ingresses on edit (#10196) 2023-08-29 15:12:40 +12:00
cmeng
1e24451cc9 fix(relative-path): not deploy git stack via unpacker EE-6043 (#10194) 2023-08-29 11:48:57 +12:00
Prabhat Khera
adcfcdd6e3 fix ECR registry token refresh (#10190) 2023-08-29 10:32:47 +12:00
Dakota Walsh
e6e3810fa4 fix(registry): ecr secret fix [EE-5673] (#10108) 2023-08-28 08:38:40 +12:00
andres-portainer
5e20854f86 fix(docker): use version negotiation for the Docker client EE-5797 (#9251) 2023-08-22 17:59:46 -03:00
Chaim Lev-Ari
69f3670ce5 fix(ui/datatables): sync page count with filtering [EE-5890] (#10009) 2023-08-22 09:36:27 +03:00
Chaim Lev-Ari
f24555c6c9 feat(ui): add confirmation to delete actions [EE-4612] (#10002) 2023-08-19 19:18:58 +03:00
cmeng
1c79f10ae8 fix(migrator): prevent duplicated migration EE-5777 (#10076) 2023-08-18 21:40:42 +12:00
Chaim Lev-Ari
dc76900a28 feat(edge/stacks): reload edge stacks from server [EE-5970] (#10062) 2023-08-17 14:09:43 +03:00
cmeng
74eeb9da06 fix(datatable): image page not loading image list EE-5978 (#10070) 2023-08-17 09:53:25 +12:00
Chaim Lev-Ari
77120abf33 fix(edge/groups): filter selected environments [EE-5891] (#10016) 2023-08-16 12:24:43 +03:00
Chaim Lev-Ari
dffdf6783c fix(edge/stacks): show pending envs [EE-5913] (#10051) 2023-08-16 10:22:37 +03:00
Ali
55236129ea fix(ingress): empty initial selection + fixes [EE-5852] (#10067)
Co-authored-by: testa113 <testa113>
2023-08-16 18:07:49 +12:00
Ali
d54dd47b21 fix(environments): fix env table [EE-5971] (#10060)
Co-authored-by: testa113 <testa113>
2023-08-16 13:21:16 +12:00
Prabhat Khera
360969c93e fix edit namespace resource quota issue (#10063) 2023-08-16 10:24:55 +12:00
Chaim Lev-Ari
3ea6d2b9d9 feat(edge/configs): add context help [EE-5963] (#10054) 2023-08-15 18:46:53 +03:00
Chaim Lev-Ari
577a36e04e fix(edge/devices): search waiting room devices [EE-5895] (#10015) 2023-08-15 06:05:14 +03:00
matias-portainer
6aa978d5e9 fix(authentication): allow whitespaces when loading AD OU name EE-5206 (#9978) 2023-08-14 12:18:21 -03:00
matias-portainer
0b8d72bfd4 fix(edge/stacks): add pagination to environments list EE-5908 (#10043) 2023-08-14 12:16:49 -03:00
Chaim Lev-Ari
faa1387110 feat(edge/stacks): info for old agent status [EE-5792] (#10012) 2023-08-14 16:04:20 +03:00
Ali
f5cc245c63 fix(app): use correct withCurrentUser wrapper [EE-5928] (#10041)
Co-authored-by: testa113 <testa113>
2023-08-14 16:53:36 +12:00
cmeng
20c6965ce0 fix(stack): fail to start swarm stack with private image EE-4797 (#10046) 2023-08-14 16:13:15 +12:00
Ali
53679f9381 fix(microk8s): PO ui fixes [EE-5900] (#10032)
Co-authored-by: testa113 <testa113>
2023-08-14 12:35:03 +12:00
andres-portainer
e1951baac0 fix(unpacker): implement unpacker error parsing EE-5779 (#10006) 2023-08-10 10:26:09 -03:00
Oscar Zhou
187ec2aa9a fix(stagger): introduce stack version into DeploymentInfo struct (#10027) 2023-08-10 11:58:47 +12:00
matias-portainer
125db4f0de fix(edge/stacks): fix UI issues EE-5844 (#10022) 2023-08-09 10:09:15 -03:00
cmeng
59be96e9e8 fix(edge-stack): detaching swarm stack from git repository EE-5812 (#9997) 2023-08-07 10:33:08 +12:00
Oscar Zhou
d3420f39c1 fix(react/datatable): override getColumnCanGlobalFilter method (#9991) 2023-08-07 10:30:31 +12:00
cmeng
004c86578d fix(edge-stack): detaching from git repository EE-5812 (#9988) 2023-08-04 15:17:51 +12:00
cmeng
b3d404b378 fix(registry): registry login failure for regular stack EE-5832 (#9985) 2023-08-04 15:17:04 +12:00
Ali
82faf20c68 fix(app): update summary with ingresses [EE-5847] (#9974)
Co-authored-by: testa113 <testa113>
2023-08-04 13:48:18 +12:00
Chaim Lev-Ari
18e40cd973 fix(home): empty default sort [EE-5822] (#9950) 2023-08-03 16:21:00 -03:00
Chaim Lev-Ari
9c4d512a4c fix(docker/images): show empty size cell [EE-5823] (#9953) 2023-08-03 16:19:50 -03:00
Ali
ce5c38f841 fix(ingress): ingress ui feedback [EE-5852] (#9983)
Co-authored-by: testa113 <testa113>
2023-08-03 23:03:07 +12:00
cmeng
dbb79a181e fix(edge-stack): unable to edit edge stack EE-5845 (#9980) 2023-08-03 17:20:56 +12:00
matias-portainer
2177c27dc4 fix(endpoints): fix nil pointer dereference EE-5843 (#9970) 2023-08-02 11:06:43 -03:00
Matt Hook
bfdd72d644 show kube icon for custom template (#9967) 2023-08-02 09:43:39 +12:00
Ali
998bf481f7 fix(ingress): loading and ui fixes [EE-5132] (#9960) 2023-08-01 19:31:29 +12:00
Matt Hook
c97ef40cc0 bump compose to 2.20.2 (#9965) 2023-08-01 12:27:28 +12:00
Ali
cbae7bdf82 fix(app): improve perceived ingress load time [EE-5805] (#9948)
Co-authored-by: testa113 <testa113>
2023-07-31 20:18:52 +12:00
cmeng
f4ec4d6175 fix(stack): update gitops updates tooltip EE-5827 (#9961) 2023-07-31 18:46:04 +12:00
Prabhat Khera
ec39d5a88e upgrade helm binary to v3.12.2 (#9264) 2023-07-28 15:06:53 +12:00
Matt Hook
d0d9c2a93b post po review changes (#9265) 2023-07-28 07:53:21 +12:00
Ali
73010efd8d fix(UI): PO review tweaks [EE-5776] (#9268)
Co-authored-by: testa113 <testa113>
2023-07-28 07:50:46 +12:00
Dakota Walsh
88de50649f fix(metrics): node chart race condition EE-5447 (#9252) 2023-07-27 11:46:46 +12:00
Dakota Walsh
fc89066846 fix(jwt): replace deprecated gorilla/securecookie [EE-5153] (#9262) 2023-07-27 09:44:43 +12:00
146 changed files with 1860 additions and 885 deletions

View File

@@ -1,9 +1,6 @@
package apikey
import (
"crypto/rand"
"io"
portainer "github.com/portainer/portainer/api"
)
@@ -18,13 +15,3 @@ type APIKeyService interface {
DeleteAPIKey(apiKeyID portainer.APIKeyID) error
InvalidateUserKeyCache(userId portainer.UserID) bool
}
// generateRandomKey generates a random key of specified length
// source: https://github.com/gorilla/securecookie/blob/master/securecookie.go#L515
func generateRandomKey(length int) []byte {
k := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, k); err != nil {
return nil
}
return k
}

View File

@@ -3,6 +3,7 @@ package apikey
import (
"testing"
"github.com/portainer/portainer/api/internal/securecookie"
"github.com/stretchr/testify/assert"
)
@@ -33,7 +34,7 @@ func Test_generateRandomKey(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := generateRandomKey(tt.wantLenth)
got := securecookie.GenerateRandomKey(tt.wantLenth)
is.Equal(tt.wantLenth, len(got))
})
}
@@ -41,7 +42,7 @@ func Test_generateRandomKey(t *testing.T) {
t.Run("Generated keys are unique", func(t *testing.T) {
keys := make(map[string]bool)
for i := 0; i < 100; i++ {
key := generateRandomKey(8)
key := securecookie.GenerateRandomKey(8)
_, ok := keys[string(key)]
is.False(ok)
keys[string(key)] = true

View File

@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/securecookie"
"github.com/pkg/errors"
)
@@ -39,7 +40,7 @@ func (a *apiKeyService) HashRaw(rawKey string) []byte {
// GenerateApiKey generates a raw API key for a user (for one-time display).
// The generated API key is stored in the cache and database.
func (a *apiKeyService) GenerateApiKey(user portainer.User, description string) (string, *portainer.APIKey, error) {
randKey := generateRandomKey(32)
randKey := securecookie.GenerateRandomKey(32)
encodedRawAPIKey := base64.StdEncoding.EncodeToString(randKey)
prefixedAPIKey := portainerAPIKeyPrefix + encodedRawAPIKey

View File

@@ -30,6 +30,7 @@ var filesToBackup = []string{
"portainer.key",
"portainer.pub",
"tls",
"chisel",
}
// Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file.

View File

@@ -20,6 +20,7 @@ import (
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/datastore/migrator"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/docker"
dockerclient "github.com/portainer/portainer/api/docker/client"
@@ -119,11 +120,15 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
log.Fatal().Err(err).Msg("failed generating instance id")
}
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{})
migratorCount := migratorInstance.GetMigratorCountOfCurrentAPIVersion()
// from MigrateData
v := models.Version{
SchemaVersion: portainer.APIVersion,
Edition: int(portainer.PortainerCE),
InstanceID: instanceId.String(),
MigratorCount: migratorCount,
}
store.VersionService.UpdateVersion(&v)
@@ -152,6 +157,16 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
return store
}
// checkDBSchemaServerVersionMatch checks if the server version matches the db scehma version
func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersion string, serverEdition int) bool {
v, err := dbStore.Version().Version()
if err != nil {
return false
}
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
}
func initComposeStackManager(composeDeployer libstack.Deployer, proxyManager *proxy.Manager) portainer.ComposeStackManager {
composeWrapper, err := exec.NewComposeStackManager(composeDeployer, proxyManager)
if err != nil {
@@ -383,6 +398,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
log.Fatal().Err(err).Msg("")
}
// check if the db schema version matches with server version
if !checkDBSchemaServerVersionMatch(dataStore, portainer.APIVersion, int(portainer.Edition)) {
log.Fatal().Msg("The database schema version does not align with the server version. Please consider reverting to the previous server version or addressing the database migration issue.")
}
instanceID, err := dataStore.Version().InstanceID()
if err != nil {
log.Fatal().Err(err).Msg("failed getting instance id")

View File

@@ -50,10 +50,10 @@ func (store *Store) MigrateData() error {
if err != nil {
err = errors.Wrap(err, "failed to migrate database")
log.Warn().Msg("migration failed, restoring database to previous version")
err = store.restoreWithOptions(&BackupOptions{BackupPath: backupPath})
if err != nil {
return errors.Wrap(err, "failed to restore database")
log.Warn().Err(err).Msg("migration failed, restoring database to previous version")
restorErr := store.restoreWithOptions(&BackupOptions{BackupPath: backupPath})
if restorErr != nil {
return errors.Wrap(restorErr, "failed to restore database")
}
log.Info().Msg("database restored to previous version")

View File

@@ -1,7 +1,7 @@
package datastore
import (
portaineree "github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices"
)
@@ -72,7 +72,7 @@ func dbVersionToSemanticVersion(dbVersion int) string {
func (store *Store) getOrMigrateLegacyVersion() (*models.Version, error) {
// Very old versions of portainer did not have a version bucket, lets set some defaults
dbVersion := 24
edition := int(portaineree.PortainerCE)
edition := int(portainer.PortainerCE)
instanceId := ""
// If we already have a version key, we don't need to migrate

View File

@@ -115,10 +115,16 @@ func (m *Migrator) updateEdgeStackStatusForDB100() error {
}
if environmentStatus.Details.Ok {
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
Type: portainer.EdgeStackStatusRunning,
Time: time.Now().Unix(),
})
statusArray = append(statusArray,
portainer.EdgeStackDeploymentStatus{
Type: portainer.EdgeStackStatusDeploymentReceived,
Time: time.Now().Unix(),
},
portainer.EdgeStackDeploymentStatus{
Type: portainer.EdgeStackStatusRunning,
Time: time.Now().Unix(),
},
)
}
if environmentStatus.Details.ImagesPulled {

View File

@@ -148,6 +148,17 @@ func (m *Migrator) LatestMigrations() Migrations {
return m.migrations[len(m.migrations)-1]
}
func (m *Migrator) GetMigratorCountOfCurrentAPIVersion() int {
migratorCount := 0
latestMigrations := m.LatestMigrations()
if latestMigrations.Version.Equal(semver.MustParse(portainer.APIVersion)) {
migratorCount = len(latestMigrations.MigrationFuncs)
}
return migratorCount
}
// !NOTE: Migration funtions should ideally be idempotent.
// ! Which simply means the function can run over the same data many times but only transform it once.
// ! In practice this really just means an extra check or two to ensure we're not destroying valid data.

View File

@@ -944,6 +944,6 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.19.0\",\"MigratorCount\":3,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.19.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
}
}

View File

@@ -57,20 +57,20 @@ func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeNam
func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
return client.NewClientWithOpts(
client.WithHost(endpoint.URL),
client.WithVersion(dockerClientVersion),
client.WithAPIVersionNegotiation(),
)
}
func CreateClientFromEnv() (*client.Client, error) {
return client.NewClientWithOpts(
client.FromEnv,
client.WithVersion(dockerClientVersion),
client.WithAPIVersionNegotiation(),
)
}
func CreateSimpleClient() (*client.Client, error) {
return client.NewClientWithOpts(
client.WithVersion(dockerClientVersion),
client.WithAPIVersionNegotiation(),
)
}
@@ -82,7 +82,7 @@ func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*cli
return client.NewClientWithOpts(
client.WithHost(endpoint.URL),
client.WithVersion(dockerClientVersion),
client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli),
)
}
@@ -116,7 +116,7 @@ func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.D
return client.NewClientWithOpts(
client.WithHost(endpointURL),
client.WithVersion(dockerClientVersion),
client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli),
client.WithHTTPHeaders(headers),
)
@@ -144,7 +144,7 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.
return client.NewClientWithOpts(
client.WithHost(endpoint.URL),
client.WithVersion(dockerClientVersion),
client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli),
client.WithHTTPHeaders(headers),
)

View File

@@ -15,6 +15,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/rs/zerolog/log"
)
// SwarmStackManager represents a service for managing stacks.
@@ -64,16 +65,35 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
if registry.Authentication {
err = registryutils.EnsureRegTokenValid(manager.dataStore, &registry)
if err != nil {
return err
log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to validate registry token. Skip logging with this registry.")
continue
}
username, password, err := registryutils.GetRegEffectiveCredential(&registry)
if err != nil {
return err
log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to get effective credential. Skip logging with this registry.")
continue
}
registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL)
runCommandAndCaptureStdErr(command, registryArgs, nil, "")
err = runCommandAndCaptureStdErr(command, registryArgs, nil, "")
if err != nil {
log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to login.")
}
}
}

View File

@@ -27,7 +27,6 @@ require (
github.com/google/go-cmp v0.5.9
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/websocket v1.5.0
github.com/hashicorp/golang-lru v0.5.4
github.com/joho/godotenv v1.4.0

View File

@@ -203,8 +203,6 @@ github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

View File

@@ -3,6 +3,7 @@ package customtemplates
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"regexp"
@@ -472,3 +473,29 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po
return customTemplate, nil
}
// @id CustomTemplateCreate
// @summary Create a custom template
// @description Create a custom template.
// @description **Access policy**: authenticated
// @tags custom_templates
// @security ApiKeyAuth
// @security jwt
// @accept json,multipart/form-data
// @produce json
// @param method query string true "method for creating template" Enums(string, file, repository)
// @param body body object true "for body documentation see the relevant /custom_templates/{method} endpoint"
// @success 200 {object} portainer.CustomTemplate
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @deprecated
// @router /custom_templates [post]
func deprecatedCustomTemplateCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: method", err)
}
url := fmt.Sprintf("/custom_templates/create/%s", method)
return url, nil
}

View File

@@ -8,6 +8,7 @@ import (
httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
)
@@ -32,6 +33,7 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/custom_templates/create/{method}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateCreate))).Methods(http.MethodPost)
h.Handle("/custom_templates", middlewares.Deprecated(h, deprecatedCustomTemplateCreateUrlParser)).Methods(http.MethodPost) // Deprecated
h.Handle("/custom_templates",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateList))).Methods(http.MethodGet)
h.Handle("/custom_templates/{id}",

View File

@@ -2,6 +2,7 @@ package edgejobs
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
@@ -287,3 +288,26 @@ func (handler *Handler) addAndPersistEdgeJob(tx dataservices.DataStoreTx, edgeJo
return tx.EdgeJob().CreateWithID(edgeJob.ID, edgeJob)
}
// @id EdgeJobCreate
// @summary Create an EdgeJob
// @description **Access policy**: administrator
// @tags edge_jobs
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param method query string true "Creation Method" Enums(file, string)
// @param body body object true "for body documentation see the relevant /edge_jobs/create/{method} endpoint"
// @success 200 {object} portainer.EdgeGroup
// @failure 503 "Edge compute features are disabled"
// @failure 500
// @deprecated
// @router /edge_jobs [post]
func deprecatedEdgeJobCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
}
return fmt.Sprintf("/edge_jobs/create/%s", method), nil
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/gorilla/mux"
@@ -29,6 +30,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
h.Handle("/edge_jobs",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobList)))).Methods(http.MethodGet)
h.Handle("/edge_jobs",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeJobCreateUrlParser)))).Methods(http.MethodPost)
h.Handle("/edge_jobs/create/{method}",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobCreate)))).Methods(http.MethodPost)
h.Handle("/edge_jobs/{id}",

View File

@@ -1,6 +1,7 @@
package edgestacks
import (
"fmt"
"net/http"
httperror "github.com/portainer/libhttp/error"
@@ -18,6 +19,7 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
if err != nil {
return httperror.BadRequest("Invalid query parameter: method", err)
}
dryrun, _ := request.RetrieveBooleanQueryParameter(r, "dryrun", true)
tokenData, err := security.RetrieveTokenData(r)
@@ -60,3 +62,26 @@ func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method str
return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file")
}
// @id EdgeStackCreate
// @summary Create an EdgeStack
// @description **Access policy**: administrator
// @tags edge_stacks
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param method query string true "Creation Method" Enums(file,string,repository)
// @param body body object true "for body documentation see the relevant /edge_stacks/create/{method} endpoint"
// @success 200 {object} portainer.EdgeStack
// @failure 500
// @failure 503 "Edge compute features are disabled"
// @deprecated
// @router /edge_stacks [post]
func deprecatedEdgeStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
}
return fmt.Sprintf("/edge_stacks/create/%s", method), nil
}

View File

@@ -38,6 +38,8 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/edge_stacks/create/{method}",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
h.Handle("/edge_stacks",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeStackCreateUrlParser)))).Methods(http.MethodPost) // Deprecated
h.Handle("/edge_stacks",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet)
h.Handle("/edge_stacks/{id}",

View File

@@ -50,7 +50,7 @@ func (handler *Handler) storeStackFile(stack *portainer.EdgeStack, deploymentTyp
entryPoint = stack.ManifestPath
}
_, err := handler.FileService.StoreEdgeStackFileFromBytesByVersion(stackFolder, entryPoint, stack.Version, config)
_, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, entryPoint, config)
if err != nil {
return fmt.Errorf("unable to persist updated Compose file with version on disk: %w", err)
}

View File

@@ -294,7 +294,7 @@ func shouldReloadTLSConfiguration(endpoint *portainer.Endpoint, payload *endpoin
// When updating Docker API environment, as long as TLS is true and TLSSkipVerify is false,
// we assume that new TLS files have been uploaded and we need to reload the TLS configuration.
if endpoint.Type != portainer.DockerEnvironment ||
!strings.HasPrefix(*payload.URL, "tcp://") ||
(payload.URL != nil && !strings.HasPrefix(*payload.URL, "tcp://")) ||
payload.TLS == nil || !*payload.TLS {
return false
}

View File

@@ -34,6 +34,7 @@ type EnvironmentsQuery struct {
edgeCheckInPassedSeconds int
edgeStackId portainer.EdgeStackID
edgeStackStatus *portainer.EdgeStackStatusType
excludeIds []portainer.EndpointID
}
func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
@@ -69,6 +70,11 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
return EnvironmentsQuery{}, err
}
excludeIDs, err := getNumberArrayQueryParameter[portainer.EndpointID](r, "excludeIds")
if err != nil {
return EnvironmentsQuery{}, err
}
agentVersions := getArrayQueryParameter(r, "agentVersions")
name, _ := request.RetrieveQueryParameter(r, "name", true)
@@ -97,6 +103,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
types: endpointTypes,
tagIds: tagIDs,
endpointIds: endpointIDs,
excludeIds: excludeIDs,
tagsPartialMatch: tagsPartialMatch,
groupIds: groupIDs,
status: status,
@@ -118,6 +125,12 @@ func (handler *Handler) filterEndpointsByQuery(filteredEndpoints []portainer.End
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, query.endpointIds)
}
if len(query.excludeIds) > 0 {
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
return !slices.Contains(query.excludeIds, endpoint.ID)
})
}
if len(query.groupIds) > 0 {
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
}
@@ -208,9 +221,12 @@ func endpointStatusInStackMatchesFilter(edgeStackStatus map[portainer.EndpointID
status, ok := edgeStackStatus[envId]
// consider that if the env has no status in the stack it is in Pending state
// workaround because Stack.Status[EnvId].Details.Pending is never set to True in the codebase
if !ok && statusFilter == portainer.EdgeStackStatusPending {
return true
if statusFilter == portainer.EdgeStackStatusPending {
return !ok || len(status.Status) == 0
}
if !ok {
return false
}
return slices.ContainsFunc(status.Status, func(s portainer.EdgeStackDeploymentStatus) bool {

View File

@@ -5,6 +5,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/slices"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
)
@@ -124,6 +125,28 @@ func Test_Filter_edgeFilter(t *testing.T) {
runTests(tests, t, handler, endpoints)
}
func Test_Filter_excludeIDs(t *testing.T) {
ids := []portainer.EndpointID{1, 2, 3, 4, 5, 6, 7, 8, 9}
environments := slices.Map(ids, func(id portainer.EndpointID) portainer.Endpoint {
return portainer.Endpoint{ID: id, GroupID: 1, Type: portainer.DockerEnvironment}
})
handler := setupFilterTest(t, environments)
tests := []filterTest{
{
title: "should exclude IDs 2,5,8",
expected: []portainer.EndpointID{1, 3, 4, 6, 7, 9},
query: EnvironmentsQuery{
excludeIds: []portainer.EndpointID{2, 5, 8},
},
},
}
runTests(tests, t, handler, environments)
}
func runTests(tests []filterTest, t *testing.T, handler *Handler, endpoints []portainer.Endpoint) {
for _, test := range tests {
t.Run(test.title, func(t *testing.T) {

View File

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

View File

@@ -13,6 +13,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/registryutils"
k "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackbuilders"
@@ -176,6 +177,14 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
handler.KubernetesDeployer,
user)
// Refresh ECR registry secret if needed
// RefreshEcrSecret method checks if the namespace has any ECR registry
// otherwise return nil
cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
if err == nil {
registryutils.RefreshEcrSecret(cli, endpoint, handler.DataStore, payload.Namespace)
}
stackBuilderDirector := stackbuilders.NewStackBuilderDirector(k8sStackBuilder)
_, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint)
if httpErr != nil {

View File

@@ -14,6 +14,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/endpointutils"
@@ -58,6 +59,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
h.Handle("/stacks/create/{type}/{method}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost)
h.Handle("/stacks",
bouncer.AuthenticatedAccess(middlewares.Deprecated(h, deprecatedStackCreateUrlParser))).Methods(http.MethodPost) // Deprecated
h.Handle("/stacks",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet)
h.Handle("/stacks/{id}",

View File

@@ -1,6 +1,7 @@
package stacks
import (
"fmt"
"net/http"
"github.com/pkg/errors"
@@ -139,3 +140,53 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
return response.JSON(w, stack)
}
func getStackTypeFromQueryParameter(r *http.Request) (string, error) {
stackType, err := request.RetrieveNumericQueryParameter(r, "type", false)
if err != nil {
return "", err
}
switch stackType {
case 1:
return "swarm", nil
case 2:
return "standalone", nil
case 3:
return "kubernetes", nil
}
return "", errors.New(request.ErrInvalidQueryParameter)
}
// @id StackCreate
// @summary Deploy a new stack
// @description Deploy a new stack into a Docker environment(endpoint) specified via the environment(endpoint) identifier.
// @description **Access policy**: authenticated
// @tags stacks
// @security ApiKeyAuth
// @security jwt
// @accept json,multipart/form-data
// @produce json
// @param type query int true "Stack deployment type. Possible values: 1 (Swarm stack), 2 (Compose stack) or 3 (Kubernetes stack)." Enums(1,2,3)
// @param method query string true "Stack deployment method. Possible values: file, string, repository or url." Enums(string, file, repository, url)
// @param endpointId query int true "Identifier of the environment(endpoint) that will be used to deploy the stack"
// @param body body object true "for body documentation see the relevant /stacks/create/{type}/{method} endpoint"
// @success 200 {object} portainer.Stack
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @deprecated
// @router /stacks [post]
func deprecatedStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
}
stackType, err := getStackTypeFromQueryParameter(r)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: type", err)
}
return fmt.Sprintf("/stacks/create/%s/%s", stackType, method), nil
}

View File

@@ -190,7 +190,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St
if stack.Type == portainer.DockerSwarmStack {
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.UndeployRemoteSwarmStack(stack, endpoint)
}
@@ -200,7 +200,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St
if stack.Type == portainer.DockerComposeStack {
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.UndeployRemoteComposeStack(stack, endpoint)
}

View File

@@ -117,7 +117,7 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
stack.AutoUpdate.JobID = jobID
}
err = handler.startStack(stack, endpoint)
err = handler.startStack(stack, endpoint, securityContext)
if err != nil {
return httperror.InternalServerError("Unable to start stack", err)
}
@@ -136,12 +136,16 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
return response.JSON(w, stack)
}
func (handler *Handler) startStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
func (handler *Handler) startStack(
stack *portainer.Stack,
endpoint *portainer.Endpoint,
securityContext *security.RestrictedRequestContext,
) error {
switch stack.Type {
case portainer.DockerComposeStack:
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StartRemoteComposeStack(stack, endpoint)
}
@@ -149,11 +153,23 @@ func (handler *Handler) startStack(stack *portainer.Stack, endpoint *portainer.E
case portainer.DockerSwarmStack:
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StartRemoteSwarmStack(stack, endpoint)
}
return handler.SwarmStackManager.Deploy(stack, true, true, endpoint)
user, err := handler.DataStore.User().Read(securityContext.UserID)
if err != nil {
return fmt.Errorf("unable to load user information from the database: %w", err)
}
registries, err := handler.DataStore.Registry().ReadAll()
if err != nil {
return fmt.Errorf("unable to retrieve registries from the database: %w", err)
}
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
return handler.StackDeployer.DeploySwarmStack(stack, endpoint, filteredRegistries, true, true)
}
return nil

View File

@@ -125,7 +125,7 @@ func (handler *Handler) stopStack(stack *portainer.Stack, endpoint *portainer.En
case portainer.DockerComposeStack:
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StopRemoteComposeStack(stack, endpoint)
}
@@ -133,7 +133,7 @@ func (handler *Handler) stopStack(stack *portainer.Stack, endpoint *portainer.En
case portainer.DockerSwarmStack:
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StopRemoteSwarmStack(stack, endpoint)
}

View File

@@ -198,6 +198,11 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
stack.Env = payload.Env
if stack.GitConfig != nil {
// detach from git
stack.GitConfig = nil
}
stackFolder := strconv.Itoa(int(stack.ID))
_, err = handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil {
@@ -263,6 +268,11 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
stack.Env = payload.Env
if stack.GitConfig != nil {
// detach from git
stack.GitConfig = nil
}
stackFolder := strconv.Itoa(int(stack.ID))
_, err = handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil {

View File

@@ -13,6 +13,7 @@ import (
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
k "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/stacks/deployments"
@@ -113,6 +114,14 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
return httperror.InternalServerError("Failed to persist deployment file in a temp directory", err)
}
// Refresh ECR registry secret if needed
// RefreshEcrSecret method checks if the namespace has any ECR registry
// otherwise return nil
cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
if err == nil {
registryutils.RefreshEcrSecret(cli, endpoint, handler.DataStore, stack.Namespace)
}
//use temp dir as the stack project path for deployment
//so if the deployment failed, the original file won't be over-written
stack.ProjectPath = tempFileDir

View File

@@ -22,6 +22,7 @@ var (
errAdminCannotRemoveSelf = errors.New("Cannot remove your own user account. Contact another administrator")
errCannotRemoveLastLocalAdmin = errors.New("Cannot remove the last local administrator account")
errCryptoHashFailure = errors.New("Unable to hash data")
errWrongPassword = errors.New("Wrong password")
)
func hideFields(user *portainer.User) {

View File

@@ -21,9 +21,10 @@ type themePayload struct {
}
type userUpdatePayload struct {
Username string `validate:"required" example:"bob"`
Password string `validate:"required" example:"cg9Wgky3"`
Theme *themePayload
Username string `validate:"required" example:"bob"`
Password string `validate:"required" example:"cg9Wgky3"`
NewPassword string `validate:"required" example:"asfj2emv"`
Theme *themePayload
// User role (1 for administrator account and 2 for regular account)
Role int `validate:"required" enums:"1,2" example:"2"`
@@ -37,12 +38,14 @@ func (payload *userUpdatePayload) Validate(r *http.Request) error {
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 nil
}
// @id UserUpdate
// @summary Update a user
// @description Update user details. A regular user account can only update his details.
// @description A regular user account cannot change their username or role.
// @description **Access policy**: authenticated
// @tags users
// @security ApiKeyAuth
@@ -95,6 +98,10 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
}
if payload.Username != "" && payload.Username != user.Username {
if tokenData.Role != portainer.AdministratorRole {
return httperror.Forbidden("Permission denied. Unable to update username", httperrors.ErrResourceAccessDenied)
}
sameNameUser, err := handler.DataStore.User().UserByUsername(payload.Username)
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
return httperror.InternalServerError("Unable to retrieve users from the database", err)
@@ -106,8 +113,28 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
user.Username = payload.Username
}
if payload.Password != "" {
user.Password, err = handler.CryptoService.Hash(payload.Password)
if payload.Password != "" && payload.NewPassword == "" {
if tokenData.Role == portainer.AdministratorRole {
return httperror.BadRequest("Existing password field specified without new password field.", errors.New("To change the password as an admin, you only need 'newPassword' in your request"))
}
return httperror.BadRequest("Existing password field specified without new password field.", errors.New("To change the password, you must include both 'password' and 'newPassword' in your request"))
}
if payload.NewPassword != "" {
// Non-admins need to supply the previous password
if tokenData.Role != portainer.AdministratorRole {
err := handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
if err != nil {
return httperror.Forbidden("Current password doesn't match. Password left unchanged", errors.New("Current password does not match the password provided. Please try again"))
}
}
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
return httperror.BadRequest("Password does not meet the minimum strength requirements", nil)
}
user.Password, err = handler.CryptoService.Hash(payload.NewPassword)
if err != nil {
return httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure)
}

View File

@@ -87,7 +87,7 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques
}
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
return httperror.BadRequest("Password does not meet the requirements", nil)
return httperror.BadRequest("Password does not meet the minimum strength requirements", nil)
}
user.Password, err = handler.CryptoService.Hash(payload.NewPassword)

View File

@@ -0,0 +1,25 @@
package middlewares
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/rs/zerolog/log"
)
// deprecate api route
func Deprecated(router http.Handler, urlBuilder func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
newUrl, err := urlBuilder(w, r)
if err != nil {
httperror.WriteError(w, err.StatusCode, err.Error(), err)
return
}
log.Warn().Msgf("This api is deprecated. Use %s instead", newUrl)
redirectedRequest := r.Clone(r.Context())
redirectedRequest.URL.Path = newUrl
router.ServeHTTP(w, redirectedRequest)
})
}

View File

@@ -6,7 +6,7 @@ import (
func (transport *baseTransport) proxyDeploymentsRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) {
switch request.Method {
case http.MethodPost, http.MethodPatch:
case http.MethodPost, http.MethodPatch, http.MethodPut:
transport.refreshRegistry(request, namespace)
}

View File

@@ -100,6 +100,7 @@ func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.Endpoint
endpointGroup := getAssociatedGroup(&endpoint, groups)
if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) {
endpoint.UserAccessPolicies = nil
endpoints[n] = endpoint
n++
}

View File

@@ -0,0 +1,16 @@
package securecookie
import (
"crypto/rand"
"io"
)
// GenerateRandomKey generates a random key of specified length
// source: https://github.com/gorilla/securecookie/blob/master/securecookie.go#L515
func GenerateRandomKey(length int) []byte {
k := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, k); err != nil {
return nil
}
return k
}

View File

@@ -63,3 +63,12 @@ func RemoveIndex[T any](s []T, index int) []T {
s[index] = s[len(s)-1]
return s[:len(s)-1]
}
// Map applies the given function to each element of the slice and returns a new slice with the results
func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}

View File

@@ -90,7 +90,10 @@ func (service *service) upgradeDocker(licenseKey, version, envType string) error
}
func (service *service) checkImageForDocker(ctx context.Context, image string, skipPullImage bool) error {
cli, err := client.NewClientWithOpts(client.FromEnv)
cli, err := client.NewClientWithOpts(
client.FromEnv,
client.WithAPIVersionNegotiation(),
)
if err != nil {
return errors.Wrap(err, "failed to create docker client")
}

View File

@@ -9,7 +9,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/golang-jwt/jwt/v4"
"github.com/gorilla/securecookie"
"github.com/portainer/portainer/api/internal/securecookie"
"github.com/rs/zerolog/log"
)

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -154,17 +155,29 @@ func (factory *ClientFactory) createCachedAdminKubeClient(endpoint *portainer.En
}, nil
}
// CreateClient returns a pointer to a new Clientset instance
// CreateClient returns a pointer to a new Clientset instance.
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
switch endpoint.Type {
case portainer.KubernetesLocalEnvironment:
return buildLocalClient()
case portainer.AgentOnKubernetesEnvironment:
return factory.buildAgentClient(endpoint)
case portainer.EdgeAgentOnKubernetesEnvironment:
return factory.buildEdgeClient(endpoint)
case portainer.KubernetesLocalEnvironment, portainer.AgentOnKubernetesEnvironment, portainer.EdgeAgentOnKubernetesEnvironment:
c, err := factory.CreateConfig(endpoint)
if err != nil {
return nil, err
}
return kubernetes.NewForConfig(c)
}
return nil, errors.New("unsupported environment type")
}
// CreateConfig returns a pointer to a new kubeconfig ready to create a client.
func (factory *ClientFactory) CreateConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
switch endpoint.Type {
case portainer.KubernetesLocalEnvironment:
return buildLocalConfig()
case portainer.AgentOnKubernetesEnvironment:
return factory.buildAgentConfig(endpoint)
case portainer.EdgeAgentOnKubernetesEnvironment:
return factory.buildEdgeConfig(endpoint)
}
return nil, errors.New("unsupported environment type")
}
@@ -184,20 +197,64 @@ func (rt *agentHeaderRoundTripper) RoundTrip(req *http.Request) (*http.Response,
return rt.roundTripper.RoundTrip(req)
}
func (factory *ClientFactory) buildAgentClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL)
func (factory *ClientFactory) buildAgentConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
var clientURL strings.Builder
if !strings.HasPrefix(endpoint.URL, "http") {
clientURL.WriteString("https://")
}
clientURL.WriteString(endpoint.URL)
clientURL.WriteString("/kubernetes")
return factory.createRemoteClient(endpointURL)
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}
config, err := clientcmd.BuildConfigFromFlags(clientURL.String(), "")
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{
signatureHeader: signature,
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
roundTripper: rt,
}
})
return config, nil
}
func (factory *ClientFactory) buildEdgeClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
func (factory *ClientFactory) buildEdgeConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
if err != nil {
return nil, errors.Wrap(err, "failed activating tunnel")
}
endpointURL := fmt.Sprintf("http://127.0.0.1:%d/kubernetes", tunnel.Port)
return factory.createRemoteClient(endpointURL)
config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
if err != nil {
return nil, err
}
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
config.Insecure = true
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
return &agentHeaderRoundTripper{
signatureHeader: signature,
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
roundTripper: rt,
}
})
return config, nil
}
func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernetes.Clientset, error) {
@@ -227,34 +284,14 @@ func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernete
}
func (factory *ClientFactory) CreateRemoteMetricsClient(endpoint *portainer.Endpoint) (*metricsv.Clientset, error) {
endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL)
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
config, err := factory.CreateConfig(endpoint)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to create metrics KubeConfig")
}
config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
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{
signatureHeader: signature,
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
roundTripper: rt,
}
})
return metricsv.NewForConfig(config)
}
func buildLocalClient() (*kubernetes.Clientset, error) {
func buildLocalConfig() (*rest.Config, error) {
config, err := rest.InClusterConfig()
if err != nil {
return nil, err
@@ -263,7 +300,7 @@ func buildLocalClient() (*kubernetes.Clientset, error) {
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
return kubernetes.NewForConfig(config)
return config, nil
}
func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint) error {

View File

@@ -301,6 +301,8 @@ type (
// StackDeploymentInfo records the information of a deployed stack
StackDeploymentInfo struct {
// Version is the version of the stack and also is the deployed version in edge agent
Version int `json:"Version"`
// FileVersion is the version of the stack file, used to detect changes
FileVersion int `json:"FileVersion"`
// ConfigHash is the commit hash of the git repository used for deploying the stack
@@ -1557,7 +1559,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.19.0"
APIVersion = "2.19.1"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax

View File

@@ -17,6 +17,18 @@ type Scheduler struct {
mu sync.Mutex
}
type PermanentError struct {
err error
}
func NewPermanentError(err error) *PermanentError {
return &PermanentError{err: err}
}
func (e *PermanentError) Error() string {
return e.err.Error()
}
func NewScheduler(ctx context.Context) *Scheduler {
crontab := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)))
crontab.Start()
@@ -84,14 +96,24 @@ func (s *Scheduler) StopJob(jobID string) error {
func (s *Scheduler) StartJobEvery(duration time.Duration, job func() error) string {
ctx, cancel := context.WithCancel(context.Background())
j := cron.FuncJob(func() {
if err := job(); err != nil {
log.Debug().Msg("job returned an error")
cancel()
jobFn := cron.FuncJob(func() {
err := job()
if err == nil {
return
}
var permErr *PermanentError
if errors.As(err, &permErr) {
log.Error().Err(permErr).Msg("job returned a permanent error, it will be stopped")
cancel()
return
}
log.Error().Err(err).Msg("job returned an error, it will be rescheduled")
})
entryID := s.crontab.Schedule(cron.Every(duration), j)
entryID := s.crontab.Schedule(cron.Every(duration), jobFn)
s.mu.Lock()
s.activeJobs[entryID] = cancel

View File

@@ -49,7 +49,7 @@ func Test_JobCanBeStopped(t *testing.T) {
assert.False(t, workDone, "job shouldn't had a chance to run")
}
func Test_JobShouldStop_UponError(t *testing.T) {
func Test_JobShouldStop_UponPermError(t *testing.T) {
s := NewScheduler(context.Background())
defer s.Shutdown()
@@ -58,7 +58,7 @@ func Test_JobShouldStop_UponError(t *testing.T) {
s.StartJobEvery(jobInterval, func() error {
acc++
close(ch)
return fmt.Errorf("failed")
return NewPermanentError(fmt.Errorf("failed"))
})
<-time.After(3 * jobInterval)
@@ -66,6 +66,28 @@ func Test_JobShouldStop_UponError(t *testing.T) {
assert.Equal(t, 1, acc, "job stop after the first run because it returns an error")
}
func Test_JobShouldNotStop_UponError(t *testing.T) {
s := NewScheduler(context.Background())
defer s.Shutdown()
var acc int
ch := make(chan struct{})
s.StartJobEvery(jobInterval, func() error {
acc++
if acc == 2 {
close(ch)
return NewPermanentError(fmt.Errorf("failed"))
}
return errors.New("non-permanent error")
})
<-time.After(3 * jobInterval)
<-ch
assert.Equal(t, 2, acc)
}
func Test_CanTerminateAllJobs_ByShuttingDownScheduler(t *testing.T) {
s := NewScheduler(context.Background())

View File

@@ -8,6 +8,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/pkg/errors"
@@ -29,7 +30,9 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
log.Debug().Int("stack_id", int(stackID)).Msg("redeploying stack")
stack, err := datastore.Stack().Read(stackID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(errors.WithMessagef(err, "failed to get the stack %v", stackID))
} else if err != nil {
return errors.WithMessagef(err, "failed to get the stack %v", stackID)
}
@@ -38,7 +41,15 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
}
endpoint, err := datastore.Endpoint().Endpoint(stack.EndpointID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(
errors.WithMessagef(err,
"failed to find the environment %v associated to the stack %v",
stack.EndpointID,
stack.ID,
),
)
} else if err != nil {
return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID)
}
@@ -78,14 +89,16 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
}
registries, err := getUserRegistries(datastore, user, endpoint.ID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(err)
} else if err != nil {
return err
}
switch stack.Type {
case portainer.DockerComposeStack:
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteComposeStack(stack, endpoint, registries, true, false)
} else {
err = deployer.DeployComposeStack(stack, endpoint, registries, true, false)
@@ -95,7 +108,7 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID)
}
case portainer.DockerSwarmStack:
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteSwarmStack(stack, endpoint, registries, true, true)
} else {
err = deployer.DeploySwarmStack(stack, endpoint, registries, true, true)

View File

@@ -1,7 +1,9 @@
package deployments
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
@@ -12,6 +14,7 @@ import (
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/pkg/stdcopy"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
@@ -184,16 +187,18 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
case <-statusCh:
}
stdErr := &bytes.Buffer{}
out, err := cli.ContainerLogs(ctx, unpackerContainer.ID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true})
if err != nil {
log.Error().Err(err).Msg("unable to get logs from unpacker container")
} else {
outputBytes, err := io.ReadAll(out)
_, err = stdcopy.StdCopy(io.Discard, stdErr, out)
if err != nil {
log.Error().Err(err).Msg("unable to parse logs from unpacker container")
log.Warn().Err(err).Msg("unable to parse logs from unpacker container")
} else {
log.Info().
Str("output", string(outputBytes)).
Str("output", stdErr.String()).
Msg("Stack deployment output")
}
}
@@ -204,6 +209,26 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
}
if status.State.ExitCode != 0 {
dec := json.NewDecoder(stdErr)
for {
errorStruct := struct {
Level string
Error string
}{}
if err := dec.Decode(&errorStruct); errors.Is(err, io.EOF) {
break
} else if err != nil {
log.Warn().Err(err).Msg("unable to parse logs from unpacker container")
continue
}
if errorStruct.Level == "error" {
return fmt.Errorf("an error occurred while running unpacker container with exit code %d: %s", status.State.ExitCode, errorStruct.Error)
}
}
return fmt.Errorf("an error occurred while running unpacker container with exit code %d", status.State.ExitCode)
}

View File

@@ -84,7 +84,7 @@ func (config *ComposeStackDeploymentConfig) Deploy() error {
return err
}
}
if stackutils.IsGitStack(config.stack) {
if stackutils.IsRelativePathStack(config.stack) {
return config.StackDeployer.DeployRemoteComposeStack(config.stack, config.endpoint, config.registries, config.forcePullImage, config.ForceCreate)
}

View File

@@ -78,7 +78,7 @@ func (config *SwarmStackDeploymentConfig) Deploy() error {
}
}
if stackutils.IsGitStack(config.stack) {
if stackutils.IsRelativePathStack(config.stack) {
return config.StackDeployer.DeployRemoteSwarmStack(config.stack, config.endpoint, config.registries, config.prune, config.pullImage)
}

View File

@@ -47,3 +47,10 @@ func SanitizeLabel(value string) string {
func IsGitStack(stack *portainer.Stack) bool {
return stack.GitConfig != nil && len(stack.GitConfig.URL) != 0
}
// IsRelativePathStack checks if the stack is a git stack or not
func IsRelativePathStack(stack *portainer.Stack) bool {
// Always return false in CE
// This function is only for code consistency with EE
return false
}

View File

@@ -1,6 +1,7 @@
import _ from 'lodash-es';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
import { confirmDelete } from '@@/modals/confirm';
angular.module('portainer.docker').controller('ImageController', [
'$async',
@@ -120,30 +121,42 @@ angular.module('portainer.docker').controller('ImageController', [
}
$scope.removeTag = function (repository) {
ImageService.deleteImage(repository, false)
.then(function success() {
if ($scope.image.RepoTags.length === 1) {
Notifications.success('Image successfully deleted', repository);
$state.go('docker.images', {}, { reload: true });
} else {
Notifications.success('Tag successfully deleted', repository);
$state.go('docker.images.image', { id: $transition$.params().id }, { reload: true });
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove image');
});
return $async(async () => {
if (!(await confirmDelete('Are you sure you want to delete this tag?'))) {
return;
}
ImageService.deleteImage(repository, false)
.then(function success() {
if ($scope.image.RepoTags.length === 1) {
Notifications.success('Image successfully deleted', repository);
$state.go('docker.images', {}, { reload: true });
} else {
Notifications.success('Tag successfully deleted', repository);
$state.go('docker.images.image', { id: $transition$.params().id }, { reload: true });
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove image');
});
});
};
$scope.removeImage = function (id) {
ImageService.deleteImage(id, false)
.then(function success() {
Notifications.success('Image successfully deleted', id);
$state.go('docker.images', {}, { reload: true });
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove image');
});
return $async(async () => {
if (!(await confirmDelete('Deleting this image will also delete all associated tags. Are you sure you want to delete this image?'))) {
return;
}
ImageService.deleteImage(id, false)
.then(function success() {
Notifications.success('Image successfully deleted', id);
$state.go('docker.images', {}, { reload: true });
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove image');
});
});
};
function exportImage(image) {

View File

@@ -57,7 +57,8 @@ angular.module('portainer.docker').controller('ImagesController', [
function confirmImageForceRemoval() {
return confirmDestructive({
title: 'Are you sure?',
message: 'Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.',
message:
"Forcing removal of an image will remove it even if it's used by stopped containers, and delete all associated tags. Are you sure you want to remove the selected image(s)?",
confirmButton: buildConfirmButton('Remove the image', 'danger'),
});
}
@@ -65,7 +66,7 @@ angular.module('portainer.docker').controller('ImagesController', [
function confirmRegularRemove() {
return confirmDestructive({
title: 'Are you sure?',
message: 'Removing the image will remove all tags associated to that image. Are you sure you want to remove the image?',
message: 'Removing an image will also delete all associated tags. Are you sure you want to remove the selected image(s)?',
confirmButton: buildConfirmButton('Remove the image', 'danger'),
});
}

View File

@@ -72,6 +72,11 @@ angular
component: 'editEdgeStackView',
},
},
params: {
status: {
dynamic: true,
},
},
};
const edgeJobs = {

View File

@@ -92,7 +92,6 @@ export const componentsModule = angular
'query',
'title',
'data-cy',
'hideEnvironmentIds',
])
)
.component(

View File

@@ -1,4 +1,5 @@
import _ from 'lodash-es';
import { confirmDelete } from '@@/modals/confirm';
export class EdgeGroupsController {
/* @ngInject */
@@ -26,6 +27,10 @@ export class EdgeGroupsController {
}
async removeActionAsync(selectedItems) {
if (!(await confirmDelete('Do you want to remove the selected Edge Group(s)?'))) {
return;
}
for (let item of selectedItems) {
try {
await this.EdgeGroupService.remove(item.Id);

View File

@@ -15,7 +15,7 @@ export class EdgeJobsViewController {
}
removeAction(selectedItems) {
confirmDelete('Do you want to remove the selected edge job(s)?').then((confirmed) => {
confirmDelete('Do you want to remove the selected Edge job(s)?').then((confirmed) => {
if (!confirmed) {
return;
}

View File

@@ -14,9 +14,13 @@
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
Switch to advanced mode to copy and paste multiple key/values
</div>
<div class="col-sm-12 small text-muted vertical-center" ng-if="!$ctrl.formValues.IsSimple">
<div class="col-sm-12 small text-muted vertical-center" ng-if="!$ctrl.formValues.IsSimple && $ctrl.type === 'configmap'">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
Generate a configuration entry per line, use YAML format
Generate a ConfigMap entry per line, use YAML format
</div>
<div class="col-sm-12 small text-muted vertical-center" ng-if="!$ctrl.formValues.IsSimple && $ctrl.type === 'secret'">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
Generate a Secret entry per line, use YAML format
</div>
</div>

View File

@@ -8,5 +8,6 @@ angular.module('portainer.kubernetes').component('kubernetesConfigurationData',
isValid: '=',
isCreation: '=',
isEditorDirty: '=',
type: '<',
},
});

View File

@@ -16,7 +16,6 @@ import {
ApplicationSummaryWidget,
ApplicationDetailsWidget,
} from '@/react/kubernetes/applications/DetailsView';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withFormValidation } from '@/react-tools/withFormValidation';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
@@ -104,7 +103,7 @@ export const ngModule = angular
.component(
'applicationDetailsWidget',
r2a(
withUIRouter(withReactQuery(withUserProvider(ApplicationDetailsWidget))),
withUIRouter(withReactQuery(withCurrentUser(ApplicationDetailsWidget))),
[]
)
);

View File

@@ -352,7 +352,7 @@
<!-- #region CONFIGMAPS -->
<div class="form-group">
<div class="col-sm-12 vertical-center">
<label class="control-label !pt-0 text-left">ConfigMap</label>
<label class="control-label !pt-0 text-left">ConfigMaps</label>
</div>
<div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.ConfigMaps.length">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
@@ -503,7 +503,7 @@
<!-- #region SECRETS -->
<div class="form-group">
<div class="col-sm-12 vertical-center pt-2.5">
<label class="control-label !pt-0 text-left">Secret</label>
<label class="control-label !pt-0 text-left">Secrets</label>
</div>
<div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.Secrets.length">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>

View File

@@ -26,95 +26,93 @@
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady">
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve container metrics">
<span class="small text-warning vertical-center">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
Portainer was unable to retrieve any metrics associated to that container. Please contact your administrator to ensure that the Kubernetes metrics feature is properly
configured.
</span>
</information-panel>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-md-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve container metrics">
<span class="small text-warning vertical-center">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
Portainer was unable to retrieve any metrics associated to that container. Please contact your administrator to ensure that the Kubernetes metrics feature is properly
configured.
</span>
</information-panel>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-md-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
</div>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<span class="small text-warning">
This view displays real-time statistics about the container <b>{{ ctrl.state.transition.containerName | trimcontainername }}</b
>.
</span>
</div>
</div>
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
<option value="30">30s</option>
<option value="60">60s</option>
</select>
</div>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" size="'sm'"></pr-icon>
</div>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<span class="small text-warning">
This view displays real-time statistics about the container <b>{{ ctrl.state.transition.containerName | trimcontainername }}</b
>.
</span>
</div>
<div class="form-group" ng-if="ctrl.state.networkStatsUnavailable">
<div class="col-sm-12">
<span class="small text-muted">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
Network stats are unavailable for this container.
</span>
</div>
</div>
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
<option value="30">30s</option>
<option value="60">60s</option>
</select>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'svg-memory'"></pr-icon>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" size="'sm'"></pr-icon>
</span>
</div>
<div class="form-group" ng-if="ctrl.state.networkStatsUnavailable">
<div class="col-sm-12">
<span class="small text-muted">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
Network stats are unavailable for this container.
</span>
</div>
<span class="vertical-center"> Memory usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="memoryChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-6 col-md-12 col-sm-12" ng-if="!ctrl.state.networkStatsUnavailable">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'cpu'"></pr-icon>
</div>
<span class="vertical-center"> CPU usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="cpuChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'svg-memory'"></pr-icon>
</div>
<span class="vertical-center"> Memory usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="memoryChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-6 col-md-12 col-sm-12" ng-if="!ctrl.state.networkStatsUnavailable">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'cpu'"></pr-icon>
</div>
<span class="vertical-center"> CPU usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="cpuChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -19,6 +19,7 @@ class KubernetesApplicationStatsController {
this.ChartService = ChartService;
this.onInit = this.onInit.bind(this);
this.initCharts = this.initCharts.bind(this);
}
changeUpdateRepeater() {
@@ -68,17 +69,26 @@ class KubernetesApplicationStatsController {
}
initCharts() {
const cpuChartCtx = $('#cpuChart');
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
this.cpuChart = cpuChart;
const memoryChartCtx = $('#memoryChart');
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
this.memoryChart = memoryChart;
this.updateCPUChart();
this.updateMemoryChart();
this.setUpdateRepeater();
let i = 0;
const findCharts = setInterval(() => {
let cpuChartCtx = $('#cpuChart');
let memoryChartCtx = $('#memoryChart');
if (cpuChartCtx.length !== 0 && memoryChartCtx.length !== 0) {
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
this.cpuChart = cpuChart;
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
this.memoryChart = memoryChart;
this.updateCPUChart();
this.updateMemoryChart();
this.setUpdateRepeater();
clearInterval(findCharts);
return;
}
i++;
if (i >= 10) {
clearInterval(findCharts);
}
}, 200);
}
getStats() {

View File

@@ -15,86 +15,84 @@
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady">
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve node metrics">
<span class="small text-muted vertical-center">
<pr-icon icon="'alert-triangle'" mode="'primary'"></pr-icon>
Portainer was unable to retrieve any metrics associated to that node. Please contact your administrator to ensure that the Kubernetes metrics feature is properly configured.
</span>
</information-panel>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-md-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve node metrics">
<span class="small text-muted vertical-center">
<pr-icon icon="'alert-triangle'" mode="'primary'"></pr-icon>
Portainer was unable to retrieve any metrics associated to that node. Please contact your administrator to ensure that the Kubernetes metrics feature is properly configured.
</span>
</information-panel>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-md-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
</div>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted">
This view displays real-time statistics about the node <b>{{ ctrl.state.transition.nodeName }}</b
>.
</span>
</div>
</div>
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
<option value="30">30s</option>
<option value="60">60s</option>
</select>
</div>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</div>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted">
This view displays real-time statistics about the node <b>{{ ctrl.state.transition.nodeName }}</b
>.
</span>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-show="ctrl.state.getMetrics">
<div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'svg-memory'"></pr-icon>
</div>
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
<option value="30">30s</option>
<option value="60">60s</option>
</select>
</div>
<span class="vertical-center"> Memory usage </span>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span>
</div>
</div>
<rd-widget-body>
<div class="chart-node" style="position: relative">
<canvas id="memoryChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'cpu'"></pr-icon>
</div>
<span class="vertical-center"> CPU usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-node" style="position: relative">
<canvas id="cpuChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-show="ctrl.state.getMetrics">
<div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'svg-memory'"></pr-icon>
</div>
<span class="vertical-center"> Memory usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-node" style="position: relative">
<canvas id="memoryChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'cpu'"></pr-icon>
</div>
<span class="vertical-center"> CPU usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-node" style="position: relative">
<canvas id="cpuChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -17,6 +17,7 @@ class KubernetesNodeStatsController {
this.ChartService = ChartService;
this.onInit = this.onInit.bind(this);
this.initCharts = this.initCharts.bind(this);
}
changeUpdateRepeater() {
@@ -63,17 +64,20 @@ class KubernetesNodeStatsController {
}
initCharts() {
const cpuChartCtx = $('#cpuChart');
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
this.cpuChart = cpuChart;
const memoryChartCtx = $('#memoryChart');
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
this.memoryChart = memoryChart;
this.updateCPUChart();
this.updateMemoryChart();
this.setUpdateRepeater();
const findCharts = setInterval(() => {
let cpuChartCtx = $('#cpuChart');
let memoryChartCtx = $('#memoryChart');
if (cpuChartCtx.length !== 0 && memoryChartCtx.length !== 0) {
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
this.cpuChart = cpuChart;
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
this.memoryChart = memoryChart;
this.updateCPUChart();
this.updateMemoryChart();
this.setUpdateRepeater();
clearInterval(findCharts);
}
}, 200);
}
getStats() {
@@ -84,7 +88,7 @@ class KubernetesNodeStatsController {
const memory = filesizeParser(stats.usage.memory);
const cpu = KubernetesResourceReservationHelper.parseCPU(stats.usage.cpu);
this.stats = {
read: stats.creationTimestamp,
read: stats.metadata.creationTimestamp,
MemoryUsage: memory,
CPUUsage: (cpu / this.nodeCPU) * 100,
};
@@ -118,12 +122,6 @@ class KubernetesNodeStatsController {
this.nodeCPU = node.CPU || 1;
await this.getStats();
if (this.state.getMetrics) {
this.$document.ready(() => {
this.initCharts();
});
}
} else {
this.state.getMetrics = false;
}
@@ -132,6 +130,11 @@ class KubernetesNodeStatsController {
this.Notifications.error('Failure', err, 'Unable to retrieve node stats');
} finally {
this.state.viewReady = true;
if (this.state.getMetrics) {
this.$document.ready(() => {
this.initCharts();
});
}
}
}

View File

@@ -88,6 +88,7 @@
is-valid="ctrl.state.isDataValid"
on-change-validation="ctrl.isFormValid()"
is-creation="true"
type="'configmap'"
is-editor-dirty="ctrl.state.isEditorDirty"
></kubernetes-configuration-data>
</div>

View File

@@ -100,6 +100,7 @@
is-valid="ctrl.state.isDataValid"
on-change-validation="ctrl.isFormValid()"
is-creation="false"
type="'configmap'"
is-editor-dirty="ctrl.state.isEditorDirty"
></kubernetes-configuration-data>

View File

@@ -87,7 +87,7 @@
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
<span>
More information about types of secret can be found in the official
<a class="hyperlink" href="https://kubernetes.io/docs/concepts/configuration/secret/#secret-types" target="_blank">kubernetes documentation</a>.
<a class="hyperlink" href="https://kubernetes.io/docs/concepts/configuration/secret/#secret-types" target="_blank">Kubernetes documentation</a>.
</span>
</div>
</div>
@@ -186,6 +186,7 @@
is-valid="ctrl.state.isDataValid"
on-change-validation="ctrl.isFormValid()"
is-creation="true"
type="'secret'"
is-editor-dirty="ctrl.state.isEditorDirty"
></kubernetes-configuration-data>

View File

@@ -107,6 +107,7 @@
is-valid="ctrl.state.isDataValid"
on-change-validation="ctrl.isFormValid()"
is-creation="false"
type="'secret'"
is-editor-dirty="ctrl.state.isEditorDirty"
></kubernetes-configuration-data>

View File

@@ -22,7 +22,7 @@
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="kubernetesClusterSetupForm">
<div class="col-sm-12 form-section-title"> Networking </div>
<div class="col-sm-12 form-section-title"> Networking - Services </div>
<div class="form-group">
<div class="col-sm-12 text-muted small">
@@ -41,6 +41,8 @@
</div>
</div>
<div class="col-sm-12 form-section-title"> Networking - Ingresses </div>
<ingress-class-datatable
on-change-controllers="(ctrl.onChangeControllers)"
allow-none-ingress-class="ctrl.formValues.AllowNoneIngressClass"
@@ -51,47 +53,58 @@
view="'cluster'"
></ingress-class-datatable>
<label htmlFor="foldingButtonIngControllerSettings" class="col-sm-12 form-section-title flex cursor-pointer items-center">
<button
id="foldingButtonIngControllerSettings"
type="button"
class="mx-2 !ml-0 inline-flex w-2 items-center justify-center border-0 bg-transparent"
ng-click="ctrl.toggleAdvancedIngSettings($event)"
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.AllowNoneIngressClass"
name="'allowNoIngressClass'"
label="'Allow ingress class to be set to &quot;none&quot;'"
tooltip="'This allows users setting up ingresses to select &quot;none&quot; as the ingress class.'"
on-change="(ctrl.onToggleAllowNoneIngressClass)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.IngressAvailabilityPerNamespace"
name="'ingressAvailabilityPerNamespace'"
label="'Configure ingress controller availability per namespace'"
tooltip="'This allows an administrator to configure, in each namespace, which ingress controllers will be available for users to select when setting up ingresses for applications.'"
on-change="(ctrl.onToggleIngressAvailabilityPerNamespace)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.RestrictStandardUserIngressW"
name="'restrictStandardUserIngressW'"
label="'Only allow admins to deploy ingresses'"
feature-id="ctrl.limitedFeatureIngressDeploy"
tooltip="'Enforces only allowing admins to deploy ingresses (and disallows standard users from doing so).'"
on-change="(ctrl.onToggleRestrictStandardUserIngressW)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8 text-muted'"
data-cy="kubeSetup-restrictStandardUserIngressWToggle"
disabled="!ctrl.isRBACEnabled"
>
</por-switch-field>
</div>
</div>
<div class="mb-4 !inline-flex gap-1 !align-top">
<div class="icon icon-sm"><pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon></div>
<div class="text-muted small"
>You may set up ingress defaults (hostnames and annotations) via Create/Edit ingress. Users may then select them via the hostname dropdown in Create/Edit
application.</div
>
<pr-icon ng-if="!ctrl.state.isIngToggleSectionExpanded" icon="'chevron-right'"></pr-icon>
<pr-icon ng-if="ctrl.state.isIngToggleSectionExpanded" icon="'chevron-down'"></pr-icon>
</button>
More settings
</label>
<div ng-if="ctrl.state.isIngToggleSectionExpanded" class="ml-4">
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.AllowNoneIngressClass"
name="'allowNoIngressClass'"
label="'Allow ingress class to be set to &quot;none&quot;'"
tooltip="'This allows users setting up ingresses to select &quot;none&quot; as the ingress class.'"
on-change="(ctrl.onToggleAllowNoneIngressClass)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.IngressAvailabilityPerNamespace"
name="'ingressAvailabilityPerNamespace'"
label="'Configure ingress controller availability per namespace'"
tooltip="'This allows an administrator to configure, in each namespace, which ingress controllers will be available for users to select when setting up ingresses for applications.'"
on-change="(ctrl.onToggleIngressAvailabilityPerNamespace)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
</div>
<!-- auto update window -->
@@ -161,19 +174,6 @@
>
</por-switch-field>
</div>
<div class="col-sm-12 mt-5">
<por-switch-field
name="'restrictStandardUserIngressW'"
label="'Only allow admins to deploy ingresses'"
tooltip="'Enforces only allowing admins to deploy ingresses (and disallows standard users from doing so).'"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8 text-muted'"
data-cy="kubeSetup-restrictStandardUserIngressWToggle"
feature-id="ctrl.limitedFeatureIngressDeploy"
disabled="!ctrl.isRBACEnabled"
></por-switch-field>
</div>
</div>
<!-- #endregion -->

View File

@@ -189,7 +189,7 @@ class KubernetesConfigureController {
await getMetricsForAllNodes(this.endpoint.Id);
this.state.metrics.isServerRunning = true;
this.state.metrics.pending = false;
this.state.metrics.userClick = false;
this.state.metrics.userClick = true;
this.formValues.UseServerMetrics = true;
} catch (_) {
this.state.metrics.isServerRunning = false;

View File

@@ -16,7 +16,7 @@ import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
import { confirmUpdate } from '@@/modals/confirm';
import { confirmUpdateNamespace } from '@/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace';
import { getMetricsForAllNodes, getMetricsForAllPods } from '@/react/kubernetes/services/service.ts';
import { getMetricsForAllPods } from '@/react/kubernetes/services/service.ts';
class KubernetesResourcePoolController {
/* #region CONSTRUCTOR */
@@ -36,7 +36,8 @@ class KubernetesResourcePoolController {
KubernetesApplicationService,
KubernetesIngressService,
KubernetesVolumeService,
KubernetesNamespaceService
KubernetesNamespaceService,
KubernetesNodeService
) {
Object.assign(this, {
$async,
@@ -54,6 +55,7 @@ class KubernetesResourcePoolController {
KubernetesIngressService,
KubernetesVolumeService,
KubernetesNamespaceService,
KubernetesNodeService,
});
this.IngressClassTypes = KubernetesIngressClassTypes;
@@ -366,7 +368,7 @@ class KubernetesResourcePoolController {
const name = this.$state.params.id;
const [nodes, pools] = await Promise.all([getMetricsForAllNodes, this.KubernetesResourcePoolService.get('', { getQuota: true })]);
const [nodes, pools] = await Promise.all([this.KubernetesNodeService.get(), this.KubernetesResourcePoolService.get('', { getQuota: true })]);
this.ingressControllers = [];
if (this.state.ingressAvailabilityPerNamespace) {

View File

@@ -20,7 +20,7 @@ const { CREATE, UPDATE, DELETE } = KubernetesResourceActions;
* Get summary of Kubernetes resources to be created, updated or deleted
* @param {KubernetesApplicationFormValues} formValues
*/
export default function (formValues, oldFormValues = {}) {
export function getApplicationResources(formValues, oldFormValues = {}) {
if (oldFormValues instanceof KubernetesApplicationFormValues) {
const resourceSummary = getUpdatedApplicationResources(oldFormValues, formValues);
return resourceSummary;
@@ -139,9 +139,9 @@ function getUpdatedApplicationResources(oldFormValues, newFormValues) {
}
// Ingress
const oldIngresses = KubernetesIngressConverter.applicationFormValuesToIngresses(oldFormValues, oldService.Name);
const newServicePorts = newFormValues.Services.flatMap((service) => service.Ports);
const oldServicePorts = oldFormValues.Services.flatMap((service) => service.Ports);
const oldIngresses = generateNewIngressesFromFormPaths(oldFormValues.OriginalIngresses, oldServicePorts, oldServicePorts);
const newServicePorts = newFormValues.Services.flatMap((service) => service.Ports);
const newIngresses = generateNewIngressesFromFormPaths(newFormValues.OriginalIngresses, newServicePorts, oldServicePorts);
resources.push(...getIngressUpdateSummary(oldIngresses, newIngresses));
} else if (!oldService && newService) {
@@ -190,7 +190,7 @@ function getApplicationResourceType(app) {
function getIngressUpdateSummary(oldIngresses, newIngresses) {
const ingressesSummaries = newIngresses
.map((newIng) => {
const oldIng = _.find(oldIngresses, { Name: newIng.Name });
const oldIng = oldIngresses.find((oldIng) => oldIng.Name === newIng.Name);
return getIngressUpdateResourceSummary(oldIng, newIng);
})
.filter((s) => s); // remove nulls

View File

@@ -3,7 +3,7 @@ import { KubernetesConfigurationFormValues } from 'Kubernetes/models/configurati
import { KubernetesResourcePoolFormValues } from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesApplicationFormValues } from 'Kubernetes/models/application/formValues';
import { KubernetesResourceActions, KubernetesResourceTypes } from 'Kubernetes/models/resource-types/models';
import getApplicationResources from './resources/applicationResources';
import { getApplicationResources } from './resources/applicationResources';
import getNamespaceResources from './resources/namespaceResources';
import getConfigurationResources from './resources/configurationResources';

View File

@@ -6,10 +6,11 @@ import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.ser
class PorAccessManagementController {
/* @ngInject */
constructor($scope, Notifications, AccessService, RoleService) {
Object.assign(this, { $scope, Notifications, AccessService, RoleService });
constructor($scope, $state, Notifications, AccessService, RoleService) {
Object.assign(this, { $scope, $state, Notifications, AccessService, RoleService });
this.limitedToBE = false;
this.$state = $state;
this.unauthorizeAccess = this.unauthorizeAccess.bind(this);
this.updateAction = this.updateAction.bind(this);
@@ -105,6 +106,7 @@ class PorAccessManagementController {
this.availableUsersAndTeams = _.orderBy(data.availableUsersAndTeams, 'Name', 'asc');
this.authorizedUsersAndTeams = data.authorizedUsersAndTeams;
} catch (err) {
this.$state.go('portainer.home');
this.availableUsersAndTeams = [];
this.authorizedUsersAndTeams = [];
this.Notifications.error('Failure', err, 'Unable to retrieve accesses');

View File

@@ -14,14 +14,19 @@
{{ $ctrl.model.Title }}
</span>
<div class="space-left blocklist-item-subtitle inline-flex items-center">
<pr-icon ng-if="$ctrl.model.Platform === 1 || $ctrl.model.Platform === 'linux' || !$ctrl.model.Platform" icon="'svg-linux'" class="mr-1"></pr-icon>
<span ng-if="!$ctrl.model.Platform"> &amp; </span>
<pr-icon
icon="'svg-microsoft'"
ng-if="$ctrl.model.Platform === 2 || $ctrl.model.Platform === 'windows' || !$ctrl.model.Platform"
class-name="'[&>*]:flex [&>*]:items-center'"
size="'lg'"
></pr-icon>
<div ng-if="$ctrl.typeLabel !== 'manifest'" class="vertical-center gap-1">
<pr-icon ng-if="$ctrl.model.Platform === 1 || $ctrl.model.Platform === 'linux' || !$ctrl.model.Platform" icon="'svg-linux'" class="mr-1"></pr-icon>
<pr-icon
icon="'svg-microsoft'"
ng-if="$ctrl.model.Platform === 2 || $ctrl.model.Platform === 'windows' || !$ctrl.model.Platform"
class-name="'[&>*]:flex [&>*]:items-center'"
size="'lg'"
></pr-icon>
</div>
<!-- currently only kubernetes uses the typeLabel of 'manifest' -->
<div ng-if="$ctrl.typeLabel === 'manifest'" class="vertical-center">
<pr-icon icon="'svg-kubernetes'" size="'lg'" class="align-bottom" class-name="'[&>*]:flex [&>*]:items-center'"></pr-icon>
</div>
{{ $ctrl.typeLabel }}
</div>
</div>

View File

@@ -54,8 +54,8 @@ export function UserService($q, Users, TeamService, TeamMembershipService) {
return Users.remove({ id: id }).$promise;
};
service.updateUser = function (id, { password, role, username }) {
return Users.update({ id }, { password, role, username }).$promise;
service.updateUser = function (id, { newPassword, role, username }) {
return Users.update({ id }, { newPassword, role, username }).$promise;
};
service.updateUserPassword = function (id, currentPassword, newPassword) {

View File

@@ -41,7 +41,7 @@ export default class LdapSettingsBaseDnBuilderController {
}
getOUValues(dn, domainSuffix = '') {
const regex = /(\w+)=(\w*),?/;
const regex = /(\w+)=([a-zA-Z0-9_ ]*),?/;
let ouValues = [];
let left = dn;
let match = left.match(regex);
@@ -49,7 +49,7 @@ export default class LdapSettingsBaseDnBuilderController {
const [, type, value] = match;
ouValues.push({ type, value });
left = left.replace(regex, '');
match = left.match(/(\w+)=(\w+),?/);
match = left.match(regex);
}
return ouValues;
}

View File

@@ -72,7 +72,7 @@ angular.module('portainer.app').controller('UserController', [
if (!confirmed) {
return;
}
UserService.updateUser($scope.user.Id, { password: $scope.formValues.newPassword })
UserService.updateUser($scope.user.Id, { newPassword: $scope.formValues.newPassword })
.then(function success() {
Notifications.success('Success', 'Password successfully updated');

View File

@@ -117,8 +117,8 @@ export function createMockEnvironment(): Environment {
StartTime: '',
},
StatusMessage: {
Detail: '',
Summary: '',
detail: '',
summary: '',
},
};
}

View File

@@ -1,4 +1,4 @@
import { semverCompare } from './utils';
import { semverCompare } from './semver-utils';
describe('semverCompare', () => {
test('sort array', () => {

View File

@@ -0,0 +1,27 @@
/**
* Compares two semver strings.
*
* returns:
* - `-1` if `a < b`
* - `0` if `a == b`
* - `1` if `a > b`
*/
export function semverCompare(a: string, b: string) {
if (a.startsWith(`${b}-`)) {
return -1;
}
if (b.startsWith(`${a}-`)) {
return 1;
}
return a.localeCompare(b, undefined, {
numeric: true,
sensitivity: 'case',
caseFirst: 'upper',
});
}
export function isVersionSmaller(a: string, b: string) {
return semverCompare(a, b) < 0;
}

View File

@@ -0,0 +1,19 @@
import { Story, Meta } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { InlineLoader, Props } from './InlineLoader';
export default {
title: 'Components/InlineLoader',
component: InlineLoader,
} as Meta;
function Template({ className, children }: PropsWithChildren<Props>) {
return <InlineLoader className={className}>{children}</InlineLoader>;
}
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
className: 'test-class',
children: 'Loading...',
};

View File

@@ -0,0 +1,23 @@
import { Loader2 } from 'lucide-react';
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import { Icon } from '@@/Icon';
export type Props = {
className: string;
};
export function InlineLoader({
children,
className,
}: PropsWithChildren<Props>) {
return (
<div
className={clsx('text-muted flex items-center gap-2 text-sm', className)}
>
<Icon icon={Loader2} className="animate-spin-slow" />
{children}
</div>
);
}

View File

@@ -0,0 +1 @@
export { InlineLoader } from './InlineLoader';

View File

@@ -247,6 +247,12 @@ const docURLs = [
locationRegex: /#!\/edge\/jobs/,
examples: ['#!/edge/jobs', '#!/edge/jobs/new'],
},
{
desc: 'Edge Compute / Edge Configurations',
docURL: 'https://docs.portainer.io/user/edge/configurations',
locationRegex: /#!\/edge\/configurations/,
examples: ['#!/edge/configurations', '#!/edge/configurations/new'],
},
{
desc: 'Nomad / Dashboard',
docURL: 'https://docs.portainer.io/user/nomad/dashboard',

View File

@@ -8,29 +8,22 @@ interface Props {
boundaryLinks?: boolean;
currentPage: number;
directionLinks?: boolean;
itemsPerPage: number;
onPageChange(page: number): void;
totalCount: number;
pageCount: number;
maxSize: number;
isInputVisible?: boolean;
}
export function PageSelector({
currentPage,
totalCount,
itemsPerPage,
pageCount,
onPageChange,
maxSize = 5,
directionLinks = true,
boundaryLinks = false,
isInputVisible = false,
}: Props) {
const pages = generatePagesArray(
currentPage,
totalCount,
itemsPerPage,
maxSize
);
const pages = generatePagesArray(currentPage, pageCount, maxSize);
const last = pages[pages.length - 1];
if (pages.length <= 1) {
@@ -42,7 +35,7 @@ export function PageSelector({
{isInputVisible && (
<PageInput
onChange={(page) => onPageChange(page)}
totalPages={Math.ceil(totalCount / itemsPerPage)}
totalPages={pageCount}
/>
)}
<ul className="pagination">

View File

@@ -9,7 +9,7 @@ interface Props {
page: number;
pageLimit: number;
showAll?: boolean;
totalCount: number;
pageCount: number;
isPageInputVisible?: boolean;
className?: string;
}
@@ -20,7 +20,7 @@ export function PaginationControls({
onPageLimitChange,
showAll,
onPageChange,
totalCount,
pageCount,
isPageInputVisible,
className,
}: Props) {
@@ -38,8 +38,7 @@ export function PaginationControls({
maxSize={5}
onPageChange={onPageChange}
currentPage={page}
itemsPerPage={pageLimit}
totalCount={totalCount}
pageCount={pageCount}
isInputVisible={isPageInputVisible}
/>
)}

View File

@@ -12,12 +12,10 @@ export /**
*/
function generatePagesArray(
currentPage: number,
collectionLength: number,
rowsPerPage: number,
totalPages: number,
paginationRange: number
): (number | '...')[] {
const pages: (number | '...')[] = [];
const totalPages = Math.ceil(collectionLength / rowsPerPage);
const halfWay = Math.ceil(paginationRange / 2);
let position;

View File

@@ -17,7 +17,7 @@ interface WidgetProps {
}
const meta: Meta<WidgetProps> = {
title: 'Widget',
title: 'Components/Widget',
component: Widget,
args: {
loading: false,

View File

@@ -3,6 +3,7 @@ import {
TableState,
useReactTable,
Row,
Column,
getCoreRowModel,
getPaginationRowModel,
getFilteredRowModel,
@@ -33,6 +34,20 @@ import { createSelectColumn } from './select-column';
import { TableRow } from './TableRow';
import { type TableState as GlobalTableState } from './useTableState';
export type PaginationProps =
| {
isServerSidePagination?: false;
totalCount?: never;
page?: never;
onPageChange?: never;
}
| {
isServerSidePagination: true;
totalCount: number;
page: number;
onPageChange(page: number): void;
};
export interface Props<
D extends Record<string, unknown>,
TMeta extends TableMeta<D> = TableMeta<D>
@@ -49,12 +64,8 @@ export interface Props<
titleIcon?: IconProps['icon'];
initialTableState?: Partial<TableState>;
isLoading?: boolean;
totalCount?: number;
description?: ReactNode;
pageCount?: number;
highlightedItemId?: string;
onPageChange?(page: number): void;
settingsManager: GlobalTableState<BasicTableSettings>;
renderRow?(row: Row<D>, highlightedItemId?: string): ReactNode;
getRowCanExpand?(row: Row<D>): boolean;
@@ -78,10 +89,7 @@ export function Datatable<
emptyContentLabel,
initialTableState = {},
isLoading,
totalCount = dataset.length,
description,
pageCount,
onPageChange = () => null,
settingsManager: settings,
renderRow = defaultRenderRow,
highlightedItemId,
@@ -89,8 +97,16 @@ export function Datatable<
getRowCanExpand,
'data-cy': dataCy,
meta,
}: Props<D, TMeta>) {
const isServerSidePagination = typeof pageCount !== 'undefined';
onPageChange = () => {},
page,
totalCount = dataset.length,
isServerSidePagination = false,
}: Props<D, TMeta> & PaginationProps) {
const pageCount = useMemo(
() => Math.ceil(totalCount / settings.pageSize),
[settings.pageSize, totalCount]
);
const enableRowSelection = getIsSelectionEnabled(
disableSelect,
isRowSelectable
@@ -107,6 +123,7 @@ export function Datatable<
initialState: {
pagination: {
pageSize: settings.pageSize,
pageIndex: page || 0,
},
sorting: settings.sortBy ? [settings.sortBy] : [],
globalFilter: settings.search,
@@ -116,6 +133,7 @@ export function Datatable<
defaultColumn: {
enableColumnFilter: false,
enableHiding: true,
sortingFn: 'alphanumeric',
},
enableRowSelection,
autoResetExpanded: false,
@@ -123,14 +141,18 @@ export function Datatable<
getRowId,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getFacetedMinMaxValues: getFacetedMinMaxValues(),
getExpandedRowModel: getExpandedRowModel(),
getRowCanExpand,
...(isServerSidePagination ? { manualPagination: true, pageCount } : {}),
getColumnCanGlobalFilter,
...(isServerSidePagination
? { manualPagination: true, pageCount }
: {
getSortedRowModel: getSortedRowModel(),
}),
meta,
});
@@ -158,6 +180,7 @@ export function Datatable<
renderTableActions={() => renderTableActions(selectedItems)}
renderTableSettings={() => renderTableSettings(tableInstance)}
/>
<DatatableContent<D>
tableInstance={tableInstance}
renderRow={(row) => renderRow(row, highlightedItemId)}
@@ -170,9 +193,9 @@ export function Datatable<
<DatatableFooter
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
page={tableState.pagination.pageIndex}
page={typeof page === 'number' ? page : tableState.pagination.pageIndex}
pageSize={tableState.pagination.pageSize}
totalCount={totalCount}
pageCount={tableInstance.getPageCount()}
totalSelected={selectedItems.length}
/>
</Table.Container>
@@ -258,3 +281,10 @@ function globalFilterFn<D>(
return false;
}
function getColumnCanGlobalFilter<D>(column: Column<D, unknown>): boolean {
if (column.id === 'select') {
return false;
}
return true;
}

View File

@@ -8,7 +8,7 @@ interface Props {
pageSize: number;
page: number;
onPageChange(page: number): void;
totalCount: number;
pageCount: number;
onPageSizeChange(pageSize: number): void;
}
@@ -17,7 +17,7 @@ export function DatatableFooter({
pageSize,
page,
onPageChange,
totalCount,
pageCount,
onPageSizeChange,
}: Props) {
return (
@@ -28,7 +28,7 @@ export function DatatableFooter({
pageLimit={pageSize}
page={page + 1}
onPageChange={(page) => onPageChange(page - 1)}
totalCount={totalCount}
pageCount={pageCount}
onPageLimitChange={onPageSizeChange}
/>
</Table.Footer>

View File

@@ -2,7 +2,11 @@ import { Row } from '@tanstack/react-table';
import { ReactNode } from 'react';
import { ExpandableDatatableTableRow } from './ExpandableDatatableRow';
import { Datatable, Props as DatatableProps } from './Datatable';
import {
Datatable,
Props as DatatableProps,
PaginationProps,
} from './Datatable';
interface Props<D extends Record<string, unknown>>
extends Omit<DatatableProps<D>, 'renderRow' | 'expandable'> {
@@ -15,7 +19,7 @@ export function ExpandableDatatable<D extends Record<string, unknown>>({
getRowCanExpand = () => true,
expandOnRowClick,
...props
}: Props<D>) {
}: Props<D> & PaginationProps) {
return (
<Datatable<D>
// eslint-disable-next-line react/jsx-props-no-spreading

View File

@@ -17,7 +17,6 @@ export function buildNameColumn<T extends Record<string, unknown>>(
cell,
enableSorting: true,
enableHiding: false,
sortingFn: 'text',
};
function createCell<T extends Record<string, unknown>>() {

View File

@@ -31,6 +31,22 @@
display: none;
}
.portainer-selector-root .portainer-selector__group-heading {
text-transform: none !important;
font-size: 85% !important;
}
.input-group .portainer-selector-root:last-child .portainer-selector__control {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
.input-group .portainer-selector-root:not(:first-child):not(:last-child) .portainer-selector__control {
border-radius: 0;
}
/* input style */
.portainer-selector-root .portainer-selector__control {
border-color: var(--border-form-control-color);

View File

@@ -1,5 +1,6 @@
import clsx from 'clsx';
import { ComponentProps } from 'react';
import uuid from 'uuid';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
@@ -30,7 +31,7 @@ export function SwitchField({
checked,
label,
index,
name,
name = uuid(),
labelClass,
fieldClass,
dataCy,
@@ -43,15 +44,16 @@ export function SwitchField({
const toggleName = name ? `toggle_${name}` : '';
return (
<label className={clsx(styles.root, fieldClass)}>
<span
<div className={clsx(styles.root, fieldClass)}>
<label
className={clsx('space-right control-label !p-0 text-left', labelClass)}
htmlFor={toggleName}
>
{label}
{tooltip && (
<Tooltip message={tooltip} setHtmlMessage={setTooltipHtmlMessage} />
)}
</span>
</label>
<Switch
className={clsx('space-right', switchClass)}
name={toggleName}
@@ -63,6 +65,6 @@ export function SwitchField({
featureId={featureId}
dataCy={dataCy}
/>
</label>
</div>
);
}

View File

@@ -7,6 +7,6 @@ export const size = columnHelper.accessor('VirtualSize', {
header: 'Size',
cell: ({ getValue }) => {
const value = getValue();
return humanize(value);
return humanize(value) || '-';
},
});

View File

@@ -28,6 +28,7 @@ export function AssociatedEdgeEnvironmentsSelector({
emptyContentLabel="No environment available"
query={{
types: EdgeTypes,
excludeIds: value,
}}
onClickRow={(env) => {
if (!value.includes(env.Id)) {
@@ -35,7 +36,6 @@ export function AssociatedEdgeEnvironmentsSelector({
}
}}
data-cy="edgeGroupCreate-availableEndpoints"
hideEnvironmentIds={value}
/>
</div>
<div className="w-1/2">

View File

@@ -3,10 +3,7 @@ import { truncate } from 'lodash';
import { useMemo, useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import {
Environment,
EnvironmentId,
} from '@/react/portainer/environments/types';
import { Environment } from '@/react/portainer/environments/types';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { useTags } from '@/portainer/tags/queries';
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
@@ -47,19 +44,17 @@ export function EdgeGroupAssociationTable({
emptyContentLabel,
onClickRow,
'data-cy': dataCy,
hideEnvironmentIds = [],
}: {
title: string;
query: EnvironmentsQueryParams;
emptyContentLabel: string;
onClickRow: (env: Environment) => void;
hideEnvironmentIds?: EnvironmentId[];
} & AutomationTestingProps) {
const tableState = useTableStateWithoutStorage('Name');
const [page, setPage] = useState(1);
const [page, setPage] = useState(0);
const environmentsQuery = useEnvironmentList({
pageLimit: tableState.pageSize,
page,
page: page + 1,
search: tableState.search,
sort: tableState.sortBy.id as 'Group' | 'Name',
order: tableState.sortBy.desc ? 'desc' : 'asc',
@@ -74,25 +69,17 @@ export function EdgeGroupAssociationTable({
const environments: Array<DecoratedEnvironment> = useMemo(
() =>
environmentsQuery.environments
.filter((e) => !hideEnvironmentIds.includes(e.Id))
.map((env) => ({
...env,
Group:
groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name || '',
Tags: env.TagIds.map(
(tagId) => tagsQuery.data?.find((t) => t.ID === tagId)?.Name || ''
),
})),
[
environmentsQuery.environments,
groupsQuery.data,
hideEnvironmentIds,
tagsQuery.data,
]
environmentsQuery.environments.map((env) => ({
...env,
Group: groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name || '',
Tags: env.TagIds.map(
(tagId) => tagsQuery.data?.find((t) => t.ID === tagId)?.Name || ''
),
})),
[environmentsQuery.environments, groupsQuery.data, tagsQuery.data]
);
const totalCount = environmentsQuery.totalCount - hideEnvironmentIds.length;
const { totalCount } = environmentsQuery;
return (
<Datatable<DecoratedEnvironment>
@@ -100,8 +87,10 @@ export function EdgeGroupAssociationTable({
columns={columns}
settingsManager={tableState}
dataset={environments}
isServerSidePagination
page={page}
onPageChange={setPage}
pageCount={Math.ceil(totalCount / tableState.pageSize)}
totalCount={totalCount}
renderRow={(row) => (
<TableRow<DecoratedEnvironment>
cells={row.getVisibleCells()}
@@ -111,7 +100,6 @@ export function EdgeGroupAssociationTable({
emptyContentLabel={emptyContentLabel}
data-cy={dataCy}
disableSelect
totalCount={totalCount}
/>
);
}

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