Compare commits

..

14 Commits

Author SHA1 Message Date
andres-portainer b46bff06c6 fix: 2.24 regressions (#190)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
Co-authored-by: testA113 <aliharriss1995@gmail.com>
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
2024-12-03 15:25:53 +13:00
Oscar Zhou 5d311031e3 version: bump version to 2.24.1 (#187) 2024-12-03 09:10:19 +13:00
andres-portainer 99de11894c fix(compose): fix support for ECR BE-11392 (#150) 2024-11-18 16:42:49 -03:00
andres-portainer 02c006be8a fix(stacks): pass the registry credentials to Compose stacks BE-11388 (#148)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2024-11-18 08:39:16 +13:00
andres-portainer 60a2696a8d fix(libstack): add missing private registry credentials BE-11388 (#144) 2024-11-15 17:39:00 -03:00
Oscar Zhou 64b5d1df2d fix(swarm): failed to deploy app template [BE-11385] (#135) 2024-11-15 11:19:33 +13:00
andres-portainer 025a409ab5 fix(compose): avoid leftovers in Run() BE-11381 (#130) 2024-11-13 20:24:14 -03:00
andres-portainer 9b65f01748 feat(edgestacks): add a retry period to edge stack deployments BE-11155 (#128)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
2024-11-13 20:22:07 -03:00
andres-portainer e112ddfbeb fix(libstack): fix compose run BE-11381 (#127) 2024-11-13 14:38:44 -03:00
LP B 707fc91a32 fix(edge/stacks): use default namespace when none is specified in manifest (#125) 2024-11-13 16:30:16 +13:00
andres-portainer b2eb4388fd fix(libstack): add a different timeout for WaitForStatus BE-11376 (#119) 2024-11-12 19:30:42 -03:00
andres-portainer 5a451b2035 fix(compose): provide the project name for proper validation BE-11375 (#117) 2024-11-12 17:18:36 -03:00
Oscar Zhou 370d224d76 fix(libstack): empty project name [BE-10801] (#115) 2024-11-12 10:20:41 -03:00
Ali b4c36b0e48 fix(configmap): create portainer configmap if it doesn't exist [r8s-141] #113 (#114) 2024-11-12 18:34:58 +13:00
253 changed files with 3923 additions and 7174 deletions
-52
View File
@@ -1,52 +0,0 @@
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
+4 -5
View File
@@ -11,8 +11,6 @@ body:
The issue tracker is for reporting bugs. If you have an [idea for a new feature](https://github.com/orgs/portainer/discussions/categories/ideas) or a [general question about Portainer](https://github.com/orgs/portainer/discussions/categories/help) please post in our [GitHub Discussions](https://github.com/orgs/portainer/discussions).
You can also ask for help in our [community Slack channel](https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA).
Please note that we only provide support for current versions of Portainer. You can find a list of supported versions in our [lifecycle policy](https://docs.portainer.io/start/lifecycle).
**DO NOT FILE ISSUES FOR GENERAL SUPPORT QUESTIONS**.
@@ -92,11 +90,9 @@ body:
- type: dropdown
attributes:
label: Portainer version
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
description: We only provide support for the most recent version of Portainer and the previous 3 versions. 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'
- '2.21.4'
@@ -120,6 +116,9 @@ body:
- '2.18.1'
- '2.17.1'
- '2.17.0'
- '2.16.2'
- '2.16.1'
- '2.16.0'
validations:
required: true
+1
View File
@@ -0,0 +1 @@
portainer
+5 -7
View File
@@ -17,13 +17,11 @@ GOTESTSUM=go run gotest.tools/gotestsum@latest
##@ Building
.PHONY: all init-dist build-storybook build build-client build-server build-image devops
.PHONY: init-dist build-storybook build build-client build-server build-image devops
init-dist:
@mkdir -p dist
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-all: deps build-server build-client ## Build the client, server and download external dependancies (doesn't build an image)
build-client: init-dist ## Build the client
export NODE_ENV=$(ENV) && yarn build --config $(WEBPACK_CONFIG)
@@ -52,7 +50,7 @@ client-deps: ## Install client dependencies
yarn
tidy: ## Tidy up the go.mod file
@go mod tidy
cd api && go mod tidy
##@ Cleanup
@@ -67,10 +65,10 @@ clean: ## Remove all build and download artifacts
test: test-server test-client ## Run all tests
test-client: ## Run client tests
yarn test $(ARGS) --coverage
yarn test $(ARGS)
test-server: ## Run server tests
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover ./...
##@ Dev
.PHONY: dev dev-client dev-server
+30 -35
View File
@@ -21,7 +21,6 @@ const rwxr__r__ os.FileMode = 0o744
var filesToBackup = []string{
"certs",
"chisel",
"compose",
"config.json",
"custom_templates",
@@ -31,13 +30,40 @@ 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) {
backupDirPath, err := backupDatabaseAndFilesystem(gate, datastore, filestorePath)
if err != nil {
return "", err
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")
}
}
archivePath, err := archive.TarGzDir(backupDirPath)
@@ -55,37 +81,6 @@ 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
@@ -0,0 +1,12 @@
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,7 +59,6 @@ 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,6 +10,7 @@ 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"
@@ -46,7 +47,6 @@ 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, fileService, connection)
store := datastore.NewStore(*flags.Data, 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{Flags: flags})
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{})
migratorCount := migratorInstance.GetMigratorCountOfCurrentAPIVersion()
// from MigrateData
-1
View File
@@ -40,7 +40,6 @@ type Connection interface {
GetDatabaseFileName() string
GetDatabaseFilePath() string
GetStorePath() string
GetDatabaseFileSize() (int64, error)
IsEncryptedStore() bool
NeedsEncryptionMigration() (bool, error)
-9
View File
@@ -62,15 +62,6 @@ 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
}
+1 -2
View File
@@ -16,9 +16,8 @@ import (
)
// NewStore initializes a new Store and the associated services
func NewStore(cliFlags *portainer.CLIFlags, fileService portainer.FileService, connection portainer.Connection) *Store {
func NewStore(storePath string, 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: *store.flags.KubectlShellImage,
KubectlShellImage: portainer.DefaultKubectlShellImage,
IsDockerDesktopExtension: isDDExtention,
}
+2 -3
View File
@@ -32,7 +32,7 @@ func (store *Store) MigrateData() error {
return errors.Wrap(err, "while migrating legacy version")
}
migratorParams := store.newMigratorParameters(version, store.flags)
migratorParams := store.newMigratorParameters(version)
migrator := migrator.NewMigrator(migratorParams)
if !migrator.NeedsMigration() {
@@ -62,9 +62,8 @@ func (store *Store) MigrateData() error {
return nil
}
func (store *Store) newMigratorParameters(version *models.Version, flags *portainer.CLIFlags) *migrator.MigratorParameters {
func (store *Store) newMigratorParameters(version *models.Version) *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, store.flags)
migratorParams := store.newMigratorParameters(v)
m := migrator.NewMigrator(migratorParams)
latestMigrations := m.LatestMigrations()
@@ -48,7 +48,6 @@ func TestMigrateSettings(t *testing.T) {
}
m := migrator.NewMigrator(&migrator.MigratorParameters{
Flags: store.flags,
EndpointGroupService: store.EndpointGroupService,
EndpointService: store.EndpointService,
EndpointRelationService: store.EndpointRelationService,
@@ -1,6 +1,8 @@
package migrator
import (
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
)
@@ -18,7 +20,7 @@ func (m *Migrator) migrateSettingsToDB33() error {
}
log.Info().Msg("setting default kubectl shell image")
settings.KubectlShellImage = *m.flags.KubectlShellImage
settings.KubectlShellImage = portainer.DefaultKubectlShellImage
return m.settingsService.UpdateSettings(settings)
}
-3
View File
@@ -33,7 +33,6 @@ import (
type (
// Migrator defines a service to migrate data after a Portainer version update.
Migrator struct {
flags *portainer.CLIFlags
currentDBVersion *models.Version
migrations []Migrations
@@ -63,7 +62,6 @@ 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
@@ -93,7 +91,6 @@ 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,
+9 -22
View File
@@ -11,7 +11,6 @@ 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"
)
@@ -50,29 +49,17 @@ func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
for _, environment := range environments {
// edge environments will run after the server starts, in pending actions
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")
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)
}
} else {
// 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")
// 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)
}
}
-1
View File
@@ -42,7 +42,6 @@ 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.25.1",
"KubectlShellImage": "portainer/kubectl-shell:2.24.1",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -672,7 +672,6 @@
{
"Docker": {
"ContainerCount": 0,
"DiagnosticsData": {},
"DockerSnapshotRaw": {
"Containers": null,
"Images": null,
@@ -943,7 +942,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.25.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.24.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+1 -5
View File
@@ -29,10 +29,6 @@ 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 {
@@ -49,7 +45,7 @@ func NewTestStore(t testing.TB, init, secure bool) (bool, *Store, func(), error)
panic(err)
}
store := NewStore(flags, fileService, connection)
store := NewStore(storePath, 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/image"
"github.com/docker/docker/api/types"
"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, img Image) error {
log.Debug().Str("image", img.FullName()).Msg("starting to pull the image")
func (puller *Puller) Pull(ctx context.Context, image Image) error {
log.Debug().Str("image", image.FullName()).Msg("starting to pull the image")
registryAuth, err := puller.registryClient.EncodedRegistryAuth(img)
registryAuth, err := puller.registryClient.EncodedRegistryAuth(image)
if err != nil {
log.Debug().
Str("image", img.FullName()).
Str("image", image.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, img.FullName(), image.PullOptions{
out, err := puller.client.ImagePull(ctx, image.FullName(), types.ImagePullOptions{
RegistryAuth: registryAuth,
})
if err != nil {
+255 -2
View File
@@ -1,9 +1,20 @@
package docker
import (
"context"
"strings"
"time"
portainer "github.com/portainer/portainer/api"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/pkg/snapshot"
"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"
)
// Snapshotter represents a service used to create environment(endpoint) snapshots
@@ -26,5 +37,247 @@ func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*p
}
defer cli.Close()
return snapshot.CreateDockerSnapshot(cli)
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
}
-9
View File
@@ -58,15 +58,6 @@ 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 {
log.Warn().Err(err).Msg("unable to retrieve the Swarm configuration from disk, proceeding without it")
return err
}
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(), image.ListOptions{})
images, err := cli.ImageList(r.Context(), types.ImageListOptions{})
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker images", err)
}
@@ -6,18 +6,12 @@ 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
@@ -38,10 +32,6 @@ 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")
@@ -85,7 +75,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. it must only consist of lowercase alphanumeric characters, hyphens, or underscores as well as start with a letter or number"
// @param Name formData string true "Name of the stack"
// @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,7 +9,6 @@ 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"
@@ -18,11 +17,7 @@ import (
type edgeStackFromGitRepositoryPayload 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 `example:"stack-name" validate:"required"`
Name string `example:"myStack" 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
@@ -55,10 +50,6 @@ 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,7 +8,6 @@ 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"
@@ -16,11 +15,7 @@ import (
type edgeStackFromStringPayload 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 `example:"stack-name" validate:"required"`
Name string `example:"myStack" 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
@@ -41,10 +36,6 @@ 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/klauspost/compress/gzhttp"
"github.com/gorilla/handlers"
)
// 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(
gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))),
handlers.CompressHandler(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.25.1
// @version 2.24.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, image.PullOptions{})
out, err := docker.ImagePull(ctx, imageName, types.ImagePullOptions{})
if err != nil {
log.Error().Str("image_name", imageName).Err(err).Msg("could not pull image from registry")
@@ -1,51 +0,0 @@
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
}
+2 -16
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,12 +115,8 @@ 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
}
@@ -210,17 +206,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
return
}
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)
nonAdminNamespaces, err = pcli.GetNonAdminNamespaces(int(user.ID), 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
+4 -34
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,36 +57,6 @@ 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.
@@ -139,7 +109,7 @@ func (handler *Handler) getKubernetesVolume(w http.ResponseWriter, r *http.Reque
return response.JSON(w, volume)
}
func (handler *Handler) getKubernetesVolumes(r *http.Request, namespace string) ([]models.K8sVolumeInfo, *httperror.HandlerError) {
func (handler *Handler) getKubernetesVolumes(r *http.Request) ([]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")
@@ -152,7 +122,7 @@ func (handler *Handler) getKubernetesVolumes(r *http.Request, namespace string)
return nil, httperror.InternalServerError("Failed to prepare Kubernetes client", httpErr)
}
volumes, err := cli.GetVolumes(namespace)
volumes, err := cli.GetVolumes("")
if err != nil {
if k8serrors.IsUnauthorized(err) {
log.Error().Err(err).Str("context", "GetKubernetesVolumes").Msg("Unauthorized access")
+24 -9
View File
@@ -2,11 +2,12 @@ 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"
@@ -22,12 +23,20 @@ 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 build.BuildInfo
Dependencies build.DependenciesInfo
Runtime build.RuntimeInfo
Build BuildInfo
}
type BuildInfo struct {
BuildNumber string
ImageTag string
NodejsVersion string
YarnVersion string
WebpackVersion string
GoVersion string
GitCommit string
Env []string `json:",omitempty"`
}
// @id systemVersion
@@ -48,15 +57,21 @@ 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: build.GetBuildInfo(),
Dependencies: build.GetDependenciesInfo(),
Build: BuildInfo{
BuildNumber: build.BuildNumber,
ImageTag: build.ImageTag,
NodejsVersion: build.NodejsVersion,
YarnVersion: build.YarnVersion,
WebpackVersion: build.WebpackVersion,
GoVersion: build.GoVersion,
GitCommit: build.GitCommit,
},
}
if isAdmin {
result.Runtime = build.GetRuntimeInfo()
result.Build.Env = os.Environ()
}
latestVersion := GetLatestVersion()
+5 -7
View File
@@ -13,7 +13,6 @@ 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
@@ -80,16 +79,16 @@ func (handler *Handler) executeServiceWebhook(
service.Spec.TaskTemplate.ForceUpdate++
imageName := strings.Split(service.Spec.TaskTemplate.ContainerSpec.Image, "@sha")[0]
service.Spec.TaskTemplate.ContainerSpec.Image = imageName
var imageName = strings.Split(service.Spec.TaskTemplate.ContainerSpec.Image, "@sha")[0]
if imageTag != "" {
tagIndex := strings.LastIndex(imageName, ":")
var 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{
@@ -110,9 +109,8 @@ func (handler *Handler) executeServiceWebhook(
}
}
}
if imageTag != "" {
rc, err := dockerClient.ImagePull(context.Background(), service.Spec.TaskTemplate.ContainerSpec.Image, image.PullOptions{RegistryAuth: serviceUpdateOptions.EncodedRegistryAuth})
rc, err := dockerClient.ImagePull(context.Background(), service.Spec.TaskTemplate.ContainerSpec.Image, dockertypes.ImagePullOptions{RegistryAuth: serviceUpdateOptions.EncodedRegistryAuth})
if err != nil {
return httperror.NotFound("Error pulling image with the specified tag", err)
}
+3 -5
View File
@@ -41,13 +41,11 @@ func (o *OfflineGate) WaitingMiddleware(timeout time.Duration, next http.Handler
}
if !o.lock.RTryLockWithTimeout(timeout) {
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)
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)
return
}
defer o.lock.RUnlock()
next.ServeHTTP(w, r)
o.lock.RUnlock()
})
}
-28
View File
@@ -9,7 +9,6 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_canLockAndUnlock(t *testing.T) {
@@ -147,30 +146,3 @@ 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,8 +11,6 @@ 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 !endpointsutils.HasDirectConnectivity(&e) {
if !endpointutils.IsEdgeEndpoint(&e) || e.Edge.AsyncMode || !e.UserTrusted {
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, teamIDs []int, isRestrictDefaultNamespace bool) ([]string, error) {
func (kcl *KubeClient) GetNonAdminNamespaces(userID 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, teamIDs []int, isRestri
}
for namespace, accessPolicy := range accessPolicies {
if hasUserAccessToNamespace(userID, teamIDs, accessPolicy) {
if hasUserAccessToNamespace(userID, nil, accessPolicy) {
nonAdminNamespaces = append(nonAdminNamespaces, namespace)
}
}
+57 -157
View File
@@ -47,9 +47,7 @@ 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().
Str("context", "fetchNamespacesForNonAdmin").
Msg("Fetching namespaces for non-admin user")
log.Debug().Msgf("Fetching namespaces for non-admin user: %v", kcl.NonAdminNamespaces)
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
@@ -77,11 +75,6 @@ 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)
}
@@ -99,7 +92,6 @@ 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),
@@ -111,18 +103,13 @@ 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 namespace in a k8s endpoint.
// CreateNamespace creates a new ingress in a given namespace in a k8s endpoint.
func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) {
portainerLabels := map[string]string{
namespaceNameLabel: stackutils.SanitizeLabel(info.Name),
@@ -138,127 +125,52 @@ 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 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
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
}
}
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 {
@@ -268,6 +180,7 @@ func isSystemNamespace(namespace *corev1.Namespace) bool {
systemNamespaces := defaultSystemNamespaces()
_, isSystem := systemNamespaces[namespace.Name]
return isSystem
}
@@ -288,13 +201,10 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er
return nil
}
namespace, err := kcl.cli.CoreV1().Namespaces().Get(context.TODO(), namespaceName, metav1.GetOptions{})
nsService := kcl.cli.CoreV1().Namespaces()
namespace, err := nsService.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")
}
@@ -308,12 +218,8 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er
namespace.Labels[systemNamespaceLabel] = strconv.FormatBool(isSystem)
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")
_, err = nsService.Update(context.TODO(), namespace, metav1.UpdateOptions{})
if err != nil {
return errors.Wrap(err, "failed updating namespace object")
}
@@ -322,26 +228,29 @@ 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
}
@@ -352,10 +261,6 @@ 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)
}
@@ -370,11 +275,6 @@ 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 occurred, failed to list resource quotas for the admin user: %w", err)
return nil, fmt.Errorf("an error occured, failed to list resource quotas for the admin user: %w", err)
}
return &resourceQuotas.Items, nil
+58 -2
View File
@@ -1,9 +1,15 @@
package kubernetes
import (
"context"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/pkg/snapshot"
"github.com/rs/zerolog/log"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
type Snapshotter struct {
@@ -24,5 +30,55 @@ func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*p
return nil, err
}
return snapshot.CreateKubernetesSnapshot(client)
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
}
+9 -24
View File
@@ -133,7 +133,6 @@ type (
SecretKeyName *string
LogLevel *string
LogMode *string
KubectlShellImage *string
}
// CustomTemplateVariableDefinition
@@ -185,16 +184,6 @@ 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 {
@@ -227,7 +216,6 @@ 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
@@ -329,6 +317,9 @@ 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
@@ -608,7 +599,6 @@ 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"`
@@ -645,12 +635,11 @@ 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"`
DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"`
Time int64 `json:"Time"`
KubernetesVersion string `json:"KubernetesVersion"`
NodeCount int `json:"NodeCount"`
TotalCPU int64 `json:"TotalCPU"`
TotalMemory int64 `json:"TotalMemory"`
}
// KubernetesConfiguration represents the configuration of a Kubernetes environment(endpoint)
@@ -1628,9 +1617,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.25.1"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "STS"
APIVersion = "2.24.1"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
@@ -1678,8 +1665,6 @@ 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
+4 -5
View File
@@ -15,7 +15,6 @@ 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"
@@ -171,9 +170,9 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
}
defer cli.Close()
unpackerImg := getUnpackerImage()
image := getUnpackerImage()
reader, err := cli.ImagePull(ctx, unpackerImg, image.PullOptions{})
reader, err := cli.ImagePull(ctx, image, types.ImagePullOptions{})
if err != nil {
return errors.Wrap(err, "unable to pull unpacker image")
}
@@ -198,12 +197,12 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
}
log.Debug().
Str("image", unpackerImg).
Str("image", image).
Str("cmd", strings.Join(cmd, " ")).
Msg("running unpacker")
unpackerContainer, err := cli.ContainerCreate(ctx, &container.Config{
Image: unpackerImg,
Image: image,
Cmd: cmd,
}, &container.HostConfig{
Binds: []string{
-1
View File
@@ -31,7 +31,6 @@ const ngModule = angular
'isStackColumnVisible',
'onRefresh',
'titleIcon',
'tableKey',
])
);
-1
View File
@@ -6,5 +6,4 @@
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.Name, nodeName: volume.NodeName })"
ui-sref="docker.volumes.volume.browse({ id: volume.Id, nodeName: volume.NodeName })"
authorization="DockerAgentBrowseList"
>
<pr-icon icon="'search'" class="leading-none"></pr-icon>
@@ -58,8 +58,6 @@ 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,10 +74,6 @@ 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?tab',
url: '/:id',
views: {
'content@': {
component: 'namespaceView',
component: 'kubernetesResourcePoolView',
},
},
data: {
+1 -2
View File
@@ -19,8 +19,7 @@ class KubernetesConfigurationConverter {
res.IsRegistrySecret = secret.IsRegistrySecret;
res.SecretType = secret.SecretType;
if (secret.Annotations) {
const serviceAccountAnnotation = secret.Annotations.find((a) => a.key === 'kubernetes.io/service-account.name');
res.ServiceAccountName = serviceAccountAnnotation ? serviceAccountAnnotation.value : undefined;
res.ServiceAccountName = secret.Annotations['kubernetes.io/service-account.name'];
}
res.Labels = secret.Labels;
return res;
+1 -4
View File
@@ -109,10 +109,7 @@ class KubernetesSecretConverter {
res.Type = formValues.customType;
}
if (formValues.Type === KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value) {
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 });
}
res.Annotations = [{ name: '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', 'noWidget']
['resourceId', 'storageKey', 'namespace']
)
).name;
+10
View File
@@ -4,6 +4,7 @@ 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';
@@ -105,6 +106,15 @@ export const ngModule = angular
'name',
])
)
.component(
'createNamespaceRegistriesSelector',
r2a(withUIRouter(withReactQuery(withCurrentUser(RegistriesSelector))), [
'inputId',
'onChange',
'options',
'value',
])
)
.component(
'kubeNodesDatatable',
r2a(withUIRouter(withReactQuery(withCurrentUser(NodesDatatable))), [])
@@ -3,11 +3,26 @@ 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,7 +19,6 @@ 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
@@ -28,10 +27,6 @@ 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/services/useNamespaceServices';
import { getServices } from '@/react/kubernetes/networks/services/service';
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 */
@@ -0,0 +1,14 @@
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);
@@ -0,0 +1,16 @@
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;
@@ -0,0 +1,13 @@
<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>
@@ -0,0 +1,278 @@
<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>
@@ -0,0 +1,236 @@
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;
@@ -0,0 +1,302 @@
<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>
@@ -0,0 +1,8 @@
angular.module('portainer.kubernetes').component('kubernetesResourcePoolView', {
templateUrl: './resourcePool.html',
controller: 'KubernetesResourcePoolController',
controllerAs: 'ctrl',
bindings: {
endpoint: '<',
},
});
@@ -0,0 +1,405 @@
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);
@@ -0,0 +1,7 @@
<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>
@@ -0,0 +1,8 @@
angular.module('portainer.kubernetes').component('kubernetesResourcePoolsView', {
templateUrl: './resourcePools.html',
controller: 'KubernetesResourcePoolsController',
controllerAs: 'ctrl',
bindings: {
endpoint: '<',
},
});
@@ -0,0 +1,109 @@
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,7 +15,6 @@ 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 !ml-0"
class="btn btn-primary btn-sm"
ng-disabled="$ctrl.isUpdateButtonDisabled() || editRegistry.$invalid"
ng-click="$ctrl.updateRegistry()"
button-spinner="$ctrl.state.actionInProgress"
+1 -7
View File
@@ -241,13 +241,7 @@
environment="endpoint"
></stack-containers-datatable>
<docker-services-datatable
ng-if="services && (!orphaned || orphanedRunning)"
dataset="services"
title-icon="list"
on-refresh="(getServices)"
table-key="'stack-services'"
></docker-services-datatable>
<docker-services-datatable ng-if="services && (!orphaned || orphanedRunning)" dataset="services" title-icon="list" on-refresh="(getServices)"></docker-services-datatable>
<!-- access-control-panel -->
<access-control-panel
-8
View File
@@ -78,11 +78,6 @@ export function createMockEnvironment(): Environment {
URL: 'url',
Snapshots: [],
Kubernetes: {
Flags: {
IsServerMetricsDetected: true,
IsServerIngressClassDetected: true,
IsServerStorageDetected: true,
},
Snapshots: [],
Configuration: {
IngressClasses: [],
@@ -90,9 +85,6 @@ 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 { LucideIcon } from 'lucide-react';
import type { Icon } 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: LucideIcon;
checkIcon: Icon;
}
export function BoxOption<T extends Value>({
@@ -1,5 +1,3 @@
import { FormError } from '@@/form-components/FormError';
import styles from './BoxSelector.module.css';
import { BoxSelectorItem } from './BoxSelectorItem';
import { BoxSelectorOption, Value } from './types';
@@ -23,7 +21,6 @@ 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>({
@@ -31,7 +28,6 @@ export function BoxSelector<T extends Value>({
options,
slim = false,
hiddenSpacingCount,
error,
...props
}: Props<T>) {
return (
@@ -58,7 +54,6 @@ 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 { type LucideIcon, Check } from 'lucide-react';
import { Icon as ReactFeatherComponentType, 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?: LucideIcon;
checkIcon?: ReactFeatherComponentType;
};
export function BoxSelectorItem<T extends Value>({
+1 -1
View File
@@ -1,6 +1,6 @@
import { ReactNode } from 'react';
import { useDocsUrl } from '@@/PageHeader/ContextHelp';
import { useDocsUrl } from '../PageHeader/ContextHelp/ContextHelp';
type HelpLinkProps = {
docLink: string;
@@ -1,29 +0,0 @@
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>
);
}
@@ -0,0 +1,5 @@
.menu-icon {
background: var(--user-menu-icon-color);
cursor: pointer;
flex-shrink: 0;
}
@@ -4,7 +4,8 @@ import { useCurrentStateAndParams } from '@uirouter/react';
import { useSystemVersion } from '@/react/portainer/system/useSystemVersion';
import headerStyles from './HeaderTitle.module.css';
import headerStyles from '../HeaderTitle.module.css';
import './ContextHelp.css';
export function ContextHelp() {
const docsUrl = useDocsUrl();
@@ -17,11 +18,12 @@ export function ContextHelp() {
color="none"
className={clsx(
headerStyles.menuIcon,
'icon-badge mr-1 !p-2 text-lg cursor-pointer',
'menu-icon',
'icon-badge mr-1 !p-2 text-lg',
'text-gray-8',
'th-dark:text-gray-warm-7'
)}
title="Documentation"
title="Help"
rel="noreferrer"
data-cy="context-help-button"
>
@@ -0,0 +1 @@
export { ContextHelp } from './ContextHelp';
@@ -10,12 +10,6 @@
.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,13 +1,10 @@
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;
@@ -28,7 +25,6 @@ export function HeaderTitle({ title, children }: PropsWithChildren<Props>) {
{children && <>{children}</>}
</div>
<div className="flex items-end">
{isBE && <AskAILink />}
<NotificationsMenu />
<ContextHelp />
{!window.ddExtension && <UserMenu />}
@@ -1,66 +0,0 @@
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>
);
}
@@ -1 +0,0 @@
export { ProgressBar } from './ProgressBar';
@@ -14,7 +14,7 @@ export function RadioGroup<T extends string | number = string>({
onOptionChange,
}: Props<T>) {
return (
<div className="flex flex-wrap gap-x-2 gap-y-1">
<div>
{options.map((option) => (
<label
key={option.value}
@@ -125,13 +125,12 @@ 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';
import { useDocsUrl } from '@@/PageHeader/ContextHelp/ContextHelp';
import { EnvironmentVariablesFieldset } from './EnvironmentVariablesFieldset';
import { EnvironmentVariablesPanel } from './EnvironmentVariablesPanel';
@@ -28,35 +28,34 @@ 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.Name,
id: item.Id,
nodeName: item.NodeName,
}}
data-cy={`volume-link-${name}`}
@@ -99,7 +99,7 @@ function Cell({
props={{
to: 'docker.volumes.volume.browse',
params: {
id: item.Name,
id: item.Id,
nodeName: item.NodeName,
},
}}
@@ -1,7 +1,6 @@
import { CellContext } from '@tanstack/react-table';
import { Link } from '@@/Link';
import { Badge } from '@@/Badge';
import { EdgeGroupListItemResponse } from '../../queries/useEdgeGroups';
@@ -33,9 +32,7 @@ function NameCell({
{name}
</Link>
{(item.HasEdgeJob || item.HasEdgeStack) && (
<Badge type="info" className="ml-1">
in use
</Badge>
<span className="label label-info image-tag space-left">in use</span>
)}
</>
);
@@ -1,5 +1,5 @@
import { Formik } from 'formik';
import { useState, useMemo } from 'react';
import { useState } 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,12 +38,33 @@ export function CreateForm() {
templateType: templateParams.type,
});
const initialValues = useInitialValues(templateQuery, templateParams);
if (!initialValues) {
if (
templateParams.id &&
!(templateQuery.customTemplate || templateQuery.appTemplate)
) {
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">
@@ -107,66 +128,7 @@ function useTemplate(
});
return {
appTemplate: type === 'app' ? appTemplateQuery.data : undefined,
customTemplate: type === 'custom' ? customTemplateQuery.data : undefined,
appTemplate: appTemplateQuery.data,
customTemplate: customTemplateQuery.data,
};
}
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;
}
@@ -18,15 +18,12 @@ import { relativePathValidation } from '@/react/portainer/gitops/RelativePathFie
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { DeployMethod, GitFormModel } from '@/react/portainer/gitops/types';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { envVarValidation } from '@@/form-components/EnvironmentVariablesFieldset';
import { file } from '@@/form-components/yup-file-validation';
import { DeploymentType } from '../types';
import { staggerConfigValidation } from '../components/StaggerFieldset';
import { createHasEnvironmentTypeFunction } from '../ItemView/EditEdgeStackForm/useEdgeGroupHasType';
import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
import { FormValues, Method } from './types';
import { templateFieldsetValidation } from './TemplateFieldset/validation';
@@ -42,8 +39,6 @@ export function useValidation({
const { user } = useCurrentUser();
const gitCredentialsQuery = useGitCredentials(user.Id);
const nameValidation = useNameValidation();
const edgeGroupsQuery = useEdgeGroups();
const edgeGroups = edgeGroupsQuery.data;
return useMemo(
() =>
@@ -58,47 +53,7 @@ export function useValidation({
.min(1, 'At least one Edge group is required'),
deploymentType: mixed<DeploymentType>()
.oneOf([DeploymentType.Compose, DeploymentType.Kubernetes])
.required()
.test(
'kubernetes-deployment-type-validation',
'Kubernetes deployment type is not compatible with the selected edge group(s), which contain Docker environments',
(value) => {
if (value !== DeploymentType.Kubernetes) {
return true;
}
const hasType = createHasEnvironmentTypeFunction(
values.groupIds,
edgeGroups
);
const hasDockerEndpoint = hasType(
EnvironmentType.EdgeAgentOnDocker
);
return !hasDockerEndpoint;
}
)
.test(
'compose-deployment-type-validation',
'Compose deployment type is not compatible with the selected edge group(s), which contain Kubernetes environments',
(value) => {
if (value !== DeploymentType.Compose) {
return true;
}
const hasType = createHasEnvironmentTypeFunction(
values.groupIds,
edgeGroups
);
const hasKubeEndpoint = hasType(
EnvironmentType.EdgeAgentOnKubernetes
);
return !hasKubeEndpoint;
}
),
.required(),
envVars: envVarValidation(),
privateRegistryId: number().default(0),
prePullImage: boolean().default(false),
@@ -117,7 +72,7 @@ export function useValidation({
}),
templateValues: templateFieldsetValidation({
customVariablesDefinitions: customTemplate?.Variables || [],
appTemplateVariablesDefinitions: appTemplate?.Env || [],
envVarDefinitions: appTemplate?.Env || [],
}),
git: mixed().when('method', {
is: 'repository',
@@ -137,12 +92,6 @@ export function useValidation({
useManifestNamespaces: boolean().default(false),
})
),
[
appTemplate?.Env,
customTemplate,
edgeGroups,
gitCredentialsQuery.data,
nameValidation,
]
[appTemplate?.Env, customTemplate, gitCredentialsQuery.data, nameValidation]
);
}
@@ -6,10 +6,10 @@ export function CreateView() {
return (
<>
<PageHeader
title="Create Edge Stack"
title="Create Edge stack"
breadcrumbs={[
{ label: 'Edge Stacks', link: 'edge.stacks' },
'Create Edge Stack',
'Create Edge stack',
]}
reload
/>
@@ -1,11 +1,9 @@
import { FormikErrors, useFormikContext } from 'formik';
import { SetStateAction } from 'react';
import { useFormikContext } from 'formik';
import { GitForm } from '@/react/portainer/gitops/GitForm';
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset';
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { BoxSelector } from '@@/BoxSelector';
import { FormSection } from '@@/form-components/FormSection';
@@ -18,25 +16,30 @@ import {
import { FileUploadForm } from '@@/form-components/FileUpload';
import { TemplateFieldset } from './TemplateFieldset/TemplateFieldset';
import { useRenderCustomTemplate } from './useRenderCustomTemplate';
import { useRenderTemplate } from './useRenderTemplate';
import { DockerFormValues } from './types';
import { DockerContentField } from './DockerContentField';
import { useRenderAppTemplate } from './useRenderAppTemplate';
const buildMethods = [editor, upload, git, edgeStackTemplate] as const;
interface Props {
export function DockerComposeForm({
webhookId,
onChangeTemplate,
}: {
webhookId: string;
onChangeTemplate: (change: {
onChangeTemplate: ({
type,
id,
}: {
type: 'app' | 'custom' | undefined;
id: number | undefined;
}) => void;
}
export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
}) {
const { errors, values, setValues } = useFormikContext<DockerFormValues>();
const { method } = values;
const template = useRenderTemplate(values.templateValues, setValues);
return (
<>
<FormSection title="Build Method">
@@ -50,10 +53,10 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
</FormSection>
{method === edgeStackTemplate.value && (
<>
<TemplateFieldset
values={values.templateValues}
setValues={(templateAction) => {
<TemplateFieldset
values={values.templateValues}
setValues={(templateAction) =>
setValues((values) => {
const templateValues = applySetStateAction(
templateAction,
values.templateValues
@@ -62,36 +65,23 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
id: templateValues.templateId,
type: templateValues.type,
});
setValues((values) => ({
return {
...values,
templateValues,
}));
}}
errors={errors?.templateValues}
/>
{values.templateValues.type === 'app' && (
<AppTemplateContentField
values={values}
handleChange={handleChange}
errors={errors}
setValues={setValues}
/>
)}
{values.templateValues.type === 'custom' && (
<CustomTemplateContentField
values={values}
handleChange={handleChange}
errors={errors}
setValues={setValues}
/>
)}
</>
};
})
}
errors={errors?.templateValues}
/>
)}
{method === editor.value && (
{(method === editor.value ||
(method === edgeStackTemplate.value && template)) && (
<DockerContentField
value={values.fileContent}
onChange={(value) => handleChange({ fileContent: value })}
readonly={method === edgeStackTemplate.value && !!template?.GitConfig}
error={errors?.fileContent}
/>
)}
@@ -124,23 +114,21 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
webhookId={webhookId}
/>
{isBE && (
<FormSection title="Advanced configurations">
<RelativePathFieldset
values={values.relativePath}
gitModel={values.git}
onChange={(relativePath) =>
setValues((values) => ({
...values,
relativePath: {
...values.relativePath,
...relativePath,
},
}))
}
/>
</FormSection>
)}
<FormSection title="Advanced configurations">
<RelativePathFieldset
values={values.relativePath}
gitModel={values.git}
onChange={(relativePath) =>
setValues((values) => ({
...values,
relativePath: {
...values.relativePath,
...relativePath,
},
}))
}
/>
</FormSection>
</>
)}
</>
@@ -153,51 +141,3 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
}));
}
}
type TemplateContentFieldProps = {
values: DockerFormValues;
handleChange: (newValues: Partial<DockerFormValues>) => void;
errors?: FormikErrors<DockerFormValues>;
setValues: (values: SetStateAction<DockerFormValues>) => void;
};
function AppTemplateContentField({
values,
handleChange,
errors,
setValues,
}: TemplateContentFieldProps) {
const { isInitialLoading } = useRenderAppTemplate(
values.templateValues,
setValues
);
return (
<DockerContentField
value={values.fileContent}
onChange={(value) => handleChange({ fileContent: value })}
error={errors?.fileContent}
isLoading={isInitialLoading}
/>
);
}
function CustomTemplateContentField({
values,
handleChange,
errors,
setValues,
}: TemplateContentFieldProps) {
const { customTemplate, isInitialLoading } = useRenderCustomTemplate(
values.templateValues,
setValues
);
return (
<DockerContentField
value={values.fileContent}
onChange={(value) => handleChange({ fileContent: value })}
error={errors?.fileContent}
readonly={!!customTemplate?.GitConfig}
isLoading={isInitialLoading}
/>
);
}

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