Compare commits

..

45 Commits

Author SHA1 Message Date
AndrewHucklesby 60ca183967 version bump package.json (#258) 2024-12-20 10:26:49 +13:00
Andrew Hucklesby 9cefa2f1e8 version bump to 2.25.1 2024-12-20 08:16:57 +13:00
Steven Kang 4e38cf1f40 fix: security - CVE-2024-45337 - portainer-suite 2.25 (#250) 2024-12-19 10:55:46 +13:00
AndrewHucklesby 4454b6b890 bump version to 2.25.0 (#240) 2024-12-12 16:42:55 +13:00
Ali 117e3500ae fix(edge-stack): revert useEffect, to call matchRegistry less often [BE-11501] (#239) 2024-12-12 15:22:19 +13:00
andres-portainer 94fda6a720 fix(offlinegate): avoid leaking an RLock when the handler panics BE-11495 (#234) 2024-12-11 16:38:03 -03:00
Ali e1388eff84 fix(annotations): parse annotation keys in angular forms [r8s-170] (#233) 2024-12-11 17:50:08 +13:00
Ali 94d2e32b49 fix(apps): simplify helm status [r8s-155] (#230) 2024-12-11 13:18:34 +13:00
Ali 069f22afa4 fix(services): separate table state [BE-11401] (#152) 2024-12-11 11:58:43 +13:00
LP B 52c90d4d0a feat(app/edge-stack): ability to prune containers on edge stack update (#216) 2024-12-10 22:54:02 +01:00
Ali ce7e0d8d60 refactor(namespace): migrate namespace edit to react [r8s-125] (#38) 2024-12-11 10:15:46 +13:00
Oscar Zhou 40c7742e46 fix(edgestack): validate edge stack name for api [BE-11365] (#222) 2024-12-11 08:21:46 +13:00
Malcolm Lockyer 05e872337a feat(support): add db and activity db file size to support bundle [r8s-169] (#221) 2024-12-10 09:35:30 +13:00
Ali aac9d001f7 feat(askai): hide askAI for CE [BE-11409] (#220) 2024-12-10 09:11:51 +13:00
andres-portainer d295968948 feat(libstack): update Compose to v2.31.0 BE-11416 (#223) 2024-12-09 16:36:57 -03:00
Ali 97e7a3c5e2 fix(edge-stacks): various custom template issues [BE-11414] (#189) 2024-12-09 17:48:34 +13:00
Ali 16a1825990 feat(version): remove brackets for sts/lts [BE-11409] (#215) 2024-12-06 22:52:47 +13:00
Ali 441afead10 feat(ask-ai): integrate kapa-ai page [BE-11409] (#214) 2024-12-06 18:41:32 +13:00
Malcolm Lockyer 783ab253af feat(support): collect system info bundle to assist support troubleshooting [r8s-157] (#154) 2024-12-06 15:38:10 +13:00
Yajith Dayarathna 17648d12fe codecov integration with portainer-suite [PLA-119] (#210) 2024-12-06 12:09:09 +13:00
andres-portainer 2f4f1be99c feat(performance): increase HTTP compression performance BE-11417 (#211) 2024-12-05 19:10:56 -03:00
Ali 5d4d3888b8 fix(rbac): use team ids to get namespace access [r8s-154] (#209) 2024-12-05 17:29:45 +13:00
andres-portainer 473084e915 fix(edgestacks): remove edge stacks even after a system crash or power-off BE-10822 (#208) 2024-12-04 19:52:53 -03:00
Anthony Lapenna a8147b9713 build: tidy up packages by removing unused scripts and files (#207) 2024-12-05 11:18:49 +13:00
Yajith Dayarathna 3c3dc547b2 fix(app/edge-stack): hide non-working BE fields from CE (#205)
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
2024-12-04 19:00:40 +01:00
James Carppe c5accd0f16 Update bug report template for 2.24.1 (#191) 2024-12-04 08:34:59 +13:00
Oscar Zhou cb949e443e fix(volume): unable to inspect and browse volume [BE-11216] (#186) 2024-12-03 09:10:10 +13:00
Anthony Lapenna bb6815f681 build: introduce central Makefile and live-reload for Go (#184) 2024-12-03 08:49:03 +13:00
Anthony Lapenna a261f60764 version: display dependencies versions (#188)
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
2024-12-03 08:45:44 +13:00
LP B d393529026 fix(app): passing an initial table state overrides the default global filter state (#180) 2024-11-29 21:06:11 +01:00
Oscar Zhou 219c9593e0 fix(container): binding ip disappear after duplicate container [BE-11413] (#177) 2024-11-29 08:56:44 +13:00
andres-portainer faa6b2b790 fix(libstack): add the build step for Compose BE-11448 (#173) 2024-11-27 18:43:25 -03:00
Oscar Zhou 4046bf7b31 feat(image): build image with file [BE-11372] (#171) 2024-11-27 18:33:35 -03:00
Ali 4f708309af fix(activity logs): decode base64 [BE-11418] (#172) 2024-11-28 08:54:32 +13:00
andres-portainer f2e7680bf3 fix(compose): fix path resolution for env files BE-11428 (#167) 2024-11-26 22:09:58 -03:00
andres-portainer 5d2689b139 fix(compose): avoid creating a default network unnecessarily BE-11427 (#169) 2024-11-26 19:48:49 -03:00
andres-portainer 145ffeea40 fix(libstack): resolve env vars correctly in Compose BE-11420 (#166) 2024-11-26 18:09:12 -03:00
andres-portainer 13143bc7ea fix(libstack): fix environment variable handling in compose BE- (#165) 2024-11-26 17:37:22 -03:00
Oscar Zhou ee0dbf2d22 feat(init): allow to customize kubectl-shell image by cli flag [BE-11419] (#162) 2024-11-26 10:17:46 +13:00
andres-portainer 4265ae4dae feat(offlinegate): improve error message BE-11402 (#163) 2024-11-25 17:40:17 -03:00
andres-portainer 821c1fdbef feat(swarm): do not prevent server startup when Swarm config.json file is invalid BE-11402 (#160) 2024-11-25 17:40:10 -03:00
andres-portainer fe29d6aee3 feat(backup): reduce the locking time of the offline gate BE-11402 (#157) 2024-11-25 10:10:11 -03:00
Ali c0c7144539 fix(app templates): load app template for deployment [BE-11382] (#141) 2024-11-25 17:41:09 +13:00
Anthony Lapenna 20e3d3a15b fix: review snapshot and post init migration logic (#158) 2024-11-25 11:03:12 +13:00
James Carppe 07d1eedae3 Update template to include lifecycle policy link (#156) 2024-11-21 17:11:20 +13:00
254 changed files with 7378 additions and 4022 deletions
+52
View File
@@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = ".tmp"
[build]
args_bin = []
bin = "./dist/portainer"
cmd = "SKIP_GO_GET=true make build-server"
delay = 1000
exclude_dir = []
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = "./dist/portainer --log-level=DEBUG"
include_dir = ["api"]
include_ext = ["go"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true
+1
View File
@@ -95,6 +95,7 @@ body:
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.24.1'
- '2.24.0'
- '2.23.0'
- '2.22.0'
-1
View File
@@ -1 +0,0 @@
portainer
+7 -5
View File
@@ -17,11 +17,13 @@ GOTESTSUM=go run gotest.tools/gotestsum@latest
##@ Building
.PHONY: init-dist build-storybook build build-client build-server build-image devops
.PHONY: all init-dist build-storybook build build-client build-server build-image devops
init-dist:
@mkdir -p dist
build-all: deps build-server build-client ## Build the client, server and download external dependancies (doesn't build an image)
all: tidy deps build-server build-client ## Build the client, server and download external dependancies (doesn't build an image)
build-all: all ## Alias for the 'all' target (used by CI)
build-client: init-dist ## Build the client
export NODE_ENV=$(ENV) && yarn build --config $(WEBPACK_CONFIG)
@@ -50,7 +52,7 @@ client-deps: ## Install client dependencies
yarn
tidy: ## Tidy up the go.mod file
cd api && go mod tidy
@go mod tidy
##@ Cleanup
@@ -65,10 +67,10 @@ clean: ## Remove all build and download artifacts
test: test-server test-client ## Run all tests
test-client: ## Run client tests
yarn test $(ARGS)
yarn test $(ARGS) --coverage
test-server: ## Run server tests
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover ./...
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
##@ Dev
.PHONY: dev dev-client dev-server
+35 -30
View File
@@ -21,6 +21,7 @@ const rwxr__r__ os.FileMode = 0o744
var filesToBackup = []string{
"certs",
"chisel",
"compose",
"config.json",
"custom_templates",
@@ -30,40 +31,13 @@ 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.
func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datastore dataservices.DataStore, filestorePath string) (string, error) {
unlock := gate.Lock()
defer unlock()
backupDirPath := filepath.Join(filestorePath, "backup", time.Now().Format("2006-01-02_15-04-05"))
if err := os.MkdirAll(backupDirPath, rwxr__r__); err != nil {
return "", errors.Wrap(err, "Failed to create backup dir")
}
{
// new export
exportFilename := path.Join(backupDirPath, fmt.Sprintf("export-%d.json", time.Now().Unix()))
err := datastore.Export(exportFilename)
if err != nil {
log.Error().Err(err).Str("filename", exportFilename).Msg("failed to export")
} else {
log.Debug().Str("filename", exportFilename).Msg("file exported")
}
}
if err := backupDb(backupDirPath, datastore); err != nil {
return "", errors.Wrap(err, "Failed to backup database")
}
for _, filename := range filesToBackup {
err := filesystem.CopyPath(filepath.Join(filestorePath, filename), backupDirPath)
if err != nil {
return "", errors.Wrap(err, "Failed to create backup file")
}
backupDirPath, err := backupDatabaseAndFilesystem(gate, datastore, filestorePath)
if err != nil {
return "", err
}
archivePath, err := archive.TarGzDir(backupDirPath)
@@ -81,6 +55,37 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto
return archivePath, nil
}
func backupDatabaseAndFilesystem(gate *offlinegate.OfflineGate, datastore dataservices.DataStore, filestorePath string) (string, error) {
unlock := gate.Lock()
defer unlock()
backupDirPath := filepath.Join(filestorePath, "backup", time.Now().Format("2006-01-02_15-04-05"))
if err := os.MkdirAll(backupDirPath, rwxr__r__); err != nil {
return "", errors.Wrap(err, "Failed to create backup dir")
}
// new export
exportFilename := path.Join(backupDirPath, fmt.Sprintf("export-%d.json", time.Now().Unix()))
if err := datastore.Export(exportFilename); err != nil {
log.Error().Err(err).Str("filename", exportFilename).Msg("failed to export")
} else {
log.Debug().Str("filename", exportFilename).Msg("file exported")
}
if err := backupDb(backupDirPath, datastore); err != nil {
return "", errors.Wrap(err, "Failed to backup database")
}
for _, filename := range filesToBackup {
if err := filesystem.CopyPath(filepath.Join(filestorePath, filename), backupDirPath); err != nil {
return "", errors.Wrap(err, "Failed to create backup file")
}
}
return backupDirPath, nil
}
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
dbFileName := datastore.Connection().GetDatabaseFileName()
_, err := datastore.Backup(filepath.Join(backupDirPath, dbFileName))
-12
View File
@@ -1,12 +0,0 @@
package build
import "runtime"
// Variables to be set during the build time
var BuildNumber string
var ImageTag string
var NodejsVersion string
var YarnVersion string
var WebpackVersion string
var GoVersion string = runtime.Version()
var GitCommit string
+1
View File
@@ -59,6 +59,7 @@ func CLIFlags() *portainer.CLIFlags {
SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/<secret-key-name>.").Default(defaultSecretKeyName).String(),
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
}
}
+3 -3
View File
@@ -10,7 +10,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/build"
"github.com/portainer/portainer/api/chisel"
"github.com/portainer/portainer/api/cli"
"github.com/portainer/portainer/api/crypto"
@@ -47,6 +46,7 @@ import (
"github.com/portainer/portainer/api/platform"
"github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/pkg/build"
"github.com/portainer/portainer/pkg/featureflags"
"github.com/portainer/portainer/pkg/libhelm"
"github.com/portainer/portainer/pkg/libstack/compose"
@@ -93,7 +93,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
log.Fatal().Msg("failed creating database connection: expecting a boltdb database type but a different one was received")
}
store := datastore.NewStore(*flags.Data, fileService, connection)
store := datastore.NewStore(flags, fileService, connection)
isNew, err := store.Open()
if err != nil {
@@ -120,7 +120,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
log.Fatal().Err(err).Msg("failed generating instance id")
}
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{})
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{Flags: flags})
migratorCount := migratorInstance.GetMigratorCountOfCurrentAPIVersion()
// from MigrateData
+1
View File
@@ -40,6 +40,7 @@ type Connection interface {
GetDatabaseFileName() string
GetDatabaseFilePath() string
GetStorePath() string
GetDatabaseFileSize() (int64, error)
IsEncryptedStore() bool
NeedsEncryptionMigration() (bool, error)
+9
View File
@@ -62,6 +62,15 @@ func (connection *DbConnection) GetStorePath() string {
return connection.Path
}
func (connection *DbConnection) GetDatabaseFileSize() (int64, error) {
file, err := os.Stat(connection.GetDatabaseFilePath())
if err != nil {
return 0, fmt.Errorf("Failed to stat database file path: %s err: %w", connection.GetDatabaseFilePath(), err)
}
return file.Size(), nil
}
func (connection *DbConnection) SetEncrypted(flag bool) {
connection.isEncrypted = flag
}
+2 -1
View File
@@ -16,8 +16,9 @@ import (
)
// NewStore initializes a new Store and the associated services
func NewStore(storePath string, fileService portainer.FileService, connection portainer.Connection) *Store {
func NewStore(cliFlags *portainer.CLIFlags, fileService portainer.FileService, connection portainer.Connection) *Store {
return &Store{
flags: cliFlags,
fileService: fileService,
connection: connection,
}
+1 -1
View File
@@ -57,7 +57,7 @@ func (store *Store) checkOrCreateDefaultSettings() error {
HelmRepositoryURL: portainer.DefaultHelmRepositoryURL,
UserSessionTimeout: portainer.DefaultUserSessionTimeout,
KubeconfigExpiry: portainer.DefaultKubeconfigExpiry,
KubectlShellImage: portainer.DefaultKubectlShellImage,
KubectlShellImage: *store.flags.KubectlShellImage,
IsDockerDesktopExtension: isDDExtention,
}
+3 -2
View File
@@ -32,7 +32,7 @@ func (store *Store) MigrateData() error {
return errors.Wrap(err, "while migrating legacy version")
}
migratorParams := store.newMigratorParameters(version)
migratorParams := store.newMigratorParameters(version, store.flags)
migrator := migrator.NewMigrator(migratorParams)
if !migrator.NeedsMigration() {
@@ -62,8 +62,9 @@ func (store *Store) MigrateData() error {
return nil
}
func (store *Store) newMigratorParameters(version *models.Version) *migrator.MigratorParameters {
func (store *Store) newMigratorParameters(version *models.Version, flags *portainer.CLIFlags) *migrator.MigratorParameters {
return &migrator.MigratorParameters{
Flags: flags,
CurrentDBVersion: version,
EndpointGroupService: store.EndpointGroupService,
EndpointService: store.EndpointService,
+1 -1
View File
@@ -109,7 +109,7 @@ func TestMigrateData(t *testing.T) {
t.FailNow()
}
migratorParams := store.newMigratorParameters(v)
migratorParams := store.newMigratorParameters(v, store.flags)
m := migrator.NewMigrator(migratorParams)
latestMigrations := m.LatestMigrations()
@@ -48,6 +48,7 @@ func TestMigrateSettings(t *testing.T) {
}
m := migrator.NewMigrator(&migrator.MigratorParameters{
Flags: store.flags,
EndpointGroupService: store.EndpointGroupService,
EndpointService: store.EndpointService,
EndpointRelationService: store.EndpointRelationService,
@@ -1,8 +1,6 @@
package migrator
import (
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
)
@@ -20,7 +18,7 @@ func (m *Migrator) migrateSettingsToDB33() error {
}
log.Info().Msg("setting default kubectl shell image")
settings.KubectlShellImage = portainer.DefaultKubectlShellImage
settings.KubectlShellImage = *m.flags.KubectlShellImage
return m.settingsService.UpdateSettings(settings)
}
+3
View File
@@ -33,6 +33,7 @@ import (
type (
// Migrator defines a service to migrate data after a Portainer version update.
Migrator struct {
flags *portainer.CLIFlags
currentDBVersion *models.Version
migrations []Migrations
@@ -62,6 +63,7 @@ type (
// MigratorParameters represents the required parameters to create a new Migrator instance.
MigratorParameters struct {
Flags *portainer.CLIFlags
CurrentDBVersion *models.Version
EndpointGroupService *endpointgroup.Service
EndpointService *endpoint.Service
@@ -91,6 +93,7 @@ type (
// NewMigrator creates a new Migrator.
func NewMigrator(parameters *MigratorParameters) *Migrator {
migrator := &Migrator{
flags: parameters.Flags,
currentDBVersion: parameters.CurrentDBVersion,
endpointGroupService: parameters.EndpointGroupService,
endpointService: parameters.EndpointService,
+22 -9
View File
@@ -11,6 +11,7 @@ import (
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/pendingactions/actions"
"github.com/portainer/portainer/pkg/endpoints"
"github.com/rs/zerolog/log"
)
@@ -49,17 +50,29 @@ func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
for _, environment := range environments {
// edge environments will run after the server starts, in pending actions
if endpointutils.IsEdgeEndpoint(&environment) {
log.Info().Msgf("Adding pending action 'PostInitMigrateEnvironment' for environment %d", environment.ID)
err = postInitMigrator.createPostInitMigrationPendingAction(environment.ID)
if err != nil {
log.Error().Err(err).Msgf("Error creating pending action for environment %d", environment.ID)
if endpoints.IsEdgeEndpoint(&environment) {
// Skip edge environments that do not have direct connectivity
if !endpoints.HasDirectConnectivity(&environment) {
continue
}
log.Info().
Int("endpoint_id", int(environment.ID)).
Msg("adding pending action 'PostInitMigrateEnvironment' for environment")
if err := postInitMigrator.createPostInitMigrationPendingAction(environment.ID); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error creating pending action for environment")
}
} else {
// non-edge environments will run before the server starts.
err = postInitMigrator.MigrateEnvironment(&environment)
if err != nil {
log.Error().Err(err).Msgf("Error running post-init migrations for non-edge environment %d", environment.ID)
// Non-edge environments will run before the server starts.
if err := postInitMigrator.MigrateEnvironment(&environment); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error running post-init migrations for non-edge environment")
}
}
+1
View File
@@ -42,6 +42,7 @@ import (
// Store defines the implementation of portainer.DataStore using
// BoltDB as the storage system.
type Store struct {
flags *portainer.CLIFlags
connection portainer.Connection
fileService portainer.FileService
File diff suppressed because it is too large Load Diff
@@ -610,7 +610,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.24.0",
"KubectlShellImage": "portainer/kubectl-shell:2.25.1",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -672,6 +672,7 @@
{
"Docker": {
"ContainerCount": 0,
"DiagnosticsData": {},
"DockerSnapshotRaw": {
"Containers": null,
"Images": null,
@@ -942,7 +943,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.24.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.25.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+5 -1
View File
@@ -29,6 +29,10 @@ func MustNewTestStore(t testing.TB, init, secure bool) (bool, *Store) {
func NewTestStore(t testing.TB, init, secure bool) (bool, *Store, func(), error) {
// Creates unique temp directory in a concurrency friendly manner.
storePath := t.TempDir()
defaultKubectlShellImage := portainer.DefaultKubectlShellImage
flags := &portainer.CLIFlags{
KubectlShellImage: &defaultKubectlShellImage,
}
fileService, err := filesystem.NewService(storePath, "")
if err != nil {
@@ -45,7 +49,7 @@ func NewTestStore(t testing.TB, init, secure bool) (bool, *Store, func(), error)
panic(err)
}
store := NewStore(storePath, fileService, connection)
store := NewStore(flags, fileService, connection)
newStore, err := store.Open()
if err != nil {
return newStore, nil, nil, err
+6 -6
View File
@@ -6,7 +6,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
)
@@ -25,18 +25,18 @@ func NewPuller(client *client.Client, registryClient *RegistryClient, dataStore
}
}
func (puller *Puller) Pull(ctx context.Context, image Image) error {
log.Debug().Str("image", image.FullName()).Msg("starting to pull the image")
func (puller *Puller) Pull(ctx context.Context, img Image) error {
log.Debug().Str("image", img.FullName()).Msg("starting to pull the image")
registryAuth, err := puller.registryClient.EncodedRegistryAuth(image)
registryAuth, err := puller.registryClient.EncodedRegistryAuth(img)
if err != nil {
log.Debug().
Str("image", image.FullName()).
Str("image", img.FullName()).
Err(err).
Msg("failed to get an encoded registry auth via image, try to pull image without registry auth")
}
out, err := puller.client.ImagePull(ctx, image.FullName(), types.ImagePullOptions{
out, err := puller.client.ImagePull(ctx, img.FullName(), image.PullOptions{
RegistryAuth: registryAuth,
})
if err != nil {
+2 -255
View File
@@ -1,20 +1,9 @@
package docker
import (
"context"
"strings"
"time"
portainer "github.com/portainer/portainer/api"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/docker/consts"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
_container "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
"github.com/portainer/portainer/pkg/snapshot"
)
// Snapshotter represents a service used to create environment(endpoint) snapshots
@@ -37,247 +26,5 @@ func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*p
}
defer cli.Close()
return snapshot(cli, endpoint)
}
func snapshot(cli *client.Client, endpoint *portainer.Endpoint) (*portainer.DockerSnapshot, error) {
if _, err := cli.Ping(context.Background()); err != nil {
return nil, err
}
snapshot := &portainer.DockerSnapshot{
StackCount: 0,
}
if err := snapshotInfo(snapshot, cli); err != nil {
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot engine information")
}
if snapshot.Swarm {
if err := snapshotSwarmServices(snapshot, cli); err != nil {
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot Swarm services")
}
if err := snapshotNodes(snapshot, cli); err != nil {
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot Swarm nodes")
}
}
if err := snapshotContainers(snapshot, cli); err != nil {
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot containers")
}
if err := snapshotImages(snapshot, cli); err != nil {
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot images")
}
if err := snapshotVolumes(snapshot, cli); err != nil {
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot volumes")
}
if err := snapshotNetworks(snapshot, cli); err != nil {
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot networks")
}
if err := snapshotVersion(snapshot, cli); err != nil {
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot engine version")
}
snapshot.Time = time.Now().Unix()
return snapshot, nil
}
func snapshotInfo(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
info, err := cli.Info(context.Background())
if err != nil {
return err
}
snapshot.Swarm = info.Swarm.ControlAvailable
snapshot.DockerVersion = info.ServerVersion
snapshot.TotalCPU = info.NCPU
snapshot.TotalMemory = info.MemTotal
snapshot.SnapshotRaw.Info = info
return nil
}
func snapshotNodes(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
nodes, err := cli.NodeList(context.Background(), types.NodeListOptions{})
if err != nil {
return err
}
var nanoCpus int64
var totalMem int64
for _, node := range nodes {
nanoCpus += node.Description.Resources.NanoCPUs
totalMem += node.Description.Resources.MemoryBytes
}
snapshot.TotalCPU = int(nanoCpus / 1e9)
snapshot.TotalMemory = totalMem
snapshot.NodeCount = len(nodes)
return nil
}
func snapshotSwarmServices(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
stacks := make(map[string]struct{})
services, err := cli.ServiceList(context.Background(), types.ServiceListOptions{})
if err != nil {
return err
}
for _, service := range services {
for k, v := range service.Spec.Labels {
if k == "com.docker.stack.namespace" {
stacks[v] = struct{}{}
}
}
}
snapshot.ServiceCount = len(services)
snapshot.StackCount += len(stacks)
return nil
}
func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
containers, err := cli.ContainerList(context.Background(), container.ListOptions{All: true})
if err != nil {
return err
}
stacks := make(map[string]struct{})
gpuUseSet := make(map[string]struct{})
gpuUseAll := false
for _, container := range containers {
if container.State == "running" {
// Snapshot GPUs
response, err := cli.ContainerInspect(context.Background(), container.ID)
if err != nil {
// Inspect a container will fail when the container runs on a different
// Swarm node, so it is better to log the error instead of return error
// when the Swarm mode is enabled
if !snapshot.Swarm {
return err
} else {
if !strings.Contains(err.Error(), "No such container") {
return err
}
// It is common to have containers running on different Swarm nodes,
// so we just log the error in the debug level
log.Debug().Str("container", container.ID).Err(err).Msg("unable to inspect container in other Swarm nodes")
}
} else {
var gpuOptions *_container.DeviceRequest = nil
for _, deviceRequest := range response.HostConfig.Resources.DeviceRequests {
if deviceRequest.Driver == "nvidia" || deviceRequest.Capabilities[0][0] == "gpu" {
gpuOptions = &deviceRequest
}
}
if gpuOptions != nil {
if gpuOptions.Count == -1 {
gpuUseAll = true
}
for _, id := range gpuOptions.DeviceIDs {
gpuUseSet[id] = struct{}{}
}
}
}
}
for k, v := range container.Labels {
if k == consts.ComposeStackNameLabel {
stacks[v] = struct{}{}
}
}
}
gpuUseList := make([]string, 0, len(gpuUseSet))
for gpuUse := range gpuUseSet {
gpuUseList = append(gpuUseList, gpuUse)
}
snapshot.GpuUseAll = gpuUseAll
snapshot.GpuUseList = gpuUseList
stats := CalculateContainerStats(containers)
snapshot.ContainerCount = stats.Total
snapshot.RunningContainerCount = stats.Running
snapshot.StoppedContainerCount = stats.Stopped
snapshot.HealthyContainerCount = stats.Healthy
snapshot.UnhealthyContainerCount = stats.Unhealthy
snapshot.StackCount += len(stacks)
for _, container := range containers {
snapshot.SnapshotRaw.Containers = append(snapshot.SnapshotRaw.Containers, portainer.DockerContainerSnapshot{Container: container})
}
return nil
}
func snapshotImages(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
images, err := cli.ImageList(context.Background(), types.ImageListOptions{})
if err != nil {
return err
}
snapshot.ImageCount = len(images)
snapshot.SnapshotRaw.Images = images
return nil
}
func snapshotVolumes(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
volumes, err := cli.VolumeList(context.Background(), volume.ListOptions{})
if err != nil {
return err
}
snapshot.VolumeCount = len(volumes.Volumes)
snapshot.SnapshotRaw.Volumes = volumes
return nil
}
func snapshotNetworks(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
networks, err := cli.NetworkList(context.Background(), types.NetworkListOptions{})
if err != nil {
return err
}
snapshot.SnapshotRaw.Networks = networks
return nil
}
func snapshotVersion(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
version, err := cli.ServerVersion(context.Background())
if err != nil {
return err
}
snapshot.SnapshotRaw.Version = version
snapshot.IsPodman = isPodman(version)
return nil
}
// isPodman checks if the version is for Podman by checking if any of the components contain "podman".
// If it's podman, a component name should be "Podman Engine"
func isPodman(version types.Version) bool {
for _, component := range version.Components {
if strings.Contains(strings.ToLower(component.Name), "podman") {
return true
}
}
return false
return snapshot.CreateDockerSnapshot(cli)
}
+9
View File
@@ -58,6 +58,15 @@ type (
// Used only for EE async edge agent
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
ReadyRePullImage bool
DeployerOptionsPayload DeployerOptionsPayload
}
DeployerOptionsPayload struct {
// Prune is a flag indicating if the agent must prune the containers or not when creating/updating an edge stack
// This flag drives docker compose `--remove-orphans` and docker stack `--prune` options
// Used only for EE
Prune bool
}
// RegistryCredentials holds the credentials for a Docker registry.
+1 -1
View File
@@ -199,7 +199,7 @@ func (manager *SwarmStackManager) updateDockerCLIConfiguration(configPath string
config, err := manager.retrieveConfigurationFromDisk(configFilePath)
if err != nil {
return err
log.Warn().Err(err).Msg("unable to retrieve the Swarm configuration from disk, proceeding without it")
}
signature, err := manager.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
@@ -11,8 +11,8 @@ import (
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
)
type ImageResponse struct {
@@ -46,7 +46,7 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
return httpErr
}
images, err := cli.ImageList(r.Context(), types.ImageListOptions{})
images, err := cli.ImageList(r.Context(), image.ListOptions{})
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker images", err)
}
@@ -6,12 +6,18 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/pkg/edge"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/pkg/errors"
)
type edgeStackFromFileUploadPayload struct {
// Name of the stack
// Max length: 255
// Name must only contains lowercase characters, numbers, hyphens, or underscores
// Name must start with a lowercase character or number
// Example: stack-name or stack_123 or stackName
Name string
StackFileContent []byte
EdgeGroups []portainer.EdgeGroupID
@@ -32,6 +38,10 @@ func (payload *edgeStackFromFileUploadPayload) Validate(r *http.Request) error {
}
payload.Name = name
if !edge.IsValidEdgeStackName(payload.Name) {
return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number")
}
composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "file")
if err != nil {
return httperrors.NewInvalidPayloadError("Invalid Compose file. Ensure that the Compose file is uploaded correctly")
@@ -75,7 +85,7 @@ func (payload *edgeStackFromFileUploadPayload) Validate(r *http.Request) error {
// @security jwt
// @accept multipart/form-data
// @produce json
// @param Name formData string true "Name of the stack"
// @param Name formData string true "Name of the stack. it must only consist of lowercase alphanumeric characters, hyphens, or underscores as well as start with a letter or number"
// @param file formData file true "Content of the Stack file"
// @param EdgeGroups formData string true "JSON stringified array of Edge Groups ids"
// @param DeploymentType formData int true "deploy type 0 - 'compose', 1 - 'kubernetes'"
@@ -9,6 +9,7 @@ import (
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/pkg/edge"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/asaskevich/govalidator"
@@ -17,7 +18,11 @@ import (
type edgeStackFromGitRepositoryPayload struct {
// Name of the stack
Name string `example:"myStack" validate:"required"`
// Max length: 255
// Name must only contains lowercase characters, numbers, hyphens, or underscores
// Name must start with a lowercase character or number
// Example: stack-name or stack_123 or stackName
Name string `example:"stack-name" validate:"required"`
// URL of a Git repository hosting the Stack file
RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"`
// Reference name of a Git repository hosting the Stack file
@@ -50,6 +55,10 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro
return httperrors.NewInvalidPayloadError("Invalid stack name")
}
if !edge.IsValidEdgeStackName(payload.Name) {
return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number")
}
if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format")
}
@@ -8,6 +8,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/pkg/edge"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/pkg/errors"
@@ -15,7 +16,11 @@ import (
type edgeStackFromStringPayload struct {
// Name of the stack
Name string `example:"myStack" validate:"required"`
// Max length: 255
// Name must only contains lowercase characters, numbers, hyphens, or underscores
// Name must start with a lowercase character or number
// Example: stack-name or stack_123 or stackName
Name string `example:"stack-name" validate:"required"`
// Content of the Stack file
StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx" validate:"required"`
// List of identifiers of EdgeGroups
@@ -36,6 +41,10 @@ func (payload *edgeStackFromStringPayload) Validate(r *http.Request) error {
return httperrors.NewInvalidPayloadError("Invalid stack name")
}
if !edge.IsValidEdgeStackName(payload.Name) {
return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number")
}
if len(payload.StackFileContent) == 0 {
return httperrors.NewInvalidPayloadError("Invalid stack file content")
}
@@ -43,7 +43,7 @@ func TestCreateAndInspect(t *testing.T) {
}
payload := edgeStackFromStringPayload{
Name: "Test Stack",
Name: "test-stack",
StackFileContent: "stack content",
EdgeGroups: []portainer.EdgeGroupID{1},
DeploymentType: portainer.EdgeStackDeploymentCompose,
@@ -161,7 +161,7 @@ func TestCreateWithInvalidPayload(t *testing.T) {
{
Name: "EdgeStackDeploymentKubernetes with Docker endpoint",
Payload: edgeStackFromStringPayload{
Name: "Stack name",
Name: "stack-name",
StackFileContent: "content",
EdgeGroups: []portainer.EdgeGroupID{1},
DeploymentType: portainer.EdgeStackDeploymentKubernetes,
@@ -172,7 +172,7 @@ func TestCreateWithInvalidPayload(t *testing.T) {
{
Name: "Empty Stack File Content",
Payload: edgeStackFromStringPayload{
Name: "Stack name",
Name: "stack-name",
StackFileContent: "",
EdgeGroups: []portainer.EdgeGroupID{1},
DeploymentType: portainer.EdgeStackDeploymentCompose,
@@ -183,7 +183,7 @@ func TestCreateWithInvalidPayload(t *testing.T) {
{
Name: "Clone Git repository error",
Payload: edgeStackFromGitRepositoryPayload{
Name: "Stack name",
Name: "stack-name",
RepositoryURL: "github.com/portainer/portainer",
RepositoryReferenceName: "ref name",
RepositoryAuthentication: false,
+2 -2
View File
@@ -7,7 +7,7 @@ import (
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/pkg/featureflags"
"github.com/gorilla/handlers"
"github.com/klauspost/compress/gzhttp"
)
// Handler represents an HTTP API handler for managing static files.
@@ -20,7 +20,7 @@ type Handler struct {
func NewHandler(assetPublicPath string, wasInstanceDisabled func() bool) *Handler {
h := &Handler{
Handler: security.MWSecureHeaders(
handlers.CompressHandler(http.FileServer(http.Dir(assetPublicPath))),
gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))),
featureflags.IsEnabled("hsts"),
featureflags.IsEnabled("csp"),
),
+1 -1
View File
@@ -83,7 +83,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.24.0
// @version 2.25.1
// @description.markdown api-description.md
// @termsOfService
@@ -13,9 +13,9 @@ import (
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
@@ -131,7 +131,7 @@ func (handler *Handler) PullAndRunContainer(ctx context.Context, endpoint *porta
// TODO: add k8s implementation
// TODO: work out registry auth
func pullImage(ctx context.Context, docker *client.Client, imageName string) error {
out, err := docker.ImagePull(ctx, imageName, types.ImagePullOptions{})
out, err := docker.ImagePull(ctx, imageName, image.PullOptions{})
if err != nil {
log.Error().Str("image_name", imageName).Err(err).Msg("could not pull image from registry")
@@ -0,0 +1,51 @@
package kubernetes
import (
"bytes"
"io"
"net/http"
models "github.com/portainer/portainer/api/http/models/kubernetes"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
)
// @id UpdateKubernetesNamespaceDeprecated
// @summary Update a namespace
// @description Update a namespace within the given environment.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @produce json
// @param id path int true "Environment identifier"
// @param namespace path string true "Namespace"
// @param body body models.K8sNamespaceDetails true "Namespace details"
// @success 200 {object} portainer.K8sNamespaceInfo "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific namespace."
// @failure 500 "Server error occurred while attempting to update the namespace."
// @router /kubernetes/{id}/namespaces [put]
func deprecatedNamespaceParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
environmentId, err := request.RetrieveRouteVariableValue(r, "id")
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: id", err)
}
// Restore the original body for further use
bodyBytes, err := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
payload := models.K8sNamespaceDetails{}
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return "", httperror.BadRequest("Invalid request. Unable to parse namespace payload", err)
}
namespaceName := payload.Name
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
return "/kubernetes/" + environmentId + "/namespaces/" + namespaceName, nil
}
+16 -2
View File
@@ -81,11 +81,11 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/services/delete", httperror.LoggerHandler(h.deleteKubernetesServices)).Methods(http.MethodPost)
endpointRouter.Handle("/rbac_enabled", httperror.LoggerHandler(h.getKubernetesRBACStatus)).Methods(http.MethodGet)
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.createKubernetesNamespace)).Methods(http.MethodPost)
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut)
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.deleteKubernetesNamespace)).Methods(http.MethodDelete)
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.getKubernetesNamespaces)).Methods(http.MethodGet)
endpointRouter.Handle("/namespaces/count", httperror.LoggerHandler(h.getKubernetesNamespacesCount)).Methods(http.MethodGet)
endpointRouter.Handle("/namespaces/{namespace}", httperror.LoggerHandler(h.getKubernetesNamespace)).Methods(http.MethodGet)
endpointRouter.Handle("/namespaces/{namespace}", httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut)
endpointRouter.Handle("/volumes", httperror.LoggerHandler(h.GetAllKubernetesVolumes)).Methods(http.MethodGet)
endpointRouter.Handle("/volumes/count", httperror.LoggerHandler(h.getAllKubernetesVolumesCount)).Methods(http.MethodGet)
endpointRouter.Handle("/service_accounts", httperror.LoggerHandler(h.getAllKubernetesServiceAccounts)).Methods(http.MethodGet)
@@ -115,8 +115,12 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.createKubernetesService)).Methods(http.MethodPost)
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.updateKubernetesService)).Methods(http.MethodPut)
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.getKubernetesServicesByNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/volumes", httperror.LoggerHandler(h.GetKubernetesVolumesInNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/volumes/{volume}", httperror.LoggerHandler(h.getKubernetesVolume)).Methods(http.MethodGet)
// Deprecated
endpointRouter.Handle("/namespaces", middlewares.Deprecated(endpointRouter, deprecatedNamespaceParser)).Methods(http.MethodPut)
return h
}
@@ -206,7 +210,17 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
return
}
nonAdminNamespaces, err = pcli.GetNonAdminNamespaces(int(user.ID), endpoint.Kubernetes.Configuration.RestrictDefaultNamespace)
teamMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
if err != nil {
httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to get team memberships for user: ", err)
return
}
teamIDs := []int{}
for _, membership := range teamMemberships {
teamIDs = append(teamIDs, int(membership.TeamID))
}
nonAdminNamespaces, err = pcli.GetNonAdminNamespaces(int(user.ID), teamIDs, endpoint.Kubernetes.Configuration.RestrictDefaultNamespace)
if err != nil {
httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to retrieve non-admin namespaces. Error: ", err)
return
+34 -4
View File
@@ -27,7 +27,7 @@ import (
// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes."
// @router /kubernetes/{id}/volumes [get]
func (handler *Handler) GetAllKubernetesVolumes(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
volumes, err := handler.getKubernetesVolumes(r)
volumes, err := handler.getKubernetesVolumes(r, "")
if err != nil {
return err
}
@@ -49,7 +49,7 @@ func (handler *Handler) GetAllKubernetesVolumes(w http.ResponseWriter, r *http.R
// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes count."
// @router /kubernetes/{id}/volumes/count [get]
func (handler *Handler) getAllKubernetesVolumesCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
volumes, err := handler.getKubernetesVolumes(r)
volumes, err := handler.getKubernetesVolumes(r, "")
if err != nil {
return err
}
@@ -57,6 +57,36 @@ func (handler *Handler) getAllKubernetesVolumesCount(w http.ResponseWriter, r *h
return response.JSON(w, len(volumes))
}
// @id GetKubernetesVolumesInNamespace
// @summary Get Kubernetes volumes within a namespace in the given Portainer environment
// @description Get a list of kubernetes volumes within the specified namespace in the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param namespace path string true "Namespace identifier"
// @param withApplications query boolean false "When set to True, include the applications that are using the volumes. It is set to false by default"
// @success 200 {object} map[string]kubernetes.K8sVolumeInfo "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes in the namespace."
// @router /kubernetes/{id}/namespaces/{namespace}/volumes [get]
func (handler *Handler) GetKubernetesVolumesInNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
log.Error().Err(err).Str("context", "GetKubernetesVolumesInNamespace").Msg("Unable to retrieve namespace identifier")
return httperror.BadRequest("Invalid namespace identifier", err)
}
volumes, httpErr := handler.getKubernetesVolumes(r, namespace)
if httpErr != nil {
return httpErr
}
return response.JSON(w, volumes)
}
// @id GetKubernetesVolume
// @summary Get a Kubernetes volume within the given Portainer environment
// @description Get a Kubernetes volume within the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier.
@@ -109,7 +139,7 @@ func (handler *Handler) getKubernetesVolume(w http.ResponseWriter, r *http.Reque
return response.JSON(w, volume)
}
func (handler *Handler) getKubernetesVolumes(r *http.Request) ([]models.K8sVolumeInfo, *httperror.HandlerError) {
func (handler *Handler) getKubernetesVolumes(r *http.Request, namespace string) ([]models.K8sVolumeInfo, *httperror.HandlerError) {
withApplications, err := request.RetrieveBooleanQueryParameter(r, "withApplications", true)
if err != nil {
log.Error().Err(err).Str("context", "GetKubernetesVolumes").Bool("withApplications", withApplications).Msg("Unable to parse query parameter")
@@ -122,7 +152,7 @@ func (handler *Handler) getKubernetesVolumes(r *http.Request) ([]models.K8sVolum
return nil, httperror.InternalServerError("Failed to prepare Kubernetes client", httpErr)
}
volumes, err := cli.GetVolumes("")
volumes, err := cli.GetVolumes(namespace)
if err != nil {
if k8serrors.IsUnauthorized(err) {
log.Error().Err(err).Str("context", "GetKubernetesVolumes").Msg("Unauthorized access")
+9 -24
View File
@@ -2,12 +2,11 @@ package system
import (
"net/http"
"os"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/build"
"github.com/portainer/portainer/api/http/client"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/pkg/build"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -23,20 +22,12 @@ type versionResponse struct {
LatestVersion string `json:"LatestVersion" example:"2.0.0"`
ServerVersion string
VersionSupport string `json:"VersionSupport" example:"STS/LTS"`
ServerEdition string `json:"ServerEdition" example:"CE/EE"`
DatabaseVersion string
Build BuildInfo
}
type BuildInfo struct {
BuildNumber string
ImageTag string
NodejsVersion string
YarnVersion string
WebpackVersion string
GoVersion string
GitCommit string
Env []string `json:",omitempty"`
Build build.BuildInfo
Dependencies build.DependenciesInfo
Runtime build.RuntimeInfo
}
// @id systemVersion
@@ -57,21 +48,15 @@ func (handler *Handler) version(w http.ResponseWriter, r *http.Request) *httperr
result := &versionResponse{
ServerVersion: portainer.APIVersion,
VersionSupport: portainer.APIVersionSupport,
DatabaseVersion: portainer.APIVersion,
ServerEdition: portainer.Edition.GetEditionLabel(),
Build: BuildInfo{
BuildNumber: build.BuildNumber,
ImageTag: build.ImageTag,
NodejsVersion: build.NodejsVersion,
YarnVersion: build.YarnVersion,
WebpackVersion: build.WebpackVersion,
GoVersion: build.GoVersion,
GitCommit: build.GitCommit,
},
Build: build.GetBuildInfo(),
Dependencies: build.GetDependenciesInfo(),
}
if isAdmin {
result.Build.Env = os.Environ()
result.Runtime = build.GetRuntimeInfo()
}
latestVersion := GetLatestVersion()
+7 -5
View File
@@ -13,6 +13,7 @@ import (
"github.com/portainer/portainer/pkg/libhttp/response"
dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/image"
)
// @summary Execute a webhook
@@ -79,16 +80,16 @@ func (handler *Handler) executeServiceWebhook(
service.Spec.TaskTemplate.ForceUpdate++
var imageName = strings.Split(service.Spec.TaskTemplate.ContainerSpec.Image, "@sha")[0]
imageName := strings.Split(service.Spec.TaskTemplate.ContainerSpec.Image, "@sha")[0]
service.Spec.TaskTemplate.ContainerSpec.Image = imageName
if imageTag != "" {
var tagIndex = strings.LastIndex(imageName, ":")
tagIndex := strings.LastIndex(imageName, ":")
if tagIndex == -1 {
tagIndex = len(imageName)
}
service.Spec.TaskTemplate.ContainerSpec.Image = imageName[:tagIndex] + ":" + imageTag
} else {
service.Spec.TaskTemplate.ContainerSpec.Image = imageName
}
serviceUpdateOptions := dockertypes.ServiceUpdateOptions{
@@ -109,8 +110,9 @@ func (handler *Handler) executeServiceWebhook(
}
}
}
if imageTag != "" {
rc, err := dockerClient.ImagePull(context.Background(), service.Spec.TaskTemplate.ContainerSpec.Image, dockertypes.ImagePullOptions{RegistryAuth: serviceUpdateOptions.EncodedRegistryAuth})
rc, err := dockerClient.ImagePull(context.Background(), service.Spec.TaskTemplate.ContainerSpec.Image, image.PullOptions{RegistryAuth: serviceUpdateOptions.EncodedRegistryAuth})
if err != nil {
return httperror.NotFound("Error pulling image with the specified tag", err)
}
+5 -3
View File
@@ -41,11 +41,13 @@ func (o *OfflineGate) WaitingMiddleware(timeout time.Duration, next http.Handler
}
if !o.lock.RTryLockWithTimeout(timeout) {
log.Error().Msg("timeout waiting for the offline gate to signal")
httperror.WriteError(w, http.StatusRequestTimeout, "Timeout waiting for the offline gate to signal", http.ErrHandlerTimeout)
log.Error().Str("url", r.URL.Path).Msg("request timed out while waiting for the backup process to finish")
httperror.WriteError(w, http.StatusRequestTimeout, "Request timed out while waiting for the backup process to finish", http.ErrHandlerTimeout)
return
}
defer o.lock.RUnlock()
next.ServeHTTP(w, r)
o.lock.RUnlock()
})
}
+28
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_canLockAndUnlock(t *testing.T) {
@@ -146,3 +147,30 @@ func Test_waitingMiddleware_mayTimeout_whenLockedForTooLong(t *testing.T) {
assert.Equal(t, http.StatusRequestTimeout, response.Result().StatusCode, "Request support to timeout waiting for the gate")
}
func Test_waitingMiddleware_handlerPanics(t *testing.T) {
o := NewOfflineGate()
request := httptest.NewRequest(http.MethodPost, "/", nil)
response := httptest.NewRecorder()
wg := sync.WaitGroup{}
wg.Add(1)
go func() {
defer func() {
recover()
wg.Done()
}()
o.WaitingMiddleware(time.Second, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("panic")
})).ServeHTTP(response, request)
}()
wg.Wait()
require.True(t, o.lock.TryLock())
o.lock.Unlock()
}
@@ -11,6 +11,8 @@ import (
log "github.com/rs/zerolog/log"
)
// TODO: this file should be migrated to package/server-ce/pkg/endpoints
// IsLocalEndpoint returns true if this is a local environment(endpoint)
func IsLocalEndpoint(endpoint *portainer.Endpoint) bool {
return strings.HasPrefix(endpoint.URL, "unix://") ||
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"github.com/portainer/portainer/api/agent"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/pendingactions"
endpointsutils "github.com/portainer/portainer/pkg/endpoints"
"github.com/rs/zerolog/log"
)
@@ -64,7 +64,7 @@ func NewBackgroundSnapshotter(dataStore dataservices.DataStore, tunnelService po
}
for _, e := range endpoints {
if !endpointutils.IsEdgeEndpoint(&e) || e.Edge.AsyncMode || !e.UserTrusted {
if !endpointsutils.HasDirectConnectivity(&e) {
continue
}
+2 -2
View File
@@ -124,7 +124,7 @@ func (kcl *KubeClient) UpdateNamespaceAccessPolicies(accessPolicies map[string]p
}
// GetNonAdminNamespaces retrieves namespaces for a non-admin user, excluding the default namespace if restricted.
func (kcl *KubeClient) GetNonAdminNamespaces(userID int, isRestrictDefaultNamespace bool) ([]string, error) {
func (kcl *KubeClient) GetNonAdminNamespaces(userID int, teamIDs []int, isRestrictDefaultNamespace bool) ([]string, error) {
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
if err != nil {
return nil, fmt.Errorf("an error occurred during the getNonAdminNamespaces operation, unable to get namespace access policies via portainer-config. check if portainer-config configMap exists in the Kubernetes cluster: %w", err)
@@ -136,7 +136,7 @@ func (kcl *KubeClient) GetNonAdminNamespaces(userID int, isRestrictDefaultNamesp
}
for namespace, accessPolicy := range accessPolicies {
if hasUserAccessToNamespace(userID, nil, accessPolicy) {
if hasUserAccessToNamespace(userID, teamIDs, accessPolicy) {
nonAdminNamespaces = append(nonAdminNamespaces, namespace)
}
}
+157 -57
View File
@@ -47,7 +47,9 @@ func (kcl *KubeClient) GetNamespaces() (map[string]portainer.K8sNamespaceInfo, e
// fetchNamespacesForNonAdmin gets the namespaces in the current k8s environment(endpoint) for the non-admin user.
func (kcl *KubeClient) fetchNamespacesForNonAdmin() (map[string]portainer.K8sNamespaceInfo, error) {
log.Debug().Msgf("Fetching namespaces for non-admin user: %v", kcl.NonAdminNamespaces)
log.Debug().
Str("context", "fetchNamespacesForNonAdmin").
Msg("Fetching namespaces for non-admin user")
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
@@ -75,6 +77,11 @@ func (kcl *KubeClient) fetchNamespacesForNonAdmin() (map[string]portainer.K8sNam
func (kcl *KubeClient) fetchNamespaces() (map[string]portainer.K8sNamespaceInfo, error) {
namespaces, err := kcl.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{})
if err != nil {
log.Error().
Str("context", "fetchNamespaces").
Err(err).
Msg("Failed to list namespaces")
return nil, fmt.Errorf("an error occurred during the fetchNamespacesForAdmin operation, unable to list namespaces for the admin user: %w", err)
}
@@ -92,6 +99,7 @@ func parseNamespace(namespace *corev1.Namespace) portainer.K8sNamespaceInfo {
Id: string(namespace.UID),
Name: namespace.Name,
Status: namespace.Status,
Annotations: namespace.Annotations,
CreationDate: namespace.CreationTimestamp.Format(time.RFC3339),
NamespaceOwner: namespace.Labels[namespaceOwnerLabel],
IsSystem: isSystemNamespace(namespace),
@@ -103,13 +111,18 @@ func parseNamespace(namespace *corev1.Namespace) portainer.K8sNamespaceInfo {
func (kcl *KubeClient) GetNamespace(name string) (portainer.K8sNamespaceInfo, error) {
namespace, err := kcl.cli.CoreV1().Namespaces().Get(context.TODO(), name, metav1.GetOptions{})
if err != nil {
log.Error().
Str("context", "GetNamespace").
Str("namespace", name).
Err(err).
Msg("Failed to get namespace")
return portainer.K8sNamespaceInfo{}, err
}
return parseNamespace(namespace), nil
}
// CreateNamespace creates a new ingress in a given namespace in a k8s endpoint.
// CreateNamespace creates a new namespace in a k8s endpoint.
func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) {
portainerLabels := map[string]string{
namespaceNameLabel: stackutils.SanitizeLabel(info.Name),
@@ -125,52 +138,127 @@ func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) (*corev1
if err != nil {
log.Error().
Err(err).
Str("context", "CreateNamespace").
Str("Namespace", info.Name).
Msg("Failed to create the namespace")
return nil, err
}
if info.ResourceQuota != nil && info.ResourceQuota.Enabled {
log.Info().Msgf("Creating resource quota for namespace %s", info.Name)
log.Debug().Msgf("Creating resource quota with details: %+v", info.ResourceQuota)
resourceQuota := &corev1.ResourceQuota{
ObjectMeta: metav1.ObjectMeta{
Name: "portainer-rq-" + info.Name,
Namespace: info.Name,
Labels: portainerLabels,
},
Spec: corev1.ResourceQuotaSpec{
Hard: corev1.ResourceList{},
},
}
if info.ResourceQuota.Enabled {
memory := resource.MustParse(info.ResourceQuota.Memory)
cpu := resource.MustParse(info.ResourceQuota.CPU)
if memory.Value() > 0 {
memQuota := memory
resourceQuota.Spec.Hard[corev1.ResourceLimitsMemory] = memQuota
resourceQuota.Spec.Hard[corev1.ResourceRequestsMemory] = memQuota
}
if cpu.Value() > 0 {
cpuQuota := cpu
resourceQuota.Spec.Hard[corev1.ResourceLimitsCPU] = cpuQuota
resourceQuota.Spec.Hard[corev1.ResourceRequestsCPU] = cpuQuota
}
}
_, err := kcl.cli.CoreV1().ResourceQuotas(info.Name).Create(context.Background(), resourceQuota, metav1.CreateOptions{})
if err != nil {
log.Error().Msgf("Failed to create resource quota for namespace %s: %s", info.Name, err)
return nil, err
}
if err := kcl.createOrUpdateNamespaceResourceQuota(info, portainerLabels); err != nil {
log.Error().
Err(err).
Str("context", "CreateNamespace").
Str("name", info.Name).
Msg("failed to create or update resource quota for namespace")
return nil, err
}
return namespace, nil
}
// UpdateIngress updates an ingress in a given namespace in a k8s endpoint.
func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) {
portainerLabels := map[string]string{
namespaceNameLabel: stackutils.SanitizeLabel(info.Name),
namespaceOwnerLabel: stackutils.SanitizeLabel(info.Owner),
}
namespace := corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: info.Name,
Annotations: info.Annotations,
},
}
updatedNamespace, err := kcl.cli.CoreV1().Namespaces().Update(context.Background(), &namespace, metav1.UpdateOptions{})
if err != nil {
log.Error().
Str("context", "UpdateNamespace").
Str("namespace", info.Name).
Err(err).
Msg("Failed to update namespace")
return nil, err
}
if err := kcl.createOrUpdateNamespaceResourceQuota(info, portainerLabels); err != nil {
log.Error().
Err(err).
Str("context", "UpdateNamespace").
Str("name", info.Name).
Msg("failed to create or update resource quota for namespace")
return nil, err
}
return updatedNamespace, nil
}
func (kcl *KubeClient) createOrUpdateNamespaceResourceQuota(info models.K8sNamespaceDetails, portainerLabels map[string]string) error {
if !info.ResourceQuota.Enabled {
if err := kcl.deleteNamespaceResourceQuota(info.Name); err != nil {
log.Debug().Err(err).Str("context", "createOrUpdateNamespaceResourceQuota").Str("name", info.Name).Msg("failed to delete resource quota for namespace")
}
return nil
}
resourceQuota := &corev1.ResourceQuota{
ObjectMeta: metav1.ObjectMeta{
Name: "portainer-rq-" + info.Name,
Namespace: info.Name,
Labels: portainerLabels,
},
Spec: corev1.ResourceQuotaSpec{
Hard: corev1.ResourceList{},
},
}
if info.ResourceQuota.Enabled {
memory := resource.MustParse(info.ResourceQuota.Memory)
cpu := resource.MustParse(info.ResourceQuota.CPU)
if memory.Value() > 0 {
memQuota := memory
resourceQuota.Spec.Hard[corev1.ResourceLimitsMemory] = memQuota
resourceQuota.Spec.Hard[corev1.ResourceRequestsMemory] = memQuota
}
if cpu.Value() > 0 {
cpuQuota := cpu
resourceQuota.Spec.Hard[corev1.ResourceLimitsCPU] = cpuQuota
resourceQuota.Spec.Hard[corev1.ResourceRequestsCPU] = cpuQuota
}
}
_, err := kcl.cli.CoreV1().ResourceQuotas(info.Name).Update(context.Background(), resourceQuota, metav1.UpdateOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
log.Warn().
Str("context", "createOrUpdateNamespaceResourceQuota").
Str("name", info.Name).
Msg("resource quota not found, creating")
_, err = kcl.cli.CoreV1().ResourceQuotas(info.Name).Create(context.Background(), resourceQuota, metav1.CreateOptions{})
}
}
return err
}
func (kcl *KubeClient) deleteNamespaceResourceQuota(namespaceName string) error {
err := kcl.cli.CoreV1().ResourceQuotas(namespaceName).Delete(context.Background(), "portainer-rq-"+namespaceName, metav1.DeleteOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
log.Error().
Str("context", "deleteNamespaceResourceQuota").
Str("name", namespaceName).
Err(err).
Msg("failed to delete resource quota for namespace")
return err
}
log.Warn().
Str("context", "deleteNamespaceResourceQuota").
Str("name", namespaceName).
Msg("resource quota to delete not found")
return nil
}
func isSystemNamespace(namespace *corev1.Namespace) bool {
systemLabelValue, hasSystemLabel := namespace.Labels[systemNamespaceLabel]
if hasSystemLabel {
@@ -180,7 +268,6 @@ func isSystemNamespace(namespace *corev1.Namespace) bool {
systemNamespaces := defaultSystemNamespaces()
_, isSystem := systemNamespaces[namespace.Name]
return isSystem
}
@@ -201,10 +288,13 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er
return nil
}
nsService := kcl.cli.CoreV1().Namespaces()
namespace, err := nsService.Get(context.TODO(), namespaceName, metav1.GetOptions{})
namespace, err := kcl.cli.CoreV1().Namespaces().Get(context.TODO(), namespaceName, metav1.GetOptions{})
if err != nil {
log.Error().
Str("context", "ToggleSystemState").
Str("namespace", namespaceName).
Err(err).
Msg("failed to get namespace")
return errors.Wrap(err, "failed fetching namespace object")
}
@@ -218,8 +308,12 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er
namespace.Labels[systemNamespaceLabel] = strconv.FormatBool(isSystem)
_, err = nsService.Update(context.TODO(), namespace, metav1.UpdateOptions{})
if err != nil {
if _, err := kcl.cli.CoreV1().Namespaces().Update(context.TODO(), namespace, metav1.UpdateOptions{}); err != nil {
log.Error().
Str("context", "ToggleSystemState").
Str("namespace", namespaceName).
Err(err).
Msg("failed updating namespace object")
return errors.Wrap(err, "failed updating namespace object")
}
@@ -228,29 +322,26 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er
}
return nil
}
// UpdateIngress updates an ingress in a given namespace in a k8s endpoint.
func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) {
namespace := corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: info.Name,
Annotations: info.Annotations,
},
}
return kcl.cli.CoreV1().Namespaces().Update(context.Background(), &namespace, metav1.UpdateOptions{})
}
func (kcl *KubeClient) DeleteNamespace(namespaceName string) (*corev1.Namespace, error) {
namespace, err := kcl.cli.CoreV1().Namespaces().Get(context.Background(), namespaceName, metav1.GetOptions{})
if err != nil {
log.Error().
Str("context", "DeleteNamespace").
Str("namespace", namespaceName).
Err(err).
Msg("failed fetching namespace object")
return nil, err
}
err = kcl.cli.CoreV1().Namespaces().Delete(context.Background(), namespaceName, metav1.DeleteOptions{})
if err != nil {
log.Error().
Str("context", "DeleteNamespace").
Str("namespace", namespaceName).
Err(err).
Msg("failed deleting namespace object")
return nil, err
}
@@ -261,6 +352,10 @@ func (kcl *KubeClient) DeleteNamespace(namespaceName string) (*corev1.Namespace,
func (kcl *KubeClient) CombineNamespacesWithResourceQuotas(namespaces map[string]portainer.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError {
resourceQuotas, err := kcl.GetResourceQuotas("")
if err != nil && !k8serrors.IsNotFound(err) {
log.Error().
Str("context", "CombineNamespacesWithResourceQuotas").
Err(err).
Msg("unable to retrieve resource quotas from the Kubernetes for an admin user")
return httperror.InternalServerError("an error occurred during the CombineNamespacesWithResourceQuotas operation, unable to retrieve resource quotas from the Kubernetes for an admin user. Error: ", err)
}
@@ -275,6 +370,11 @@ func (kcl *KubeClient) CombineNamespacesWithResourceQuotas(namespaces map[string
func (kcl *KubeClient) CombineNamespaceWithResourceQuota(namespace portainer.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError {
resourceQuota, err := kcl.GetPortainerResourceQuota(namespace.Name)
if err != nil && !k8serrors.IsNotFound(err) {
log.Error().
Str("context", "CombineNamespaceWithResourceQuota").
Str("namespace", namespace.Name).
Err(err).
Msg("unable to retrieve the resource quota associated with the namespace")
return httperror.InternalServerError(fmt.Sprintf("an error occurred during the CombineNamespaceWithResourceQuota operation, unable to retrieve the resource quota associated with the namespace: %s for a non-admin user. Error: ", namespace.Name), err)
}
+1 -1
View File
@@ -49,7 +49,7 @@ func (kcl *KubeClient) fetchResourceQuotasForNonAdmin(namespace string) (*[]core
func (kcl *KubeClient) fetchResourceQuotas(namespace string) (*[]corev1.ResourceQuota, error) {
resourceQuotas, err := kcl.cli.CoreV1().ResourceQuotas(namespace).List(context.TODO(), metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("an error occured, failed to list resource quotas for the admin user: %w", err)
return nil, fmt.Errorf("an error occurred, failed to list resource quotas for the admin user: %w", err)
}
return &resourceQuotas.Items, nil
+2 -58
View File
@@ -1,15 +1,9 @@
package kubernetes
import (
"context"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/rs/zerolog/log"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"github.com/portainer/portainer/pkg/snapshot"
)
type Snapshotter struct {
@@ -30,55 +24,5 @@ func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*p
return nil, err
}
return snapshot(client, endpoint)
}
func snapshot(cli *kubernetes.Clientset, endpoint *portainer.Endpoint) (*portainer.KubernetesSnapshot, error) {
res := cli.RESTClient().Get().AbsPath("/healthz").Do(context.TODO())
if res.Error() != nil {
return nil, res.Error()
}
snapshot := &portainer.KubernetesSnapshot{}
err := snapshotVersion(snapshot, cli)
if err != nil {
log.Warn().Str("endpoint", endpoint.Name).Err(err).Msg("unable to snapshot cluster version")
}
err = snapshotNodes(snapshot, cli)
if err != nil {
log.Warn().Str("endpoint", endpoint.Name).Err(err).Msg("unable to snapshot cluster nodes")
}
snapshot.Time = time.Now().Unix()
return snapshot, nil
}
func snapshotVersion(snapshot *portainer.KubernetesSnapshot, cli *kubernetes.Clientset) error {
versionInfo, err := cli.ServerVersion()
if err != nil {
return err
}
snapshot.KubernetesVersion = versionInfo.GitVersion
return nil
}
func snapshotNodes(snapshot *portainer.KubernetesSnapshot, cli *kubernetes.Clientset) error {
nodeList, err := cli.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{})
if err != nil {
return err
}
var totalCPUs, totalMemory int64
for _, node := range nodeList.Items {
totalCPUs += node.Status.Capacity.Cpu().Value()
totalMemory += node.Status.Capacity.Memory().Value()
}
snapshot.TotalCPU = totalCPUs
snapshot.TotalMemory = totalMemory
snapshot.NodeCount = len(nodeList.Items)
return nil
return snapshot.CreateKubernetesSnapshot(client)
}
+24 -9
View File
@@ -133,6 +133,7 @@ type (
SecretKeyName *string
LogLevel *string
LogMode *string
KubectlShellImage *string
}
// CustomTemplateVariableDefinition
@@ -184,6 +185,16 @@ type (
// CustomTemplatePlatform represents a custom template platform
CustomTemplatePlatform int
// DiagnosticsData represents the diagnostics data for an environment
// this contains the logs, telnet, traceroute, dns and proxy information
// which will be part of the DockerSnapshot and KubernetesSnapshot structs
DiagnosticsData struct {
Log string `json:"Log,omitempty"`
Telnet map[string]string `json:"Telnet,omitempty"`
DNS map[string]string `json:"DNS,omitempty"`
Proxy map[string]string `json:"Proxy,omitempty"`
}
// DockerHub represents all the required information to connect and use the
// Docker Hub
DockerHub struct {
@@ -216,6 +227,7 @@ type (
GpuUseAll bool `json:"GpuUseAll"`
GpuUseList []string `json:"GpuUseList"`
IsPodman bool `json:"IsPodman"`
DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"`
}
// DockerContainerSnapshot is an extent of Docker's Container struct
@@ -317,9 +329,6 @@ type (
DeploymentType EdgeStackDeploymentType `json:"DeploymentType"`
// Uses the manifest's namespaces instead of the default one
UseManifestNamespaces bool
// Deprecated
Prune bool `json:"Prune,omitempty"`
}
EdgeStackDeploymentType int
@@ -599,6 +608,7 @@ type (
Id string `json:"Id"`
Name string `json:"Name"`
Status corev1.NamespaceStatus `json:"Status"`
Annotations map[string]string `json:"Annotations"`
CreationDate string `json:"CreationDate"`
NamespaceOwner string `json:"NamespaceOwner"`
IsSystem bool `json:"IsSystem"`
@@ -635,11 +645,12 @@ type (
// KubernetesSnapshot represents a snapshot of a specific Kubernetes environment(endpoint) at a specific time
KubernetesSnapshot struct {
Time int64 `json:"Time"`
KubernetesVersion string `json:"KubernetesVersion"`
NodeCount int `json:"NodeCount"`
TotalCPU int64 `json:"TotalCPU"`
TotalMemory int64 `json:"TotalMemory"`
Time int64 `json:"Time"`
KubernetesVersion string `json:"KubernetesVersion"`
NodeCount int `json:"NodeCount"`
TotalCPU int64 `json:"TotalCPU"`
TotalMemory int64 `json:"TotalMemory"`
DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"`
}
// KubernetesConfiguration represents the configuration of a Kubernetes environment(endpoint)
@@ -1617,7 +1628,9 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.24.0"
APIVersion = "2.25.1"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "STS"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
@@ -1665,6 +1678,8 @@ const (
AuthCookieKey = "portainer_api_key"
// PortainerCacheHeader is used to enabled FE caching for Kubernetes resources
PortainerCacheHeader = "X-Portainer-Cache"
// KubectlShellImageEnvVar is the environment variable used to override the default kubectl shell image
KubectlShellImageEnvVar = "KUBECTL_SHELL_IMAGE"
)
// List of supported features
+5 -4
View File
@@ -15,6 +15,7 @@ import (
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/api/types/system"
dockerclient "github.com/docker/docker/client"
@@ -170,9 +171,9 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
}
defer cli.Close()
image := getUnpackerImage()
unpackerImg := getUnpackerImage()
reader, err := cli.ImagePull(ctx, image, types.ImagePullOptions{})
reader, err := cli.ImagePull(ctx, unpackerImg, image.PullOptions{})
if err != nil {
return errors.Wrap(err, "unable to pull unpacker image")
}
@@ -197,12 +198,12 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
}
log.Debug().
Str("image", image).
Str("image", unpackerImg).
Str("cmd", strings.Join(cmd, " ")).
Msg("running unpacker")
unpackerContainer, err := cli.ContainerCreate(ctx, &container.Config{
Image: image,
Image: unpackerImg,
Cmd: cmd,
}, &container.HostConfig{
Binds: []string{
@@ -5,82 +5,110 @@
library css for buttons is overriden by `.widget .widget-body button`
so we have to force margin: 0
*/
.react-datetime-picker .react-calendar button {
.react-daterange-picker__calendar .react-calendar button {
margin: 0 !important;
}
/*
Extending Calendar.css from react-datetime-picker
Extending Calendar.css from react-daterange-picker__calendar
*/
.react-datetime-picker .react-calendar {
.react-daterange-picker__calendar .react-calendar {
background: var(--bg-calendar-color);
color: var(--text-main-color);
}
/* calendar nav buttons */
.react-datetime-picker .react-calendar__navigation button:disabled {
background-color: var(--bg-calendar-color);
.react-daterange-picker__calendar .react-calendar__navigation button:disabled {
background: var(--bg-calendar-color);
@apply opacity-60;
@apply brightness-95 th-dark:brightness-110;
}
.react-datetime-picker .react-calendar__navigation button:enabled:hover,
.react-datetime-picker .react-calendar__navigation button:enabled:focus {
background-color: var(--bg-daterangepicker-color);
.react-daterange-picker__calendar .react-calendar__navigation button:enabled:hover,
.react-daterange-picker__calendar .react-calendar__navigation button:enabled:focus {
background: var(--bg-daterangepicker-color);
}
/* date tile */
.react-datetime-picker .react-calendar__tile:disabled {
background-color: var(--bg-calendar-color);
.react-daterange-picker__calendar .react-calendar__tile:disabled {
background: var(--bg-calendar-color);
@apply opacity-60;
@apply brightness-95 th-dark:brightness-110;
}
.react-datetime-picker .react-calendar__tile:enabled:hover,
.react-datetime-picker .react-calendar__tile:enabled:focus {
background-color: var(--bg-daterangepicker-hover);
.react-daterange-picker__calendar .react-calendar__tile:enabled:hover,
.react-daterange-picker__calendar .react-calendar__tile:enabled:focus {
background: var(--bg-daterangepicker-hover);
}
/* today's date tile */
.react-datetime-picker .react-calendar__tile--now {
/* use background color to avoid white on yellow in dark/high contrast modes */
.react-daterange-picker__calendar .react-calendar__tile--now {
@apply th-highcontrast:text-[color:var(--bg-calendar-color)] th-dark:text-[color:var(--bg-calendar-color)];
border-radius: 0.25rem !important;
}
.react-datetime-picker .react-calendar__tile--now:enabled:hover,
.react-datetime-picker .react-calendar__tile--now:enabled:focus {
.react-daterange-picker__calendar .react-calendar__tile--now:enabled:hover,
.react-daterange-picker__calendar .react-calendar__tile--now:enabled:focus {
background: var(--bg-daterangepicker-hover);
color: var(--text-daterangepicker-hover);
}
/* probably date tile in range */
.react-datetime-picker .react-calendar__tile--hasActive {
.react-daterange-picker__calendar .react-calendar__tile--hasActive {
background: var(--bg-daterangepicker-end-date);
color: var(--text-daterangepicker-end-date);
}
.react-datetime-picker .react-calendar__tile--hasActive:enabled:hover,
.react-datetime-picker .react-calendar__tile--hasActive:enabled:focus {
.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:hover,
.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:focus {
background: var(--bg-daterangepicker-hover);
color: var(--text-daterangepicker-hover);
}
/* selected date tile */
.react-datetime-picker .react-calendar__tile--active {
background: var(--bg-daterangepicker-active);
color: var(--text-daterangepicker-active);
}
.react-datetime-picker .react-calendar__tile--active:enabled:hover,
.react-datetime-picker .react-calendar__tile--active:enabled:focus {
.react-daterange-picker__calendar .react-calendar__tile--active:enabled:hover,
.react-daterange-picker__calendar .react-calendar__tile--active:enabled:focus {
background: var(--bg-daterangepicker-hover);
color: var(--text-daterangepicker-hover);
}
.react-daterange-picker__calendar
.react-calendar__month-view__days__day:hover:not(.react-daterange-picker__calendar .react-calendar__tile--hoverEnd):not(
.react-daterange-picker__calendar .react-calendar__tile--hoverStart
):not(.react-calendar__tile--active) {
border-radius: 0.25rem !important;
}
/* on range select hover */
.react-datetime-picker .react-calendar--selectRange .react-calendar__tile--hover {
background-color: var(--bg-daterangepicker-in-range);
.react-daterange-picker__calendar .react-calendar--selectRange .react-calendar__tile--hover {
background: var(--bg-daterangepicker-in-range);
color: var(--text-daterangepicker-in-range);
}
/*
Extending DateTimePicker.css from react-datetime-picker
Extending DateTimePicker.css from react-daterange-picker__calendar
*/
.react-datetime-picker .react-datetime-picker--disabled {
.react-daterange-picker__calendar .react-daterange-picker__calendar--disabled {
@apply opacity-40;
}
/* selected date tile */
.react-daterange-picker__calendar .react-calendar__tile--active {
background: var(--bg-daterangepicker-active) !important;
color: var(--text-daterangepicker-active) !important;
}
.react-daterange-picker__calendar .react-calendar__tile--rangeStart:not(.react-calendar__tile--rangeEnd),
.react-daterange-picker__calendar .react-calendar__tile--hoverStart {
border-top-left-radius: 0.25rem;
border-bottom-left-radius: 0.25rem;
}
.react-daterange-picker__calendar .react-calendar__tile--rangeEnd:not(.react-calendar__tile--rangeStart),
.react-daterange-picker__calendar .react-calendar__tile--hoverEnd {
border-top-right-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
}
.react-daterange-picker__calendar .react-calendar__month-view__days__day--weekend {
color: inherit;
}
.react-calendar__tile--active.react-calendar__month-view__days__day--weekend {
color: var(--text-daterangepicker-active);
}
+1
View File
@@ -31,6 +31,7 @@ const ngModule = angular
'isStackColumnVisible',
'onRefresh',
'titleIcon',
'tableKey',
])
);
+1
View File
@@ -6,4 +6,5 @@
on-refresh="(getServices)"
is-add-action-visible="true"
is-stack-column-visible="true"
table-key="'services'"
></docker-services-datatable>
+1 -1
View File
@@ -14,7 +14,7 @@
<button
ng-if="showBrowseAction"
class="btn btn-xs btn-primary"
ui-sref="docker.volumes.volume.browse({ id: volume.Id, nodeName: volume.NodeName })"
ui-sref="docker.volumes.volume.browse({ id: volume.Name, nodeName: volume.NodeName })"
authorization="DockerAgentBrowseList"
>
<pr-icon icon="'search'" class="leading-none"></pr-icon>
@@ -58,6 +58,8 @@ angular.module('portainer.docker').controller('VolumeController', [
var containers = dataContainers.map(function (container) {
container.volumeData = getVolumeDataFromContainer(container, $scope.volume.Id);
$scope.volume.NodeName = container.NodeName || '';
return container;
});
$scope.containersUsingVolume = containers;
+4
View File
@@ -74,6 +74,10 @@ angular
data: {
docs: '/user/edge/stacks/add',
},
params: {
templateId: { dynamic: true },
templateType: { dynamic: true },
},
};
const stacksEdit = {
+2 -2
View File
@@ -478,10 +478,10 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
const resourcePool = {
name: 'kubernetes.resourcePools.resourcePool',
url: '/:id',
url: '/:id?tab',
views: {
'content@': {
component: 'kubernetesResourcePoolView',
component: 'namespaceView',
},
},
data: {
+2 -1
View File
@@ -19,7 +19,8 @@ class KubernetesConfigurationConverter {
res.IsRegistrySecret = secret.IsRegistrySecret;
res.SecretType = secret.SecretType;
if (secret.Annotations) {
res.ServiceAccountName = secret.Annotations['kubernetes.io/service-account.name'];
const serviceAccountAnnotation = secret.Annotations.find((a) => a.key === 'kubernetes.io/service-account.name');
res.ServiceAccountName = serviceAccountAnnotation ? serviceAccountAnnotation.value : undefined;
}
res.Labels = secret.Labels;
return res;
+4 -1
View File
@@ -109,7 +109,10 @@ class KubernetesSecretConverter {
res.Type = formValues.customType;
}
if (formValues.Type === KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value) {
res.Annotations = [{ name: 'kubernetes.io/service-account.name', value: formValues.ServiceAccountName }];
const serviceAccountAnnotation = formValues.Annotations.find((a) => a.key === 'kubernetes.io/service-account.name');
if (!serviceAccountAnnotation) {
res.Annotations.push({ key: 'kubernetes.io/service-account.name', value: formValues.ServiceAccountName });
}
}
return res;
}
+3 -3
View File
@@ -118,7 +118,7 @@ export class KubernetesIngressConverter {
const res = new KubernetesIngress();
res.Name = formValues.IngressClass.Name;
res.Namespace = formValues.Namespace;
const pairs = _.map(formValues.Annotations, (a) => [a.Key, a.Value]);
const pairs = _.map(formValues.Annotations, (a) => [a.key, a.value]);
res.Annotations = _.fromPairs(pairs);
res.Annotations[PortainerIngressClassTypes] = formValues.IngressClass.Name;
res.IngressClassName = formValues.IngressClass.Name;
@@ -149,8 +149,8 @@ export class KubernetesIngressConverter {
const annotations = _.map(_.toPairs(ingress.Annotations), ([key, value]) => {
if (key !== PortainerIngressClassTypes) {
const annotation = new KubernetesResourcePoolIngressClassAnnotationFormValue();
annotation.Key = key;
annotation.Value = value;
annotation.key = key;
annotation.value = value;
return annotation;
}
});
@@ -17,6 +17,6 @@ export const clusterManagementModule = angular
'resourceEventsDatatable',
r2a(
withUIRouter(withReactQuery(withCurrentUser(ResourceEventsDatatable))),
['resourceId', 'storageKey', 'namespace']
['resourceId', 'storageKey', 'namespace', 'noWidget']
)
).name;
-10
View File
@@ -4,7 +4,6 @@ import { r2a } from '@/react-tools/react2angular';
import { IngressClassDatatableAngular } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatableAngular';
import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector';
import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector';
import { RegistriesSelector } from '@/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector';
import { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm';
import { kubeServicesValidation } from '@/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation';
import { withReactQuery } from '@/react-tools/withReactQuery';
@@ -106,15 +105,6 @@ export const ngModule = angular
'name',
])
)
.component(
'createNamespaceRegistriesSelector',
r2a(withUIRouter(withReactQuery(withCurrentUser(RegistriesSelector))), [
'inputId',
'onChange',
'options',
'value',
])
)
.component(
'kubeNodesDatatable',
r2a(withUIRouter(withReactQuery(withCurrentUser(NodesDatatable))), [])
@@ -3,26 +3,11 @@ import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { NamespacesDatatable } from '@/react/kubernetes/namespaces/ListView/NamespacesDatatable';
import { NamespaceAppsDatatable } from '@/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable';
import { AccessDatatable } from '@/react/kubernetes/namespaces/AccessView/AccessDatatable/AccessDatatable';
export const namespacesModule = angular
.module('portainer.kubernetes.react.components.namespaces', [])
.component(
'kubernetesNamespacesDatatable',
r2a(withUIRouter(withCurrentUser(NamespacesDatatable)), [])
)
.component(
'kubernetesNamespaceApplicationsDatatable',
r2a(withUIRouter(withCurrentUser(NamespaceAppsDatatable)), [
'dataset',
'isLoading',
'onRefresh',
])
)
.component(
'namespaceAccessDatatable',
r2a(withUIRouter(withReactQuery(AccessDatatable)), [])
).name;
+5
View File
@@ -19,6 +19,7 @@ import { ServiceAccountsView } from '@/react/kubernetes/more-resources/ServiceAc
import { ClusterRolesView } from '@/react/kubernetes/more-resources/ClusterRolesView';
import { RolesView } from '@/react/kubernetes/more-resources/RolesView';
import { VolumesView } from '@/react/kubernetes/volumes/ListView/VolumesView';
import { NamespaceView } from '@/react/kubernetes/namespaces/ItemView/NamespaceView';
import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView';
export const viewsModule = angular
@@ -27,6 +28,10 @@ export const viewsModule = angular
'kubernetesCreateNamespaceView',
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateNamespaceView))), [])
)
.component(
'namespaceView',
r2a(withUIRouter(withReactQuery(withCurrentUser(NamespaceView))), [])
)
.component(
'kubernetesNamespacesView',
r2a(withUIRouter(withReactQuery(withCurrentUser(NamespacesView))), [])
@@ -3,7 +3,7 @@ import _ from 'lodash-es';
import filesizeParser from 'filesize-parser';
import * as JsonPatch from 'fast-json-patch';
import { RegistryTypes } from '@/portainer/models/registryTypes';
import { getServices } from '@/react/kubernetes/networks/services/service';
import { getServices } from '@/react/kubernetes/services/useNamespaceServices';
import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models';
import { getGlobalDeploymentOptions } from '@/react/portainer/settings/settings.service';
@@ -25,11 +25,11 @@ import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { KubernetesNodeHelper } from 'Kubernetes/node/helper';
import { updateIngress, getIngresses } from '@/react/kubernetes/ingresses/service';
import { confirmUpdateAppIngress } from '@/react/kubernetes/applications/CreateView/UpdateIngressPrompt';
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
import { isVolumeUsed } from '@/react/kubernetes/volumes/utils';
import { confirm, confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { ModalType } from '@@/modals';
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
import { isVolumeUsed } from '@/react/kubernetes/volumes/utils';
class KubernetesCreateApplicationController {
/* #region CONSTRUCTOR */
@@ -1,14 +0,0 @@
import angular from 'angular';
import controller from './storage-class-switch.controller.js';
export const storageClassSwitch = {
templateUrl: './storage-class-switch.html',
controller,
bindings: {
value: '<',
onChange: '<',
name: '<',
},
};
angular.module('portainer.kubernetes').component('storageClassSwitch', storageClassSwitch);
@@ -1,16 +0,0 @@
import { FeatureId } from '@/react/portainer/feature-flags/enums';
class StorageClassSwitchController {
/* @ngInject */
constructor() {
this.featureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA;
this.handleChange = this.handleChange.bind(this);
}
handleChange(value) {
this.onChange(this.name, value);
}
}
export default StorageClassSwitchController;
@@ -1,13 +0,0 @@
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
data-cy="'k8sNamespaceCreate-enableQuotaToggle'"
label="'Enable quota'"
label-class="'col-sm-3 col-lg-2'"
name="'k8s-resourcepool-storagequota'"
feature-id="$ctrl.featureId"
checked="$ctrl.value"
on-change="($ctrl.handleChange)"
></por-switch-field>
</div>
</div>
@@ -1,278 +0,0 @@
<page-header
ng-if="$ctrl.state.viewReady"
title="'Create a namespace'"
breadcrumbs="[{ label:'Namespaces', link:'kubernetes.resourcePools' }, 'Create a namespace']"
reload="true"
></page-header>
<kubernetes-view-loading view-ready="$ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="$ctrl.state.viewReady">
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" autocomplete="off" name="resourcePoolCreationForm">
<!-- #region NAME INPUT -->
<div class="form-group">
<label for="pool_name" class="col-sm-3 col-lg-2 control-label required text-left">Name</label>
<div class="col-sm-8">
<input
type="text"
class="form-control"
name="pool_name"
ng-model="$ctrl.formValues.Name"
ng-pattern="/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/"
ng-change="$ctrl.onChangeName()"
placeholder="my-project"
data-cy="k8sNamespaceCreate-namespaceNameInput"
required
auto-focus
/>
<span class="help-block">
<div class="form-group" ng-show="resourcePoolCreationForm.pool_name.$invalid || $ctrl.state.isAlreadyExist || $ctrl.state.hasPrefixKube">
<div class="col-sm-12 small text-warning">
<div ng-messages="resourcePoolCreationForm.pool_name.$error">
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>This field is required.</p>
<p class="vertical-center" ng-message="pattern"
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This field must consist of lower case alphanumeric characters or '-', and contain at most 63
characters, and must start and end with an alphanumeric character.</p
>
</div>
<p class="vertical-center" ng-if="$ctrl.state.hasPrefixKube"
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Prefix "kube-" is reserved for Kubernetes system namespaces.</p
>
<p class="vertical-center" ng-if="$ctrl.state.isAlreadyExist">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> A namespace with the same name already exists.
</p>
</div>
</div>
</span>
</div>
</div>
<div class="col-sm-12 !p-0">
<annotations-be-teaser></annotations-be-teaser>
</div>
<!-- #endregion -->
<div class="col-sm-12 form-section-title"> Quota </div>
<!-- #region QUOTA -->
<!-- quotas-switch -->
<div class="form-group">
<div class="col-sm-12 small text-muted">
<p class="vertical-center">
<pr-icon class="vertical-center" icon="'info'" mode="'primary'"></pr-icon>
A namespace segments the underlying physical Kubernetes cluster into smaller virtual clusters. You should assign a capped limit of resources to this namespace or
disable for the safe operation of your platform.
</p>
</div>
<div class="col-sm-12">
<por-switch-field
data-cy="'k8sNamespaceCreate-resourceAssignmentToggle'"
label="'Resource assignment'"
label-class="'col-sm-3 col-lg-2'"
name="'k8s-resourcepool-resourcequota'"
checked="$ctrl.formValues.HasQuota"
on-change="($ctrl.onToggleResourceQuota)"
></por-switch-field>
</div>
</div>
<!-- !quotas-switch -->
<div ng-if="$ctrl.formValues.HasQuota">
<div class="col-sm-12 form-section-title"> Resource limits </div>
<div>
<div class="form-group">
<span class="col-sm-12 small text-warning" ng-switch on="$ctrl.formValues.HasQuota && !$ctrl.isQuotaValid()">
<p class="vertical-center mb-0" ng-switch-when="true"
><pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon> At least a single limit must be set for the quota to be valid.
</p>
<p class="vertical-center mb-0" ng-switch-default></p>
</span>
</div>
<!-- memory-limit-input -->
<div class="form-group !mb-0 flex flex-row">
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label text-left"> Memory limit (MB) </label>
<div class="col-xs-6">
<por-slider
min="$ctrl.defaults.MemoryLimit"
max="$ctrl.state.sliderMaxMemory"
step="128"
ng-if="$ctrl.state.sliderMaxMemory"
value="$ctrl.formValues.MemoryLimit"
on-change="($ctrl.handleMemoryLimitChange)"
visible-tooltip="true"
data-cy="k8sNamespaceCreate-memoryLimitSlider"
></por-slider>
</div>
<div class="col-sm-2 vertical-center pt-6">
<input
name="memory_limit"
type="number"
min="{{ $ctrl.defaults.MemoryLimit }}"
max="{{ $ctrl.state.sliderMaxMemory }}"
class="form-control"
ng-model="$ctrl.formValues.MemoryLimit"
id="memory-limit"
data-cy="k8sNamespaceCreate-memoryLimitInput"
required
/>
</div>
</div>
<div class="flex w-full flex-row">
<span class="col-sm-3 col-lg-2"></span>
<span class="help-block col-sm-9 col-lg-10">
<div ng-show="resourcePoolCreationForm.memory_limit.$invalid">
<div class="small text-warning">
<div ng-messages="resourcePoolCreationForm.pool_name.$error">
<p class="vertical-center"
><pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon> Value must be between {{ $ctrl.defaults.MemoryLimit }} and
{{ $ctrl.state.sliderMaxMemory }}
</p>
</div>
</div>
</div>
</span>
</div>
<!-- !memory-limit-input -->
<!-- cpu-limit-input -->
<div class="form-group flex flex-row">
<label for="cpu-limit" class="col-sm-3 col-lg-2 control-label text-left"> CPU limit </label>
<div class="col-xs-8">
<por-slider
min="$ctrl.defaults.CpuLimit"
max="$ctrl.state.sliderMaxCpu"
step="0.1"
ng-if="$ctrl.state.sliderMaxCpu"
value="$ctrl.formValues.CpuLimit"
on-change="($ctrl.handleCpuLimitChange)"
data-cy="k8sNamespaceCreate-cpuLimitSlider"
visible-tooltip="true"
></por-slider>
</div>
</div>
<!-- !cpu-limit-input -->
</div>
</div>
<!-- #endregion -->
<!-- #region LOAD-BALANCERS -->
<div class="col-sm-12 form-section-title"> Load balancers </div>
<div class="form-group">
<span class="col-sm-12 text-muted small vertical-center">
<pr-icon icon="'info'" mode="'primary'" class="vertical-center"></pr-icon>
You can set a quota on the amount of external load balancers that can be created inside this namespace. Set this quota to 0 to effectively disable the use of load
balancers in this namespace.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
data-cy="'k8sNamespaceCreate-loadBalancerQuotaToggle'"
label="'Load Balancer quota'"
label-class="'col-sm-3 col-lg-2'"
name="'k8s-resourcepool-lbquota'"
feature-id="$ctrl.LBQuotaFeatureId"
checked="$ctrl.formValues.UseLoadBalancersQuota"
on-change="($ctrl.onToggleLoadBalancerQuota)"
></por-switch-field>
</div>
</div>
<!-- #endregion -->
<div ng-if="$ctrl.state.ingressAvailabilityPerNamespace">
<!-- #region INGRESSES -->
<div class="col-sm-12 form-section-title"> Networking </div>
<ingress-class-datatable
ng-if="$ctrl.state.ingressAvailabilityPerNamespace"
on-change-controllers="($ctrl.onChangeIngressControllerAvailability)"
ingress-controllers="$ctrl.ingressControllers"
initial-ingress-controllers="$ctrl.initialIngressControllers"
description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'"
no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'"
view="'namespace'"
></ingress-class-datatable>
<!-- #endregion -->
</div>
<!-- #region REGISTRIES -->
<div class="col-sm-12 form-section-title"> Registries </div>
<div class="form-group">
<div class="col-sm-12 small text-muted">
<p class="vertical-center">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
Define which registries can be used by users who have access to this namespace.
</p>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 col-lg-2 control-label !pt-0 text-left" for="registries-selector"> Select registries </label>
<div class="col-sm-8 col-lg-9">
<span class="small text-muted" ng-if="!$ctrl.registries.length && $ctrl.state.isAdmin">
No registries available. Head over to the <a ui-sref="portainer.registries">registry view</a> to define a container registry.
</span>
<span class="small text-muted" ng-if="!$ctrl.registries.length && !$ctrl.state.isAdmin">
No registries available. Contact your administrator to create a container registry.
</span>
<create-namespace-registries-selector
input-id="'registries-selector'"
value="$ctrl.formValues.Registries"
on-change="($ctrl.onRegistriesChange)"
options="$ctrl.registries"
>
</create-namespace-registries-selector>
</div>
</div>
<!-- #endregion -->
<!-- #region STORAGES -->
<div class="col-sm-12 form-section-title"> Storage </div>
<div class="form-group">
<span class="col-sm-12 text-muted small vertical-center">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
Quotas can be set on each storage option to prevent users from exceeding a specific threshold when deploying applications. You can set a quota to 0 to effectively
prevent the usage of a specific storage option inside this namespace.
</span>
</div>
<div class="col-sm-12 form-section-title vertical-center">
<pr-icon icon="'svg-route'"></pr-icon>
standard
</div>
<storage-class-switch value="sc.Selected" name="sc.Name" on-change="(ctrl.onToggleStorageQuota)" authorization="K8sResourcePoolDetailsW"></storage-class-switch>
<!-- #endregion -->
<!-- summary -->
<kubernetes-summary-view ng-if="resourcePoolCreationForm.$valid && !$ctrl.isCreateButtonDisabled()" form-values="$ctrl.formValues"></kubernetes-summary-view>
<!-- !summary -->
<div class="col-sm-12 form-section-title"> Actions </div>
<!-- #region ACTIONS -->
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="!resourcePoolCreationForm.$valid || $ctrl.isCreateButtonDisabled()"
ng-click="$ctrl.createResourcePool()"
button-spinner="$ctrl.state.actionInProgress"
>
<span ng-hide="$ctrl.state.actionInProgress" data-cy="k8sNamespace-createNamespaceButton">Create namespace</span>
<span ng-show="$ctrl.state.actionInProgress">Creation in progress...</span>
</button>
</div>
</div>
<!-- #endregion -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>
@@ -1,236 +0,0 @@
import _ from 'lodash-es';
import filesizeParser from 'filesize-parser';
import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models';
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassHostFormValue } from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap';
class KubernetesCreateResourcePoolController {
/* #region CONSTRUCTOR */
/* @ngInject */
constructor($async, $state, $scope, Notifications, KubernetesNodeService, KubernetesResourcePoolService, KubernetesIngressService, Authentication, EndpointService) {
Object.assign(this, {
$async,
$state,
$scope,
Notifications,
KubernetesNodeService,
KubernetesResourcePoolService,
KubernetesIngressService,
Authentication,
EndpointService,
});
this.IngressClassTypes = KubernetesIngressClassTypes;
this.EndpointService = EndpointService;
this.LBQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_LB_QUOTA;
this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this);
this.onToggleLoadBalancerQuota = this.onToggleLoadBalancerQuota.bind(this);
this.onToggleResourceQuota = this.onToggleResourceQuota.bind(this);
this.onChangeIngressControllerAvailability = this.onChangeIngressControllerAvailability.bind(this);
this.onRegistriesChange = this.onRegistriesChange.bind(this);
this.handleMemoryLimitChange = this.handleMemoryLimitChange.bind(this);
this.handleCpuLimitChange = this.handleCpuLimitChange.bind(this);
}
/* #endregion */
onRegistriesChange(registries) {
return this.$scope.$evalAsync(() => {
this.formValues.Registries = registries;
});
}
onToggleStorageQuota(storageClassName, enabled) {
this.$scope.$evalAsync(() => {
this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled }));
});
}
onToggleLoadBalancerQuota(enabled) {
this.$scope.$evalAsync(() => {
this.formValues.UseLoadBalancersQuota = enabled;
});
}
onToggleResourceQuota(enabled) {
this.$scope.$evalAsync(() => {
this.formValues.HasQuota = enabled;
});
}
/* #region INGRESS MANAGEMENT */
onChangeIngressControllerAvailability(controllerClassMap) {
this.ingressControllers = controllerClassMap;
}
/* #endregion */
isCreateButtonDisabled() {
return (
this.state.actionInProgress ||
(this.formValues.HasQuota && !this.isQuotaValid()) ||
this.state.isAlreadyExist ||
this.state.hasPrefixKube ||
this.state.duplicates.ingressHosts.hasRefs
);
}
onChangeName() {
this.state.isAlreadyExist = _.find(this.resourcePools, (resourcePool) => resourcePool.Namespace.Name === this.formValues.Name) !== undefined;
this.state.hasPrefixKube = /^kube-/.test(this.formValues.Name);
}
isQuotaValid() {
if (
this.state.sliderMaxCpu < this.formValues.CpuLimit ||
this.state.sliderMaxMemory < this.formValues.MemoryLimit ||
(this.formValues.CpuLimit === 0 && this.formValues.MemoryLimit === 0)
) {
return false;
}
return true;
}
checkDefaults() {
if (this.formValues.CpuLimit < this.defaults.CpuLimit) {
this.formValues.CpuLimit = this.defaults.CpuLimit;
}
if (this.formValues.MemoryLimit < KubernetesResourceReservationHelper.megaBytesValue(this.defaults.MemoryLimit)) {
this.formValues.MemoryLimit = KubernetesResourceReservationHelper.megaBytesValue(this.defaults.MemoryLimit);
}
}
handleMemoryLimitChange(memoryLimit) {
return this.$async(async () => {
this.formValues.MemoryLimit = memoryLimit;
});
}
handleCpuLimitChange(cpuLimit) {
return this.$async(async () => {
this.formValues.CpuLimit = cpuLimit;
});
}
/* #region CREATE NAMESPACE */
createResourcePool() {
return this.$async(async () => {
this.state.actionInProgress = true;
try {
this.checkDefaults();
this.formValues.Owner = this.Authentication.getUserDetails().username;
await this.KubernetesResourcePoolService.create(this.formValues);
await updateIngressControllerClassMap(this.endpoint.Id, this.ingressControllers || [], this.formValues.Name);
this.Notifications.success('Namespace successfully created', this.formValues.Name);
this.$state.go('kubernetes.resourcePools');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to create namespace');
} finally {
this.state.actionInProgress = false;
}
});
}
/* #endregion */
/* #region GET INGRESSES */
getIngresses() {
return this.$async(async () => {
try {
this.allIngresses = await this.KubernetesIngressService.get();
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve ingresses.');
}
});
}
/* #endregion */
/* #region GET NAMESPACES */
getResourcePools() {
return this.$async(async () => {
try {
this.resourcePools = await this.KubernetesResourcePoolService.get('', { getQuota: true });
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve namespaces');
}
});
}
/* #endregion */
/* #region GET REGISTRIES */
getRegistries() {
return this.$async(async () => {
try {
this.registries = await this.EndpointService.registries(this.endpoint.Id);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve registries');
}
});
}
/* #endregion */
/* #region ON INIT */
$onInit() {
return this.$async(async () => {
try {
const endpoint = await this.EndpointService.endpoint(this.endpoint.Id);
this.defaults = KubernetesResourceQuotaDefaults;
this.formValues = new KubernetesResourcePoolFormValues(this.defaults);
this.formValues.EndpointId = this.endpoint.Id;
this.formValues.HasQuota = false;
this.state = {
actionInProgress: false,
sliderMaxMemory: 0,
sliderMaxCpu: 0,
viewReady: false,
isAlreadyExist: false,
hasPrefixKube: false,
canUseIngress: false,
duplicates: {
ingressHosts: new KubernetesFormValidationReferences(),
},
isAdmin: this.Authentication.isAdmin(),
ingressAvailabilityPerNamespace: endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace,
};
const nodes = await this.KubernetesNodeService.get();
this.ingressControllers = [];
if (this.state.ingressAvailabilityPerNamespace) {
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, allowedOnly: true });
this.initialIngressControllers = structuredClone(this.ingressControllers);
}
_.forEach(nodes, (item) => {
this.state.sliderMaxMemory += filesizeParser(item.Memory);
this.state.sliderMaxCpu += item.CPU;
});
this.state.sliderMaxMemory = KubernetesResourceReservationHelper.megaBytesValue(this.state.sliderMaxMemory);
await this.getResourcePools();
if (this.state.canUseIngress) {
await this.getIngresses();
const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses;
this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses);
}
_.forEach(this.formValues.IngressClasses, (ic) => {
if (ic.Hosts.length === 0) {
ic.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue());
}
});
await this.getRegistries();
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to load view data');
} finally {
this.state.viewReady = true;
}
});
}
/* #endregion */
}
export default KubernetesCreateResourcePoolController;
@@ -1,302 +0,0 @@
<page-header
ng-if="ctrl.state.viewReady"
title="'Namespace details'"
breadcrumbs="[{ label:'Namespaces', link:'kubernetes.resourcePools' }, ctrl.pool.Namespace.Name]"
reload="true"
></page-header>
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady">
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body classes="no-padding">
<uib-tabset active="ctrl.state.activeTab" justified="true" type="pills">
<uib-tab index="0" classes="btn-sm" select="ctrl.selectTab(0)">
<uib-tab-heading class="vertical-center"> <pr-icon icon="'layers'"></pr-icon> Namespace </uib-tab-heading>
<form class="form-horizontal widget-body" autocomplete="off" name="resourcePoolEditForm" style="margin-top: 10px">
<table class="table">
<tbody>
<tr>
<td class="w-[40%]">Name</td>
<td>
{{ ctrl.pool.Namespace.Name }}
<span class="label label-info image-tag label-margins" ng-if="ctrl.isSystem">system</span>
</td>
</tr>
</tbody>
</table>
<div class="col-sm-12 !p-0">
<annotations-be-teaser></annotations-be-teaser>
</div>
<!-- !name-input -->
<div ng-if="ctrl.isAdmin && ctrl.isEditable" class="col-sm-12 form-section-title">Resource quota</div>
<!-- quotas-switch -->
<div ng-if="ctrl.isAdmin && ctrl.isEditable" class="form-group">
<div class="col-sm-12 mt-2" ng-if="ctrl.state.resourceOverCommitEnabled">
<div class="form-group">
<div class="col-sm-3 col-lg-2">
<label class="control-label text-left"> Resource assignment </label>
</div>
<div class="col-sm-9 pt-2">
<label class="switch">
<input type="checkbox" ng-model="ctrl.formValues.HasQuota" />
<span class="slider round"></span>
</label>
</div>
</div>
</div>
</div>
<div ng-if="ctrl.formValues.HasQuota">
<kubernetes-resource-reservation
ng-if="ctrl.pool.Quota"
description="Resource reservation represents the total amount of resource assigned to all the applications deployed inside this namespace."
cpu-reservation="ctrl.state.resourceReservation.CPU"
memory-reservation="ctrl.state.resourceReservation.Memory"
cpu-limit="ctrl.formValues.CpuLimit"
memory-limit="ctrl.formValues.MemoryLimit"
display-usage="ctrl.state.useServerMetrics"
cpu-usage="ctrl.state.resourceUsage.CPU"
memory-usage="ctrl.state.resourceUsage.Memory"
>
</kubernetes-resource-reservation>
</div>
<!-- !quotas-switch -->
<div ng-if="ctrl.formValues.HasQuota && ctrl.isAdmin && ctrl.isEditable">
<div class="col-sm-12 form-section-title"> Resource limits </div>
<div>
<div class="form-group">
<span class="col-sm-12 small text-warning" ng-switch on="ctrl.formValues.HasQuota && ctrl.isAdmin && ctrl.isEditable && !ctrl.isQuotaValid()">
<p class="vertical-center mb-0" ng-switch-when="true"
><pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon> At least a single limit must be set for the quota to be valid.
</p>
<p class="vertical-center mb-0" ng-switch-default></p>
</span>
</div>
<!-- memory-limit-input -->
<div class="form-group flex">
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label vertical-center text-left"> Memory limit (MB) </label>
<div class="col-sm-6">
<por-slider
min="ctrl.ResourceQuotaDefaults.MemoryLimit"
max="ctrl.state.sliderMaxMemory"
step="128"
ng-if="ctrl.state.sliderMaxMemory"
value="ctrl.formValues.MemoryLimit"
on-change="(ctrl.handleMemoryLimitChange)"
visible-tooltip="true"
data-cy="k8sNamespaceEdit-memoryLimitSlider"
></por-slider>
</div>
<div class="col-sm-2 vertical-center pt-6">
<input
name="memory_limit"
type="number"
data-cy="k8sNamespaceEdit-memoryLimitInput"
min="{{ ctrl.ResourceQuotaDefaults.MemoryLimit }}"
max="{{ ctrl.state.sliderMaxMemory }}"
class="form-control"
ng-model="ctrl.formValues.MemoryLimit"
id="memory-limit"
data-cy="k8sNamespaceEdit-memoryLimitInput"
required
/>
</div>
</div>
<div class="form-group" ng-show="resourcePoolEditForm.memory_limit.$invalid">
<div class="col-sm-3 col-lg-2"></div>
<div class="col-sm-8 small text-warning">
<div ng-messages="resourcePoolEditForm.pool_name.$error">
<p class="vertical-center">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Value must be between {{ ctrl.ResourceQuotaDefaults.MemoryLimit }} and
{{ ctrl.state.sliderMaxMemory }}.
</p>
</div>
</div>
</div>
<!-- !memory-limit-input -->
<!-- cpu-limit-input -->
<div class="form-group">
<label for="cpu-limit" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px"> CPU limit </label>
<div class="col-sm-8">
<por-slider
min="ctrl.ResourceQuotaDefaults.CpuLimit"
max="ctrl.state.sliderMaxCpu"
step="0.1"
ng-if="ctrl.state.sliderMaxCpu"
value="ctrl.formValues.CpuLimit"
on-change="(ctrl.handleCpuLimitChange)"
data-cy="k8sNamespaceEdit-cpuLimitSlider"
visible-tooltip="true"
></por-slider>
</div>
</div>
<!-- !cpu-limit-input -->
</div>
</div>
<!-- #region LOADBALANCERS -->
<div class="col-sm-12 form-section-title"> Load balancers </div>
<div class="form-group">
<div class="col-sm-12 small text-muted">
<p class="vertical-center">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
You can set a quota on the amount of external load balancers that can be created inside this namespace. Set this quota to 0 to effectively disable the use of
load balancers in this namespace.
</p>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
data-cy="'k8sNamespaceCreate-loadBalancerQuotaToggle'"
label="'Load Balancer quota'"
label-class="'col-sm-3 col-lg-2'"
name="'k8s-resourcepool-Lbquota'"
feature-id="ctrl.LBQuotaFeatureId"
checked="ctrl.formValues.UseLoadBalancersQuota"
on-change="(ctrl.onToggleLoadBalancersQuota)"
></por-switch-field>
</div>
</div>
<!-- #endregion -->
<div ng-if="ctrl.isAdmin && ctrl.isEditable && ctrl.state.ingressAvailabilityPerNamespace">
<!-- #region INGRESSES -->
<div class="col-sm-12 form-section-title"> Networking </div>
<ingress-class-datatable
ng-if="ctrl.state.ingressAvailabilityPerNamespace"
on-change-controllers="(ctrl.onChangeIngressControllerAvailability)"
ingress-controllers="ctrl.ingressControllers"
initial-ingress-controllers="$ctrl.initialIngressControllers"
description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'"
no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'"
view="'namespace'"
></ingress-class-datatable>
<!-- #endregion -->
</div>
<!-- #region REGISTRIES -->
<div>
<div class="col-sm-12 form-section-title"> Registries </div>
<div class="form-group" ng-if="!ctrl.isAdmin || ctrl.isSystem">
<label class="col-sm-3 col-lg-2 control-label text-left" style="padding-top: 0"> Selected registries </label>
<div class="col-sm-9 col-lg-4"> {{ ctrl.selectedRegistries ? ctrl.selectedRegistries : 'None' }} </div>
</div>
<div ng-if="ctrl.isAdmin && !ctrl.isSystem">
<div class="form-group">
<div class="col-sm-12 small text-muted">
<p class="vertical-center">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
Define which registries can be used by users who have access to this namespace.
</p>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 col-lg-2 control-label !pt-0 text-left" for="registries-selector"> Select registries </label>
<div class="col-sm-9 col-lg-4">
<create-namespace-registries-selector
input-id="'registries-selector'"
value="ctrl.formValues.Registries"
on-change="(ctrl.onRegistriesChange)"
options="ctrl.registries"
>
</create-namespace-registries-selector>
</div>
</div>
</div>
</div>
<!-- #endregion -->
<!-- #region STORAGES -->
<div class="col-sm-12 form-section-title"> Storage </div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
<p class="vertical-center">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
Quotas can be set on each storage option to prevent users from exceeding a specific threshold when deploying applications. You can set a quota to 0 to
effectively prevent the usage of a specific storage option inside this namespace.
</p>
</span>
</div>
<div class="form-section-title text-muted col-sm-12" style="width: 100%">
<div class="vertical-center"> <pr-icon icon="'svg-route'"></pr-icon>standard </div>
<hr />
</div>
<storage-class-switch value="sc.Selected" name="sc.Name" on-change="(ctrl.onToggleStorageQuota)" authorization="K8sResourcePoolDetailsW"> </storage-class-switch>
<!-- #endregion -->
<!-- summary -->
<kubernetes-summary-view
ng-if="resourcePoolEditForm.$valid && !ctrl.isUpdateButtonDisabled()"
form-values="ctrl.formValues"
old-form-values="ctrl.savedFormValues"
></kubernetes-summary-view>
<!-- !summary -->
<!-- actions -->
<div ng-if="ctrl.isAdmin" class="col-sm-12 form-section-title"> Actions </div>
<div ng-if="ctrl.isAdmin" class="form-group">
<div class="col-sm-12">
<button
type="button"
ng-if="!ctrl.isSystem"
class="btn btn-primary btn-sm !ml-0 !mr-1"
ng-disabled="!resourcePoolEditForm.$valid || ctrl.isUpdateButtonDisabled()"
ng-click="ctrl.updateResourcePool()"
button-spinner="ctrl.state.actionInProgress"
>
<span ng-hide="ctrl.state.actionInProgress" data-cy="k8sNamespaceEdit-updateNamespaceButton">Update namespace</span>
<span ng-show="ctrl.state.actionInProgress">Update in progress...</span>
</button>
<button
ng-if="!ctrl.isDefaultNamespace"
type="button"
class="btn btn-light btn-sm !ml-0"
ng-click="ctrl.markUnmarkAsSystem()"
button-spinner="ctrl.state.actionInProgress"
data-cy="k8sNamespaceEdit-markSystem"
>
<span ng-if="ctrl.isSystem">Unmark as system</span>
<span ng-if="!ctrl.isSystem">Mark as system</span>
</button>
</div>
</div>
<!-- !actions -->
</form>
</uib-tab>
<uib-tab index="1" classes="btn-sm" select="ctrl.selectTab(1)">
<uib-tab-heading class="vertical-center">
<pr-icon icon="'history'"></pr-icon> Events
<div ng-if="ctrl.hasEventWarnings()">
<pr-icon icon="'alert-triangle'" mode="'warning'" class-name="'mr-0.5'"></pr-icon>
{{ ctrl.state.eventWarningCount }} warning(s)
</div>
</uib-tab-heading>
<resource-events-datatable namespace="ctrl.pool.Namespace.Name" storage-key="'kubernetes.resourcepool.events'"> </resource-events-datatable>
</uib-tab>
<uib-tab index="2" ng-if="ctrl.pool.Yaml" select="ctrl.showEditor()" classes="btn-sm">
<uib-tab-heading class="vertical-center"><pr-icon icon="'code'"></pr-icon> YAML </uib-tab-heading>
<div class="px-5" ng-if="ctrl.state.showEditorTab">
<kube-yaml-inspector identifier="'namespace-yaml'" data="ctrl.pool.Yaml" data-cy="k8sNamespaceEdit-yamlEditor" hide-message="true" />
</div>
</uib-tab>
</uib-tabset>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div ng-if="ctrl.applications && ctrl.applications.length > 0">
<kubernetes-namespace-applications-datatable dataset="ctrl.applications" on-refresh="(ctrl.getApplications)" is-loading="ctrl.state.applicationsLoading">
</kubernetes-namespace-applications-datatable>
</div>
</div>
@@ -1,8 +0,0 @@
angular.module('portainer.kubernetes').component('kubernetesResourcePoolView', {
templateUrl: './resourcePool.html',
controller: 'KubernetesResourcePoolController',
controllerAs: 'ctrl',
bindings: {
endpoint: '<',
},
});
@@ -1,405 +0,0 @@
import angular from 'angular';
import _ from 'lodash-es';
import filesizeParser from 'filesize-parser';
import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models';
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models';
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
import { KubernetesResourcePoolFormValues } from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap';
import { confirmUpdate } from '@@/modals/confirm';
import { confirmUpdateNamespace } from '@/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace';
import { getMetricsForAllPods } from '@/react/kubernetes/metrics/metrics.ts';
class KubernetesResourcePoolController {
/* #region CONSTRUCTOR */
/* @ngInject */
constructor(
$async,
$state,
$scope,
Authentication,
Notifications,
LocalStorage,
EndpointService,
KubernetesResourceQuotaService,
KubernetesResourcePoolService,
KubernetesEventService,
KubernetesPodService,
KubernetesApplicationService,
KubernetesIngressService,
KubernetesVolumeService,
KubernetesNamespaceService,
KubernetesNodeService
) {
Object.assign(this, {
$async,
$state,
$scope,
Authentication,
Notifications,
LocalStorage,
EndpointService,
KubernetesResourceQuotaService,
KubernetesResourcePoolService,
KubernetesEventService,
KubernetesPodService,
KubernetesApplicationService,
KubernetesIngressService,
KubernetesVolumeService,
KubernetesNamespaceService,
KubernetesNodeService,
});
this.IngressClassTypes = KubernetesIngressClassTypes;
this.ResourceQuotaDefaults = KubernetesResourceQuotaDefaults;
this.EndpointService = EndpointService;
this.LBQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_LB_QUOTA;
this.StorageQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA;
this.StorageQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA;
this.updateResourcePoolAsync = this.updateResourcePoolAsync.bind(this);
this.getEvents = this.getEvents.bind(this);
this.onToggleLoadBalancersQuota = this.onToggleLoadBalancersQuota.bind(this);
this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this);
this.onChangeIngressControllerAvailability = this.onChangeIngressControllerAvailability.bind(this);
this.onRegistriesChange = this.onRegistriesChange.bind(this);
this.handleMemoryLimitChange = this.handleMemoryLimitChange.bind(this);
this.handleCpuLimitChange = this.handleCpuLimitChange.bind(this);
}
/* #endregion */
onRegistriesChange(registries) {
return this.$scope.$evalAsync(() => {
this.formValues.Registries = registries;
});
}
onToggleLoadBalancersQuota(checked) {
return this.$scope.$evalAsync(() => {
this.formValues.UseLoadBalancersQuota = checked;
});
}
onToggleStorageQuota(storageClassName, enabled) {
this.$scope.$evalAsync(() => {
this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled }));
});
}
onChangeIngressControllerAvailability(controllerClassMap) {
this.ingressControllers = controllerClassMap;
}
selectTab(index) {
this.LocalStorage.storeActiveTab('resourcePool', index);
}
isUpdateButtonDisabled() {
return this.state.actionInProgress || (this.formValues.HasQuota && !this.isQuotaValid()) || this.state.duplicates.ingressHosts.hasRefs;
}
isQuotaValid() {
if (
this.state.sliderMaxCpu < this.formValues.CpuLimit ||
this.state.sliderMaxMemory < this.formValues.MemoryLimit ||
(this.formValues.CpuLimit === 0 && this.formValues.MemoryLimit === 0)
) {
return false;
}
return true;
}
checkDefaults() {
if (this.formValues.CpuLimit < KubernetesResourceQuotaDefaults.CpuLimit) {
this.formValues.CpuLimit = KubernetesResourceQuotaDefaults.CpuLimit;
}
if (this.formValues.MemoryLimit < KubernetesResourceReservationHelper.megaBytesValue(KubernetesResourceQuotaDefaults.MemoryLimit)) {
this.formValues.MemoryLimit = KubernetesResourceReservationHelper.megaBytesValue(KubernetesResourceQuotaDefaults.MemoryLimit);
}
}
handleMemoryLimitChange(memoryLimit) {
return this.$async(async () => {
this.formValues.MemoryLimit = memoryLimit;
});
}
handleCpuLimitChange(cpuLimit) {
return this.$async(async () => {
this.formValues.CpuLimit = cpuLimit;
});
}
showEditor() {
this.state.showEditorTab = true;
this.selectTab(2);
}
hasResourceQuotaBeenReduced() {
if (this.formValues.HasQuota && this.oldQuota) {
const cpuLimit = this.formValues.CpuLimit;
const memoryLimit = KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit);
if (cpuLimit < this.oldQuota.CpuLimit || memoryLimit < this.oldQuota.MemoryLimit) {
return true;
}
}
return false;
}
/* #region UPDATE NAMESPACE */
async updateResourcePoolAsync(oldFormValues, newFormValues) {
this.state.actionInProgress = true;
try {
this.checkDefaults();
await this.KubernetesResourcePoolService.patch(oldFormValues, newFormValues);
await updateIngressControllerClassMap(this.endpoint.Id, this.ingressControllers || [], this.formValues.Name);
this.Notifications.success('Namespace successfully updated', this.pool.Namespace.Name);
this.$state.reload(this.$state.current);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to create namespace');
} finally {
this.state.actionInProgress = false;
}
}
updateResourcePool() {
const ingressesToDelete = _.filter(this.formValues.IngressClasses, { WasSelected: true, Selected: false });
const registriesToDelete = _.filter(this.registries, { WasChecked: true, Checked: false });
const warnings = {
quota: this.hasResourceQuotaBeenReduced(),
ingress: ingressesToDelete.length !== 0,
registries: registriesToDelete.length !== 0,
};
if (warnings.quota || warnings.registries) {
confirmUpdateNamespace(warnings.quota, warnings.ingress, warnings.registries).then((confirmed) => {
if (confirmed) {
return this.$async(this.updateResourcePoolAsync, this.savedFormValues, this.formValues);
}
});
} else {
return this.$async(this.updateResourcePoolAsync, this.savedFormValues, this.formValues);
}
}
async confirmMarkUnmarkAsSystem() {
const message = this.isSystem
? 'Unmarking this namespace as system will allow non administrator users to manage it and the resources in contains depending on the access control settings. Are you sure?'
: 'Marking this namespace as a system namespace will prevent non administrator users from managing it and the resources it contains. Are you sure?';
return new Promise((resolve) => {
confirmUpdate(message, resolve);
});
}
markUnmarkAsSystem() {
return this.$async(async () => {
try {
const namespaceName = this.$state.params.id;
this.state.actionInProgress = true;
const confirmed = await this.confirmMarkUnmarkAsSystem();
if (!confirmed) {
return;
}
await this.KubernetesResourcePoolService.toggleSystem(this.endpoint.Id, namespaceName, !this.isSystem);
this.Notifications.success('Namespace successfully updated', namespaceName);
this.$state.reload(this.$state.current);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to create namespace');
} finally {
this.state.actionInProgress = false;
}
});
}
/* #endregion */
hasEventWarnings() {
return this.state.eventWarningCount;
}
/* #region GET EVENTS */
getEvents() {
return this.$async(async () => {
try {
this.state.eventsLoading = true;
this.events = await this.KubernetesEventService.get(this.pool.Namespace.Name);
this.state.eventWarningCount = KubernetesEventHelper.warningCount(this.events);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve namespace related events');
} finally {
this.state.eventsLoading = false;
}
});
}
/* #endregion */
/* #region GET APPLICATIONS */
getApplications() {
return this.$async(async () => {
try {
this.state.applicationsLoading = true;
this.applications = await this.KubernetesApplicationService.get(this.pool.Namespace.Name);
this.applications = _.map(this.applications, (app) => {
const resourceReservation = KubernetesResourceReservationHelper.computeResourceReservation(app.Pods);
app.CPU = resourceReservation.CPU;
app.Memory = resourceReservation.Memory;
return app;
});
if (this.state.useServerMetrics) {
await this.getResourceUsage(this.pool.Namespace.Name);
}
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve applications.');
} finally {
this.state.applicationsLoading = false;
}
});
}
/* #endregion */
/* #region GET REGISTRIES */
getRegistries() {
return this.$async(async () => {
try {
const namespace = this.$state.params.id;
if (this.isAdmin) {
this.registries = await this.EndpointService.registries(this.endpoint.Id);
this.registries.forEach((reg) => {
if (reg.RegistryAccesses && reg.RegistryAccesses[this.endpoint.Id] && reg.RegistryAccesses[this.endpoint.Id].Namespaces.includes(namespace)) {
reg.Checked = true;
reg.WasChecked = true;
this.formValues.Registries.push(reg);
}
});
this.selectedRegistries = this.formValues.Registries.map((r) => r.Name).join(', ');
return;
}
const registries = await this.EndpointService.registries(this.endpoint.Id, namespace);
this.selectedRegistries = registries.map((r) => r.Name).join(', ');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve registries');
}
});
}
/* #endregion */
async getResourceUsage(namespace) {
try {
const namespaceMetrics = await getMetricsForAllPods(this.$state.params.endpointId, namespace);
// extract resource usage of all containers within each pod of the namespace
const containerResourceUsageList = namespaceMetrics.items.flatMap((i) => i.containers.map((c) => c.usage));
const namespaceResourceUsage = containerResourceUsageList.reduce((total, u) => {
total.CPU += KubernetesResourceReservationHelper.parseCPU(u.cpu);
total.Memory += KubernetesResourceReservationHelper.megaBytesValue(u.memory);
return total;
}, new KubernetesResourceReservation());
this.state.resourceUsage = namespaceResourceUsage;
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve namespace resource usage');
}
}
/* #region ON INIT */
$onInit() {
return this.$async(async () => {
try {
this.endpoint = await this.EndpointService.endpoint(this.endpoint.Id);
this.isAdmin = this.Authentication.isAdmin();
this.state = {
actionInProgress: false,
sliderMaxMemory: 0,
sliderMaxCpu: 0,
cpuUsage: 0,
memoryUsage: 0,
resourceReservation: { CPU: 0, Memory: 0 },
activeTab: 0,
currentName: this.$state.$current.name,
showEditorTab: false,
eventsLoading: true,
applicationsLoading: true,
ingressesLoading: true,
viewReady: false,
eventWarningCount: 0,
useServerMetrics: this.endpoint.Kubernetes.Configuration.UseServerMetrics,
duplicates: {
ingressHosts: new KubernetesFormValidationReferences(),
},
ingressAvailabilityPerNamespace: this.endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace,
};
this.state.activeTab = this.LocalStorage.getActiveTab('resourcePool');
const name = this.$state.params.id;
const [nodes, pool] = await Promise.all([this.KubernetesNodeService.get(), this.KubernetesResourcePoolService.get(name)]);
this.ingressControllers = [];
if (this.state.ingressAvailabilityPerNamespace) {
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, namespace: name });
this.initialIngressControllers = structuredClone(this.ingressControllers);
}
this.pool = pool;
this.formValues = new KubernetesResourcePoolFormValues(KubernetesResourceQuotaDefaults);
this.formValues.Name = this.pool.Namespace.Name;
this.formValues.EndpointId = this.endpoint.Id;
this.formValues.IsSystem = this.pool.Namespace.IsSystem;
_.forEach(nodes, (item) => {
this.state.sliderMaxMemory += filesizeParser(item.Memory);
this.state.sliderMaxCpu += item.CPU;
});
this.state.sliderMaxMemory = KubernetesResourceReservationHelper.megaBytesValue(this.state.sliderMaxMemory);
const quota = this.pool.Quota;
if (quota) {
this.oldQuota = angular.copy(quota);
this.formValues = KubernetesResourceQuotaConverter.quotaToResourcePoolFormValues(quota);
this.formValues.EndpointId = this.endpoint.Id;
this.state.resourceReservation.CPU = quota.CpuLimitUsed;
this.state.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(quota.MemoryLimitUsed);
}
this.isSystem = KubernetesNamespaceHelper.isSystemNamespace(this.pool.Namespace.Name);
this.isDefaultNamespace = KubernetesNamespaceHelper.isDefaultNamespace(this.pool.Namespace.Name);
this.isEditable = !this.isSystem && !this.isDefaultNamespace;
await this.getEvents();
await this.getApplications();
await this.getRegistries();
this.savedFormValues = angular.copy(this.formValues);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to load view data');
} finally {
this.state.viewReady = true;
}
});
}
/* #endregion */
$onDestroy() {
if (this.state.currentName !== this.$state.$current.name) {
this.LocalStorage.storeActiveTab('resourcePool', 0);
}
}
}
export default KubernetesResourcePoolController;
angular.module('portainer.kubernetes').controller('KubernetesResourcePoolController', KubernetesResourcePoolController);
@@ -1,7 +0,0 @@
<page-header ng-if="ctrl.state.viewReady" title="'Namespace list'" breadcrumbs="['Namespaces']" on-reload="(ctrl.onReload)" reload="true"></page-header>
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady">
<kubernetes-namespaces-datatable dataset="ctrl.resourcePools" on-remove="(ctrl.removeAction)" on-refresh="(ctrl.getResourcePools)"></kubernetes-namespaces-datatable>
</div>
@@ -1,8 +0,0 @@
angular.module('portainer.kubernetes').component('kubernetesResourcePoolsView', {
templateUrl: './resourcePools.html',
controller: 'KubernetesResourcePoolsController',
controllerAs: 'ctrl',
bindings: {
endpoint: '<',
},
});
@@ -1,109 +0,0 @@
import angular from 'angular';
import { confirm } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';
import { dispatchCacheRefreshEvent } from '@/portainer/services/http-request.helper';
class KubernetesResourcePoolsController {
/* @ngInject */
constructor($async, $state, Notifications, KubernetesResourcePoolService, KubernetesNamespaceService) {
this.$async = $async;
this.$state = $state;
this.Notifications = Notifications;
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
this.KubernetesNamespaceService = KubernetesNamespaceService;
this.onInit = this.onInit.bind(this);
this.getResourcePools = this.getResourcePools.bind(this);
this.getResourcePoolsAsync = this.getResourcePoolsAsync.bind(this);
this.removeAction = this.removeAction.bind(this);
this.removeActionAsync = this.removeActionAsync.bind(this);
this.onReload = this.onReload.bind(this);
}
async onReload() {
this.$state.reload(this.$state.current);
}
async removeActionAsync(selectedItems) {
let actionCount = selectedItems.length;
for (const pool of selectedItems) {
try {
const isTerminating = pool.Namespace.Status === 'Terminating';
if (isTerminating) {
const ns = await this.KubernetesNamespaceService.getJSONAsync(pool.Namespace.Name);
ns.$promise.then(async (namespace) => {
const n = JSON.parse(namespace.data);
if (n.spec && n.spec.finalizers) {
delete n.spec.finalizers;
}
await this.KubernetesNamespaceService.updateFinalizeAsync(n);
});
} else {
await this.KubernetesResourcePoolService.delete(pool);
}
this.Notifications.success('Namespace successfully removed', pool.Namespace.Name);
const index = this.resourcePools.indexOf(pool);
this.resourcePools.splice(index, 1);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to remove namespace');
} finally {
--actionCount;
if (actionCount === 0) {
this.$state.reload(this.$state.current);
}
}
}
}
removeAction(selectedItems) {
const isTerminatingNS = selectedItems.some((pool) => pool.Namespace.Status === 'Terminating');
const message = isTerminatingNS
? 'At least one namespace is in a terminating state. For terminating state namespaces, you may continue and force removal, but doing so without having properly cleaned up may lead to unstable and unpredictable behavior. Are you sure you wish to proceed?'
: 'Do you want to remove the selected namespace(s)? All the resources associated to the selected namespace(s) will be removed too. Are you sure you wish to proceed?';
confirm({
title: isTerminatingNS ? 'Force namespace removal' : 'Are you sure?',
message,
confirmButton: buildConfirmButton('Remove', 'danger'),
modalType: ModalType.Destructive,
}).then((confirmed) => {
if (confirmed) {
return this.$async(this.removeActionAsync, selectedItems);
}
});
}
async getResourcePoolsAsync() {
try {
this.resourcePools = await this.KubernetesResourcePoolService.get('', { getQuota: true });
// make sure table refreshes with fresh data when namespaces are in a terminating state
if (this.resourcePools.some((namespace) => namespace.Namespace.Status === 'Terminating')) {
dispatchCacheRefreshEvent();
}
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retreive namespaces');
}
}
getResourcePools() {
return this.$async(this.getResourcePoolsAsync);
}
async onInit() {
this.state = {
viewReady: false,
};
await this.getResourcePools();
this.state.viewReady = true;
}
$onInit() {
return this.$async(this.onInit);
}
}
export default KubernetesResourcePoolsController;
angular.module('portainer.kubernetes').controller('KubernetesResourcePoolsController', KubernetesResourcePoolsController);
@@ -15,6 +15,7 @@ const BoxSelectorReact = react2angular(BoxSelector, [
'radioName',
'slim',
'hiddenSpacingCount',
'error',
]);
export const boxSelectorModule = angular
@@ -271,7 +271,7 @@
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="$ctrl.isUpdateButtonDisabled() || editRegistry.$invalid"
ng-click="$ctrl.updateRegistry()"
button-spinner="$ctrl.state.actionInProgress"
+7 -1
View File
@@ -241,7 +241,13 @@
environment="endpoint"
></stack-containers-datatable>
<docker-services-datatable ng-if="services && (!orphaned || orphanedRunning)" dataset="services" title-icon="list" on-refresh="(getServices)"></docker-services-datatable>
<docker-services-datatable
ng-if="services && (!orphaned || orphanedRunning)"
dataset="services"
title-icon="list"
on-refresh="(getServices)"
table-key="'stack-services'"
></docker-services-datatable>
<!-- access-control-panel -->
<access-control-panel
+8
View File
@@ -78,6 +78,11 @@ export function createMockEnvironment(): Environment {
URL: 'url',
Snapshots: [],
Kubernetes: {
Flags: {
IsServerMetricsDetected: true,
IsServerIngressClassDetected: true,
IsServerStorageDetected: true,
},
Snapshots: [],
Configuration: {
IngressClasses: [],
@@ -85,6 +90,9 @@ export function createMockEnvironment(): Environment {
AllowNoneIngressClass: false,
},
},
UserAccessPolicies: {},
TeamAccessPolicies: {},
ComposeSyntaxMaxVersion: '0',
EdgeKey: '',
EnableGPUManagement: false,
Id: 3,
@@ -1,6 +1,6 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import type { Icon } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
@@ -16,7 +16,7 @@ interface Props<T extends Value> {
tooltip?: string;
className?: string;
type?: 'radio' | 'checkbox';
checkIcon: Icon;
checkIcon: LucideIcon;
}
export function BoxOption<T extends Value>({
@@ -1,3 +1,5 @@
import { FormError } from '@@/form-components/FormError';
import styles from './BoxSelector.module.css';
import { BoxSelectorItem } from './BoxSelectorItem';
import { BoxSelectorOption, Value } from './types';
@@ -21,6 +23,7 @@ export type Props<T extends Value> = Union<T> & {
options: ReadonlyArray<BoxSelectorOption<T>> | Array<BoxSelectorOption<T>>;
slim?: boolean;
hiddenSpacingCount?: number;
error?: string;
};
export function BoxSelector<T extends Value>({
@@ -28,6 +31,7 @@ export function BoxSelector<T extends Value>({
options,
slim = false,
hiddenSpacingCount,
error,
...props
}: Props<T>) {
return (
@@ -54,6 +58,7 @@ export function BoxSelector<T extends Value>({
<div key={index} className="flex-1" />
))}
</div>
{error && <FormError>{error}</FormError>}
</div>
</div>
);
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import { Icon as ReactFeatherComponentType, Check } from 'lucide-react';
import { type LucideIcon, Check } from 'lucide-react';
import { Fragment } from 'react';
import { Icon } from '@/react/components/Icon';
@@ -22,7 +22,7 @@ type Props<T extends Value> = {
isSelected(value: T): boolean;
type?: 'radio' | 'checkbox';
slim?: boolean;
checkIcon?: ReactFeatherComponentType;
checkIcon?: LucideIcon;
};
export function BoxSelectorItem<T extends Value>({
+1 -1
View File
@@ -1,6 +1,6 @@
import { ReactNode } from 'react';
import { useDocsUrl } from '../PageHeader/ContextHelp/ContextHelp';
import { useDocsUrl } from '@@/PageHeader/ContextHelp';
type HelpLinkProps = {
docLink: string;
@@ -0,0 +1,29 @@
import { BotMessageSquare } from 'lucide-react';
import clsx from 'clsx';
import headerStyles from './HeaderTitle.module.css';
const docsUrl = 'https://www.portainer.io/ask-the-ai';
export function AskAILink() {
return (
<div className={headerStyles.menuButton}>
<a
href={docsUrl}
target="_blank"
color="none"
className={clsx(
headerStyles.menuIcon,
'icon-badge mr-1 !p-2 text-lg cursor-pointer',
'text-gray-8',
'th-dark:text-gray-warm-7'
)}
title="Ask AI"
rel="noreferrer"
data-cy="ask-ai-button"
>
<BotMessageSquare className="lucide" />
</a>
</div>
);
}
@@ -4,8 +4,7 @@ import { useCurrentStateAndParams } from '@uirouter/react';
import { useSystemVersion } from '@/react/portainer/system/useSystemVersion';
import headerStyles from '../HeaderTitle.module.css';
import './ContextHelp.css';
import headerStyles from './HeaderTitle.module.css';
export function ContextHelp() {
const docsUrl = useDocsUrl();
@@ -18,12 +17,11 @@ export function ContextHelp() {
color="none"
className={clsx(
headerStyles.menuIcon,
'menu-icon',
'icon-badge mr-1 !p-2 text-lg',
'icon-badge mr-1 !p-2 text-lg cursor-pointer',
'text-gray-8',
'th-dark:text-gray-warm-7'
)}
title="Help"
title="Documentation"
rel="noreferrer"
data-cy="context-help-button"
>
@@ -1,5 +0,0 @@
.menu-icon {
background: var(--user-menu-icon-color);
cursor: pointer;
flex-shrink: 0;
}
@@ -1 +0,0 @@
export { ContextHelp } from './ContextHelp';
@@ -10,6 +10,12 @@
.menu-icon {
background: var(--user-menu-icon-color);
position: relative;
flex-shrink: 0;
}
.menu-icon:hover {
/* keep the links and button icon colors consistent on hover */
@apply text-gray-8 th-dark:text-gray-warm-7;
}
.menu-list {
@@ -1,10 +1,13 @@
import { PropsWithChildren } from 'react';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { ContextHelp } from '@@/PageHeader/ContextHelp';
import { useHeaderContext } from './HeaderContainer';
import { NotificationsMenu } from './NotificationsMenu';
import { UserMenu } from './UserMenu';
import { AskAILink } from './AskAILink';
interface Props {
title: string;
@@ -25,6 +28,7 @@ export function HeaderTitle({ title, children }: PropsWithChildren<Props>) {
{children && <>{children}</>}
</div>
<div className="flex items-end">
{isBE && <AskAILink />}
<NotificationsMenu />
<ContextHelp />
{!window.ddExtension && <UserMenu />}
@@ -0,0 +1,66 @@
import clsx from 'clsx';
type Step = { value: number; color?: string; className?: string };
type StepWithPercent = Step & { percent: number };
interface Props {
steps: Array<Step>;
total: number;
className?: string;
}
export function ProgressBar({ steps, total, className }: Props) {
const { steps: reducedSteps } = steps.reduce<{
steps: Array<StepWithPercent>;
total: number;
totalPercent: number;
}>(
(acc, cur) => {
const value =
acc.total + cur.value > total ? total - acc.total : cur.value;
// If the remaining acc.total + the current value adds up to the total, then make sure the percentage will fill the remaining bar space
const percent =
acc.total + value === total
? 100 - acc.totalPercent
: Math.floor((value / total) * 100);
return {
steps: [
...acc.steps,
{
...cur,
value,
percent,
},
],
total: acc.total + value,
totalPercent: acc.totalPercent + percent,
};
},
{ steps: [], total: 0, totalPercent: 0 }
);
const sum = steps.reduce((sum, s) => sum + s.value, 0);
return (
<div
className={clsx(
'progress shadow-none h-2.5 rounded-full',
sum > 100 ? 'text-blue-8' : 'text-error-7',
className
)}
aria-valuemin={0}
aria-valuemax={100}
role="progressbar"
>
{reducedSteps.map((step, index) => (
<div
key={index}
className={clsx('progress-bar shadow-none', step.className)}
style={{
width: `${step.percent}%`,
backgroundColor: step.color,
}}
/>
))}
</div>
);
}
@@ -0,0 +1 @@
export { ProgressBar } from './ProgressBar';
@@ -14,7 +14,7 @@ export function RadioGroup<T extends string | number = string>({
onOptionChange,
}: Props<T>) {
return (
<div>
<div className="flex flex-wrap gap-x-2 gap-y-1">
{options.map((option) => (
<label
key={option.value}
@@ -125,12 +125,13 @@ export function Datatable<D extends DefaultType>({
pageIndex: page || 0,
},
sorting: settings.sortBy ? [settings.sortBy] : [],
...initialTableState,
globalFilter: {
search: settings.search,
...initialTableState.globalFilter,
},
...initialTableState,
},
defaultColumn: {
enableColumnFilter: false,
@@ -1,7 +1,7 @@
import { ComponentProps } from 'react';
import { Alert } from '@@/Alert';
import { useDocsUrl } from '@@/PageHeader/ContextHelp/ContextHelp';
import { useDocsUrl } from '@@/PageHeader/ContextHelp';
import { EnvironmentVariablesFieldset } from './EnvironmentVariablesFieldset';
import { EnvironmentVariablesPanel } from './EnvironmentVariablesPanel';
@@ -14,13 +14,8 @@ type StringPortBinding = {
containerPort: number;
};
type NumericPortBinding = {
hostPort: number;
protocol: Protocol;
containerPort: number;
};
type RangePortBinding = {
hostIp: string;
hostPort: Range;
protocol: Protocol;
containerPort: Range;
@@ -42,9 +37,7 @@ export function toViewModel(portBindings: PortMap): Values {
return value === 'tcp' || value === 'udp';
}
function parsePorts(
portBindings: PortMap
): Array<StringPortBinding | NumericPortBinding> {
function parsePorts(portBindings: PortMap): Array<StringPortBinding> {
return Object.entries(portBindings).flatMap(([key, bindings]) => {
const [containerPort, protocol] = key.split('/');
@@ -63,15 +56,24 @@ export function toViewModel(portBindings: PortMap): Values {
}
return bindings.map((binding) => {
let port = '';
if (binding.HostPort) {
port = binding.HostPort;
}
if (binding.HostIp) {
port = `${binding.HostIp}:${port}`;
}
if (binding.HostPort?.includes('-')) {
// Range port
return {
hostPort: binding.HostPort,
hostPort: port,
protocol,
containerPort: containerPortNumber,
};
}
return {
hostPort: parseInt(binding.HostPort || '0', 10),
hostPort: port,
protocol,
containerPort: containerPortNumber,
};
@@ -79,9 +81,9 @@ export function toViewModel(portBindings: PortMap): Values {
});
}
function sortPorts(ports: Array<StringPortBinding | NumericPortBinding>) {
const rangePorts = ports.filter(isStringPortBinding);
const nonRangePorts = ports.filter(isNumericPortBinding);
function sortPorts(ports: Array<StringPortBinding>) {
const rangePorts = ports.filter(isRangePortBinding);
const nonRangePorts = ports.filter((port) => !isRangePortBinding(port));
return {
rangePorts,
@@ -93,27 +95,40 @@ export function toViewModel(portBindings: PortMap): Values {
};
}
function combinePorts(ports: Array<NumericPortBinding>) {
function combinePorts(ports: Array<StringPortBinding>) {
return ports
.reduce((acc, port) => {
let hostIp = '';
let hostPort = 0;
if (port.hostPort.includes(':')) {
const [ipStr, portStr] = port.hostPort.split(':');
hostIp = ipStr;
hostPort = parseInt(portStr || '0', 10);
} else {
hostPort = parseInt(port.hostPort || '0', 10);
}
const lastPort = acc[acc.length - 1];
if (
lastPort &&
lastPort.hostIp === hostIp &&
lastPort.containerPort.end === port.containerPort - 1 &&
lastPort.hostPort.end === port.hostPort - 1 &&
lastPort.hostPort.end === hostPort - 1 &&
lastPort.protocol === port.protocol
) {
lastPort.hostIp = hostIp;
lastPort.containerPort.end = port.containerPort;
lastPort.hostPort.end = port.hostPort;
lastPort.hostPort.end = hostPort;
return acc;
}
return [
...acc,
{
hostIp,
hostPort: {
start: port.hostPort,
end: port.hostPort,
start: hostPort,
end: hostPort,
},
containerPort: {
start: port.containerPort,
@@ -123,34 +138,32 @@ export function toViewModel(portBindings: PortMap): Values {
},
];
}, [] as Array<RangePortBinding>)
.map(({ protocol, containerPort, hostPort }) => ({
hostPort: getRange(hostPort.start, hostPort.end),
.map(({ protocol, containerPort, hostPort, hostIp }) => ({
hostPort: getRange(hostPort.start, hostPort.end, hostIp),
containerPort: getRange(containerPort.start, containerPort.end),
protocol,
}));
function getRange(start: number, end: number): string {
function getRange(start: number, end: number, hostIp?: string): string {
if (start === end) {
if (start === 0) {
return '';
}
if (hostIp) {
return `${hostIp}:${start}`;
}
return start.toString();
}
if (hostIp) {
return `${hostIp}:${start}-${end}`;
}
return `${start}-${end}`;
}
}
}
function isNumericPortBinding(
port: StringPortBinding | NumericPortBinding
): port is NumericPortBinding {
return port.hostPort !== 'string';
}
function isStringPortBinding(
port: StringPortBinding | NumericPortBinding
): port is StringPortBinding {
return port.hostPort === 'string';
function isRangePortBinding(port: StringPortBinding): boolean {
return port.hostPort.includes('-');
}
@@ -57,10 +57,15 @@ export async function buildImageFromDockerfileContentAndFiles(
const dockerfile = new Blob([content], { type: 'text/plain' });
const uploadFiles = [dockerfile, ...files];
const formData = new FormData();
uploadFiles.forEach((file, index) => {
formData.append(`file${index}`, file);
});
return buildImage(
environmentId,
{ t: names },
{ file: uploadFiles },
formData,
'multipart/form-data'
);
}
@@ -28,34 +28,35 @@ import { TableActions } from './TableActions';
import { type TableSettings as TableSettingsType } from './types';
import { TableSettings } from './TableSettings';
const tableKey = 'services';
const store = createPersistedStore<TableSettingsType>(
tableKey,
'name',
(set) => ({
...refreshableSettings(set),
...hiddenColumnsSettings(set),
expanded: {},
setExpanded(value) {
set({ expanded: value });
},
})
);
export function ServicesDatatable({
titleIcon = Shuffle,
dataset,
isAddActionVisible,
isStackColumnVisible,
onRefresh,
tableKey,
}: {
dataset: Array<ServiceViewModel> | undefined;
titleIcon?: IconProps['icon'];
isAddActionVisible?: boolean;
isStackColumnVisible?: boolean;
onRefresh?(): void;
tableKey: string;
}) {
// use a unique tableKey so that unrelated services datatables don't share state
const store = createPersistedStore<TableSettingsType>(
tableKey,
'name',
(set) => ({
...refreshableSettings(set),
...hiddenColumnsSettings(set),
expanded: {},
setExpanded(value) {
set({ expanded: value });
},
})
);
// useRef so that updating the parent filter doesn't cause a re-render
const parentFilteredStatusRef = useRef<Map<string, boolean>>(new Map());
const environmentId = useEnvironmentId();
@@ -81,7 +81,7 @@ function Cell({
<Link
to=".volume"
params={{
id: item.Id,
id: item.Name,
nodeName: item.NodeName,
}}
data-cy={`volume-link-${name}`}
@@ -99,7 +99,7 @@ function Cell({
props={{
to: 'docker.volumes.volume.browse',
params: {
id: item.Id,
id: item.Name,
nodeName: item.NodeName,
},
}}
@@ -1,6 +1,7 @@
import { CellContext } from '@tanstack/react-table';
import { Link } from '@@/Link';
import { Badge } from '@@/Badge';
import { EdgeGroupListItemResponse } from '../../queries/useEdgeGroups';
@@ -32,7 +33,9 @@ function NameCell({
{name}
</Link>
{(item.HasEdgeJob || item.HasEdgeStack) && (
<span className="label label-info image-tag space-left">in use</span>
<Badge type="info" className="ml-1">
in use
</Badge>
)}
</>
);
@@ -1,5 +1,5 @@
import { Formik } from 'formik';
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { toGitFormModel } from '@/react/portainer/gitops/types';
import { getDefaultRelativePathModel } from '@/react/portainer/gitops/RelativePathFieldset/types';
@@ -18,12 +18,12 @@ import { DeploymentType } from '../types';
import { getDefaultStaggerConfig } from '../components/StaggerFieldset.types';
import { InnerForm } from './InnerForm';
import { FormValues } from './types';
import { useValidation } from './CreateForm.validation';
import { Values as TemplateValues } from './TemplateFieldset/types';
import { getInitialTemplateValues } from './TemplateFieldset/TemplateFieldset';
import { useTemplateParams } from './useTemplateParams';
import { useCreate } from './useCreate';
import { FormValues } from './types';
export function CreateForm() {
const [webhookId] = useState(() => createWebhookId());
@@ -38,33 +38,12 @@ export function CreateForm() {
templateType: templateParams.type,
});
if (
templateParams.id &&
!(templateQuery.customTemplate || templateQuery.appTemplate)
) {
const initialValues = useInitialValues(templateQuery, templateParams);
if (!initialValues) {
return null;
}
const template = templateQuery.customTemplate || templateQuery.appTemplate;
const initialValues: FormValues = {
name: '',
groupIds: [],
deploymentType: DeploymentType.Compose,
envVars: [],
privateRegistryId: 0,
prePullImage: false,
retryDeploy: false,
staggerConfig: getDefaultStaggerConfig(),
method: templateParams.id ? 'template' : 'editor',
git: toGitFormModel(undefined, parseAutoUpdateResponse()),
relativePath: getDefaultRelativePathModel(),
enableWebhook: false,
fileContent: '',
templateValues: getTemplateValues(templateParams.type, template),
useManifestNamespaces: false,
};
return (
<div className="row">
<div className="col-sm-12">
@@ -128,7 +107,66 @@ function useTemplate(
});
return {
appTemplate: appTemplateQuery.data,
customTemplate: customTemplateQuery.data,
appTemplate: type === 'app' ? appTemplateQuery.data : undefined,
customTemplate: type === 'custom' ? customTemplateQuery.data : undefined,
};
}
function useInitialValues(
templateQuery: {
appTemplate: TemplateViewModel | undefined;
customTemplate: CustomTemplate | undefined;
},
templateParams: {
id: number | undefined;
type: 'app' | 'custom' | undefined;
}
) {
const template = templateQuery.customTemplate || templateQuery.appTemplate;
const initialValues: FormValues = useMemo(
() => ({
name: '',
groupIds: [],
// if edge custom templates allow kube manifests/helm charts in future, add logic for setting this for the initial deploymentType
deploymentType: DeploymentType.Compose,
envVars: [],
privateRegistryId:
templateQuery.customTemplate?.EdgeSettings?.PrivateRegistryId ?? 0,
prePullImage:
templateQuery.customTemplate?.EdgeSettings?.PrePullImage ?? false,
retryDeploy:
templateQuery.customTemplate?.EdgeSettings?.RetryDeploy ?? false,
staggerConfig:
templateQuery.customTemplate?.EdgeSettings?.StaggerConfig ??
getDefaultStaggerConfig(),
method: templateParams.id ? 'template' : 'editor',
git: toGitFormModel(
templateQuery.customTemplate?.GitConfig,
parseAutoUpdateResponse()
),
relativePath:
templateQuery.customTemplate?.EdgeSettings?.RelativePathSettings ??
getDefaultRelativePathModel(),
enableWebhook: false,
fileContent: '',
templateValues: getTemplateValues(templateParams.type, template),
useManifestNamespaces: false,
}),
[
templateQuery.customTemplate,
templateParams.id,
templateParams.type,
template,
]
);
if (
templateParams.id &&
!templateQuery.customTemplate &&
!templateQuery.appTemplate
) {
return null;
}
return initialValues;
}

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