Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d16462195f | |||
| 55c98912ed | |||
| 45bd7984b0 | |||
| 1ed9a0106e | |||
| f8b2ee8c0d | |||
| d32b0f8b7e | |||
| 24fdb1f600 | |||
| 4010174f66 | |||
| e2b812a611 | |||
| d72b3a9ba2 | |||
| 85f52d2574 | |||
| 33ea22c0a9 | |||
| 0d52f9dd0e | |||
| 3caffe1e85 | |||
| 87b8dd61c3 | |||
| ad77cd195c | |||
| eb2a754580 | |||
| 9258db58db | |||
| 8d1c90f912 | |||
| 1c62bd6ca5 | |||
| 13317ec43c | |||
| 35dcb5ca46 | |||
| 4454b6b890 | |||
| 117e3500ae | |||
| 94fda6a720 | |||
| e1388eff84 | |||
| 94d2e32b49 | |||
| 069f22afa4 | |||
| 52c90d4d0a | |||
| ce7e0d8d60 | |||
| 40c7742e46 | |||
| 05e872337a | |||
| aac9d001f7 | |||
| d295968948 | |||
| 97e7a3c5e2 | |||
| 16a1825990 | |||
| 441afead10 | |||
| 783ab253af | |||
| 17648d12fe | |||
| 2f4f1be99c | |||
| 5d4d3888b8 | |||
| 473084e915 | |||
| a8147b9713 | |||
| 3c3dc547b2 | |||
| c5accd0f16 | |||
| cb949e443e | |||
| bb6815f681 | |||
| a261f60764 | |||
| d393529026 | |||
| 219c9593e0 | |||
| faa6b2b790 | |||
| 4046bf7b31 | |||
| 4f708309af | |||
| f2e7680bf3 | |||
| 5d2689b139 | |||
| 145ffeea40 | |||
| 13143bc7ea | |||
| ee0dbf2d22 | |||
| 4265ae4dae | |||
| 821c1fdbef | |||
| fe29d6aee3 | |||
| c0c7144539 | |||
| 20e3d3a15b | |||
| 07d1eedae3 | |||
| 4ad3d70739 | |||
| e6a1c29655 | |||
| 333dfe1ebf | |||
| c59872553a | |||
| 1a39370f5b | |||
| bc44056815 | |||
| 17c92343e0 | |||
| cd6935b07a | |||
| 47d428f3eb | |||
| 2baae7072f | |||
| 2e9e459aa3 | |||
| 7444e2c1c7 | |||
| d6469eb33d | |||
| a2da6f1827 |
@@ -0,0 +1,52 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = ".tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./dist/portainer"
|
||||
cmd = "SKIP_GO_GET=true make build-server"
|
||||
delay = 1000
|
||||
exclude_dir = []
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = "./dist/portainer --log-level=DEBUG"
|
||||
include_dir = ["api"]
|
||||
include_ext = ["go"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
silent = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[proxy]
|
||||
app_port = 0
|
||||
enabled = false
|
||||
proxy_port = 0
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
@@ -11,6 +11,8 @@ 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**.
|
||||
|
||||
@@ -90,11 +92,16 @@ body:
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Portainer version
|
||||
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.
|
||||
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
multiple: false
|
||||
options:
|
||||
- '2.25.1'
|
||||
- '2.25.0'
|
||||
- '2.24.1'
|
||||
- '2.24.0'
|
||||
- '2.23.0'
|
||||
- '2.22.0'
|
||||
- '2.21.5'
|
||||
- '2.21.4'
|
||||
- '2.21.3'
|
||||
- '2.21.2'
|
||||
@@ -114,11 +121,6 @@ body:
|
||||
- '2.18.3'
|
||||
- '2.18.2'
|
||||
- '2.18.1'
|
||||
- '2.17.1'
|
||||
- '2.17.0'
|
||||
- '2.16.2'
|
||||
- '2.16.1'
|
||||
- '2.16.0'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -20,8 +20,6 @@ linters-settings:
|
||||
deny:
|
||||
- pkg: 'encoding/json'
|
||||
desc: 'use github.com/segmentio/encoding/json'
|
||||
- pkg: 'github.com/sirupsen/logrus'
|
||||
desc: 'logging is allowed only by github.com/rs/zerolog'
|
||||
- pkg: 'golang.org/x/exp'
|
||||
desc: 'exp is not allowed'
|
||||
- pkg: 'github.com/portainer/libcrypto'
|
||||
|
||||
@@ -17,11 +17,13 @@ GOTESTSUM=go run gotest.tools/gotestsum@latest
|
||||
|
||||
|
||||
##@ Building
|
||||
.PHONY: init-dist build-storybook build build-client build-server build-image devops
|
||||
.PHONY: all init-dist build-storybook build build-client build-server build-image devops
|
||||
init-dist:
|
||||
@mkdir -p dist
|
||||
|
||||
build-all: deps build-server build-client ## Build the client, server and download external dependancies (doesn't build an image)
|
||||
all: tidy deps build-server build-client ## Build the client, server and download external dependancies (doesn't build an image)
|
||||
|
||||
build-all: all ## Alias for the 'all' target (used by CI)
|
||||
|
||||
build-client: init-dist ## Build the client
|
||||
export NODE_ENV=$(ENV) && yarn build --config $(WEBPACK_CONFIG)
|
||||
@@ -50,7 +52,7 @@ client-deps: ## Install client dependencies
|
||||
yarn
|
||||
|
||||
tidy: ## Tidy up the go.mod file
|
||||
cd api && go mod tidy
|
||||
@go mod tidy
|
||||
|
||||
|
||||
##@ Cleanup
|
||||
@@ -65,10 +67,10 @@ clean: ## Remove all build and download artifacts
|
||||
test: test-server test-client ## Run all tests
|
||||
|
||||
test-client: ## Run client tests
|
||||
yarn test $(ARGS)
|
||||
yarn test $(ARGS) --coverage
|
||||
|
||||
test-server: ## Run server tests
|
||||
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover ./...
|
||||
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
|
||||
|
||||
##@ Dev
|
||||
.PHONY: dev dev-client dev-server
|
||||
|
||||
+35
-30
@@ -21,6 +21,7 @@ const rwxr__r__ os.FileMode = 0o744
|
||||
|
||||
var filesToBackup = []string{
|
||||
"certs",
|
||||
"chisel",
|
||||
"compose",
|
||||
"config.json",
|
||||
"custom_templates",
|
||||
@@ -30,40 +31,13 @@ var filesToBackup = []string{
|
||||
"portainer.key",
|
||||
"portainer.pub",
|
||||
"tls",
|
||||
"chisel",
|
||||
}
|
||||
|
||||
// Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file.
|
||||
func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datastore dataservices.DataStore, filestorePath string) (string, error) {
|
||||
unlock := gate.Lock()
|
||||
defer unlock()
|
||||
|
||||
backupDirPath := filepath.Join(filestorePath, "backup", time.Now().Format("2006-01-02_15-04-05"))
|
||||
if err := os.MkdirAll(backupDirPath, rwxr__r__); err != nil {
|
||||
return "", errors.Wrap(err, "Failed to create backup dir")
|
||||
}
|
||||
|
||||
{
|
||||
// new export
|
||||
exportFilename := path.Join(backupDirPath, fmt.Sprintf("export-%d.json", time.Now().Unix()))
|
||||
|
||||
err := datastore.Export(exportFilename)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("filename", exportFilename).Msg("failed to export")
|
||||
} else {
|
||||
log.Debug().Str("filename", exportFilename).Msg("file exported")
|
||||
}
|
||||
}
|
||||
|
||||
if err := backupDb(backupDirPath, datastore); err != nil {
|
||||
return "", errors.Wrap(err, "Failed to backup database")
|
||||
}
|
||||
|
||||
for _, filename := range filesToBackup {
|
||||
err := filesystem.CopyPath(filepath.Join(filestorePath, filename), backupDirPath)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "Failed to create backup file")
|
||||
}
|
||||
backupDirPath, err := backupDatabaseAndFilesystem(gate, datastore, filestorePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
archivePath, err := archive.TarGzDir(backupDirPath)
|
||||
@@ -81,6 +55,37 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto
|
||||
return archivePath, nil
|
||||
}
|
||||
|
||||
func backupDatabaseAndFilesystem(gate *offlinegate.OfflineGate, datastore dataservices.DataStore, filestorePath string) (string, error) {
|
||||
unlock := gate.Lock()
|
||||
defer unlock()
|
||||
|
||||
backupDirPath := filepath.Join(filestorePath, "backup", time.Now().Format("2006-01-02_15-04-05"))
|
||||
if err := os.MkdirAll(backupDirPath, rwxr__r__); err != nil {
|
||||
return "", errors.Wrap(err, "Failed to create backup dir")
|
||||
}
|
||||
|
||||
// new export
|
||||
exportFilename := path.Join(backupDirPath, fmt.Sprintf("export-%d.json", time.Now().Unix()))
|
||||
|
||||
if err := datastore.Export(exportFilename); err != nil {
|
||||
log.Error().Err(err).Str("filename", exportFilename).Msg("failed to export")
|
||||
} else {
|
||||
log.Debug().Str("filename", exportFilename).Msg("file exported")
|
||||
}
|
||||
|
||||
if err := backupDb(backupDirPath, datastore); err != nil {
|
||||
return "", errors.Wrap(err, "Failed to backup database")
|
||||
}
|
||||
|
||||
for _, filename := range filesToBackup {
|
||||
if err := filesystem.CopyPath(filepath.Join(filestorePath, filename), backupDirPath); err != nil {
|
||||
return "", errors.Wrap(err, "Failed to create backup file")
|
||||
}
|
||||
}
|
||||
|
||||
return backupDirPath, nil
|
||||
}
|
||||
|
||||
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
|
||||
dbFileName := datastore.Connection().GetDatabaseFileName()
|
||||
_, err := datastore.Backup(filepath.Join(backupDirPath, dbFileName))
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package build
|
||||
|
||||
import "runtime"
|
||||
|
||||
// Variables to be set during the build time
|
||||
var BuildNumber string
|
||||
var ImageTag string
|
||||
var NodejsVersion string
|
||||
var YarnVersion string
|
||||
var WebpackVersion string
|
||||
var GoVersion string = runtime.Version()
|
||||
var GitCommit string
|
||||
@@ -59,6 +59,7 @@ func CLIFlags() *portainer.CLIFlags {
|
||||
SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/<secret-key-name>.").Default(defaultSecretKeyName).String(),
|
||||
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
|
||||
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
|
||||
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-3
@@ -19,7 +19,5 @@ func Confirm(message string) (bool, error) {
|
||||
}
|
||||
|
||||
answer = strings.ReplaceAll(answer, "\n", "")
|
||||
answer = strings.ToLower(answer)
|
||||
|
||||
return answer == "y" || answer == "yes", nil
|
||||
return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes"), nil
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/apikey"
|
||||
"github.com/portainer/portainer/api/build"
|
||||
"github.com/portainer/portainer/api/chisel"
|
||||
"github.com/portainer/portainer/api/cli"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
@@ -47,6 +46,7 @@ import (
|
||||
"github.com/portainer/portainer/api/platform"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/pkg/build"
|
||||
"github.com/portainer/portainer/pkg/featureflags"
|
||||
"github.com/portainer/portainer/pkg/libhelm"
|
||||
"github.com/portainer/portainer/pkg/libstack/compose"
|
||||
@@ -93,7 +93,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
||||
log.Fatal().Msg("failed creating database connection: expecting a boltdb database type but a different one was received")
|
||||
}
|
||||
|
||||
store := datastore.NewStore(*flags.Data, fileService, connection)
|
||||
store := datastore.NewStore(flags, fileService, connection)
|
||||
|
||||
isNew, err := store.Open()
|
||||
if err != nil {
|
||||
@@ -120,7 +120,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
||||
log.Fatal().Err(err).Msg("failed generating instance id")
|
||||
}
|
||||
|
||||
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{})
|
||||
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{Flags: flags})
|
||||
migratorCount := migratorInstance.GetMigratorCountOfCurrentAPIVersion()
|
||||
|
||||
// from MigrateData
|
||||
|
||||
@@ -40,6 +40,7 @@ type Connection interface {
|
||||
GetDatabaseFileName() string
|
||||
GetDatabaseFilePath() string
|
||||
GetStorePath() string
|
||||
GetDatabaseFileSize() (int64, error)
|
||||
|
||||
IsEncryptedStore() bool
|
||||
NeedsEncryptionMigration() (bool, error)
|
||||
|
||||
@@ -62,6 +62,15 @@ func (connection *DbConnection) GetStorePath() string {
|
||||
return connection.Path
|
||||
}
|
||||
|
||||
func (connection *DbConnection) GetDatabaseFileSize() (int64, error) {
|
||||
file, err := os.Stat(connection.GetDatabaseFilePath())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("Failed to stat database file path: %s err: %w", connection.GetDatabaseFilePath(), err)
|
||||
}
|
||||
|
||||
return file.Size(), nil
|
||||
}
|
||||
|
||||
func (connection *DbConnection) SetEncrypted(flag bool) {
|
||||
connection.isEncrypted = flag
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ type Service struct {
|
||||
connection portainer.Connection
|
||||
idxVersion map[portainer.EdgeStackID]int
|
||||
mu sync.RWMutex
|
||||
cacheInvalidationFn func(portainer.EdgeStackID)
|
||||
cacheInvalidationFn func(portainer.Transaction, portainer.EdgeStackID)
|
||||
}
|
||||
|
||||
func (service *Service) BucketName() string {
|
||||
@@ -23,7 +23,7 @@ func (service *Service) BucketName() string {
|
||||
}
|
||||
|
||||
// NewService creates a new instance of a service.
|
||||
func NewService(connection portainer.Connection, cacheInvalidationFn func(portainer.EdgeStackID)) (*Service, error) {
|
||||
func NewService(connection portainer.Connection, cacheInvalidationFn func(portainer.Transaction, portainer.EdgeStackID)) (*Service, error) {
|
||||
err := connection.SetServiceName(BucketName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -36,7 +36,7 @@ func NewService(connection portainer.Connection, cacheInvalidationFn func(portai
|
||||
}
|
||||
|
||||
if s.cacheInvalidationFn == nil {
|
||||
s.cacheInvalidationFn = func(portainer.EdgeStackID) {}
|
||||
s.cacheInvalidationFn = func(portainer.Transaction, portainer.EdgeStackID) {}
|
||||
}
|
||||
|
||||
es, err := s.EdgeStacks()
|
||||
@@ -106,7 +106,7 @@ func (service *Service) Create(id portainer.EdgeStackID, edgeStack *portainer.Ed
|
||||
|
||||
service.mu.Lock()
|
||||
service.idxVersion[id] = edgeStack.Version
|
||||
service.cacheInvalidationFn(id)
|
||||
service.cacheInvalidationFn(service.connection, id)
|
||||
service.mu.Unlock()
|
||||
|
||||
return nil
|
||||
@@ -125,7 +125,7 @@ func (service *Service) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *por
|
||||
}
|
||||
|
||||
service.idxVersion[ID] = edgeStack.Version
|
||||
service.cacheInvalidationFn(ID)
|
||||
service.cacheInvalidationFn(service.connection, ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func (service *Service) UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc
|
||||
updateFunc(edgeStack)
|
||||
|
||||
service.idxVersion[ID] = edgeStack.Version
|
||||
service.cacheInvalidationFn(ID)
|
||||
service.cacheInvalidationFn(service.connection, ID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func (service *Service) DeleteEdgeStack(ID portainer.EdgeStackID) error {
|
||||
|
||||
delete(service.idxVersion, ID)
|
||||
|
||||
service.cacheInvalidationFn(ID)
|
||||
service.cacheInvalidationFn(service.connection, ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -44,8 +44,7 @@ func (service ServiceTx) EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeSta
|
||||
var stack portainer.EdgeStack
|
||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||
|
||||
err := service.tx.GetObject(BucketName, identifier, &stack)
|
||||
if err != nil {
|
||||
if err := service.tx.GetObject(BucketName, identifier, &stack); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -65,18 +64,17 @@ func (service ServiceTx) EdgeStackVersion(ID portainer.EdgeStackID) (int, bool)
|
||||
func (service ServiceTx) Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error {
|
||||
edgeStack.ID = id
|
||||
|
||||
err := service.tx.CreateObjectWithId(
|
||||
if err := service.tx.CreateObjectWithId(
|
||||
BucketName,
|
||||
int(edgeStack.ID),
|
||||
edgeStack,
|
||||
)
|
||||
if err != nil {
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
service.service.mu.Lock()
|
||||
service.service.idxVersion[id] = edgeStack.Version
|
||||
service.service.cacheInvalidationFn(id)
|
||||
service.service.cacheInvalidationFn(service.tx, id)
|
||||
service.service.mu.Unlock()
|
||||
|
||||
return nil
|
||||
@@ -89,13 +87,12 @@ func (service ServiceTx) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *po
|
||||
|
||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||
|
||||
err := service.tx.UpdateObject(BucketName, identifier, edgeStack)
|
||||
if err != nil {
|
||||
if err := service.tx.UpdateObject(BucketName, identifier, edgeStack); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
service.service.idxVersion[ID] = edgeStack.Version
|
||||
service.service.cacheInvalidationFn(ID)
|
||||
service.service.cacheInvalidationFn(service.tx, ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -119,14 +116,13 @@ func (service ServiceTx) DeleteEdgeStack(ID portainer.EdgeStackID) error {
|
||||
|
||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||
|
||||
err := service.tx.DeleteObject(BucketName, identifier)
|
||||
if err != nil {
|
||||
if err := service.tx.DeleteObject(BucketName, identifier); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
delete(service.service.idxVersion, ID)
|
||||
|
||||
service.service.cacheInvalidationFn(ID)
|
||||
service.service.cacheInvalidationFn(service.tx, ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package endpointrelation
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||
@@ -13,9 +15,11 @@ const BucketName = "endpoint_relations"
|
||||
|
||||
// Service represents a service for managing environment(endpoint) relation data.
|
||||
type Service struct {
|
||||
connection portainer.Connection
|
||||
updateStackFn func(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
||||
updateStackFnTx func(tx portainer.Transaction, ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
||||
connection portainer.Connection
|
||||
updateStackFn func(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
||||
updateStackFnTx func(tx portainer.Transaction, ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
||||
endpointRelationsCache []portainer.EndpointRelation
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (service *Service) BucketName() string {
|
||||
@@ -76,6 +80,10 @@ func (service *Service) Create(endpointRelation *portainer.EndpointRelation) err
|
||||
err := service.connection.CreateObjectWithId(BucketName, int(endpointRelation.EndpointID), endpointRelation)
|
||||
cache.Del(endpointRelation.EndpointID)
|
||||
|
||||
service.mu.Lock()
|
||||
service.endpointRelationsCache = nil
|
||||
service.mu.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -92,6 +100,10 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
||||
|
||||
updatedRelationState, _ := service.EndpointRelation(endpointID)
|
||||
|
||||
service.mu.Lock()
|
||||
service.endpointRelationsCache = nil
|
||||
service.mu.Unlock()
|
||||
|
||||
service.updateEdgeStacksAfterRelationChange(previousRelationState, updatedRelationState)
|
||||
|
||||
return nil
|
||||
@@ -108,27 +120,15 @@ func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID)
|
||||
return err
|
||||
}
|
||||
|
||||
service.mu.Lock()
|
||||
service.endpointRelationsCache = nil
|
||||
service.mu.Unlock()
|
||||
|
||||
service.updateEdgeStacksAfterRelationChange(deletedRelation, nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) InvalidateEdgeCacheForEdgeStack(edgeStackID portainer.EdgeStackID) {
|
||||
rels, err := service.EndpointRelations()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("cannot retrieve endpoint relations")
|
||||
return
|
||||
}
|
||||
|
||||
for _, rel := range rels {
|
||||
for id := range rel.EdgeStacks {
|
||||
if edgeStackID == id {
|
||||
cache.Del(rel.EndpointID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) updateEdgeStacksAfterRelationChange(previousRelationState *portainer.EndpointRelation, updatedRelationState *portainer.EndpointRelation) {
|
||||
relations, _ := service.EndpointRelations()
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ func (service ServiceTx) Create(endpointRelation *portainer.EndpointRelation) er
|
||||
err := service.tx.CreateObjectWithId(BucketName, int(endpointRelation.EndpointID), endpointRelation)
|
||||
cache.Del(endpointRelation.EndpointID)
|
||||
|
||||
service.service.mu.Lock()
|
||||
service.service.endpointRelationsCache = nil
|
||||
service.service.mu.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -61,6 +65,10 @@ func (service ServiceTx) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
||||
|
||||
updatedRelationState, _ := service.EndpointRelation(endpointID)
|
||||
|
||||
service.service.mu.Lock()
|
||||
service.service.endpointRelationsCache = nil
|
||||
service.service.mu.Unlock()
|
||||
|
||||
service.updateEdgeStacksAfterRelationChange(previousRelationState, updatedRelationState)
|
||||
|
||||
return nil
|
||||
@@ -77,27 +85,44 @@ func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID)
|
||||
return err
|
||||
}
|
||||
|
||||
service.service.mu.Lock()
|
||||
service.service.endpointRelationsCache = nil
|
||||
service.service.mu.Unlock()
|
||||
|
||||
service.updateEdgeStacksAfterRelationChange(deletedRelation, nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service ServiceTx) InvalidateEdgeCacheForEdgeStack(edgeStackID portainer.EdgeStackID) {
|
||||
rels, err := service.EndpointRelations()
|
||||
rels, err := service.cachedEndpointRelations()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("cannot retrieve endpoint relations")
|
||||
return
|
||||
}
|
||||
|
||||
for _, rel := range rels {
|
||||
for id := range rel.EdgeStacks {
|
||||
if edgeStackID == id {
|
||||
cache.Del(rel.EndpointID)
|
||||
}
|
||||
if _, ok := rel.EdgeStacks[edgeStackID]; ok {
|
||||
cache.Del(rel.EndpointID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (service ServiceTx) cachedEndpointRelations() ([]portainer.EndpointRelation, error) {
|
||||
service.service.mu.Lock()
|
||||
defer service.service.mu.Unlock()
|
||||
|
||||
if service.service.endpointRelationsCache == nil {
|
||||
var err error
|
||||
service.service.endpointRelationsCache, err = service.EndpointRelations()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return service.service.endpointRelationsCache, nil
|
||||
}
|
||||
|
||||
func (service ServiceTx) updateEdgeStacksAfterRelationChange(previousRelationState *portainer.EndpointRelation, updatedRelationState *portainer.EndpointRelation) {
|
||||
relations, _ := service.EndpointRelations()
|
||||
|
||||
@@ -133,6 +158,7 @@ func (service ServiceTx) updateEdgeStacksAfterRelationChange(previousRelationSta
|
||||
}
|
||||
|
||||
numDeployments := 0
|
||||
|
||||
for _, r := range relations {
|
||||
for sId, enabled := range r.EdgeStacks {
|
||||
if enabled && sId == refStackId {
|
||||
|
||||
@@ -16,8 +16,9 @@ import (
|
||||
)
|
||||
|
||||
// NewStore initializes a new Store and the associated services
|
||||
func NewStore(storePath string, fileService portainer.FileService, connection portainer.Connection) *Store {
|
||||
func NewStore(cliFlags *portainer.CLIFlags, fileService portainer.FileService, connection portainer.Connection) *Store {
|
||||
return &Store{
|
||||
flags: cliFlags,
|
||||
fileService: fileService,
|
||||
connection: connection,
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func (store *Store) checkOrCreateDefaultSettings() error {
|
||||
HelmRepositoryURL: portainer.DefaultHelmRepositoryURL,
|
||||
UserSessionTimeout: portainer.DefaultUserSessionTimeout,
|
||||
KubeconfigExpiry: portainer.DefaultKubeconfigExpiry,
|
||||
KubectlShellImage: portainer.DefaultKubectlShellImage,
|
||||
KubectlShellImage: *store.flags.KubectlShellImage,
|
||||
|
||||
IsDockerDesktopExtension: isDDExtention,
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func (store *Store) MigrateData() error {
|
||||
return errors.Wrap(err, "while migrating legacy version")
|
||||
}
|
||||
|
||||
migratorParams := store.newMigratorParameters(version)
|
||||
migratorParams := store.newMigratorParameters(version, store.flags)
|
||||
migrator := migrator.NewMigrator(migratorParams)
|
||||
|
||||
if !migrator.NeedsMigration() {
|
||||
@@ -62,8 +62,9 @@ func (store *Store) MigrateData() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) newMigratorParameters(version *models.Version) *migrator.MigratorParameters {
|
||||
func (store *Store) newMigratorParameters(version *models.Version, flags *portainer.CLIFlags) *migrator.MigratorParameters {
|
||||
return &migrator.MigratorParameters{
|
||||
Flags: flags,
|
||||
CurrentDBVersion: version,
|
||||
EndpointGroupService: store.EndpointGroupService,
|
||||
EndpointService: store.EndpointService,
|
||||
|
||||
@@ -109,7 +109,7 @@ func TestMigrateData(t *testing.T) {
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
migratorParams := store.newMigratorParameters(v)
|
||||
migratorParams := store.newMigratorParameters(v, store.flags)
|
||||
m := migrator.NewMigrator(migratorParams)
|
||||
latestMigrations := m.LatestMigrations()
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ func TestMigrateSettings(t *testing.T) {
|
||||
}
|
||||
|
||||
m := migrator.NewMigrator(&migrator.MigratorParameters{
|
||||
Flags: store.flags,
|
||||
EndpointGroupService: store.EndpointGroupService,
|
||||
EndpointService: store.EndpointService,
|
||||
EndpointRelationService: store.EndpointRelationService,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -20,7 +18,7 @@ func (m *Migrator) migrateSettingsToDB33() error {
|
||||
}
|
||||
|
||||
log.Info().Msg("setting default kubectl shell image")
|
||||
settings.KubectlShellImage = portainer.DefaultKubectlShellImage
|
||||
settings.KubectlShellImage = *m.flags.KubectlShellImage
|
||||
|
||||
return m.settingsService.UpdateSettings(settings)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
type (
|
||||
// Migrator defines a service to migrate data after a Portainer version update.
|
||||
Migrator struct {
|
||||
flags *portainer.CLIFlags
|
||||
currentDBVersion *models.Version
|
||||
migrations []Migrations
|
||||
|
||||
@@ -62,6 +63,7 @@ type (
|
||||
|
||||
// MigratorParameters represents the required parameters to create a new Migrator instance.
|
||||
MigratorParameters struct {
|
||||
Flags *portainer.CLIFlags
|
||||
CurrentDBVersion *models.Version
|
||||
EndpointGroupService *endpointgroup.Service
|
||||
EndpointService *endpoint.Service
|
||||
@@ -91,6 +93,7 @@ type (
|
||||
// NewMigrator creates a new Migrator.
|
||||
func NewMigrator(parameters *MigratorParameters) *Migrator {
|
||||
migrator := &Migrator{
|
||||
flags: parameters.Flags,
|
||||
currentDBVersion: parameters.CurrentDBVersion,
|
||||
endpointGroupService: parameters.EndpointGroupService,
|
||||
endpointService: parameters.EndpointService,
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"github.com/portainer/portainer/api/pendingactions/actions"
|
||||
"github.com/portainer/portainer/pkg/endpoints"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -49,17 +50,29 @@ func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
|
||||
|
||||
for _, environment := range environments {
|
||||
// edge environments will run after the server starts, in pending actions
|
||||
if endpointutils.IsEdgeEndpoint(&environment) {
|
||||
log.Info().Msgf("Adding pending action 'PostInitMigrateEnvironment' for environment %d", environment.ID)
|
||||
err = postInitMigrator.createPostInitMigrationPendingAction(environment.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error creating pending action for environment %d", environment.ID)
|
||||
if endpoints.IsEdgeEndpoint(&environment) {
|
||||
// Skip edge environments that do not have direct connectivity
|
||||
if !endpoints.HasDirectConnectivity(&environment) {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("adding pending action 'PostInitMigrateEnvironment' for environment")
|
||||
|
||||
if err := postInitMigrator.createPostInitMigrationPendingAction(environment.ID); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error creating pending action for environment")
|
||||
}
|
||||
} else {
|
||||
// non-edge environments will run before the server starts.
|
||||
err = postInitMigrator.MigrateEnvironment(&environment)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error running post-init migrations for non-edge environment %d", environment.ID)
|
||||
// Non-edge environments will run before the server starts.
|
||||
if err := postInitMigrator.MigrateEnvironment(&environment); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error running post-init migrations for non-edge environment")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import (
|
||||
// Store defines the implementation of portainer.DataStore using
|
||||
// BoltDB as the storage system.
|
||||
type Store struct {
|
||||
flags *portainer.CLIFlags
|
||||
connection portainer.Connection
|
||||
|
||||
fileService portainer.FileService
|
||||
@@ -99,7 +100,9 @@ func (store *Store) initServices() error {
|
||||
}
|
||||
store.EndpointRelationService = endpointRelationService
|
||||
|
||||
edgeStackService, err := edgestack.NewService(store.connection, endpointRelationService.InvalidateEdgeCacheForEdgeStack)
|
||||
edgeStackService, err := edgestack.NewService(store.connection, func(tx portainer.Transaction, ID portainer.EdgeStackID) {
|
||||
endpointRelationService.Tx(tx).InvalidateEdgeCacheForEdgeStack(ID)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -610,7 +610,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.24.0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.26.0",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -672,6 +672,7 @@
|
||||
{
|
||||
"Docker": {
|
||||
"ContainerCount": 0,
|
||||
"DiagnosticsData": {},
|
||||
"DockerSnapshotRaw": {
|
||||
"Containers": null,
|
||||
"Images": null,
|
||||
@@ -942,7 +943,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.24.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.26.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -29,6 +29,10 @@ func MustNewTestStore(t testing.TB, init, secure bool) (bool, *Store) {
|
||||
func NewTestStore(t testing.TB, init, secure bool) (bool, *Store, func(), error) {
|
||||
// Creates unique temp directory in a concurrency friendly manner.
|
||||
storePath := t.TempDir()
|
||||
defaultKubectlShellImage := portainer.DefaultKubectlShellImage
|
||||
flags := &portainer.CLIFlags{
|
||||
KubectlShellImage: &defaultKubectlShellImage,
|
||||
}
|
||||
|
||||
fileService, err := filesystem.NewService(storePath, "")
|
||||
if err != nil {
|
||||
@@ -45,7 +49,7 @@ func NewTestStore(t testing.TB, init, secure bool) (bool, *Store, func(), error)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
store := NewStore(storePath, fileService, connection)
|
||||
store := NewStore(flags, fileService, connection)
|
||||
newStore, err := store.Open()
|
||||
if err != nil {
|
||||
return newStore, nil, nil, err
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -25,18 +25,18 @@ func NewPuller(client *client.Client, registryClient *RegistryClient, dataStore
|
||||
}
|
||||
}
|
||||
|
||||
func (puller *Puller) Pull(ctx context.Context, image Image) error {
|
||||
log.Debug().Str("image", image.FullName()).Msg("starting to pull the image")
|
||||
func (puller *Puller) Pull(ctx context.Context, img Image) error {
|
||||
log.Debug().Str("image", img.FullName()).Msg("starting to pull the image")
|
||||
|
||||
registryAuth, err := puller.registryClient.EncodedRegistryAuth(image)
|
||||
registryAuth, err := puller.registryClient.EncodedRegistryAuth(img)
|
||||
if err != nil {
|
||||
log.Debug().
|
||||
Str("image", image.FullName()).
|
||||
Str("image", img.FullName()).
|
||||
Err(err).
|
||||
Msg("failed to get an encoded registry auth via image, try to pull image without registry auth")
|
||||
}
|
||||
|
||||
out, err := puller.client.ImagePull(ctx, image.FullName(), types.ImagePullOptions{
|
||||
out, err := puller.client.ImagePull(ctx, img.FullName(), image.PullOptions{
|
||||
RegistryAuth: registryAuth,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
+2
-255
@@ -1,20 +1,9 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/docker/consts"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
_container "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/portainer/portainer/pkg/snapshot"
|
||||
)
|
||||
|
||||
// Snapshotter represents a service used to create environment(endpoint) snapshots
|
||||
@@ -37,247 +26,5 @@ func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*p
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
return snapshot(cli, endpoint)
|
||||
}
|
||||
|
||||
func snapshot(cli *client.Client, endpoint *portainer.Endpoint) (*portainer.DockerSnapshot, error) {
|
||||
if _, err := cli.Ping(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snapshot := &portainer.DockerSnapshot{
|
||||
StackCount: 0,
|
||||
}
|
||||
|
||||
if err := snapshotInfo(snapshot, cli); err != nil {
|
||||
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot engine information")
|
||||
}
|
||||
|
||||
if snapshot.Swarm {
|
||||
if err := snapshotSwarmServices(snapshot, cli); err != nil {
|
||||
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot Swarm services")
|
||||
}
|
||||
|
||||
if err := snapshotNodes(snapshot, cli); err != nil {
|
||||
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot Swarm nodes")
|
||||
}
|
||||
}
|
||||
|
||||
if err := snapshotContainers(snapshot, cli); err != nil {
|
||||
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot containers")
|
||||
}
|
||||
|
||||
if err := snapshotImages(snapshot, cli); err != nil {
|
||||
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot images")
|
||||
}
|
||||
|
||||
if err := snapshotVolumes(snapshot, cli); err != nil {
|
||||
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot volumes")
|
||||
}
|
||||
|
||||
if err := snapshotNetworks(snapshot, cli); err != nil {
|
||||
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot networks")
|
||||
}
|
||||
|
||||
if err := snapshotVersion(snapshot, cli); err != nil {
|
||||
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot engine version")
|
||||
}
|
||||
|
||||
snapshot.Time = time.Now().Unix()
|
||||
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func snapshotInfo(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
|
||||
info, err := cli.Info(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
snapshot.Swarm = info.Swarm.ControlAvailable
|
||||
snapshot.DockerVersion = info.ServerVersion
|
||||
snapshot.TotalCPU = info.NCPU
|
||||
snapshot.TotalMemory = info.MemTotal
|
||||
snapshot.SnapshotRaw.Info = info
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func snapshotNodes(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
|
||||
nodes, err := cli.NodeList(context.Background(), types.NodeListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var nanoCpus int64
|
||||
var totalMem int64
|
||||
|
||||
for _, node := range nodes {
|
||||
nanoCpus += node.Description.Resources.NanoCPUs
|
||||
totalMem += node.Description.Resources.MemoryBytes
|
||||
}
|
||||
|
||||
snapshot.TotalCPU = int(nanoCpus / 1e9)
|
||||
snapshot.TotalMemory = totalMem
|
||||
snapshot.NodeCount = len(nodes)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func snapshotSwarmServices(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
|
||||
stacks := make(map[string]struct{})
|
||||
|
||||
services, err := cli.ServiceList(context.Background(), types.ServiceListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, service := range services {
|
||||
for k, v := range service.Spec.Labels {
|
||||
if k == "com.docker.stack.namespace" {
|
||||
stacks[v] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshot.ServiceCount = len(services)
|
||||
snapshot.StackCount += len(stacks)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
|
||||
containers, err := cli.ContainerList(context.Background(), container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stacks := make(map[string]struct{})
|
||||
gpuUseSet := make(map[string]struct{})
|
||||
gpuUseAll := false
|
||||
|
||||
for _, container := range containers {
|
||||
if container.State == "running" {
|
||||
// Snapshot GPUs
|
||||
response, err := cli.ContainerInspect(context.Background(), container.ID)
|
||||
if err != nil {
|
||||
// Inspect a container will fail when the container runs on a different
|
||||
// Swarm node, so it is better to log the error instead of return error
|
||||
// when the Swarm mode is enabled
|
||||
if !snapshot.Swarm {
|
||||
return err
|
||||
} else {
|
||||
if !strings.Contains(err.Error(), "No such container") {
|
||||
return err
|
||||
}
|
||||
// It is common to have containers running on different Swarm nodes,
|
||||
// so we just log the error in the debug level
|
||||
log.Debug().Str("container", container.ID).Err(err).Msg("unable to inspect container in other Swarm nodes")
|
||||
}
|
||||
} else {
|
||||
var gpuOptions *_container.DeviceRequest = nil
|
||||
for _, deviceRequest := range response.HostConfig.Resources.DeviceRequests {
|
||||
if deviceRequest.Driver == "nvidia" || deviceRequest.Capabilities[0][0] == "gpu" {
|
||||
gpuOptions = &deviceRequest
|
||||
}
|
||||
}
|
||||
|
||||
if gpuOptions != nil {
|
||||
if gpuOptions.Count == -1 {
|
||||
gpuUseAll = true
|
||||
}
|
||||
|
||||
for _, id := range gpuOptions.DeviceIDs {
|
||||
gpuUseSet[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range container.Labels {
|
||||
if k == consts.ComposeStackNameLabel {
|
||||
stacks[v] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gpuUseList := make([]string, 0, len(gpuUseSet))
|
||||
for gpuUse := range gpuUseSet {
|
||||
gpuUseList = append(gpuUseList, gpuUse)
|
||||
}
|
||||
|
||||
snapshot.GpuUseAll = gpuUseAll
|
||||
snapshot.GpuUseList = gpuUseList
|
||||
|
||||
stats := CalculateContainerStats(containers)
|
||||
|
||||
snapshot.ContainerCount = stats.Total
|
||||
snapshot.RunningContainerCount = stats.Running
|
||||
snapshot.StoppedContainerCount = stats.Stopped
|
||||
snapshot.HealthyContainerCount = stats.Healthy
|
||||
snapshot.UnhealthyContainerCount = stats.Unhealthy
|
||||
snapshot.StackCount += len(stacks)
|
||||
|
||||
for _, container := range containers {
|
||||
snapshot.SnapshotRaw.Containers = append(snapshot.SnapshotRaw.Containers, portainer.DockerContainerSnapshot{Container: container})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func snapshotImages(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
|
||||
images, err := cli.ImageList(context.Background(), types.ImageListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
snapshot.ImageCount = len(images)
|
||||
snapshot.SnapshotRaw.Images = images
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func snapshotVolumes(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
|
||||
volumes, err := cli.VolumeList(context.Background(), volume.ListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
snapshot.VolumeCount = len(volumes.Volumes)
|
||||
snapshot.SnapshotRaw.Volumes = volumes
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func snapshotNetworks(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
|
||||
networks, err := cli.NetworkList(context.Background(), types.NetworkListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
snapshot.SnapshotRaw.Networks = networks
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func snapshotVersion(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
|
||||
version, err := cli.ServerVersion(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
snapshot.SnapshotRaw.Version = version
|
||||
snapshot.IsPodman = isPodman(version)
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPodman checks if the version is for Podman by checking if any of the components contain "podman".
|
||||
// If it's podman, a component name should be "Podman Engine"
|
||||
func isPodman(version types.Version) bool {
|
||||
for _, component := range version.Components {
|
||||
if strings.Contains(strings.ToLower(component.Name), "podman") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return snapshot.CreateDockerSnapshot(cli)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,20 @@ 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 up --remove-orphans` and `docker stack up --prune` options
|
||||
// Used only for EE
|
||||
Prune bool
|
||||
// RemoveVolumes is a flag indicating if the agent must remove the named volumes declared
|
||||
// in the compose file and anonymouse volumes attached to containers
|
||||
// This flag drives `docker compose down --volumes` option
|
||||
// Used only for EE
|
||||
RemoveVolumes bool
|
||||
}
|
||||
|
||||
// RegistryCredentials holds the credentials for a Docker registry.
|
||||
|
||||
@@ -199,7 +199,7 @@ func (manager *SwarmStackManager) updateDockerCLIConfiguration(configPath string
|
||||
|
||||
config, err := manager.retrieveConfigurationFromDisk(configFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
log.Warn().Err(err).Msg("unable to retrieve the Swarm configuration from disk, proceeding without it")
|
||||
}
|
||||
|
||||
signature, err := manager.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
|
||||
@@ -44,13 +44,13 @@ func (service *Service) executeDeviceAction(configuration portainer.OpenAMTConfi
|
||||
}
|
||||
|
||||
func parseAction(actionRaw string) (portainer.PowerState, error) {
|
||||
switch strings.ToLower(actionRaw) {
|
||||
case "power on":
|
||||
if strings.EqualFold(actionRaw, "power on") {
|
||||
return powerOnState, nil
|
||||
case "power off":
|
||||
} else if strings.EqualFold(actionRaw, "power off") {
|
||||
return powerOffState, nil
|
||||
case "restart":
|
||||
} else if strings.EqualFold(actionRaw, "restart") {
|
||||
return restartState, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("unsupported device action %s", actionRaw)
|
||||
}
|
||||
|
||||
+14
-4
@@ -13,6 +13,12 @@ import (
|
||||
"github.com/urfave/negroni"
|
||||
)
|
||||
|
||||
const csrfSkipHeader = "X-CSRF-Token-Skip"
|
||||
|
||||
func SkipCSRFToken(w http.ResponseWriter) {
|
||||
w.Header().Set(csrfSkipHeader, "1")
|
||||
}
|
||||
|
||||
func WithProtect(handler http.Handler) (http.Handler, error) {
|
||||
// IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck)
|
||||
// DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml
|
||||
@@ -42,10 +48,14 @@ func withSendCSRFToken(handler http.Handler) http.Handler {
|
||||
sw := negroni.NewResponseWriter(w)
|
||||
|
||||
sw.Before(func(sw negroni.ResponseWriter) {
|
||||
statusCode := sw.Status()
|
||||
if statusCode >= 200 && statusCode < 300 {
|
||||
csrfToken := gorillacsrf.Token(r)
|
||||
sw.Header().Set("X-CSRF-Token", csrfToken)
|
||||
if len(sw.Header().Get(csrfSkipHeader)) > 0 {
|
||||
sw.Header().Del(csrfSkipHeader)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 {
|
||||
sw.Header().Set("X-CSRF-Token", gorillacsrf.Token(r))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
)
|
||||
|
||||
type ImageResponse struct {
|
||||
@@ -46,7 +46,7 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
|
||||
return httpErr
|
||||
}
|
||||
|
||||
images, err := cli.ImageList(r.Context(), types.ImageListOptions{})
|
||||
images, err := cli.ImageList(r.Context(), image.ListOptions{})
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve Docker images", err)
|
||||
}
|
||||
|
||||
@@ -6,12 +6,18 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/pkg/edge"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type edgeStackFromFileUploadPayload struct {
|
||||
// Name of the stack
|
||||
// Max length: 255
|
||||
// Name must only contains lowercase characters, numbers, hyphens, or underscores
|
||||
// Name must start with a lowercase character or number
|
||||
// Example: stack-name or stack_123 or stackName
|
||||
Name string
|
||||
StackFileContent []byte
|
||||
EdgeGroups []portainer.EdgeGroupID
|
||||
@@ -32,6 +38,10 @@ func (payload *edgeStackFromFileUploadPayload) Validate(r *http.Request) error {
|
||||
}
|
||||
payload.Name = name
|
||||
|
||||
if !edge.IsValidEdgeStackName(payload.Name) {
|
||||
return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number")
|
||||
}
|
||||
|
||||
composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "file")
|
||||
if err != nil {
|
||||
return httperrors.NewInvalidPayloadError("Invalid Compose file. Ensure that the Compose file is uploaded correctly")
|
||||
@@ -75,7 +85,7 @@ func (payload *edgeStackFromFileUploadPayload) Validate(r *http.Request) error {
|
||||
// @security jwt
|
||||
// @accept multipart/form-data
|
||||
// @produce json
|
||||
// @param Name formData string true "Name of the stack"
|
||||
// @param Name formData string true "Name of the stack. it must only consist of lowercase alphanumeric characters, hyphens, or underscores as well as start with a letter or number"
|
||||
// @param file formData file true "Content of the Stack file"
|
||||
// @param EdgeGroups formData string true "JSON stringified array of Edge Groups ids"
|
||||
// @param DeploymentType formData int true "deploy type 0 - 'compose', 1 - 'kubernetes'"
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/pkg/edge"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
@@ -17,7 +18,11 @@ import (
|
||||
|
||||
type edgeStackFromGitRepositoryPayload struct {
|
||||
// Name of the stack
|
||||
Name string `example:"myStack" validate:"required"`
|
||||
// Max length: 255
|
||||
// Name must only contains lowercase characters, numbers, hyphens, or underscores
|
||||
// Name must start with a lowercase character or number
|
||||
// Example: stack-name or stack_123 or stackName
|
||||
Name string `example:"stack-name" validate:"required"`
|
||||
// URL of a Git repository hosting the Stack file
|
||||
RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"`
|
||||
// Reference name of a Git repository hosting the Stack file
|
||||
@@ -50,6 +55,10 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro
|
||||
return httperrors.NewInvalidPayloadError("Invalid stack name")
|
||||
}
|
||||
|
||||
if !edge.IsValidEdgeStackName(payload.Name) {
|
||||
return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number")
|
||||
}
|
||||
|
||||
if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
|
||||
return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/pkg/edge"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -15,7 +16,11 @@ import (
|
||||
|
||||
type edgeStackFromStringPayload struct {
|
||||
// Name of the stack
|
||||
Name string `example:"myStack" validate:"required"`
|
||||
// Max length: 255
|
||||
// Name must only contains lowercase characters, numbers, hyphens, or underscores
|
||||
// Name must start with a lowercase character or number
|
||||
// Example: stack-name or stack_123 or stackName
|
||||
Name string `example:"stack-name" validate:"required"`
|
||||
// Content of the Stack file
|
||||
StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx" validate:"required"`
|
||||
// List of identifiers of EdgeGroups
|
||||
@@ -36,6 +41,10 @@ func (payload *edgeStackFromStringPayload) Validate(r *http.Request) error {
|
||||
return httperrors.NewInvalidPayloadError("Invalid stack name")
|
||||
}
|
||||
|
||||
if !edge.IsValidEdgeStackName(payload.Name) {
|
||||
return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number")
|
||||
}
|
||||
|
||||
if len(payload.StackFileContent) == 0 {
|
||||
return httperrors.NewInvalidPayloadError("Invalid stack file content")
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestCreateAndInspect(t *testing.T) {
|
||||
}
|
||||
|
||||
payload := edgeStackFromStringPayload{
|
||||
Name: "Test Stack",
|
||||
Name: "test-stack",
|
||||
StackFileContent: "stack content",
|
||||
EdgeGroups: []portainer.EdgeGroupID{1},
|
||||
DeploymentType: portainer.EdgeStackDeploymentCompose,
|
||||
@@ -161,7 +161,7 @@ func TestCreateWithInvalidPayload(t *testing.T) {
|
||||
{
|
||||
Name: "EdgeStackDeploymentKubernetes with Docker endpoint",
|
||||
Payload: edgeStackFromStringPayload{
|
||||
Name: "Stack name",
|
||||
Name: "stack-name",
|
||||
StackFileContent: "content",
|
||||
EdgeGroups: []portainer.EdgeGroupID{1},
|
||||
DeploymentType: portainer.EdgeStackDeploymentKubernetes,
|
||||
@@ -172,7 +172,7 @@ func TestCreateWithInvalidPayload(t *testing.T) {
|
||||
{
|
||||
Name: "Empty Stack File Content",
|
||||
Payload: edgeStackFromStringPayload{
|
||||
Name: "Stack name",
|
||||
Name: "stack-name",
|
||||
StackFileContent: "",
|
||||
EdgeGroups: []portainer.EdgeGroupID{1},
|
||||
DeploymentType: portainer.EdgeStackDeploymentCompose,
|
||||
@@ -183,7 +183,7 @@ func TestCreateWithInvalidPayload(t *testing.T) {
|
||||
{
|
||||
Name: "Clone Git repository error",
|
||||
Payload: edgeStackFromGitRepositoryPayload{
|
||||
Name: "Stack name",
|
||||
Name: "stack-name",
|
||||
RepositoryURL: "github.com/portainer/portainer",
|
||||
RepositoryReferenceName: "ref name",
|
||||
RepositoryAuthentication: false,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -20,6 +21,7 @@ type updateStatusPayload struct {
|
||||
Status *portainer.EdgeStackStatusType
|
||||
EndpointID portainer.EndpointID
|
||||
Time int64
|
||||
Version int
|
||||
}
|
||||
|
||||
func (payload *updateStatusPayload) Validate(r *http.Request) error {
|
||||
@@ -69,6 +71,10 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
||||
|
||||
var stack *portainer.EdgeStack
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
if r.Context().Err() != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stack, err = handler.updateEdgeStackStatus(tx, r, portainer.EdgeStackID(stackID), payload)
|
||||
return err
|
||||
}); err != nil {
|
||||
@@ -80,6 +86,10 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
||||
return httperror.InternalServerError("Unexpected error", err)
|
||||
}
|
||||
|
||||
if ok, _ := strconv.ParseBool(r.Header.Get("X-Portainer-No-Body")); ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
@@ -87,18 +97,23 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
|
||||
stack, err := tx.EdgeStack().EdgeStack(stackID)
|
||||
if err != nil {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
// skip error because agent tries to report on deleted stack
|
||||
// Skip error because agent tries to report on deleted stack
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Int("stackID", int(stackID)).
|
||||
Int("status", int(*payload.Status)).
|
||||
Msg("Unable to find a stack inside the database, skipping error")
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unable to retrieve Edge stack from the database: %w. Environment ID: %d", err, payload.EndpointID)
|
||||
}
|
||||
|
||||
if payload.Version > 0 && payload.Version < stack.Version {
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
endpoint, err := tx.Endpoint().Endpoint(payload.EndpointID)
|
||||
if err != nil {
|
||||
return nil, handler.handlerDBErr(fmt.Errorf("unable to find the environment from the database: %w. Environment ID: %d", err, payload.EndpointID), "unable to find the environment")
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package endpointedge
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/edge"
|
||||
@@ -13,8 +15,12 @@ import (
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var edgeStackSingleFlightGroup = singleflight.Group{}
|
||||
|
||||
// @summary Inspect an Edge Stack for an Environment(Endpoint)
|
||||
// @description **Access policy**: public
|
||||
// @tags edge, endpoints, edge_stacks
|
||||
@@ -42,13 +48,26 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
|
||||
return httperror.BadRequest("Invalid edge stack identifier route variable", fmt.Errorf("invalid Edge stack route variable: %w. Environment name: %s", err, endpoint.Name))
|
||||
}
|
||||
|
||||
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
|
||||
s, err, _ := edgeStackSingleFlightGroup.Do(strconv.Itoa(edgeStackID), func() (any, error) {
|
||||
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return nil, httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
|
||||
}
|
||||
|
||||
return edgeStack, err
|
||||
})
|
||||
if err != nil {
|
||||
var httpErr *httperror.HandlerError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find Edge stack from the database: %w. Environment name: %s", err, endpoint.Name))
|
||||
}
|
||||
|
||||
// WARNING: this variable must not be mutated
|
||||
edgeStack := s.(*portainer.EdgeStack)
|
||||
|
||||
fileName := edgeStack.EntryPoint
|
||||
if endpointutils.IsDockerEndpoint(endpoint) {
|
||||
if fileName == "" {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/pkg/featureflags"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/klauspost/compress/gzhttp"
|
||||
)
|
||||
|
||||
// Handler represents an HTTP API handler for managing static files.
|
||||
@@ -20,7 +20,7 @@ type Handler struct {
|
||||
func NewHandler(assetPublicPath string, wasInstanceDisabled func() bool) *Handler {
|
||||
h := &Handler{
|
||||
Handler: security.MWSecureHeaders(
|
||||
handlers.CompressHandler(http.FileServer(http.Dir(assetPublicPath))),
|
||||
gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))),
|
||||
featureflags.IsEnabled("hsts"),
|
||||
featureflags.IsEnabled("csp"),
|
||||
),
|
||||
|
||||
@@ -83,7 +83,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.24.0
|
||||
// @version 2.26.0
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -131,7 +131,7 @@ func (handler *Handler) PullAndRunContainer(ctx context.Context, endpoint *porta
|
||||
// TODO: add k8s implementation
|
||||
// TODO: work out registry auth
|
||||
func pullImage(ctx context.Context, docker *client.Client, imageName string) error {
|
||||
out, err := docker.ImagePull(ctx, imageName, types.ImagePullOptions{})
|
||||
out, err := docker.ImagePull(ctx, imageName, image.PullOptions{})
|
||||
if err != nil {
|
||||
log.Error().Str("image_name", imageName).Err(err).Msg("could not pull image from registry")
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"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"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// @id GetKubernetesCronJobs
|
||||
// @summary Get a list of kubernetes Cron Jobs
|
||||
// @description Get a list of kubernetes Cron Jobs that the user has access to.
|
||||
// @description **Access policy**: Authenticated user.
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @success 200 {array} models.K8sCronJob "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."
|
||||
// @failure 500 "Server error occurred while attempting to retrieve the list of Cron Jobs."
|
||||
// @router /kubernetes/{id}/cron_jobs [get]
|
||||
func (handler *Handler) getAllKubernetesCronJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
cli, httpErr := handler.prepareKubeClient(r)
|
||||
if httpErr != nil {
|
||||
log.Error().Err(httpErr).Str("context", "GetAllKubernetesCronJobs").Msg("Unable to prepare kube client")
|
||||
return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr)
|
||||
}
|
||||
|
||||
cronJobs, err := cli.GetCronJobs("")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "GetAllKubernetesCronJobs").Msg("Unable to fetch Cron Jobs across all namespaces")
|
||||
return httperror.InternalServerError("unable to fetch Cron Jobs. Error: ", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, cronJobs)
|
||||
}
|
||||
|
||||
// @id DeleteCronJobs
|
||||
// @summary Delete Cron Jobs
|
||||
// @description Delete the provided list of Cron Jobs.
|
||||
// @description **Access policy**: Authenticated user.
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @accept json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @param payload body models.K8sCronJobDeleteRequests true "A map where the key is the namespace and the value is an array of Cron Jobs to delete"
|
||||
// @success 204 "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 service account."
|
||||
// @failure 500 "Server error occurred while attempting to delete Cron Jobs."
|
||||
// @router /kubernetes/{id}/cron_jobs/delete [POST]
|
||||
func (handler *Handler) deleteKubernetesCronJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload models.K8sCronJobDeleteRequests
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
cli, handlerErr := handler.getProxyKubeClient(r)
|
||||
if handlerErr != nil {
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
err = cli.DeleteCronJobs(payload)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to delete Cron Jobs", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
)
|
||||
|
||||
// @id UpdateKubernetesNamespaceDeprecated
|
||||
// @summary Update a namespace
|
||||
// @description Update a namespace within the given environment.
|
||||
// @description **Access policy**: Authenticated user.
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @param namespace path string true "Namespace"
|
||||
// @param body body models.K8sNamespaceDetails true "Namespace details"
|
||||
// @success 200 {object} portainer.K8sNamespaceInfo "Success"
|
||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
||||
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
||||
// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific namespace."
|
||||
// @failure 500 "Server error occurred while attempting to update the namespace."
|
||||
// @router /kubernetes/{id}/namespaces [put]
|
||||
func deprecatedNamespaceParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
environmentId, err := request.RetrieveRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: id", err)
|
||||
}
|
||||
|
||||
// Restore the original body for further use
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
payload := models.K8sNamespaceDetails{}
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid request. Unable to parse namespace payload", err)
|
||||
}
|
||||
namespaceName := payload.Name
|
||||
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
return "/kubernetes/" + environmentId + "/namespaces/" + namespaceName, nil
|
||||
}
|
||||
@@ -55,6 +55,10 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
||||
endpointRouter.Handle("/applications/count", httperror.LoggerHandler(h.getAllKubernetesApplicationsCount)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/configmaps", httperror.LoggerHandler(h.GetAllKubernetesConfigMaps)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/cron_jobs", httperror.LoggerHandler(h.getAllKubernetesCronJobs)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/cron_jobs/delete", httperror.LoggerHandler(h.deleteKubernetesCronJobs)).Methods(http.MethodPost)
|
||||
endpointRouter.Handle("/jobs", httperror.LoggerHandler(h.getAllKubernetesJobs)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/jobs/delete", httperror.LoggerHandler(h.deleteKubernetesJobs)).Methods(http.MethodPost)
|
||||
endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost)
|
||||
endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet)
|
||||
@@ -81,11 +85,11 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
||||
endpointRouter.Handle("/services/delete", httperror.LoggerHandler(h.deleteKubernetesServices)).Methods(http.MethodPost)
|
||||
endpointRouter.Handle("/rbac_enabled", httperror.LoggerHandler(h.getKubernetesRBACStatus)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.createKubernetesNamespace)).Methods(http.MethodPost)
|
||||
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut)
|
||||
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.deleteKubernetesNamespace)).Methods(http.MethodDelete)
|
||||
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.getKubernetesNamespaces)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/namespaces/count", httperror.LoggerHandler(h.getKubernetesNamespacesCount)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/namespaces/{namespace}", httperror.LoggerHandler(h.getKubernetesNamespace)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/namespaces/{namespace}", httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut)
|
||||
endpointRouter.Handle("/volumes", httperror.LoggerHandler(h.GetAllKubernetesVolumes)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/volumes/count", httperror.LoggerHandler(h.getAllKubernetesVolumesCount)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/service_accounts", httperror.LoggerHandler(h.getAllKubernetesServiceAccounts)).Methods(http.MethodGet)
|
||||
@@ -115,8 +119,12 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
||||
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.createKubernetesService)).Methods(http.MethodPost)
|
||||
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.updateKubernetesService)).Methods(http.MethodPut)
|
||||
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.getKubernetesServicesByNamespace)).Methods(http.MethodGet)
|
||||
namespaceRouter.Handle("/volumes", httperror.LoggerHandler(h.GetKubernetesVolumesInNamespace)).Methods(http.MethodGet)
|
||||
namespaceRouter.Handle("/volumes/{volume}", httperror.LoggerHandler(h.getKubernetesVolume)).Methods(http.MethodGet)
|
||||
|
||||
// Deprecated
|
||||
endpointRouter.Handle("/namespaces", middlewares.Deprecated(endpointRouter, deprecatedNamespaceParser)).Methods(http.MethodPut)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -206,7 +214,17 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
nonAdminNamespaces, err = pcli.GetNonAdminNamespaces(int(user.ID), endpoint.Kubernetes.Configuration.RestrictDefaultNamespace)
|
||||
teamMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to get team memberships for user: ", err)
|
||||
return
|
||||
}
|
||||
teamIDs := []int{}
|
||||
for _, membership := range teamMemberships {
|
||||
teamIDs = append(teamIDs, int(membership.TeamID))
|
||||
}
|
||||
|
||||
nonAdminNamespaces, err = pcli.GetNonAdminNamespaces(int(user.ID), teamIDs, endpoint.Kubernetes.Configuration.RestrictDefaultNamespace)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to retrieve non-admin namespaces. Error: ", err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"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"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// @id GetKubernetesJobs
|
||||
// @summary Get a list of kubernetes Jobs
|
||||
// @description Get a list of kubernetes Jobs that the user has access to.
|
||||
// @description **Access policy**: Authenticated user.
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @param includeCronJobChildren query bool false "Whether to include Jobs that have a cronjob owner"
|
||||
// @success 200 {array} models.K8sJob "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."
|
||||
// @failure 500 "Server error occurred while attempting to retrieve the list of Jobs."
|
||||
// @router /kubernetes/{id}/jobs [get]
|
||||
func (handler *Handler) getAllKubernetesJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
includeCronJobChildren, err := request.RetrieveBooleanQueryParameter(r, "includeCronJobChildren", true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "GetAllKubernetesJobs").Msg("Invalid query parameter includeCronJobChildren")
|
||||
return httperror.BadRequest("an error occurred during the GetAllKubernetesJobs operation, invalid query parameter includeCronJobChildren. Error: ", err)
|
||||
}
|
||||
|
||||
cli, httpErr := handler.prepareKubeClient(r)
|
||||
if httpErr != nil {
|
||||
log.Error().Err(httpErr).Str("context", "GetAllKubernetesJobs").Msg("Unable to prepare kube client")
|
||||
return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr)
|
||||
}
|
||||
|
||||
jobs, err := cli.GetJobs("", includeCronJobChildren)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "GetAllKubernetesJobs").Msg("Unable to fetch Jobs across all namespaces")
|
||||
return httperror.InternalServerError("unable to fetch Jobs. Error: ", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, jobs)
|
||||
}
|
||||
|
||||
// @id DeleteJobs
|
||||
// @summary Delete Jobs
|
||||
// @description Delete the provided list of Jobs.
|
||||
// @description **Access policy**: Authenticated user.
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @accept json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @param payload body models.K8sJobDeleteRequests true "A map where the key is the namespace and the value is an array of Jobs to delete"
|
||||
// @success 204 "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 service account."
|
||||
// @failure 500 "Server error occurred while attempting to delete Jobs."
|
||||
// @router /kubernetes/{id}/jobs/delete [POST]
|
||||
func (handler *Handler) deleteKubernetesJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload models.K8sJobDeleteRequests
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
cli, handlerErr := handler.getProxyKubeClient(r)
|
||||
if handlerErr != nil {
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
err = cli.DeleteJobs(payload)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to delete Jobs", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes."
|
||||
// @router /kubernetes/{id}/volumes [get]
|
||||
func (handler *Handler) GetAllKubernetesVolumes(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
volumes, err := handler.getKubernetesVolumes(r)
|
||||
volumes, err := handler.getKubernetesVolumes(r, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -49,7 +49,7 @@ func (handler *Handler) GetAllKubernetesVolumes(w http.ResponseWriter, r *http.R
|
||||
// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes count."
|
||||
// @router /kubernetes/{id}/volumes/count [get]
|
||||
func (handler *Handler) getAllKubernetesVolumesCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
volumes, err := handler.getKubernetesVolumes(r)
|
||||
volumes, err := handler.getKubernetesVolumes(r, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -57,6 +57,36 @@ func (handler *Handler) getAllKubernetesVolumesCount(w http.ResponseWriter, r *h
|
||||
return response.JSON(w, len(volumes))
|
||||
}
|
||||
|
||||
// @id GetKubernetesVolumesInNamespace
|
||||
// @summary Get Kubernetes volumes within a namespace in the given Portainer environment
|
||||
// @description Get a list of kubernetes volumes within the specified namespace in the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier.
|
||||
// @description **Access policy**: Authenticated user.
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @param namespace path string true "Namespace identifier"
|
||||
// @param withApplications query boolean false "When set to True, include the applications that are using the volumes. It is set to false by default"
|
||||
// @success 200 {object} map[string]kubernetes.K8sVolumeInfo "Success"
|
||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||
// @failure 403 "Unauthorized access or operation not allowed."
|
||||
// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes in the namespace."
|
||||
// @router /kubernetes/{id}/namespaces/{namespace}/volumes [get]
|
||||
func (handler *Handler) GetKubernetesVolumesInNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "GetKubernetesVolumesInNamespace").Msg("Unable to retrieve namespace identifier")
|
||||
return httperror.BadRequest("Invalid namespace identifier", err)
|
||||
}
|
||||
|
||||
volumes, httpErr := handler.getKubernetesVolumes(r, namespace)
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
return response.JSON(w, volumes)
|
||||
}
|
||||
|
||||
// @id GetKubernetesVolume
|
||||
// @summary Get a Kubernetes volume within the given Portainer environment
|
||||
// @description Get a Kubernetes volume within the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier.
|
||||
@@ -109,7 +139,7 @@ func (handler *Handler) getKubernetesVolume(w http.ResponseWriter, r *http.Reque
|
||||
return response.JSON(w, volume)
|
||||
}
|
||||
|
||||
func (handler *Handler) getKubernetesVolumes(r *http.Request) ([]models.K8sVolumeInfo, *httperror.HandlerError) {
|
||||
func (handler *Handler) getKubernetesVolumes(r *http.Request, namespace string) ([]models.K8sVolumeInfo, *httperror.HandlerError) {
|
||||
withApplications, err := request.RetrieveBooleanQueryParameter(r, "withApplications", true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "GetKubernetesVolumes").Bool("withApplications", withApplications).Msg("Unable to parse query parameter")
|
||||
@@ -122,7 +152,7 @@ func (handler *Handler) getKubernetesVolumes(r *http.Request) ([]models.K8sVolum
|
||||
return nil, httperror.InternalServerError("Failed to prepare Kubernetes client", httpErr)
|
||||
}
|
||||
|
||||
volumes, err := cli.GetVolumes("")
|
||||
volumes, err := cli.GetVolumes(namespace)
|
||||
if err != nil {
|
||||
if k8serrors.IsUnauthorized(err) {
|
||||
log.Error().Err(err).Str("context", "GetKubernetesVolumes").Msg("Unauthorized access")
|
||||
|
||||
@@ -2,12 +2,11 @@ package system
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/build"
|
||||
"github.com/portainer/portainer/api/http/client"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/pkg/build"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
@@ -23,20 +22,12 @@ type versionResponse struct {
|
||||
LatestVersion string `json:"LatestVersion" example:"2.0.0"`
|
||||
|
||||
ServerVersion string
|
||||
VersionSupport string `json:"VersionSupport" example:"STS/LTS"`
|
||||
ServerEdition string `json:"ServerEdition" example:"CE/EE"`
|
||||
DatabaseVersion string
|
||||
Build BuildInfo
|
||||
}
|
||||
|
||||
type BuildInfo struct {
|
||||
BuildNumber string
|
||||
ImageTag string
|
||||
NodejsVersion string
|
||||
YarnVersion string
|
||||
WebpackVersion string
|
||||
GoVersion string
|
||||
GitCommit string
|
||||
Env []string `json:",omitempty"`
|
||||
Build build.BuildInfo
|
||||
Dependencies build.DependenciesInfo
|
||||
Runtime build.RuntimeInfo
|
||||
}
|
||||
|
||||
// @id systemVersion
|
||||
@@ -57,21 +48,15 @@ func (handler *Handler) version(w http.ResponseWriter, r *http.Request) *httperr
|
||||
|
||||
result := &versionResponse{
|
||||
ServerVersion: portainer.APIVersion,
|
||||
VersionSupport: portainer.APIVersionSupport,
|
||||
DatabaseVersion: portainer.APIVersion,
|
||||
ServerEdition: portainer.Edition.GetEditionLabel(),
|
||||
Build: BuildInfo{
|
||||
BuildNumber: build.BuildNumber,
|
||||
ImageTag: build.ImageTag,
|
||||
NodejsVersion: build.NodejsVersion,
|
||||
YarnVersion: build.YarnVersion,
|
||||
WebpackVersion: build.WebpackVersion,
|
||||
GoVersion: build.GoVersion,
|
||||
GitCommit: build.GitCommit,
|
||||
},
|
||||
Build: build.GetBuildInfo(),
|
||||
Dependencies: build.GetDependenciesInfo(),
|
||||
}
|
||||
|
||||
if isAdmin {
|
||||
result.Build.Env = os.Environ()
|
||||
result.Runtime = build.GetRuntimeInfo()
|
||||
}
|
||||
|
||||
latestVersion := GetLatestVersion()
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
dockertypes "github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
)
|
||||
|
||||
// @summary Execute a webhook
|
||||
@@ -79,16 +80,16 @@ func (handler *Handler) executeServiceWebhook(
|
||||
|
||||
service.Spec.TaskTemplate.ForceUpdate++
|
||||
|
||||
var imageName = strings.Split(service.Spec.TaskTemplate.ContainerSpec.Image, "@sha")[0]
|
||||
imageName := strings.Split(service.Spec.TaskTemplate.ContainerSpec.Image, "@sha")[0]
|
||||
service.Spec.TaskTemplate.ContainerSpec.Image = imageName
|
||||
|
||||
if imageTag != "" {
|
||||
var tagIndex = strings.LastIndex(imageName, ":")
|
||||
tagIndex := strings.LastIndex(imageName, ":")
|
||||
if tagIndex == -1 {
|
||||
tagIndex = len(imageName)
|
||||
}
|
||||
|
||||
service.Spec.TaskTemplate.ContainerSpec.Image = imageName[:tagIndex] + ":" + imageTag
|
||||
} else {
|
||||
service.Spec.TaskTemplate.ContainerSpec.Image = imageName
|
||||
}
|
||||
|
||||
serviceUpdateOptions := dockertypes.ServiceUpdateOptions{
|
||||
@@ -109,8 +110,9 @@ func (handler *Handler) executeServiceWebhook(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if imageTag != "" {
|
||||
rc, err := dockerClient.ImagePull(context.Background(), service.Spec.TaskTemplate.ContainerSpec.Image, dockertypes.ImagePullOptions{RegistryAuth: serviceUpdateOptions.EncodedRegistryAuth})
|
||||
rc, err := dockerClient.ImagePull(context.Background(), service.Spec.TaskTemplate.ContainerSpec.Image, image.PullOptions{RegistryAuth: serviceUpdateOptions.EncodedRegistryAuth})
|
||||
if err != nil {
|
||||
return httperror.NotFound("Error pulling image with the specified tag", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type K8sCronJob struct {
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Namespace string `json:"Namespace"`
|
||||
Command string `json:"Command"`
|
||||
Schedule string `json:"Schedule"`
|
||||
Timezone string `json:"Timezone"`
|
||||
Suspend bool `json:"Suspend"`
|
||||
Jobs []K8sJob `json:"Jobs"`
|
||||
IsSystem bool `json:"IsSystem"`
|
||||
}
|
||||
|
||||
type (
|
||||
K8sCronJobDeleteRequests map[string][]string
|
||||
)
|
||||
|
||||
func (r K8sCronJobDeleteRequests) Validate(request *http.Request) error {
|
||||
if len(r) == 0 {
|
||||
return errors.New("missing deletion request list in payload")
|
||||
}
|
||||
|
||||
for ns := range r {
|
||||
if len(ns) == 0 {
|
||||
return errors.New("deletion given with empty namespace")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
// K8sJob struct
|
||||
type K8sJob struct {
|
||||
ID string `json:"Id"`
|
||||
Namespace string `json:"Namespace"`
|
||||
Name string `json:"Name"`
|
||||
PodName string `json:"PodName"`
|
||||
Container corev1.Container `json:"Container,omitempty"`
|
||||
Command string `json:"Command,omitempty"`
|
||||
BackoffLimit int32 `json:"BackoffLimit,omitempty"`
|
||||
Completions int32 `json:"Completions,omitempty"`
|
||||
StartTime string `json:"StartTime"`
|
||||
FinishTime string `json:"FinishTime"`
|
||||
Duration string `json:"Duration"`
|
||||
Status string `json:"Status"`
|
||||
FailedReason string `json:"FailedReason"`
|
||||
IsSystem bool `json:"IsSystem"`
|
||||
}
|
||||
|
||||
type (
|
||||
K8sJobDeleteRequests map[string][]string
|
||||
)
|
||||
|
||||
func (r K8sJobDeleteRequests) Validate(request *http.Request) error {
|
||||
if len(r) == 0 {
|
||||
return errors.New("missing deletion request list in payload")
|
||||
}
|
||||
|
||||
for ns := range r {
|
||||
if len(ns) == 0 {
|
||||
return errors.New("deletion given with empty namespace")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -41,11 +41,13 @@ func (o *OfflineGate) WaitingMiddleware(timeout time.Duration, next http.Handler
|
||||
}
|
||||
|
||||
if !o.lock.RTryLockWithTimeout(timeout) {
|
||||
log.Error().Msg("timeout waiting for the offline gate to signal")
|
||||
httperror.WriteError(w, http.StatusRequestTimeout, "Timeout waiting for the offline gate to signal", http.ErrHandlerTimeout)
|
||||
log.Error().Str("url", r.URL.Path).Msg("request timed out while waiting for the backup process to finish")
|
||||
httperror.WriteError(w, http.StatusRequestTimeout, "Request timed out while waiting for the backup process to finish", http.ErrHandlerTimeout)
|
||||
return
|
||||
}
|
||||
|
||||
defer o.lock.RUnlock()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
o.lock.RUnlock()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_canLockAndUnlock(t *testing.T) {
|
||||
@@ -146,3 +147,30 @@ func Test_waitingMiddleware_mayTimeout_whenLockedForTooLong(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusRequestTimeout, response.Result().StatusCode, "Request support to timeout waiting for the gate")
|
||||
}
|
||||
|
||||
func Test_waitingMiddleware_handlerPanics(t *testing.T) {
|
||||
o := NewOfflineGate()
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
recover()
|
||||
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
o.WaitingMiddleware(time.Second, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
panic("panic")
|
||||
})).ServeHTTP(response, request)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
require.True(t, o.lock.TryLock())
|
||||
o.lock.Unlock()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
@@ -37,6 +38,8 @@ type (
|
||||
dockerClientFactory *dockerclient.ClientFactory
|
||||
gitService portainer.GitService
|
||||
snapshotService portainer.SnapshotService
|
||||
dockerID string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// TransportParameters is used to create a new Transport
|
||||
@@ -679,9 +682,7 @@ func (transport *Transport) executeGenericResourceDeletionOperation(request *htt
|
||||
}
|
||||
|
||||
if resourceControl != nil {
|
||||
if err := transport.dataStore.ResourceControl().Delete(resourceControl.ID); err != nil {
|
||||
return response, err
|
||||
}
|
||||
err = transport.dataStore.ResourceControl().Delete(resourceControl.ID)
|
||||
}
|
||||
|
||||
return response, err
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/portainer/portainer/api/internal/snapshot"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const volumeObjectIdentifier = "ResourceID"
|
||||
@@ -50,15 +49,6 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo
|
||||
|
||||
volumeData := responseObject["Volumes"].([]any)
|
||||
|
||||
if transport.snapshotService != nil {
|
||||
// Filling snapshot data can improve the performance of getVolumeResourceID
|
||||
if err = transport.snapshotService.FillSnapshotData(transport.endpoint); err != nil {
|
||||
log.Info().Err(err).
|
||||
Int("endpoint id", int(transport.endpoint.ID)).
|
||||
Msg("snapshot is not filled into the endpoint.")
|
||||
}
|
||||
}
|
||||
|
||||
for _, volumeObject := range volumeData {
|
||||
volume := volumeObject.(map[string]any)
|
||||
|
||||
@@ -147,7 +137,7 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
if _, err = cli.VolumeInspect(context.Background(), volumeID); err == nil {
|
||||
if _, err := cli.VolumeInspect(context.Background(), volumeID); err == nil {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusConflict,
|
||||
}, errors.New("a volume with the same name already exists")
|
||||
@@ -222,14 +212,27 @@ func (transport *Transport) getVolumeResourceID(volumeName string) (string, erro
|
||||
}
|
||||
|
||||
func (transport *Transport) getDockerID() (string, error) {
|
||||
if len(transport.endpoint.Snapshots) > 0 {
|
||||
dockerID, err := snapshot.FetchDockerID(transport.endpoint.Snapshots[0])
|
||||
// ignore err - in case of error, just generate not from snapshot
|
||||
if err == nil {
|
||||
return dockerID, nil
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
|
||||
// Local cache
|
||||
if transport.dockerID != "" {
|
||||
return transport.dockerID, nil
|
||||
}
|
||||
|
||||
// Snapshot cache
|
||||
if transport.snapshotService != nil {
|
||||
endpoint := portainer.Endpoint{ID: transport.endpoint.ID}
|
||||
|
||||
if err := transport.snapshotService.FillSnapshotData(&endpoint); err == nil && len(endpoint.Snapshots) > 0 {
|
||||
if dockerID, err := snapshot.FetchDockerID(endpoint.Snapshots[0]); err == nil {
|
||||
transport.dockerID = dockerID
|
||||
return dockerID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remote value
|
||||
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -242,8 +245,11 @@ func (transport *Transport) getDockerID() (string, error) {
|
||||
}
|
||||
|
||||
if info.Swarm.Cluster != nil {
|
||||
return info.Swarm.Cluster.ID, nil
|
||||
transport.dockerID = info.Swarm.Cluster.ID
|
||||
return transport.dockerID, nil
|
||||
}
|
||||
|
||||
return info.ID, nil
|
||||
transport.dockerID = info.ID
|
||||
|
||||
return transport.dockerID, nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ func NewStatus(oldStatus map[portainer.EndpointID]portainer.EdgeStackStatus, rel
|
||||
status := map[portainer.EndpointID]portainer.EdgeStackStatus{}
|
||||
|
||||
for _, environmentID := range relatedEnvironmentIDs {
|
||||
|
||||
newEnvStatus := portainer.EdgeStackStatus{
|
||||
Status: []portainer.EdgeStackDeploymentStatus{},
|
||||
EndpointID: environmentID,
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
log "github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// TODO: this file should be migrated to package/server-ce/pkg/endpoints
|
||||
|
||||
// IsLocalEndpoint returns true if this is a local environment(endpoint)
|
||||
func IsLocalEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return strings.HasPrefix(endpoint.URL, "unix://") ||
|
||||
|
||||
@@ -10,13 +10,13 @@ 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"
|
||||
)
|
||||
|
||||
// Service repesents a service to manage environment(endpoint) snapshots.
|
||||
// Service represents a service to manage environment(endpoint) snapshots.
|
||||
// It provides an interface to start background snapshots as well as
|
||||
// specific Docker/Kubernetes environment(endpoint) snapshot methods.
|
||||
type Service struct {
|
||||
@@ -64,7 +64,7 @@ func NewBackgroundSnapshotter(dataStore dataservices.DataStore, tunnelService po
|
||||
}
|
||||
|
||||
for _, e := range endpoints {
|
||||
if !endpointutils.IsEdgeEndpoint(&e) || e.Edge.AsyncMode || !e.UserTrusted {
|
||||
if !endpointsutils.HasDirectConnectivity(&e) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -174,30 +174,6 @@ func (service *Service) FillSnapshotData(endpoint *portainer.Endpoint) error {
|
||||
return FillSnapshotData(service.dataStore, endpoint)
|
||||
}
|
||||
|
||||
func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error {
|
||||
snapshot, err := tx.Snapshot().Read(endpoint.ID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{}
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if snapshot.Docker != nil {
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{*snapshot.Docker}
|
||||
}
|
||||
|
||||
if snapshot.Kubernetes != nil {
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{*snapshot.Kubernetes}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) snapshotKubernetesEndpoint(endpoint *portainer.Endpoint) error {
|
||||
kubernetesSnapshot, err := service.kubernetesSnapshotter.CreateSnapshot(endpoint)
|
||||
if err != nil {
|
||||
@@ -285,11 +261,16 @@ func (service *Service) snapshotEndpoints() error {
|
||||
|
||||
snapshotError := service.SnapshotEndpoint(&endpoint)
|
||||
|
||||
service.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
if err := service.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
updateEndpointStatus(tx, &endpoint, snapshotError, service.pendingActionsService)
|
||||
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("unable to update environment status")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -340,12 +321,31 @@ func FetchDockerID(snapshot portainer.DockerSnapshot) (string, error) {
|
||||
return info.ID, nil
|
||||
}
|
||||
|
||||
swarmInfo := info.Swarm
|
||||
if swarmInfo.Cluster == nil {
|
||||
if info.Swarm.Cluster == nil {
|
||||
return "", errors.New("swarm environment is missing cluster info snapshot")
|
||||
}
|
||||
|
||||
clusterInfo := swarmInfo.Cluster
|
||||
|
||||
return clusterInfo.ID, nil
|
||||
return info.Swarm.Cluster.ID, nil
|
||||
}
|
||||
|
||||
func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error {
|
||||
snapshot, err := tx.Snapshot().Read(endpoint.ID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{}
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
|
||||
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if snapshot.Docker != nil {
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{*snapshot.Docker}
|
||||
}
|
||||
|
||||
if snapshot.Kubernetes != nil {
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{*snapshot.Kubernetes}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+5
-10
@@ -64,8 +64,7 @@ func (service *Service) Init(host, certPath, keyPath string) error {
|
||||
// path not supplied and certificates doesn't exist - generate self-signed
|
||||
certPath, keyPath = service.fileService.GetDefaultSSLCertsPath()
|
||||
|
||||
err = generateSelfSignedCertificates(host, certPath, keyPath)
|
||||
if err != nil {
|
||||
if err := generateSelfSignedCertificates(host, certPath, keyPath); err != nil {
|
||||
return errors.Wrap(err, "failed generating self signed certs")
|
||||
}
|
||||
|
||||
@@ -98,8 +97,7 @@ func (service *Service) SetCertificates(certData, keyData []byte) error {
|
||||
return errors.New("missing certificate files")
|
||||
}
|
||||
|
||||
_, err := tls.X509KeyPair(certData, keyData)
|
||||
if err != nil {
|
||||
if _, err := tls.X509KeyPair(certData, keyData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -108,8 +106,7 @@ func (service *Service) SetCertificates(certData, keyData []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = service.cacheInfo(certPath, keyPath, false)
|
||||
if err != nil {
|
||||
if err := service.cacheInfo(certPath, keyPath, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -130,8 +127,7 @@ func (service *Service) SetHTTPEnabled(httpEnabled bool) error {
|
||||
|
||||
settings.HTTPEnabled = httpEnabled
|
||||
|
||||
err = service.dataStore.SSLSettings().UpdateSettings(settings)
|
||||
if err != nil {
|
||||
if err := service.dataStore.SSLSettings().UpdateSettings(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -152,8 +148,7 @@ func (service *Service) cacheCertificate(certPath, keyPath string) error {
|
||||
}
|
||||
|
||||
func (service *Service) cacheInfo(certPath string, keyPath string, selfSigned bool) error {
|
||||
err := service.cacheCertificate(certPath, keyPath)
|
||||
if err != nil {
|
||||
if err := service.cacheCertificate(certPath, keyPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ func (kcl *KubeClient) UpdateNamespaceAccessPolicies(accessPolicies map[string]p
|
||||
}
|
||||
|
||||
// GetNonAdminNamespaces retrieves namespaces for a non-admin user, excluding the default namespace if restricted.
|
||||
func (kcl *KubeClient) GetNonAdminNamespaces(userID int, isRestrictDefaultNamespace bool) ([]string, error) {
|
||||
func (kcl *KubeClient) GetNonAdminNamespaces(userID int, teamIDs []int, isRestrictDefaultNamespace bool) ([]string, error) {
|
||||
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the getNonAdminNamespaces operation, unable to get namespace access policies via portainer-config. check if portainer-config configMap exists in the Kubernetes cluster: %w", err)
|
||||
@@ -136,7 +136,7 @@ func (kcl *KubeClient) GetNonAdminNamespaces(userID int, isRestrictDefaultNamesp
|
||||
}
|
||||
|
||||
for namespace, accessPolicy := range accessPolicies {
|
||||
if hasUserAccessToNamespace(userID, nil, accessPolicy) {
|
||||
if hasUserAccessToNamespace(userID, teamIDs, accessPolicy) {
|
||||
nonAdminNamespaces = append(nonAdminNamespaces, namespace)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// GetCronJobs returns all cronjobs in the given namespace
|
||||
// If the user is a kube admin, it returns all cronjobs in the namespace
|
||||
// Otherwise, it returns only the cronjobs in the non-admin namespaces
|
||||
func (kcl *KubeClient) GetCronJobs(namespace string) ([]models.K8sCronJob, error) {
|
||||
if kcl.IsKubeAdmin {
|
||||
return kcl.fetchCronJobs(namespace)
|
||||
}
|
||||
|
||||
return kcl.fetchCronJobsForNonAdmin(namespace)
|
||||
}
|
||||
|
||||
// fetchCronJobsForNonAdmin returns all cronjobs in the given namespace
|
||||
// It returns only the cronjobs in the non-admin namespaces
|
||||
func (kcl *KubeClient) fetchCronJobsForNonAdmin(namespace string) ([]models.K8sCronJob, error) {
|
||||
cronJobs, err := kcl.fetchCronJobs(namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
|
||||
results := make([]models.K8sCronJob, 0)
|
||||
for _, cronJob := range cronJobs {
|
||||
if _, ok := nonAdminNamespaceSet[cronJob.Namespace]; ok {
|
||||
results = append(results, cronJob)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// fetchCronJobs returns all cronjobs in the given namespace
|
||||
// It returns all cronjobs in the namespace
|
||||
func (kcl *KubeClient) fetchCronJobs(namespace string) ([]models.K8sCronJob, error) {
|
||||
cronJobs, err := kcl.cli.BatchV1().CronJobs(namespace).List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jobs, err := kcl.cli.BatchV1().Jobs(namespace).List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]models.K8sCronJob, 0)
|
||||
for _, cronJob := range cronJobs.Items {
|
||||
results = append(results, kcl.parseCronJob(cronJob, jobs))
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// parseCronJob converts a batchv1.CronJob object to a models.K8sCronJob object.
|
||||
func (kcl *KubeClient) parseCronJob(cronJob batchv1.CronJob, jobsList *batchv1.JobList) models.K8sCronJob {
|
||||
jobs, err := kcl.getCronJobExecutions(cronJob.Name, jobsList)
|
||||
if err != nil {
|
||||
return models.K8sCronJob{}
|
||||
}
|
||||
|
||||
timezone := "<none>"
|
||||
if cronJob.Spec.TimeZone != nil {
|
||||
timezone = *cronJob.Spec.TimeZone
|
||||
}
|
||||
|
||||
suspend := false
|
||||
if cronJob.Spec.Suspend != nil {
|
||||
suspend = *cronJob.Spec.Suspend
|
||||
}
|
||||
|
||||
return models.K8sCronJob{
|
||||
Id: string(cronJob.UID),
|
||||
Name: cronJob.Name,
|
||||
Namespace: cronJob.Namespace,
|
||||
Command: strings.Join(cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Command, " "),
|
||||
Schedule: cronJob.Spec.Schedule,
|
||||
Timezone: timezone,
|
||||
Suspend: suspend,
|
||||
Jobs: jobs,
|
||||
IsSystem: kcl.isSystemCronJob(cronJob.Namespace),
|
||||
}
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) isSystemCronJob(namespace string) bool {
|
||||
return kcl.isSystemNamespace(namespace)
|
||||
}
|
||||
|
||||
// DeleteCronJobs deletes the provided list of cronjobs in its namespace
|
||||
// it returns an error if any of the cronjobs are not found or if there is an error deleting the cronjobs
|
||||
func (kcl *KubeClient) DeleteCronJobs(payload models.K8sCronJobDeleteRequests) error {
|
||||
var errors []error
|
||||
for namespace := range payload {
|
||||
for _, cronJobName := range payload[namespace] {
|
||||
client := kcl.cli.BatchV1().CronJobs(namespace)
|
||||
|
||||
_, err := client.Get(context.Background(), cronJobName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
errors = append(errors, err)
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), cronJobName, metav1.DeleteOptions{}); err != nil {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errorlist.Combine(errors)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
kfake "k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
// TestFetchCronJobs tests the fetchCronJobs method for both admin and non-admin clients
|
||||
// It creates a fake Kubernetes client and passes it to the fetchCronJobs method
|
||||
// It then logs the fetched Cron Jobs
|
||||
// non-admin client will have access to the default namespace only
|
||||
func (kcl *KubeClient) TestFetchCronJobs(t *testing.T) {
|
||||
t.Run("admin client can fetch Cron Jobs from all namespaces", func(t *testing.T) {
|
||||
kcl.cli = kfake.NewSimpleClientset()
|
||||
kcl.instanceID = "test"
|
||||
kcl.IsKubeAdmin = true
|
||||
|
||||
cronJobs, err := kcl.GetCronJobs("")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch Cron Jobs: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Fetched Cron Jobs: %v", cronJobs)
|
||||
})
|
||||
|
||||
t.Run("non-admin client can fetch Cron Jobs from the default namespace only", func(t *testing.T) {
|
||||
kcl.cli = kfake.NewSimpleClientset()
|
||||
kcl.instanceID = "test"
|
||||
kcl.IsKubeAdmin = false
|
||||
kcl.NonAdminNamespaces = []string{"default"}
|
||||
|
||||
cronJobs, err := kcl.GetCronJobs("")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch Cron Jobs: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Fetched Cron Jobs: %v", cronJobs)
|
||||
})
|
||||
|
||||
t.Run("delete Cron Jobs", func(t *testing.T) {
|
||||
kcl.cli = kfake.NewSimpleClientset()
|
||||
kcl.instanceID = "test"
|
||||
|
||||
_, err := kcl.cli.BatchV1().CronJobs("default").Create(context.Background(), &batchv1.CronJob{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-cronjob"},
|
||||
}, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create cron job: %v", err)
|
||||
}
|
||||
|
||||
err = kcl.DeleteCronJobs(models.K8sCronJobDeleteRequests{
|
||||
"default": []string{"test-cronjob"},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete Cron Jobs: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Deleted Cron Jobs")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// GetJobs returns all jobs in the given namespace
|
||||
// If the user is a kube admin, it returns all jobs in the namespace
|
||||
// Otherwise, it returns only the jobs in the non-admin namespaces
|
||||
func (kcl *KubeClient) GetJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) {
|
||||
if kcl.IsKubeAdmin {
|
||||
return kcl.fetchJobs(namespace, includeCronJobChildren)
|
||||
}
|
||||
|
||||
return kcl.fetchJobsForNonAdmin(namespace, includeCronJobChildren)
|
||||
}
|
||||
|
||||
// fetchJobsForNonAdmin returns all jobs in the given namespace
|
||||
// It returns only the jobs in the non-admin namespaces
|
||||
func (kcl *KubeClient) fetchJobsForNonAdmin(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) {
|
||||
jobs, err := kcl.fetchJobs(namespace, includeCronJobChildren)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
|
||||
results := make([]models.K8sJob, 0)
|
||||
for _, job := range jobs {
|
||||
if _, ok := nonAdminNamespaceSet[job.Namespace]; ok {
|
||||
results = append(results, job)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// fetchJobs returns all jobs in the given namespace
|
||||
// It returns all jobs in the namespace
|
||||
func (kcl *KubeClient) fetchJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) {
|
||||
jobs, err := kcl.cli.BatchV1().Jobs(namespace).List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]models.K8sJob, 0)
|
||||
for _, job := range jobs.Items {
|
||||
if !includeCronJobChildren && checkCronJobOwner(job) {
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, kcl.parseJob(job))
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// checkCronJobOwner checks if the job has a cronjob owner
|
||||
// it returns true if the job has a cronjob owner
|
||||
// otherwise, it returns false
|
||||
func checkCronJobOwner(job batchv1.Job) bool {
|
||||
for _, owner := range job.OwnerReferences {
|
||||
if owner.Kind == "CronJob" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// parseJob converts a batchv1.Job object to a models.K8sJob object.
|
||||
func (kcl *KubeClient) parseJob(job batchv1.Job) models.K8sJob {
|
||||
times := parseJobTimes(job)
|
||||
status, failedReason := determineJobStatus(job)
|
||||
podName := getJobPodName(kcl, job)
|
||||
|
||||
return models.K8sJob{
|
||||
ID: string(job.UID),
|
||||
Namespace: job.Namespace,
|
||||
Name: job.Name,
|
||||
PodName: podName,
|
||||
Command: strings.Join(job.Spec.Template.Spec.Containers[0].Command, " "),
|
||||
Container: job.Spec.Template.Spec.Containers[0],
|
||||
BackoffLimit: *job.Spec.BackoffLimit,
|
||||
Completions: *job.Spec.Completions,
|
||||
StartTime: times.start,
|
||||
FinishTime: times.finish,
|
||||
Duration: times.duration,
|
||||
Status: status,
|
||||
FailedReason: failedReason,
|
||||
IsSystem: kcl.isSystemJob(job.Namespace),
|
||||
}
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) isSystemJob(namespace string) bool {
|
||||
return kcl.isSystemNamespace(namespace)
|
||||
}
|
||||
|
||||
type jobTimes struct {
|
||||
start string
|
||||
finish string
|
||||
duration string
|
||||
}
|
||||
|
||||
func parseJobTimes(job batchv1.Job) jobTimes {
|
||||
times := jobTimes{
|
||||
start: "N/A",
|
||||
finish: "N/A",
|
||||
duration: "N/A",
|
||||
}
|
||||
|
||||
if st := job.Status.StartTime; st != nil {
|
||||
times.start = st.Time.Format(time.RFC3339)
|
||||
times.duration = time.Since(st.Time).Truncate(time.Minute).String()
|
||||
|
||||
if ct := job.Status.CompletionTime; ct != nil {
|
||||
times.finish = ct.Time.Format(time.RFC3339)
|
||||
times.duration = ct.Time.Sub(st.Time).String()
|
||||
}
|
||||
}
|
||||
|
||||
return times
|
||||
}
|
||||
|
||||
func determineJobStatus(job batchv1.Job) (status, failedReason string) {
|
||||
failedReason = "N/A"
|
||||
|
||||
switch {
|
||||
case job.Status.Failed > 0:
|
||||
return "Failed", getLatestJobCondition(job.Status.Conditions)
|
||||
case job.Status.Succeeded > 0:
|
||||
return "Succeeded", failedReason
|
||||
case job.Status.Active == 0:
|
||||
return "Completed", failedReason
|
||||
default:
|
||||
return "Running", failedReason
|
||||
}
|
||||
}
|
||||
|
||||
func getJobPodName(kcl *KubeClient, job batchv1.Job) string {
|
||||
pod, err := kcl.getLatestJobPod(job.Namespace, job.Name)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).
|
||||
Str("job", job.Name).
|
||||
Str("namespace", job.Namespace).
|
||||
Msg("Failed to get latest job pod")
|
||||
return ""
|
||||
}
|
||||
|
||||
if pod != nil {
|
||||
return pod.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getCronJobExecutions returns the jobs for a given cronjob
|
||||
// it returns the jobs for the cronjob
|
||||
func (kcl *KubeClient) getCronJobExecutions(cronJobName string, jobs *batchv1.JobList) ([]models.K8sJob, error) {
|
||||
maxItems := 5
|
||||
|
||||
results := make([]models.K8sJob, 0)
|
||||
for _, job := range jobs.Items {
|
||||
for _, owner := range job.OwnerReferences {
|
||||
if owner.Kind == "CronJob" && owner.Name == cronJobName {
|
||||
results = append(results, kcl.parseJob(job))
|
||||
|
||||
if len(results) >= maxItems {
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// DeleteJobs deletes the provided list of jobs
|
||||
// it returns an error if any of the jobs are not found or if there is an error deleting the jobs
|
||||
func (kcl *KubeClient) DeleteJobs(payload models.K8sJobDeleteRequests) error {
|
||||
var errors []error
|
||||
for namespace := range payload {
|
||||
for _, jobName := range payload[namespace] {
|
||||
client := kcl.cli.BatchV1().Jobs(namespace)
|
||||
|
||||
_, err := client.Get(context.Background(), jobName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
errors = append(errors, err)
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), jobName, metav1.DeleteOptions{}); err != nil {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errorlist.Combine(errors)
|
||||
}
|
||||
|
||||
// getLatestJobCondition returns the latest condition of the job
|
||||
// it returns the latest condition of the job
|
||||
// this is only used for the failed reason
|
||||
func getLatestJobCondition(conditions []batchv1.JobCondition) string {
|
||||
if len(conditions) == 0 {
|
||||
return "No conditions"
|
||||
}
|
||||
|
||||
sort.Slice(conditions, func(i, j int) bool {
|
||||
return conditions[i].LastTransitionTime.After(conditions[j].LastTransitionTime.Time)
|
||||
})
|
||||
|
||||
latest := conditions[0]
|
||||
return fmt.Sprintf("%s: %s", latest.Type, latest.Message)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
kfake "k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
// TestFetchJobs tests the fetchJobs method for both admin and non-admin clients
|
||||
// It creates a fake Kubernetes client and passes it to the fetchJobs method
|
||||
// It then logs the fetched jobs
|
||||
// non-admin client will have access to the default namespace only
|
||||
func (kcl *KubeClient) TestFetchJobs(t *testing.T) {
|
||||
t.Run("admin client can fetch jobs from all namespaces", func(t *testing.T) {
|
||||
kcl.cli = kfake.NewSimpleClientset()
|
||||
kcl.instanceID = "test"
|
||||
kcl.IsKubeAdmin = true
|
||||
|
||||
jobs, err := kcl.GetJobs("", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch jobs: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Fetched jobs: %v", jobs)
|
||||
})
|
||||
|
||||
t.Run("non-admin client can fetch jobs from the default namespace only", func(t *testing.T) {
|
||||
kcl.cli = kfake.NewSimpleClientset()
|
||||
kcl.instanceID = "test"
|
||||
kcl.IsKubeAdmin = false
|
||||
kcl.NonAdminNamespaces = []string{"default"}
|
||||
|
||||
jobs, err := kcl.GetJobs("", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch jobs: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Fetched jobs: %v", jobs)
|
||||
})
|
||||
|
||||
t.Run("delete jobs", func(t *testing.T) {
|
||||
kcl.cli = kfake.NewSimpleClientset()
|
||||
kcl.instanceID = "test"
|
||||
|
||||
_, err := kcl.cli.BatchV1().Jobs("default").Create(context.Background(), &batchv1.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-job"},
|
||||
}, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create job: %v", err)
|
||||
}
|
||||
|
||||
err = kcl.DeleteJobs(models.K8sJobDeleteRequests{
|
||||
"default": []string{"test-job"},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete jobs: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
+157
-57
@@ -47,7 +47,9 @@ func (kcl *KubeClient) GetNamespaces() (map[string]portainer.K8sNamespaceInfo, e
|
||||
|
||||
// fetchNamespacesForNonAdmin gets the namespaces in the current k8s environment(endpoint) for the non-admin user.
|
||||
func (kcl *KubeClient) fetchNamespacesForNonAdmin() (map[string]portainer.K8sNamespaceInfo, error) {
|
||||
log.Debug().Msgf("Fetching namespaces for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||
log.Debug().
|
||||
Str("context", "fetchNamespacesForNonAdmin").
|
||||
Msg("Fetching namespaces for non-admin user")
|
||||
|
||||
if len(kcl.NonAdminNamespaces) == 0 {
|
||||
return nil, nil
|
||||
@@ -75,6 +77,11 @@ func (kcl *KubeClient) fetchNamespacesForNonAdmin() (map[string]portainer.K8sNam
|
||||
func (kcl *KubeClient) fetchNamespaces() (map[string]portainer.K8sNamespaceInfo, error) {
|
||||
namespaces, err := kcl.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "fetchNamespaces").
|
||||
Err(err).
|
||||
Msg("Failed to list namespaces")
|
||||
|
||||
return nil, fmt.Errorf("an error occurred during the fetchNamespacesForAdmin operation, unable to list namespaces for the admin user: %w", err)
|
||||
}
|
||||
|
||||
@@ -92,6 +99,7 @@ func parseNamespace(namespace *corev1.Namespace) portainer.K8sNamespaceInfo {
|
||||
Id: string(namespace.UID),
|
||||
Name: namespace.Name,
|
||||
Status: namespace.Status,
|
||||
Annotations: namespace.Annotations,
|
||||
CreationDate: namespace.CreationTimestamp.Format(time.RFC3339),
|
||||
NamespaceOwner: namespace.Labels[namespaceOwnerLabel],
|
||||
IsSystem: isSystemNamespace(namespace),
|
||||
@@ -103,13 +111,18 @@ func parseNamespace(namespace *corev1.Namespace) portainer.K8sNamespaceInfo {
|
||||
func (kcl *KubeClient) GetNamespace(name string) (portainer.K8sNamespaceInfo, error) {
|
||||
namespace, err := kcl.cli.CoreV1().Namespaces().Get(context.TODO(), name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "GetNamespace").
|
||||
Str("namespace", name).
|
||||
Err(err).
|
||||
Msg("Failed to get namespace")
|
||||
return portainer.K8sNamespaceInfo{}, err
|
||||
}
|
||||
|
||||
return parseNamespace(namespace), nil
|
||||
}
|
||||
|
||||
// CreateNamespace creates a new ingress in a given namespace in a k8s endpoint.
|
||||
// CreateNamespace creates a new namespace in a k8s endpoint.
|
||||
func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) {
|
||||
portainerLabels := map[string]string{
|
||||
namespaceNameLabel: stackutils.SanitizeLabel(info.Name),
|
||||
@@ -125,52 +138,127 @@ func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) (*corev1
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("context", "CreateNamespace").
|
||||
Str("Namespace", info.Name).
|
||||
Msg("Failed to create the namespace")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if info.ResourceQuota != nil && info.ResourceQuota.Enabled {
|
||||
log.Info().Msgf("Creating resource quota for namespace %s", info.Name)
|
||||
log.Debug().Msgf("Creating resource quota with details: %+v", info.ResourceQuota)
|
||||
|
||||
resourceQuota := &corev1.ResourceQuota{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "portainer-rq-" + info.Name,
|
||||
Namespace: info.Name,
|
||||
Labels: portainerLabels,
|
||||
},
|
||||
Spec: corev1.ResourceQuotaSpec{
|
||||
Hard: corev1.ResourceList{},
|
||||
},
|
||||
}
|
||||
|
||||
if info.ResourceQuota.Enabled {
|
||||
memory := resource.MustParse(info.ResourceQuota.Memory)
|
||||
cpu := resource.MustParse(info.ResourceQuota.CPU)
|
||||
if memory.Value() > 0 {
|
||||
memQuota := memory
|
||||
resourceQuota.Spec.Hard[corev1.ResourceLimitsMemory] = memQuota
|
||||
resourceQuota.Spec.Hard[corev1.ResourceRequestsMemory] = memQuota
|
||||
}
|
||||
|
||||
if cpu.Value() > 0 {
|
||||
cpuQuota := cpu
|
||||
resourceQuota.Spec.Hard[corev1.ResourceLimitsCPU] = cpuQuota
|
||||
resourceQuota.Spec.Hard[corev1.ResourceRequestsCPU] = cpuQuota
|
||||
}
|
||||
}
|
||||
|
||||
_, err := kcl.cli.CoreV1().ResourceQuotas(info.Name).Create(context.Background(), resourceQuota, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
log.Error().Msgf("Failed to create resource quota for namespace %s: %s", info.Name, err)
|
||||
return nil, err
|
||||
}
|
||||
if err := kcl.createOrUpdateNamespaceResourceQuota(info, portainerLabels); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("context", "CreateNamespace").
|
||||
Str("name", info.Name).
|
||||
Msg("failed to create or update resource quota for namespace")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return namespace, nil
|
||||
}
|
||||
|
||||
// UpdateIngress updates an ingress in a given namespace in a k8s endpoint.
|
||||
func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) {
|
||||
portainerLabels := map[string]string{
|
||||
namespaceNameLabel: stackutils.SanitizeLabel(info.Name),
|
||||
namespaceOwnerLabel: stackutils.SanitizeLabel(info.Owner),
|
||||
}
|
||||
|
||||
namespace := corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: info.Name,
|
||||
Annotations: info.Annotations,
|
||||
},
|
||||
}
|
||||
|
||||
updatedNamespace, err := kcl.cli.CoreV1().Namespaces().Update(context.Background(), &namespace, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "UpdateNamespace").
|
||||
Str("namespace", info.Name).
|
||||
Err(err).
|
||||
Msg("Failed to update namespace")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := kcl.createOrUpdateNamespaceResourceQuota(info, portainerLabels); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("context", "UpdateNamespace").
|
||||
Str("name", info.Name).
|
||||
Msg("failed to create or update resource quota for namespace")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedNamespace, nil
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) createOrUpdateNamespaceResourceQuota(info models.K8sNamespaceDetails, portainerLabels map[string]string) error {
|
||||
if !info.ResourceQuota.Enabled {
|
||||
if err := kcl.deleteNamespaceResourceQuota(info.Name); err != nil {
|
||||
log.Debug().Err(err).Str("context", "createOrUpdateNamespaceResourceQuota").Str("name", info.Name).Msg("failed to delete resource quota for namespace")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
resourceQuota := &corev1.ResourceQuota{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "portainer-rq-" + info.Name,
|
||||
Namespace: info.Name,
|
||||
Labels: portainerLabels,
|
||||
},
|
||||
Spec: corev1.ResourceQuotaSpec{
|
||||
Hard: corev1.ResourceList{},
|
||||
},
|
||||
}
|
||||
|
||||
if info.ResourceQuota.Enabled {
|
||||
memory := resource.MustParse(info.ResourceQuota.Memory)
|
||||
cpu := resource.MustParse(info.ResourceQuota.CPU)
|
||||
|
||||
if memory.Value() > 0 {
|
||||
memQuota := memory
|
||||
resourceQuota.Spec.Hard[corev1.ResourceLimitsMemory] = memQuota
|
||||
resourceQuota.Spec.Hard[corev1.ResourceRequestsMemory] = memQuota
|
||||
}
|
||||
|
||||
if cpu.Value() > 0 {
|
||||
cpuQuota := cpu
|
||||
resourceQuota.Spec.Hard[corev1.ResourceLimitsCPU] = cpuQuota
|
||||
resourceQuota.Spec.Hard[corev1.ResourceRequestsCPU] = cpuQuota
|
||||
}
|
||||
}
|
||||
|
||||
_, err := kcl.cli.CoreV1().ResourceQuotas(info.Name).Update(context.Background(), resourceQuota, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
log.Warn().
|
||||
Str("context", "createOrUpdateNamespaceResourceQuota").
|
||||
Str("name", info.Name).
|
||||
Msg("resource quota not found, creating")
|
||||
_, err = kcl.cli.CoreV1().ResourceQuotas(info.Name).Create(context.Background(), resourceQuota, metav1.CreateOptions{})
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) deleteNamespaceResourceQuota(namespaceName string) error {
|
||||
err := kcl.cli.CoreV1().ResourceQuotas(namespaceName).Delete(context.Background(), "portainer-rq-"+namespaceName, metav1.DeleteOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
log.Error().
|
||||
Str("context", "deleteNamespaceResourceQuota").
|
||||
Str("name", namespaceName).
|
||||
Err(err).
|
||||
Msg("failed to delete resource quota for namespace")
|
||||
return err
|
||||
}
|
||||
log.Warn().
|
||||
Str("context", "deleteNamespaceResourceQuota").
|
||||
Str("name", namespaceName).
|
||||
Msg("resource quota to delete not found")
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSystemNamespace(namespace *corev1.Namespace) bool {
|
||||
systemLabelValue, hasSystemLabel := namespace.Labels[systemNamespaceLabel]
|
||||
if hasSystemLabel {
|
||||
@@ -180,7 +268,6 @@ func isSystemNamespace(namespace *corev1.Namespace) bool {
|
||||
systemNamespaces := defaultSystemNamespaces()
|
||||
|
||||
_, isSystem := systemNamespaces[namespace.Name]
|
||||
|
||||
return isSystem
|
||||
}
|
||||
|
||||
@@ -201,10 +288,13 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er
|
||||
return nil
|
||||
}
|
||||
|
||||
nsService := kcl.cli.CoreV1().Namespaces()
|
||||
|
||||
namespace, err := nsService.Get(context.TODO(), namespaceName, metav1.GetOptions{})
|
||||
namespace, err := kcl.cli.CoreV1().Namespaces().Get(context.TODO(), namespaceName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "ToggleSystemState").
|
||||
Str("namespace", namespaceName).
|
||||
Err(err).
|
||||
Msg("failed to get namespace")
|
||||
return errors.Wrap(err, "failed fetching namespace object")
|
||||
}
|
||||
|
||||
@@ -218,8 +308,12 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er
|
||||
|
||||
namespace.Labels[systemNamespaceLabel] = strconv.FormatBool(isSystem)
|
||||
|
||||
_, err = nsService.Update(context.TODO(), namespace, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
if _, err := kcl.cli.CoreV1().Namespaces().Update(context.TODO(), namespace, metav1.UpdateOptions{}); err != nil {
|
||||
log.Error().
|
||||
Str("context", "ToggleSystemState").
|
||||
Str("namespace", namespaceName).
|
||||
Err(err).
|
||||
Msg("failed updating namespace object")
|
||||
return errors.Wrap(err, "failed updating namespace object")
|
||||
}
|
||||
|
||||
@@ -228,29 +322,26 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// UpdateIngress updates an ingress in a given namespace in a k8s endpoint.
|
||||
func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) {
|
||||
namespace := corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: info.Name,
|
||||
Annotations: info.Annotations,
|
||||
},
|
||||
}
|
||||
|
||||
return kcl.cli.CoreV1().Namespaces().Update(context.Background(), &namespace, metav1.UpdateOptions{})
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) DeleteNamespace(namespaceName string) (*corev1.Namespace, error) {
|
||||
namespace, err := kcl.cli.CoreV1().Namespaces().Get(context.Background(), namespaceName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "DeleteNamespace").
|
||||
Str("namespace", namespaceName).
|
||||
Err(err).
|
||||
Msg("failed fetching namespace object")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = kcl.cli.CoreV1().Namespaces().Delete(context.Background(), namespaceName, metav1.DeleteOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "DeleteNamespace").
|
||||
Str("namespace", namespaceName).
|
||||
Err(err).
|
||||
Msg("failed deleting namespace object")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -261,6 +352,10 @@ func (kcl *KubeClient) DeleteNamespace(namespaceName string) (*corev1.Namespace,
|
||||
func (kcl *KubeClient) CombineNamespacesWithResourceQuotas(namespaces map[string]portainer.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError {
|
||||
resourceQuotas, err := kcl.GetResourceQuotas("")
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
log.Error().
|
||||
Str("context", "CombineNamespacesWithResourceQuotas").
|
||||
Err(err).
|
||||
Msg("unable to retrieve resource quotas from the Kubernetes for an admin user")
|
||||
return httperror.InternalServerError("an error occurred during the CombineNamespacesWithResourceQuotas operation, unable to retrieve resource quotas from the Kubernetes for an admin user. Error: ", err)
|
||||
}
|
||||
|
||||
@@ -275,6 +370,11 @@ func (kcl *KubeClient) CombineNamespacesWithResourceQuotas(namespaces map[string
|
||||
func (kcl *KubeClient) CombineNamespaceWithResourceQuota(namespace portainer.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError {
|
||||
resourceQuota, err := kcl.GetPortainerResourceQuota(namespace.Name)
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
log.Error().
|
||||
Str("context", "CombineNamespaceWithResourceQuota").
|
||||
Str("namespace", namespace.Name).
|
||||
Err(err).
|
||||
Msg("unable to retrieve the resource quota associated with the namespace")
|
||||
return httperror.InternalServerError(fmt.Sprintf("an error occurred during the CombineNamespaceWithResourceQuota operation, unable to retrieve the resource quota associated with the namespace: %s for a non-admin user. Error: ", namespace.Name), err)
|
||||
}
|
||||
|
||||
|
||||
@@ -275,3 +275,22 @@ func isPodUsingSecret(pod *corev1.Pod, secretName string) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getLatestJobPod returns the pods that are owned by a job
|
||||
// it returns an error if there is an error fetching the pods
|
||||
func (kcl *KubeClient) getLatestJobPod(namespace string, jobName string) (*corev1.Pod, error) {
|
||||
pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, pod := range pods.Items {
|
||||
for _, owner := range pod.OwnerReferences {
|
||||
if owner.Kind == "Job" && owner.Name == jobName {
|
||||
return &pod, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func (kcl *KubeClient) fetchResourceQuotasForNonAdmin(namespace string) (*[]core
|
||||
func (kcl *KubeClient) fetchResourceQuotas(namespace string) (*[]corev1.ResourceQuota, error) {
|
||||
resourceQuotas, err := kcl.cli.CoreV1().ResourceQuotas(namespace).List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occured, failed to list resource quotas for the admin user: %w", err)
|
||||
return nil, fmt.Errorf("an error occurred, failed to list resource quotas for the admin user: %w", err)
|
||||
}
|
||||
|
||||
return &resourceQuotas.Items, nil
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"github.com/portainer/portainer/pkg/snapshot"
|
||||
)
|
||||
|
||||
type Snapshotter struct {
|
||||
@@ -30,55 +24,5 @@ func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*p
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return snapshot(client, endpoint)
|
||||
}
|
||||
|
||||
func snapshot(cli *kubernetes.Clientset, endpoint *portainer.Endpoint) (*portainer.KubernetesSnapshot, error) {
|
||||
res := cli.RESTClient().Get().AbsPath("/healthz").Do(context.TODO())
|
||||
if res.Error() != nil {
|
||||
return nil, res.Error()
|
||||
}
|
||||
|
||||
snapshot := &portainer.KubernetesSnapshot{}
|
||||
|
||||
err := snapshotVersion(snapshot, cli)
|
||||
if err != nil {
|
||||
log.Warn().Str("endpoint", endpoint.Name).Err(err).Msg("unable to snapshot cluster version")
|
||||
}
|
||||
|
||||
err = snapshotNodes(snapshot, cli)
|
||||
if err != nil {
|
||||
log.Warn().Str("endpoint", endpoint.Name).Err(err).Msg("unable to snapshot cluster nodes")
|
||||
}
|
||||
|
||||
snapshot.Time = time.Now().Unix()
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func snapshotVersion(snapshot *portainer.KubernetesSnapshot, cli *kubernetes.Clientset) error {
|
||||
versionInfo, err := cli.ServerVersion()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
snapshot.KubernetesVersion = versionInfo.GitVersion
|
||||
return nil
|
||||
}
|
||||
|
||||
func snapshotNodes(snapshot *portainer.KubernetesSnapshot, cli *kubernetes.Clientset) error {
|
||||
nodeList, err := cli.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var totalCPUs, totalMemory int64
|
||||
for _, node := range nodeList.Items {
|
||||
totalCPUs += node.Status.Capacity.Cpu().Value()
|
||||
totalMemory += node.Status.Capacity.Memory().Value()
|
||||
}
|
||||
|
||||
snapshot.TotalCPU = totalCPUs
|
||||
snapshot.TotalMemory = totalMemory
|
||||
snapshot.NodeCount = len(nodeList.Items)
|
||||
return nil
|
||||
return snapshot.CreateKubernetesSnapshot(client)
|
||||
}
|
||||
|
||||
+37
-26
@@ -5,10 +5,12 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -21,38 +23,46 @@ type service struct {
|
||||
dataStore dataservices.DataStore
|
||||
environment *portainer.Endpoint
|
||||
platform ContainerPlatform
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewService(dataStore dataservices.DataStore) (Service, error) {
|
||||
func NewService(dataStore dataservices.DataStore) (*service, error) {
|
||||
return &service{dataStore: dataStore}, nil
|
||||
}
|
||||
|
||||
return &service{
|
||||
dataStore: dataStore,
|
||||
}, nil
|
||||
func (service *service) loadEnvAndPlatform() error {
|
||||
if service.environment != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
environment, platform, err := guessLocalEnvironment(service.dataStore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
service.environment = environment
|
||||
service.platform = platform
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *service) GetLocalEnvironment() (*portainer.Endpoint, error) {
|
||||
if service.environment == nil {
|
||||
environment, platform, err := guessLocalEnvironment(service.dataStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
|
||||
service.environment = environment
|
||||
service.platform = platform
|
||||
if err := service.loadEnvAndPlatform(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return service.environment, nil
|
||||
}
|
||||
|
||||
func (service *service) GetPlatform() (ContainerPlatform, error) {
|
||||
if service.environment == nil {
|
||||
environment, platform, err := guessLocalEnvironment(service.dataStore)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
|
||||
service.environment = environment
|
||||
service.platform = platform
|
||||
if err := service.loadEnvAndPlatform(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return service.platform, nil
|
||||
@@ -90,15 +100,16 @@ func guessLocalEnvironment(dataStore dataservices.DataStore) (*portainer.Endpoin
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if slices.Contains(endpointTypes, endpoint.Type) {
|
||||
if platform != PlatformDocker {
|
||||
return &endpoint, platform, nil
|
||||
}
|
||||
if !slices.Contains(endpointTypes, endpoint.Type) {
|
||||
continue
|
||||
}
|
||||
|
||||
dockerPlatform := checkDockerEnvTypeForUpgrade(&endpoint)
|
||||
if dockerPlatform != "" {
|
||||
return &endpoint, dockerPlatform, nil
|
||||
}
|
||||
if platform != PlatformDocker {
|
||||
return &endpoint, platform, nil
|
||||
}
|
||||
|
||||
if dockerPlatform := checkDockerEnvTypeForUpgrade(&endpoint); dockerPlatform != "" {
|
||||
return &endpoint, dockerPlatform, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-9
@@ -133,6 +133,7 @@ type (
|
||||
SecretKeyName *string
|
||||
LogLevel *string
|
||||
LogMode *string
|
||||
KubectlShellImage *string
|
||||
}
|
||||
|
||||
// CustomTemplateVariableDefinition
|
||||
@@ -184,6 +185,16 @@ type (
|
||||
// CustomTemplatePlatform represents a custom template platform
|
||||
CustomTemplatePlatform int
|
||||
|
||||
// DiagnosticsData represents the diagnostics data for an environment
|
||||
// this contains the logs, telnet, traceroute, dns and proxy information
|
||||
// which will be part of the DockerSnapshot and KubernetesSnapshot structs
|
||||
DiagnosticsData struct {
|
||||
Log string `json:"Log,omitempty"`
|
||||
Telnet map[string]string `json:"Telnet,omitempty"`
|
||||
DNS map[string]string `json:"DNS,omitempty"`
|
||||
Proxy map[string]string `json:"Proxy,omitempty"`
|
||||
}
|
||||
|
||||
// DockerHub represents all the required information to connect and use the
|
||||
// Docker Hub
|
||||
DockerHub struct {
|
||||
@@ -216,6 +227,7 @@ type (
|
||||
GpuUseAll bool `json:"GpuUseAll"`
|
||||
GpuUseList []string `json:"GpuUseList"`
|
||||
IsPodman bool `json:"IsPodman"`
|
||||
DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"`
|
||||
}
|
||||
|
||||
// DockerContainerSnapshot is an extent of Docker's Container struct
|
||||
@@ -317,9 +329,6 @@ type (
|
||||
DeploymentType EdgeStackDeploymentType `json:"DeploymentType"`
|
||||
// Uses the manifest's namespaces instead of the default one
|
||||
UseManifestNamespaces bool
|
||||
|
||||
// Deprecated
|
||||
Prune bool `json:"Prune,omitempty"`
|
||||
}
|
||||
|
||||
EdgeStackDeploymentType int
|
||||
@@ -361,6 +370,7 @@ type (
|
||||
Error string
|
||||
// EE only feature
|
||||
RollbackTo *int
|
||||
Version int `json:"Version,omitempty"`
|
||||
}
|
||||
|
||||
// EdgeStackStatusType represents an edge stack status type
|
||||
@@ -599,6 +609,7 @@ type (
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Status corev1.NamespaceStatus `json:"Status"`
|
||||
Annotations map[string]string `json:"Annotations"`
|
||||
CreationDate string `json:"CreationDate"`
|
||||
NamespaceOwner string `json:"NamespaceOwner"`
|
||||
IsSystem bool `json:"IsSystem"`
|
||||
@@ -635,11 +646,12 @@ type (
|
||||
|
||||
// KubernetesSnapshot represents a snapshot of a specific Kubernetes environment(endpoint) at a specific time
|
||||
KubernetesSnapshot struct {
|
||||
Time int64 `json:"Time"`
|
||||
KubernetesVersion string `json:"KubernetesVersion"`
|
||||
NodeCount int `json:"NodeCount"`
|
||||
TotalCPU int64 `json:"TotalCPU"`
|
||||
TotalMemory int64 `json:"TotalMemory"`
|
||||
Time int64 `json:"Time"`
|
||||
KubernetesVersion string `json:"KubernetesVersion"`
|
||||
NodeCount int `json:"NodeCount"`
|
||||
TotalCPU int64 `json:"TotalCPU"`
|
||||
TotalMemory int64 `json:"TotalMemory"`
|
||||
DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"`
|
||||
}
|
||||
|
||||
// KubernetesConfiguration represents the configuration of a Kubernetes environment(endpoint)
|
||||
@@ -1384,6 +1396,13 @@ type (
|
||||
Prune bool
|
||||
}
|
||||
|
||||
ComposeDownOptions struct {
|
||||
// RemoveVolumes will remove the named volumes declared in the compose file
|
||||
// and anonymous volumes attached to the stack's containers
|
||||
// Drives `docker compose down --volumes`
|
||||
RemoveVolumes bool
|
||||
}
|
||||
|
||||
ComposeRunOptions struct {
|
||||
ComposeOptions
|
||||
|
||||
@@ -1617,7 +1636,9 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.24.0"
|
||||
APIVersion = "2.26.0"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "STS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
@@ -1665,6 +1686,8 @@ const (
|
||||
AuthCookieKey = "portainer_api_key"
|
||||
// PortainerCacheHeader is used to enabled FE caching for Kubernetes resources
|
||||
PortainerCacheHeader = "X-Portainer-Cache"
|
||||
// KubectlShellImageEnvVar is the environment variable used to override the default kubectl shell image
|
||||
KubectlShellImageEnvVar = "KUBECTL_SHELL_IMAGE"
|
||||
)
|
||||
|
||||
// List of supported features
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/docker/docker/api/types/system"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
@@ -170,9 +171,9 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
image := getUnpackerImage()
|
||||
unpackerImg := getUnpackerImage()
|
||||
|
||||
reader, err := cli.ImagePull(ctx, image, types.ImagePullOptions{})
|
||||
reader, err := cli.ImagePull(ctx, unpackerImg, image.PullOptions{})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to pull unpacker image")
|
||||
}
|
||||
@@ -197,12 +198,12 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("image", image).
|
||||
Str("image", unpackerImg).
|
||||
Str("cmd", strings.Join(cmd, " ")).
|
||||
Msg("running unpacker")
|
||||
|
||||
unpackerContainer, err := cli.ContainerCreate(ctx, &container.Config{
|
||||
Image: image,
|
||||
Image: unpackerImg,
|
||||
Cmd: cmd,
|
||||
}, &container.HostConfig{
|
||||
Binds: []string{
|
||||
|
||||
@@ -5,82 +5,110 @@
|
||||
library css for buttons is overriden by `.widget .widget-body button`
|
||||
so we have to force margin: 0
|
||||
*/
|
||||
.react-datetime-picker .react-calendar button {
|
||||
.react-daterange-picker__calendar .react-calendar button {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/*
|
||||
Extending Calendar.css from react-datetime-picker
|
||||
Extending Calendar.css from react-daterange-picker__calendar
|
||||
*/
|
||||
.react-datetime-picker .react-calendar {
|
||||
.react-daterange-picker__calendar .react-calendar {
|
||||
background: var(--bg-calendar-color);
|
||||
color: var(--text-main-color);
|
||||
}
|
||||
|
||||
/* calendar nav buttons */
|
||||
.react-datetime-picker .react-calendar__navigation button:disabled {
|
||||
background-color: var(--bg-calendar-color);
|
||||
.react-daterange-picker__calendar .react-calendar__navigation button:disabled {
|
||||
background: var(--bg-calendar-color);
|
||||
@apply opacity-60;
|
||||
@apply brightness-95 th-dark:brightness-110;
|
||||
}
|
||||
.react-datetime-picker .react-calendar__navigation button:enabled:hover,
|
||||
.react-datetime-picker .react-calendar__navigation button:enabled:focus {
|
||||
background-color: var(--bg-daterangepicker-color);
|
||||
.react-daterange-picker__calendar .react-calendar__navigation button:enabled:hover,
|
||||
.react-daterange-picker__calendar .react-calendar__navigation button:enabled:focus {
|
||||
background: var(--bg-daterangepicker-color);
|
||||
}
|
||||
|
||||
/* date tile */
|
||||
.react-datetime-picker .react-calendar__tile:disabled {
|
||||
background-color: var(--bg-calendar-color);
|
||||
.react-daterange-picker__calendar .react-calendar__tile:disabled {
|
||||
background: var(--bg-calendar-color);
|
||||
@apply opacity-60;
|
||||
@apply brightness-95 th-dark:brightness-110;
|
||||
}
|
||||
.react-datetime-picker .react-calendar__tile:enabled:hover,
|
||||
.react-datetime-picker .react-calendar__tile:enabled:focus {
|
||||
background-color: var(--bg-daterangepicker-hover);
|
||||
.react-daterange-picker__calendar .react-calendar__tile:enabled:hover,
|
||||
.react-daterange-picker__calendar .react-calendar__tile:enabled:focus {
|
||||
background: var(--bg-daterangepicker-hover);
|
||||
}
|
||||
|
||||
/* today's date tile */
|
||||
.react-datetime-picker .react-calendar__tile--now {
|
||||
/* use background color to avoid white on yellow in dark/high contrast modes */
|
||||
.react-daterange-picker__calendar .react-calendar__tile--now {
|
||||
@apply th-highcontrast:text-[color:var(--bg-calendar-color)] th-dark:text-[color:var(--bg-calendar-color)];
|
||||
border-radius: 0.25rem !important;
|
||||
}
|
||||
.react-datetime-picker .react-calendar__tile--now:enabled:hover,
|
||||
.react-datetime-picker .react-calendar__tile--now:enabled:focus {
|
||||
.react-daterange-picker__calendar .react-calendar__tile--now:enabled:hover,
|
||||
.react-daterange-picker__calendar .react-calendar__tile--now:enabled:focus {
|
||||
background: var(--bg-daterangepicker-hover);
|
||||
color: var(--text-daterangepicker-hover);
|
||||
}
|
||||
|
||||
/* probably date tile in range */
|
||||
.react-datetime-picker .react-calendar__tile--hasActive {
|
||||
.react-daterange-picker__calendar .react-calendar__tile--hasActive {
|
||||
background: var(--bg-daterangepicker-end-date);
|
||||
color: var(--text-daterangepicker-end-date);
|
||||
}
|
||||
.react-datetime-picker .react-calendar__tile--hasActive:enabled:hover,
|
||||
.react-datetime-picker .react-calendar__tile--hasActive:enabled:focus {
|
||||
.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:hover,
|
||||
.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:focus {
|
||||
background: var(--bg-daterangepicker-hover);
|
||||
color: var(--text-daterangepicker-hover);
|
||||
}
|
||||
|
||||
/* selected date tile */
|
||||
.react-datetime-picker .react-calendar__tile--active {
|
||||
background: var(--bg-daterangepicker-active);
|
||||
color: var(--text-daterangepicker-active);
|
||||
}
|
||||
.react-datetime-picker .react-calendar__tile--active:enabled:hover,
|
||||
.react-datetime-picker .react-calendar__tile--active:enabled:focus {
|
||||
.react-daterange-picker__calendar .react-calendar__tile--active:enabled:hover,
|
||||
.react-daterange-picker__calendar .react-calendar__tile--active:enabled:focus {
|
||||
background: var(--bg-daterangepicker-hover);
|
||||
color: var(--text-daterangepicker-hover);
|
||||
}
|
||||
|
||||
.react-daterange-picker__calendar
|
||||
.react-calendar__month-view__days__day:hover:not(.react-daterange-picker__calendar .react-calendar__tile--hoverEnd):not(
|
||||
.react-daterange-picker__calendar .react-calendar__tile--hoverStart
|
||||
):not(.react-calendar__tile--active) {
|
||||
border-radius: 0.25rem !important;
|
||||
}
|
||||
|
||||
/* on range select hover */
|
||||
.react-datetime-picker .react-calendar--selectRange .react-calendar__tile--hover {
|
||||
background-color: var(--bg-daterangepicker-in-range);
|
||||
.react-daterange-picker__calendar .react-calendar--selectRange .react-calendar__tile--hover {
|
||||
background: var(--bg-daterangepicker-in-range);
|
||||
color: var(--text-daterangepicker-in-range);
|
||||
}
|
||||
|
||||
/*
|
||||
Extending DateTimePicker.css from react-datetime-picker
|
||||
Extending DateTimePicker.css from react-daterange-picker__calendar
|
||||
*/
|
||||
.react-datetime-picker .react-datetime-picker--disabled {
|
||||
.react-daterange-picker__calendar .react-daterange-picker__calendar--disabled {
|
||||
@apply opacity-40;
|
||||
}
|
||||
|
||||
/* selected date tile */
|
||||
.react-daterange-picker__calendar .react-calendar__tile--active {
|
||||
background: var(--bg-daterangepicker-active) !important;
|
||||
color: var(--text-daterangepicker-active) !important;
|
||||
}
|
||||
|
||||
.react-daterange-picker__calendar .react-calendar__tile--rangeStart:not(.react-calendar__tile--rangeEnd),
|
||||
.react-daterange-picker__calendar .react-calendar__tile--hoverStart {
|
||||
border-top-left-radius: 0.25rem;
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.react-daterange-picker__calendar .react-calendar__tile--rangeEnd:not(.react-calendar__tile--rangeStart),
|
||||
.react-daterange-picker__calendar .react-calendar__tile--hoverEnd {
|
||||
border-top-right-radius: 0.25rem;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.react-daterange-picker__calendar .react-calendar__month-view__days__day--weekend {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.react-calendar__tile--active.react-calendar__month-view__days__day--weekend {
|
||||
color: var(--text-daterangepicker-active);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ const ngModule = angular
|
||||
'isStackColumnVisible',
|
||||
'onRefresh',
|
||||
'titleIcon',
|
||||
'tableKey',
|
||||
])
|
||||
);
|
||||
|
||||
|
||||
@@ -6,4 +6,5 @@
|
||||
on-refresh="(getServices)"
|
||||
is-add-action-visible="true"
|
||||
is-stack-column-visible="true"
|
||||
table-key="'services'"
|
||||
></docker-services-datatable>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<button
|
||||
ng-if="showBrowseAction"
|
||||
class="btn btn-xs btn-primary"
|
||||
ui-sref="docker.volumes.volume.browse({ id: volume.Id, nodeName: volume.NodeName })"
|
||||
ui-sref="docker.volumes.volume.browse({ id: volume.Name, nodeName: volume.NodeName })"
|
||||
authorization="DockerAgentBrowseList"
|
||||
>
|
||||
<pr-icon icon="'search'" class="leading-none"></pr-icon>
|
||||
|
||||
@@ -58,6 +58,8 @@ angular.module('portainer.docker').controller('VolumeController', [
|
||||
|
||||
var containers = dataContainers.map(function (container) {
|
||||
container.volumeData = getVolumeDataFromContainer(container, $scope.volume.Id);
|
||||
|
||||
$scope.volume.NodeName = container.NodeName || '';
|
||||
return container;
|
||||
});
|
||||
$scope.containersUsingVolume = containers;
|
||||
|
||||
@@ -74,6 +74,10 @@ angular
|
||||
data: {
|
||||
docs: '/user/edge/stacks/add',
|
||||
},
|
||||
params: {
|
||||
templateId: { dynamic: true },
|
||||
templateType: { dynamic: true },
|
||||
},
|
||||
};
|
||||
|
||||
const stacksEdit = {
|
||||
|
||||
@@ -478,10 +478,10 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
|
||||
const resourcePool = {
|
||||
name: 'kubernetes.resourcePools.resourcePool',
|
||||
url: '/:id',
|
||||
url: '/:id?tab',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubernetesResourcePoolView',
|
||||
component: 'namespaceView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
@@ -581,6 +581,19 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
abstract: true,
|
||||
};
|
||||
|
||||
const jobs = {
|
||||
name: 'kubernetes.moreResources.jobs',
|
||||
url: '/jobs?tab',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'jobsView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/more-resources/jobs',
|
||||
},
|
||||
};
|
||||
|
||||
const serviceAccounts = {
|
||||
name: 'kubernetes.moreResources.serviceAccounts',
|
||||
url: '/serviceAccounts',
|
||||
@@ -661,6 +674,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
$stateRegistryProvider.register(ingressesEdit);
|
||||
|
||||
$stateRegistryProvider.register(moreResources);
|
||||
$stateRegistryProvider.register(jobs);
|
||||
$stateRegistryProvider.register(serviceAccounts);
|
||||
$stateRegistryProvider.register(clusterRoles);
|
||||
$stateRegistryProvider.register(roles);
|
||||
|
||||
@@ -19,7 +19,8 @@ class KubernetesConfigurationConverter {
|
||||
res.IsRegistrySecret = secret.IsRegistrySecret;
|
||||
res.SecretType = secret.SecretType;
|
||||
if (secret.Annotations) {
|
||||
res.ServiceAccountName = secret.Annotations['kubernetes.io/service-account.name'];
|
||||
const serviceAccountAnnotation = secret.Annotations.find((a) => a.key === 'kubernetes.io/service-account.name');
|
||||
res.ServiceAccountName = serviceAccountAnnotation ? serviceAccountAnnotation.value : undefined;
|
||||
}
|
||||
res.Labels = secret.Labels;
|
||||
return res;
|
||||
|
||||
@@ -109,7 +109,10 @@ class KubernetesSecretConverter {
|
||||
res.Type = formValues.customType;
|
||||
}
|
||||
if (formValues.Type === KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value) {
|
||||
res.Annotations = [{ name: 'kubernetes.io/service-account.name', value: formValues.ServiceAccountName }];
|
||||
const serviceAccountAnnotation = formValues.Annotations.find((a) => a.key === 'kubernetes.io/service-account.name');
|
||||
if (!serviceAccountAnnotation) {
|
||||
res.Annotations.push({ key: 'kubernetes.io/service-account.name', value: formValues.ServiceAccountName });
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export class KubernetesIngressConverter {
|
||||
const res = new KubernetesIngress();
|
||||
res.Name = formValues.IngressClass.Name;
|
||||
res.Namespace = formValues.Namespace;
|
||||
const pairs = _.map(formValues.Annotations, (a) => [a.Key, a.Value]);
|
||||
const pairs = _.map(formValues.Annotations, (a) => [a.key, a.value]);
|
||||
res.Annotations = _.fromPairs(pairs);
|
||||
res.Annotations[PortainerIngressClassTypes] = formValues.IngressClass.Name;
|
||||
res.IngressClassName = formValues.IngressClass.Name;
|
||||
@@ -149,8 +149,8 @@ export class KubernetesIngressConverter {
|
||||
const annotations = _.map(_.toPairs(ingress.Annotations), ([key, value]) => {
|
||||
if (key !== PortainerIngressClassTypes) {
|
||||
const annotation = new KubernetesResourcePoolIngressClassAnnotationFormValue();
|
||||
annotation.Key = key;
|
||||
annotation.Value = value;
|
||||
annotation.key = key;
|
||||
annotation.value = value;
|
||||
return annotation;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,6 +17,6 @@ export const clusterManagementModule = angular
|
||||
'resourceEventsDatatable',
|
||||
r2a(
|
||||
withUIRouter(withReactQuery(withCurrentUser(ResourceEventsDatatable))),
|
||||
['resourceId', 'storageKey', 'namespace']
|
||||
['resourceId', 'storageKey', 'namespace', 'noWidget']
|
||||
)
|
||||
).name;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { r2a } from '@/react-tools/react2angular';
|
||||
import { IngressClassDatatableAngular } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatableAngular';
|
||||
import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector';
|
||||
import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector';
|
||||
import { RegistriesSelector } from '@/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector';
|
||||
import { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm';
|
||||
import { kubeServicesValidation } from '@/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation';
|
||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
@@ -106,15 +105,6 @@ export const ngModule = angular
|
||||
'name',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'createNamespaceRegistriesSelector',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(RegistriesSelector))), [
|
||||
'inputId',
|
||||
'onChange',
|
||||
'options',
|
||||
'value',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'kubeNodesDatatable',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(NodesDatatable))), [])
|
||||
|
||||
@@ -3,26 +3,11 @@ import angular from 'angular';
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
import { NamespacesDatatable } from '@/react/kubernetes/namespaces/ListView/NamespacesDatatable';
|
||||
import { NamespaceAppsDatatable } from '@/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable';
|
||||
import { AccessDatatable } from '@/react/kubernetes/namespaces/AccessView/AccessDatatable/AccessDatatable';
|
||||
|
||||
export const namespacesModule = angular
|
||||
.module('portainer.kubernetes.react.components.namespaces', [])
|
||||
.component(
|
||||
'kubernetesNamespacesDatatable',
|
||||
r2a(withUIRouter(withCurrentUser(NamespacesDatatable)), [])
|
||||
)
|
||||
.component(
|
||||
'kubernetesNamespaceApplicationsDatatable',
|
||||
r2a(withUIRouter(withCurrentUser(NamespaceAppsDatatable)), [
|
||||
'dataset',
|
||||
'isLoading',
|
||||
'onRefresh',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'namespaceAccessDatatable',
|
||||
r2a(withUIRouter(withReactQuery(AccessDatatable)), [])
|
||||
).name;
|
||||
|
||||
@@ -19,7 +19,9 @@ 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';
|
||||
import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView';
|
||||
|
||||
export const viewsModule = angular
|
||||
.module('portainer.kubernetes.react.views', [])
|
||||
@@ -27,6 +29,10 @@ export const viewsModule = angular
|
||||
'kubernetesCreateNamespaceView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateNamespaceView))), [])
|
||||
)
|
||||
.component(
|
||||
'namespaceView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(NamespaceView))), [])
|
||||
)
|
||||
.component(
|
||||
'kubernetesNamespacesView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(NamespacesView))), [])
|
||||
@@ -84,6 +90,10 @@ export const viewsModule = angular
|
||||
'kubernetesConsoleView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(ConsoleView))), [])
|
||||
)
|
||||
.component(
|
||||
'jobsView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(JobsView))), [])
|
||||
)
|
||||
.component(
|
||||
'serviceAccountsView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(ServiceAccountsView))), [])
|
||||
|
||||
@@ -3,7 +3,7 @@ import _ from 'lodash-es';
|
||||
import filesizeParser from 'filesize-parser';
|
||||
import * as JsonPatch from 'fast-json-patch';
|
||||
import { RegistryTypes } from '@/portainer/models/registryTypes';
|
||||
import { getServices } from '@/react/kubernetes/networks/services/service';
|
||||
import { getServices } from '@/react/kubernetes/services/useNamespaceServices';
|
||||
import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models';
|
||||
import { getGlobalDeploymentOptions } from '@/react/portainer/settings/settings.service';
|
||||
|
||||
@@ -25,11 +25,11 @@ import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
||||
import { KubernetesNodeHelper } from 'Kubernetes/node/helper';
|
||||
import { updateIngress, getIngresses } from '@/react/kubernetes/ingresses/service';
|
||||
import { confirmUpdateAppIngress } from '@/react/kubernetes/applications/CreateView/UpdateIngressPrompt';
|
||||
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
|
||||
import { isVolumeUsed } from '@/react/kubernetes/volumes/utils';
|
||||
import { confirm, confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
import { ModalType } from '@@/modals';
|
||||
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
|
||||
import { isVolumeUsed } from '@/react/kubernetes/volumes/utils';
|
||||
|
||||
class KubernetesCreateApplicationController {
|
||||
/* #region CONSTRUCTOR */
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import angular from 'angular';
|
||||
import controller from './storage-class-switch.controller.js';
|
||||
|
||||
export const storageClassSwitch = {
|
||||
templateUrl: './storage-class-switch.html',
|
||||
controller,
|
||||
bindings: {
|
||||
value: '<',
|
||||
onChange: '<',
|
||||
name: '<',
|
||||
},
|
||||
};
|
||||
|
||||
angular.module('portainer.kubernetes').component('storageClassSwitch', storageClassSwitch);
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
class StorageClassSwitchController {
|
||||
/* @ngInject */
|
||||
constructor() {
|
||||
this.featureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA;
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
}
|
||||
|
||||
handleChange(value) {
|
||||
this.onChange(this.name, value);
|
||||
}
|
||||
}
|
||||
|
||||
export default StorageClassSwitchController;
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
data-cy="'k8sNamespaceCreate-enableQuotaToggle'"
|
||||
label="'Enable quota'"
|
||||
label-class="'col-sm-3 col-lg-2'"
|
||||
name="'k8s-resourcepool-storagequota'"
|
||||
feature-id="$ctrl.featureId"
|
||||
checked="$ctrl.value"
|
||||
on-change="($ctrl.handleChange)"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,278 +0,0 @@
|
||||
<page-header
|
||||
ng-if="$ctrl.state.viewReady"
|
||||
title="'Create a namespace'"
|
||||
breadcrumbs="[{ label:'Namespaces', link:'kubernetes.resourcePools' }, 'Create a namespace']"
|
||||
reload="true"
|
||||
></page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="$ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="$ctrl.state.viewReady">
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" autocomplete="off" name="resourcePoolCreationForm">
|
||||
<!-- #region NAME INPUT -->
|
||||
<div class="form-group">
|
||||
<label for="pool_name" class="col-sm-3 col-lg-2 control-label required text-left">Name</label>
|
||||
<div class="col-sm-8">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="pool_name"
|
||||
ng-model="$ctrl.formValues.Name"
|
||||
ng-pattern="/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/"
|
||||
ng-change="$ctrl.onChangeName()"
|
||||
placeholder="my-project"
|
||||
data-cy="k8sNamespaceCreate-namespaceNameInput"
|
||||
required
|
||||
auto-focus
|
||||
/>
|
||||
<span class="help-block">
|
||||
<div class="form-group" ng-show="resourcePoolCreationForm.pool_name.$invalid || $ctrl.state.isAlreadyExist || $ctrl.state.hasPrefixKube">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="resourcePoolCreationForm.pool_name.$error">
|
||||
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>This field is required.</p>
|
||||
<p class="vertical-center" ng-message="pattern"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This field must consist of lower case alphanumeric characters or '-', and contain at most 63
|
||||
characters, and must start and end with an alphanumeric character.</p
|
||||
>
|
||||
</div>
|
||||
<p class="vertical-center" ng-if="$ctrl.state.hasPrefixKube"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Prefix "kube-" is reserved for Kubernetes system namespaces.</p
|
||||
>
|
||||
<p class="vertical-center" ng-if="$ctrl.state.isAlreadyExist">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> A namespace with the same name already exists.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 !p-0">
|
||||
<annotations-be-teaser></annotations-be-teaser>
|
||||
</div>
|
||||
|
||||
<!-- #endregion -->
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Quota </div>
|
||||
<!-- #region QUOTA -->
|
||||
<!-- quotas-switch -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
<p class="vertical-center">
|
||||
<pr-icon class="vertical-center" icon="'info'" mode="'primary'"></pr-icon>
|
||||
A namespace segments the underlying physical Kubernetes cluster into smaller virtual clusters. You should assign a capped limit of resources to this namespace or
|
||||
disable for the safe operation of your platform.
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
data-cy="'k8sNamespaceCreate-resourceAssignmentToggle'"
|
||||
label="'Resource assignment'"
|
||||
label-class="'col-sm-3 col-lg-2'"
|
||||
name="'k8s-resourcepool-resourcequota'"
|
||||
checked="$ctrl.formValues.HasQuota"
|
||||
on-change="($ctrl.onToggleResourceQuota)"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !quotas-switch -->
|
||||
<div ng-if="$ctrl.formValues.HasQuota">
|
||||
<div class="col-sm-12 form-section-title"> Resource limits </div>
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 small text-warning" ng-switch on="$ctrl.formValues.HasQuota && !$ctrl.isQuotaValid()">
|
||||
<p class="vertical-center mb-0" ng-switch-when="true"
|
||||
><pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon> At least a single limit must be set for the quota to be valid.
|
||||
</p>
|
||||
<p class="vertical-center mb-0" ng-switch-default></p>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- memory-limit-input -->
|
||||
<div class="form-group !mb-0 flex flex-row">
|
||||
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label text-left"> Memory limit (MB) </label>
|
||||
<div class="col-xs-6">
|
||||
<por-slider
|
||||
min="$ctrl.defaults.MemoryLimit"
|
||||
max="$ctrl.state.sliderMaxMemory"
|
||||
step="128"
|
||||
ng-if="$ctrl.state.sliderMaxMemory"
|
||||
value="$ctrl.formValues.MemoryLimit"
|
||||
on-change="($ctrl.handleMemoryLimitChange)"
|
||||
visible-tooltip="true"
|
||||
data-cy="k8sNamespaceCreate-memoryLimitSlider"
|
||||
></por-slider>
|
||||
</div>
|
||||
<div class="col-sm-2 vertical-center pt-6">
|
||||
<input
|
||||
name="memory_limit"
|
||||
type="number"
|
||||
min="{{ $ctrl.defaults.MemoryLimit }}"
|
||||
max="{{ $ctrl.state.sliderMaxMemory }}"
|
||||
class="form-control"
|
||||
ng-model="$ctrl.formValues.MemoryLimit"
|
||||
id="memory-limit"
|
||||
data-cy="k8sNamespaceCreate-memoryLimitInput"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-row">
|
||||
<span class="col-sm-3 col-lg-2"></span>
|
||||
<span class="help-block col-sm-9 col-lg-10">
|
||||
<div ng-show="resourcePoolCreationForm.memory_limit.$invalid">
|
||||
<div class="small text-warning">
|
||||
<div ng-messages="resourcePoolCreationForm.pool_name.$error">
|
||||
<p class="vertical-center"
|
||||
><pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon> Value must be between {{ $ctrl.defaults.MemoryLimit }} and
|
||||
{{ $ctrl.state.sliderMaxMemory }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<!-- !memory-limit-input -->
|
||||
<!-- cpu-limit-input -->
|
||||
<div class="form-group flex flex-row">
|
||||
<label for="cpu-limit" class="col-sm-3 col-lg-2 control-label text-left"> CPU limit </label>
|
||||
<div class="col-xs-8">
|
||||
<por-slider
|
||||
min="$ctrl.defaults.CpuLimit"
|
||||
max="$ctrl.state.sliderMaxCpu"
|
||||
step="0.1"
|
||||
ng-if="$ctrl.state.sliderMaxCpu"
|
||||
value="$ctrl.formValues.CpuLimit"
|
||||
on-change="($ctrl.handleCpuLimitChange)"
|
||||
data-cy="k8sNamespaceCreate-cpuLimitSlider"
|
||||
visible-tooltip="true"
|
||||
></por-slider>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !cpu-limit-input -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- #region LOAD-BALANCERS -->
|
||||
<div class="col-sm-12 form-section-title"> Load balancers </div>
|
||||
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'" class="vertical-center"></pr-icon>
|
||||
You can set a quota on the amount of external load balancers that can be created inside this namespace. Set this quota to 0 to effectively disable the use of load
|
||||
balancers in this namespace.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
data-cy="'k8sNamespaceCreate-loadBalancerQuotaToggle'"
|
||||
label="'Load Balancer quota'"
|
||||
label-class="'col-sm-3 col-lg-2'"
|
||||
name="'k8s-resourcepool-lbquota'"
|
||||
feature-id="$ctrl.LBQuotaFeatureId"
|
||||
checked="$ctrl.formValues.UseLoadBalancersQuota"
|
||||
on-change="($ctrl.onToggleLoadBalancerQuota)"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
||||
<div ng-if="$ctrl.state.ingressAvailabilityPerNamespace">
|
||||
<!-- #region INGRESSES -->
|
||||
<div class="col-sm-12 form-section-title"> Networking </div>
|
||||
<ingress-class-datatable
|
||||
ng-if="$ctrl.state.ingressAvailabilityPerNamespace"
|
||||
on-change-controllers="($ctrl.onChangeIngressControllerAvailability)"
|
||||
ingress-controllers="$ctrl.ingressControllers"
|
||||
initial-ingress-controllers="$ctrl.initialIngressControllers"
|
||||
description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'"
|
||||
no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'"
|
||||
view="'namespace'"
|
||||
></ingress-class-datatable>
|
||||
<!-- #endregion -->
|
||||
</div>
|
||||
|
||||
<!-- #region REGISTRIES -->
|
||||
<div class="col-sm-12 form-section-title"> Registries </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
Define which registries can be used by users who have access to this namespace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-3 col-lg-2 control-label !pt-0 text-left" for="registries-selector"> Select registries </label>
|
||||
<div class="col-sm-8 col-lg-9">
|
||||
<span class="small text-muted" ng-if="!$ctrl.registries.length && $ctrl.state.isAdmin">
|
||||
No registries available. Head over to the <a ui-sref="portainer.registries">registry view</a> to define a container registry.
|
||||
</span>
|
||||
<span class="small text-muted" ng-if="!$ctrl.registries.length && !$ctrl.state.isAdmin">
|
||||
No registries available. Contact your administrator to create a container registry.
|
||||
</span>
|
||||
<create-namespace-registries-selector
|
||||
input-id="'registries-selector'"
|
||||
value="$ctrl.formValues.Registries"
|
||||
on-change="($ctrl.onRegistriesChange)"
|
||||
options="$ctrl.registries"
|
||||
>
|
||||
</create-namespace-registries-selector>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- #region STORAGES -->
|
||||
<div class="col-sm-12 form-section-title"> Storage </div>
|
||||
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
Quotas can be set on each storage option to prevent users from exceeding a specific threshold when deploying applications. You can set a quota to 0 to effectively
|
||||
prevent the usage of a specific storage option inside this namespace.
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12 form-section-title vertical-center">
|
||||
<pr-icon icon="'svg-route'"></pr-icon>
|
||||
standard
|
||||
</div>
|
||||
|
||||
<storage-class-switch value="sc.Selected" name="sc.Name" on-change="(ctrl.onToggleStorageQuota)" authorization="K8sResourcePoolDetailsW"></storage-class-switch>
|
||||
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- summary -->
|
||||
<kubernetes-summary-view ng-if="resourcePoolCreationForm.$valid && !$ctrl.isCreateButtonDisabled()" form-values="$ctrl.formValues"></kubernetes-summary-view>
|
||||
<!-- !summary -->
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Actions </div>
|
||||
<!-- #region ACTIONS -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="!resourcePoolCreationForm.$valid || $ctrl.isCreateButtonDisabled()"
|
||||
ng-click="$ctrl.createResourcePool()"
|
||||
button-spinner="$ctrl.state.actionInProgress"
|
||||
>
|
||||
<span ng-hide="$ctrl.state.actionInProgress" data-cy="k8sNamespace-createNamespaceButton">Create namespace</span>
|
||||
<span ng-show="$ctrl.state.actionInProgress">Creation in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #endregion -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,236 +0,0 @@
|
||||
import _ from 'lodash-es';
|
||||
import filesizeParser from 'filesize-parser';
|
||||
import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models';
|
||||
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
|
||||
import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassHostFormValue } from 'Kubernetes/models/resource-pool/formValues';
|
||||
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
|
||||
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
|
||||
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap';
|
||||
|
||||
class KubernetesCreateResourcePoolController {
|
||||
/* #region CONSTRUCTOR */
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $scope, Notifications, KubernetesNodeService, KubernetesResourcePoolService, KubernetesIngressService, Authentication, EndpointService) {
|
||||
Object.assign(this, {
|
||||
$async,
|
||||
$state,
|
||||
$scope,
|
||||
Notifications,
|
||||
KubernetesNodeService,
|
||||
KubernetesResourcePoolService,
|
||||
KubernetesIngressService,
|
||||
Authentication,
|
||||
EndpointService,
|
||||
});
|
||||
|
||||
this.IngressClassTypes = KubernetesIngressClassTypes;
|
||||
this.EndpointService = EndpointService;
|
||||
this.LBQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_LB_QUOTA;
|
||||
|
||||
this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this);
|
||||
this.onToggleLoadBalancerQuota = this.onToggleLoadBalancerQuota.bind(this);
|
||||
this.onToggleResourceQuota = this.onToggleResourceQuota.bind(this);
|
||||
this.onChangeIngressControllerAvailability = this.onChangeIngressControllerAvailability.bind(this);
|
||||
this.onRegistriesChange = this.onRegistriesChange.bind(this);
|
||||
this.handleMemoryLimitChange = this.handleMemoryLimitChange.bind(this);
|
||||
this.handleCpuLimitChange = this.handleCpuLimitChange.bind(this);
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
onRegistriesChange(registries) {
|
||||
return this.$scope.$evalAsync(() => {
|
||||
this.formValues.Registries = registries;
|
||||
});
|
||||
}
|
||||
|
||||
onToggleStorageQuota(storageClassName, enabled) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled }));
|
||||
});
|
||||
}
|
||||
|
||||
onToggleLoadBalancerQuota(enabled) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.UseLoadBalancersQuota = enabled;
|
||||
});
|
||||
}
|
||||
|
||||
onToggleResourceQuota(enabled) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.HasQuota = enabled;
|
||||
});
|
||||
}
|
||||
|
||||
/* #region INGRESS MANAGEMENT */
|
||||
onChangeIngressControllerAvailability(controllerClassMap) {
|
||||
this.ingressControllers = controllerClassMap;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
isCreateButtonDisabled() {
|
||||
return (
|
||||
this.state.actionInProgress ||
|
||||
(this.formValues.HasQuota && !this.isQuotaValid()) ||
|
||||
this.state.isAlreadyExist ||
|
||||
this.state.hasPrefixKube ||
|
||||
this.state.duplicates.ingressHosts.hasRefs
|
||||
);
|
||||
}
|
||||
|
||||
onChangeName() {
|
||||
this.state.isAlreadyExist = _.find(this.resourcePools, (resourcePool) => resourcePool.Namespace.Name === this.formValues.Name) !== undefined;
|
||||
this.state.hasPrefixKube = /^kube-/.test(this.formValues.Name);
|
||||
}
|
||||
|
||||
isQuotaValid() {
|
||||
if (
|
||||
this.state.sliderMaxCpu < this.formValues.CpuLimit ||
|
||||
this.state.sliderMaxMemory < this.formValues.MemoryLimit ||
|
||||
(this.formValues.CpuLimit === 0 && this.formValues.MemoryLimit === 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
checkDefaults() {
|
||||
if (this.formValues.CpuLimit < this.defaults.CpuLimit) {
|
||||
this.formValues.CpuLimit = this.defaults.CpuLimit;
|
||||
}
|
||||
if (this.formValues.MemoryLimit < KubernetesResourceReservationHelper.megaBytesValue(this.defaults.MemoryLimit)) {
|
||||
this.formValues.MemoryLimit = KubernetesResourceReservationHelper.megaBytesValue(this.defaults.MemoryLimit);
|
||||
}
|
||||
}
|
||||
|
||||
handleMemoryLimitChange(memoryLimit) {
|
||||
return this.$async(async () => {
|
||||
this.formValues.MemoryLimit = memoryLimit;
|
||||
});
|
||||
}
|
||||
|
||||
handleCpuLimitChange(cpuLimit) {
|
||||
return this.$async(async () => {
|
||||
this.formValues.CpuLimit = cpuLimit;
|
||||
});
|
||||
}
|
||||
|
||||
/* #region CREATE NAMESPACE */
|
||||
createResourcePool() {
|
||||
return this.$async(async () => {
|
||||
this.state.actionInProgress = true;
|
||||
try {
|
||||
this.checkDefaults();
|
||||
this.formValues.Owner = this.Authentication.getUserDetails().username;
|
||||
await this.KubernetesResourcePoolService.create(this.formValues);
|
||||
await updateIngressControllerClassMap(this.endpoint.Id, this.ingressControllers || [], this.formValues.Name);
|
||||
this.Notifications.success('Namespace successfully created', this.formValues.Name);
|
||||
this.$state.go('kubernetes.resourcePools');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to create namespace');
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region GET INGRESSES */
|
||||
getIngresses() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.allIngresses = await this.KubernetesIngressService.get();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve ingresses.');
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region GET NAMESPACES */
|
||||
getResourcePools() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.resourcePools = await this.KubernetesResourcePoolService.get('', { getQuota: true });
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve namespaces');
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region GET REGISTRIES */
|
||||
getRegistries() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.registries = await this.EndpointService.registries(this.endpoint.Id);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve registries');
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region ON INIT */
|
||||
$onInit() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
const endpoint = await this.EndpointService.endpoint(this.endpoint.Id);
|
||||
this.defaults = KubernetesResourceQuotaDefaults;
|
||||
this.formValues = new KubernetesResourcePoolFormValues(this.defaults);
|
||||
this.formValues.EndpointId = this.endpoint.Id;
|
||||
this.formValues.HasQuota = false;
|
||||
|
||||
this.state = {
|
||||
actionInProgress: false,
|
||||
sliderMaxMemory: 0,
|
||||
sliderMaxCpu: 0,
|
||||
viewReady: false,
|
||||
isAlreadyExist: false,
|
||||
hasPrefixKube: false,
|
||||
canUseIngress: false,
|
||||
duplicates: {
|
||||
ingressHosts: new KubernetesFormValidationReferences(),
|
||||
},
|
||||
isAdmin: this.Authentication.isAdmin(),
|
||||
ingressAvailabilityPerNamespace: endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace,
|
||||
};
|
||||
|
||||
const nodes = await this.KubernetesNodeService.get();
|
||||
|
||||
this.ingressControllers = [];
|
||||
if (this.state.ingressAvailabilityPerNamespace) {
|
||||
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, allowedOnly: true });
|
||||
this.initialIngressControllers = structuredClone(this.ingressControllers);
|
||||
}
|
||||
|
||||
_.forEach(nodes, (item) => {
|
||||
this.state.sliderMaxMemory += filesizeParser(item.Memory);
|
||||
this.state.sliderMaxCpu += item.CPU;
|
||||
});
|
||||
this.state.sliderMaxMemory = KubernetesResourceReservationHelper.megaBytesValue(this.state.sliderMaxMemory);
|
||||
await this.getResourcePools();
|
||||
if (this.state.canUseIngress) {
|
||||
await this.getIngresses();
|
||||
const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses;
|
||||
this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses);
|
||||
}
|
||||
_.forEach(this.formValues.IngressClasses, (ic) => {
|
||||
if (ic.Hosts.length === 0) {
|
||||
ic.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue());
|
||||
}
|
||||
});
|
||||
|
||||
await this.getRegistries();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to load view data');
|
||||
} finally {
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
}
|
||||
|
||||
export default KubernetesCreateResourcePoolController;
|
||||
@@ -1,302 +0,0 @@
|
||||
<page-header
|
||||
ng-if="ctrl.state.viewReady"
|
||||
title="'Namespace details'"
|
||||
breadcrumbs="[{ label:'Namespaces', link:'kubernetes.resourcePools' }, ctrl.pool.Namespace.Name]"
|
||||
reload="true"
|
||||
></page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="ctrl.state.viewReady">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<uib-tabset active="ctrl.state.activeTab" justified="true" type="pills">
|
||||
<uib-tab index="0" classes="btn-sm" select="ctrl.selectTab(0)">
|
||||
<uib-tab-heading class="vertical-center"> <pr-icon icon="'layers'"></pr-icon> Namespace </uib-tab-heading>
|
||||
<form class="form-horizontal widget-body" autocomplete="off" name="resourcePoolEditForm" style="margin-top: 10px">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="w-[40%]">Name</td>
|
||||
<td>
|
||||
{{ ctrl.pool.Namespace.Name }}
|
||||
<span class="label label-info image-tag label-margins" ng-if="ctrl.isSystem">system</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="col-sm-12 !p-0">
|
||||
<annotations-be-teaser></annotations-be-teaser>
|
||||
</div>
|
||||
|
||||
<!-- !name-input -->
|
||||
<div ng-if="ctrl.isAdmin && ctrl.isEditable" class="col-sm-12 form-section-title">Resource quota</div>
|
||||
<!-- quotas-switch -->
|
||||
<div ng-if="ctrl.isAdmin && ctrl.isEditable" class="form-group">
|
||||
<div class="col-sm-12 mt-2" ng-if="ctrl.state.resourceOverCommitEnabled">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-3 col-lg-2">
|
||||
<label class="control-label text-left"> Resource assignment </label>
|
||||
</div>
|
||||
<div class="col-sm-9 pt-2">
|
||||
<label class="switch">
|
||||
<input type="checkbox" ng-model="ctrl.formValues.HasQuota" />
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="ctrl.formValues.HasQuota">
|
||||
<kubernetes-resource-reservation
|
||||
ng-if="ctrl.pool.Quota"
|
||||
description="Resource reservation represents the total amount of resource assigned to all the applications deployed inside this namespace."
|
||||
cpu-reservation="ctrl.state.resourceReservation.CPU"
|
||||
memory-reservation="ctrl.state.resourceReservation.Memory"
|
||||
cpu-limit="ctrl.formValues.CpuLimit"
|
||||
memory-limit="ctrl.formValues.MemoryLimit"
|
||||
display-usage="ctrl.state.useServerMetrics"
|
||||
cpu-usage="ctrl.state.resourceUsage.CPU"
|
||||
memory-usage="ctrl.state.resourceUsage.Memory"
|
||||
>
|
||||
</kubernetes-resource-reservation>
|
||||
</div>
|
||||
<!-- !quotas-switch -->
|
||||
<div ng-if="ctrl.formValues.HasQuota && ctrl.isAdmin && ctrl.isEditable">
|
||||
<div class="col-sm-12 form-section-title"> Resource limits </div>
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 small text-warning" ng-switch on="ctrl.formValues.HasQuota && ctrl.isAdmin && ctrl.isEditable && !ctrl.isQuotaValid()">
|
||||
<p class="vertical-center mb-0" ng-switch-when="true"
|
||||
><pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon> At least a single limit must be set for the quota to be valid.
|
||||
</p>
|
||||
<p class="vertical-center mb-0" ng-switch-default></p>
|
||||
</span>
|
||||
</div>
|
||||
<!-- memory-limit-input -->
|
||||
<div class="form-group flex">
|
||||
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label vertical-center text-left"> Memory limit (MB) </label>
|
||||
<div class="col-sm-6">
|
||||
<por-slider
|
||||
min="ctrl.ResourceQuotaDefaults.MemoryLimit"
|
||||
max="ctrl.state.sliderMaxMemory"
|
||||
step="128"
|
||||
ng-if="ctrl.state.sliderMaxMemory"
|
||||
value="ctrl.formValues.MemoryLimit"
|
||||
on-change="(ctrl.handleMemoryLimitChange)"
|
||||
visible-tooltip="true"
|
||||
data-cy="k8sNamespaceEdit-memoryLimitSlider"
|
||||
></por-slider>
|
||||
</div>
|
||||
<div class="col-sm-2 vertical-center pt-6">
|
||||
<input
|
||||
name="memory_limit"
|
||||
type="number"
|
||||
data-cy="k8sNamespaceEdit-memoryLimitInput"
|
||||
min="{{ ctrl.ResourceQuotaDefaults.MemoryLimit }}"
|
||||
max="{{ ctrl.state.sliderMaxMemory }}"
|
||||
class="form-control"
|
||||
ng-model="ctrl.formValues.MemoryLimit"
|
||||
id="memory-limit"
|
||||
data-cy="k8sNamespaceEdit-memoryLimitInput"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="resourcePoolEditForm.memory_limit.$invalid">
|
||||
<div class="col-sm-3 col-lg-2"></div>
|
||||
<div class="col-sm-8 small text-warning">
|
||||
<div ng-messages="resourcePoolEditForm.pool_name.$error">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Value must be between {{ ctrl.ResourceQuotaDefaults.MemoryLimit }} and
|
||||
{{ ctrl.state.sliderMaxMemory }}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !memory-limit-input -->
|
||||
<!-- cpu-limit-input -->
|
||||
<div class="form-group">
|
||||
<label for="cpu-limit" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px"> CPU limit </label>
|
||||
<div class="col-sm-8">
|
||||
<por-slider
|
||||
min="ctrl.ResourceQuotaDefaults.CpuLimit"
|
||||
max="ctrl.state.sliderMaxCpu"
|
||||
step="0.1"
|
||||
ng-if="ctrl.state.sliderMaxCpu"
|
||||
value="ctrl.formValues.CpuLimit"
|
||||
on-change="(ctrl.handleCpuLimitChange)"
|
||||
data-cy="k8sNamespaceEdit-cpuLimitSlider"
|
||||
visible-tooltip="true"
|
||||
></por-slider>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !cpu-limit-input -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- #region LOADBALANCERS -->
|
||||
<div class="col-sm-12 form-section-title"> Load balancers </div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
You can set a quota on the amount of external load balancers that can be created inside this namespace. Set this quota to 0 to effectively disable the use of
|
||||
load balancers in this namespace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
data-cy="'k8sNamespaceCreate-loadBalancerQuotaToggle'"
|
||||
label="'Load Balancer quota'"
|
||||
label-class="'col-sm-3 col-lg-2'"
|
||||
name="'k8s-resourcepool-Lbquota'"
|
||||
feature-id="ctrl.LBQuotaFeatureId"
|
||||
checked="ctrl.formValues.UseLoadBalancersQuota"
|
||||
on-change="(ctrl.onToggleLoadBalancersQuota)"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
<div ng-if="ctrl.isAdmin && ctrl.isEditable && ctrl.state.ingressAvailabilityPerNamespace">
|
||||
<!-- #region INGRESSES -->
|
||||
<div class="col-sm-12 form-section-title"> Networking </div>
|
||||
<ingress-class-datatable
|
||||
ng-if="ctrl.state.ingressAvailabilityPerNamespace"
|
||||
on-change-controllers="(ctrl.onChangeIngressControllerAvailability)"
|
||||
ingress-controllers="ctrl.ingressControllers"
|
||||
initial-ingress-controllers="$ctrl.initialIngressControllers"
|
||||
description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'"
|
||||
no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'"
|
||||
view="'namespace'"
|
||||
></ingress-class-datatable>
|
||||
|
||||
<!-- #endregion -->
|
||||
</div>
|
||||
<!-- #region REGISTRIES -->
|
||||
<div>
|
||||
<div class="col-sm-12 form-section-title"> Registries </div>
|
||||
|
||||
<div class="form-group" ng-if="!ctrl.isAdmin || ctrl.isSystem">
|
||||
<label class="col-sm-3 col-lg-2 control-label text-left" style="padding-top: 0"> Selected registries </label>
|
||||
<div class="col-sm-9 col-lg-4"> {{ ctrl.selectedRegistries ? ctrl.selectedRegistries : 'None' }} </div>
|
||||
</div>
|
||||
|
||||
<div ng-if="ctrl.isAdmin && !ctrl.isSystem">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
Define which registries can be used by users who have access to this namespace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-3 col-lg-2 control-label !pt-0 text-left" for="registries-selector"> Select registries </label>
|
||||
<div class="col-sm-9 col-lg-4">
|
||||
<create-namespace-registries-selector
|
||||
input-id="'registries-selector'"
|
||||
value="ctrl.formValues.Registries"
|
||||
on-change="(ctrl.onRegistriesChange)"
|
||||
options="ctrl.registries"
|
||||
>
|
||||
</create-namespace-registries-selector>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- #region STORAGES -->
|
||||
<div class="col-sm-12 form-section-title"> Storage </div>
|
||||
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
Quotas can be set on each storage option to prevent users from exceeding a specific threshold when deploying applications. You can set a quota to 0 to
|
||||
effectively prevent the usage of a specific storage option inside this namespace.
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-section-title text-muted col-sm-12" style="width: 100%">
|
||||
<div class="vertical-center"> <pr-icon icon="'svg-route'"></pr-icon>standard </div>
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
<storage-class-switch value="sc.Selected" name="sc.Name" on-change="(ctrl.onToggleStorageQuota)" authorization="K8sResourcePoolDetailsW"> </storage-class-switch>
|
||||
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- summary -->
|
||||
<kubernetes-summary-view
|
||||
ng-if="resourcePoolEditForm.$valid && !ctrl.isUpdateButtonDisabled()"
|
||||
form-values="ctrl.formValues"
|
||||
old-form-values="ctrl.savedFormValues"
|
||||
></kubernetes-summary-view>
|
||||
<!-- !summary -->
|
||||
|
||||
<!-- actions -->
|
||||
<div ng-if="ctrl.isAdmin" class="col-sm-12 form-section-title"> Actions </div>
|
||||
<div ng-if="ctrl.isAdmin" class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
ng-if="!ctrl.isSystem"
|
||||
class="btn btn-primary btn-sm !ml-0 !mr-1"
|
||||
ng-disabled="!resourcePoolEditForm.$valid || ctrl.isUpdateButtonDisabled()"
|
||||
ng-click="ctrl.updateResourcePool()"
|
||||
button-spinner="ctrl.state.actionInProgress"
|
||||
>
|
||||
<span ng-hide="ctrl.state.actionInProgress" data-cy="k8sNamespaceEdit-updateNamespaceButton">Update namespace</span>
|
||||
<span ng-show="ctrl.state.actionInProgress">Update in progress...</span>
|
||||
</button>
|
||||
<button
|
||||
ng-if="!ctrl.isDefaultNamespace"
|
||||
type="button"
|
||||
class="btn btn-light btn-sm !ml-0"
|
||||
ng-click="ctrl.markUnmarkAsSystem()"
|
||||
button-spinner="ctrl.state.actionInProgress"
|
||||
data-cy="k8sNamespaceEdit-markSystem"
|
||||
>
|
||||
<span ng-if="ctrl.isSystem">Unmark as system</span>
|
||||
<span ng-if="!ctrl.isSystem">Mark as system</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</uib-tab>
|
||||
<uib-tab index="1" classes="btn-sm" select="ctrl.selectTab(1)">
|
||||
<uib-tab-heading class="vertical-center">
|
||||
<pr-icon icon="'history'"></pr-icon> Events
|
||||
<div ng-if="ctrl.hasEventWarnings()">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'" class-name="'mr-0.5'"></pr-icon>
|
||||
{{ ctrl.state.eventWarningCount }} warning(s)
|
||||
</div>
|
||||
</uib-tab-heading>
|
||||
<resource-events-datatable namespace="ctrl.pool.Namespace.Name" storage-key="'kubernetes.resourcepool.events'"> </resource-events-datatable>
|
||||
</uib-tab>
|
||||
<uib-tab index="2" ng-if="ctrl.pool.Yaml" select="ctrl.showEditor()" classes="btn-sm">
|
||||
<uib-tab-heading class="vertical-center"><pr-icon icon="'code'"></pr-icon> YAML </uib-tab-heading>
|
||||
<div class="px-5" ng-if="ctrl.state.showEditorTab">
|
||||
<kube-yaml-inspector identifier="'namespace-yaml'" data="ctrl.pool.Yaml" data-cy="k8sNamespaceEdit-yamlEditor" hide-message="true" />
|
||||
</div>
|
||||
</uib-tab>
|
||||
</uib-tabset>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="ctrl.applications && ctrl.applications.length > 0">
|
||||
<kubernetes-namespace-applications-datatable dataset="ctrl.applications" on-refresh="(ctrl.getApplications)" is-loading="ctrl.state.applicationsLoading">
|
||||
</kubernetes-namespace-applications-datatable>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,8 +0,0 @@
|
||||
angular.module('portainer.kubernetes').component('kubernetesResourcePoolView', {
|
||||
templateUrl: './resourcePool.html',
|
||||
controller: 'KubernetesResourcePoolController',
|
||||
controllerAs: 'ctrl',
|
||||
bindings: {
|
||||
endpoint: '<',
|
||||
},
|
||||
});
|
||||
@@ -1,405 +0,0 @@
|
||||
import angular from 'angular';
|
||||
import _ from 'lodash-es';
|
||||
import filesizeParser from 'filesize-parser';
|
||||
import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models';
|
||||
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
|
||||
import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models';
|
||||
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
|
||||
|
||||
import { KubernetesResourcePoolFormValues } from 'Kubernetes/models/resource-pool/formValues';
|
||||
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
|
||||
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
|
||||
import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota';
|
||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap';
|
||||
import { confirmUpdate } from '@@/modals/confirm';
|
||||
import { confirmUpdateNamespace } from '@/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace';
|
||||
import { getMetricsForAllPods } from '@/react/kubernetes/metrics/metrics.ts';
|
||||
|
||||
class KubernetesResourcePoolController {
|
||||
/* #region CONSTRUCTOR */
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$async,
|
||||
$state,
|
||||
$scope,
|
||||
Authentication,
|
||||
Notifications,
|
||||
LocalStorage,
|
||||
EndpointService,
|
||||
KubernetesResourceQuotaService,
|
||||
KubernetesResourcePoolService,
|
||||
KubernetesEventService,
|
||||
KubernetesPodService,
|
||||
KubernetesApplicationService,
|
||||
KubernetesIngressService,
|
||||
KubernetesVolumeService,
|
||||
KubernetesNamespaceService,
|
||||
KubernetesNodeService
|
||||
) {
|
||||
Object.assign(this, {
|
||||
$async,
|
||||
$state,
|
||||
$scope,
|
||||
Authentication,
|
||||
Notifications,
|
||||
LocalStorage,
|
||||
EndpointService,
|
||||
KubernetesResourceQuotaService,
|
||||
KubernetesResourcePoolService,
|
||||
KubernetesEventService,
|
||||
KubernetesPodService,
|
||||
KubernetesApplicationService,
|
||||
KubernetesIngressService,
|
||||
KubernetesVolumeService,
|
||||
KubernetesNamespaceService,
|
||||
KubernetesNodeService,
|
||||
});
|
||||
|
||||
this.IngressClassTypes = KubernetesIngressClassTypes;
|
||||
this.ResourceQuotaDefaults = KubernetesResourceQuotaDefaults;
|
||||
this.EndpointService = EndpointService;
|
||||
|
||||
this.LBQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_LB_QUOTA;
|
||||
this.StorageQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA;
|
||||
this.StorageQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA;
|
||||
|
||||
this.updateResourcePoolAsync = this.updateResourcePoolAsync.bind(this);
|
||||
this.getEvents = this.getEvents.bind(this);
|
||||
this.onToggleLoadBalancersQuota = this.onToggleLoadBalancersQuota.bind(this);
|
||||
this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this);
|
||||
this.onChangeIngressControllerAvailability = this.onChangeIngressControllerAvailability.bind(this);
|
||||
this.onRegistriesChange = this.onRegistriesChange.bind(this);
|
||||
this.handleMemoryLimitChange = this.handleMemoryLimitChange.bind(this);
|
||||
this.handleCpuLimitChange = this.handleCpuLimitChange.bind(this);
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
onRegistriesChange(registries) {
|
||||
return this.$scope.$evalAsync(() => {
|
||||
this.formValues.Registries = registries;
|
||||
});
|
||||
}
|
||||
|
||||
onToggleLoadBalancersQuota(checked) {
|
||||
return this.$scope.$evalAsync(() => {
|
||||
this.formValues.UseLoadBalancersQuota = checked;
|
||||
});
|
||||
}
|
||||
|
||||
onToggleStorageQuota(storageClassName, enabled) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled }));
|
||||
});
|
||||
}
|
||||
|
||||
onChangeIngressControllerAvailability(controllerClassMap) {
|
||||
this.ingressControllers = controllerClassMap;
|
||||
}
|
||||
|
||||
selectTab(index) {
|
||||
this.LocalStorage.storeActiveTab('resourcePool', index);
|
||||
}
|
||||
|
||||
isUpdateButtonDisabled() {
|
||||
return this.state.actionInProgress || (this.formValues.HasQuota && !this.isQuotaValid()) || this.state.duplicates.ingressHosts.hasRefs;
|
||||
}
|
||||
|
||||
isQuotaValid() {
|
||||
if (
|
||||
this.state.sliderMaxCpu < this.formValues.CpuLimit ||
|
||||
this.state.sliderMaxMemory < this.formValues.MemoryLimit ||
|
||||
(this.formValues.CpuLimit === 0 && this.formValues.MemoryLimit === 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
checkDefaults() {
|
||||
if (this.formValues.CpuLimit < KubernetesResourceQuotaDefaults.CpuLimit) {
|
||||
this.formValues.CpuLimit = KubernetesResourceQuotaDefaults.CpuLimit;
|
||||
}
|
||||
if (this.formValues.MemoryLimit < KubernetesResourceReservationHelper.megaBytesValue(KubernetesResourceQuotaDefaults.MemoryLimit)) {
|
||||
this.formValues.MemoryLimit = KubernetesResourceReservationHelper.megaBytesValue(KubernetesResourceQuotaDefaults.MemoryLimit);
|
||||
}
|
||||
}
|
||||
|
||||
handleMemoryLimitChange(memoryLimit) {
|
||||
return this.$async(async () => {
|
||||
this.formValues.MemoryLimit = memoryLimit;
|
||||
});
|
||||
}
|
||||
|
||||
handleCpuLimitChange(cpuLimit) {
|
||||
return this.$async(async () => {
|
||||
this.formValues.CpuLimit = cpuLimit;
|
||||
});
|
||||
}
|
||||
|
||||
showEditor() {
|
||||
this.state.showEditorTab = true;
|
||||
this.selectTab(2);
|
||||
}
|
||||
|
||||
hasResourceQuotaBeenReduced() {
|
||||
if (this.formValues.HasQuota && this.oldQuota) {
|
||||
const cpuLimit = this.formValues.CpuLimit;
|
||||
const memoryLimit = KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit);
|
||||
if (cpuLimit < this.oldQuota.CpuLimit || memoryLimit < this.oldQuota.MemoryLimit) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* #region UPDATE NAMESPACE */
|
||||
async updateResourcePoolAsync(oldFormValues, newFormValues) {
|
||||
this.state.actionInProgress = true;
|
||||
try {
|
||||
this.checkDefaults();
|
||||
await this.KubernetesResourcePoolService.patch(oldFormValues, newFormValues);
|
||||
await updateIngressControllerClassMap(this.endpoint.Id, this.ingressControllers || [], this.formValues.Name);
|
||||
this.Notifications.success('Namespace successfully updated', this.pool.Namespace.Name);
|
||||
this.$state.reload(this.$state.current);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to create namespace');
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
updateResourcePool() {
|
||||
const ingressesToDelete = _.filter(this.formValues.IngressClasses, { WasSelected: true, Selected: false });
|
||||
const registriesToDelete = _.filter(this.registries, { WasChecked: true, Checked: false });
|
||||
const warnings = {
|
||||
quota: this.hasResourceQuotaBeenReduced(),
|
||||
ingress: ingressesToDelete.length !== 0,
|
||||
registries: registriesToDelete.length !== 0,
|
||||
};
|
||||
|
||||
if (warnings.quota || warnings.registries) {
|
||||
confirmUpdateNamespace(warnings.quota, warnings.ingress, warnings.registries).then((confirmed) => {
|
||||
if (confirmed) {
|
||||
return this.$async(this.updateResourcePoolAsync, this.savedFormValues, this.formValues);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return this.$async(this.updateResourcePoolAsync, this.savedFormValues, this.formValues);
|
||||
}
|
||||
}
|
||||
|
||||
async confirmMarkUnmarkAsSystem() {
|
||||
const message = this.isSystem
|
||||
? 'Unmarking this namespace as system will allow non administrator users to manage it and the resources in contains depending on the access control settings. Are you sure?'
|
||||
: 'Marking this namespace as a system namespace will prevent non administrator users from managing it and the resources it contains. Are you sure?';
|
||||
|
||||
return new Promise((resolve) => {
|
||||
confirmUpdate(message, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
markUnmarkAsSystem() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
const namespaceName = this.$state.params.id;
|
||||
this.state.actionInProgress = true;
|
||||
|
||||
const confirmed = await this.confirmMarkUnmarkAsSystem();
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
await this.KubernetesResourcePoolService.toggleSystem(this.endpoint.Id, namespaceName, !this.isSystem);
|
||||
|
||||
this.Notifications.success('Namespace successfully updated', namespaceName);
|
||||
this.$state.reload(this.$state.current);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to create namespace');
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
hasEventWarnings() {
|
||||
return this.state.eventWarningCount;
|
||||
}
|
||||
|
||||
/* #region GET EVENTS */
|
||||
getEvents() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.state.eventsLoading = true;
|
||||
this.events = await this.KubernetesEventService.get(this.pool.Namespace.Name);
|
||||
this.state.eventWarningCount = KubernetesEventHelper.warningCount(this.events);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve namespace related events');
|
||||
} finally {
|
||||
this.state.eventsLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region GET APPLICATIONS */
|
||||
getApplications() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.state.applicationsLoading = true;
|
||||
this.applications = await this.KubernetesApplicationService.get(this.pool.Namespace.Name);
|
||||
this.applications = _.map(this.applications, (app) => {
|
||||
const resourceReservation = KubernetesResourceReservationHelper.computeResourceReservation(app.Pods);
|
||||
app.CPU = resourceReservation.CPU;
|
||||
app.Memory = resourceReservation.Memory;
|
||||
return app;
|
||||
});
|
||||
|
||||
if (this.state.useServerMetrics) {
|
||||
await this.getResourceUsage(this.pool.Namespace.Name);
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve applications.');
|
||||
} finally {
|
||||
this.state.applicationsLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region GET REGISTRIES */
|
||||
getRegistries() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
const namespace = this.$state.params.id;
|
||||
|
||||
if (this.isAdmin) {
|
||||
this.registries = await this.EndpointService.registries(this.endpoint.Id);
|
||||
this.registries.forEach((reg) => {
|
||||
if (reg.RegistryAccesses && reg.RegistryAccesses[this.endpoint.Id] && reg.RegistryAccesses[this.endpoint.Id].Namespaces.includes(namespace)) {
|
||||
reg.Checked = true;
|
||||
reg.WasChecked = true;
|
||||
this.formValues.Registries.push(reg);
|
||||
}
|
||||
});
|
||||
this.selectedRegistries = this.formValues.Registries.map((r) => r.Name).join(', ');
|
||||
return;
|
||||
}
|
||||
|
||||
const registries = await this.EndpointService.registries(this.endpoint.Id, namespace);
|
||||
this.selectedRegistries = registries.map((r) => r.Name).join(', ');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve registries');
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
async getResourceUsage(namespace) {
|
||||
try {
|
||||
const namespaceMetrics = await getMetricsForAllPods(this.$state.params.endpointId, namespace);
|
||||
// extract resource usage of all containers within each pod of the namespace
|
||||
const containerResourceUsageList = namespaceMetrics.items.flatMap((i) => i.containers.map((c) => c.usage));
|
||||
const namespaceResourceUsage = containerResourceUsageList.reduce((total, u) => {
|
||||
total.CPU += KubernetesResourceReservationHelper.parseCPU(u.cpu);
|
||||
total.Memory += KubernetesResourceReservationHelper.megaBytesValue(u.memory);
|
||||
return total;
|
||||
}, new KubernetesResourceReservation());
|
||||
this.state.resourceUsage = namespaceResourceUsage;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve namespace resource usage');
|
||||
}
|
||||
}
|
||||
|
||||
/* #region ON INIT */
|
||||
$onInit() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.endpoint = await this.EndpointService.endpoint(this.endpoint.Id);
|
||||
this.isAdmin = this.Authentication.isAdmin();
|
||||
|
||||
this.state = {
|
||||
actionInProgress: false,
|
||||
sliderMaxMemory: 0,
|
||||
sliderMaxCpu: 0,
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
resourceReservation: { CPU: 0, Memory: 0 },
|
||||
activeTab: 0,
|
||||
currentName: this.$state.$current.name,
|
||||
showEditorTab: false,
|
||||
eventsLoading: true,
|
||||
applicationsLoading: true,
|
||||
ingressesLoading: true,
|
||||
viewReady: false,
|
||||
eventWarningCount: 0,
|
||||
useServerMetrics: this.endpoint.Kubernetes.Configuration.UseServerMetrics,
|
||||
duplicates: {
|
||||
ingressHosts: new KubernetesFormValidationReferences(),
|
||||
},
|
||||
ingressAvailabilityPerNamespace: this.endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace,
|
||||
};
|
||||
|
||||
this.state.activeTab = this.LocalStorage.getActiveTab('resourcePool');
|
||||
|
||||
const name = this.$state.params.id;
|
||||
|
||||
const [nodes, pool] = await Promise.all([this.KubernetesNodeService.get(), this.KubernetesResourcePoolService.get(name)]);
|
||||
|
||||
this.ingressControllers = [];
|
||||
if (this.state.ingressAvailabilityPerNamespace) {
|
||||
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, namespace: name });
|
||||
this.initialIngressControllers = structuredClone(this.ingressControllers);
|
||||
}
|
||||
|
||||
this.pool = pool;
|
||||
this.formValues = new KubernetesResourcePoolFormValues(KubernetesResourceQuotaDefaults);
|
||||
this.formValues.Name = this.pool.Namespace.Name;
|
||||
this.formValues.EndpointId = this.endpoint.Id;
|
||||
this.formValues.IsSystem = this.pool.Namespace.IsSystem;
|
||||
|
||||
_.forEach(nodes, (item) => {
|
||||
this.state.sliderMaxMemory += filesizeParser(item.Memory);
|
||||
this.state.sliderMaxCpu += item.CPU;
|
||||
});
|
||||
this.state.sliderMaxMemory = KubernetesResourceReservationHelper.megaBytesValue(this.state.sliderMaxMemory);
|
||||
|
||||
const quota = this.pool.Quota;
|
||||
if (quota) {
|
||||
this.oldQuota = angular.copy(quota);
|
||||
this.formValues = KubernetesResourceQuotaConverter.quotaToResourcePoolFormValues(quota);
|
||||
this.formValues.EndpointId = this.endpoint.Id;
|
||||
|
||||
this.state.resourceReservation.CPU = quota.CpuLimitUsed;
|
||||
this.state.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(quota.MemoryLimitUsed);
|
||||
}
|
||||
this.isSystem = KubernetesNamespaceHelper.isSystemNamespace(this.pool.Namespace.Name);
|
||||
this.isDefaultNamespace = KubernetesNamespaceHelper.isDefaultNamespace(this.pool.Namespace.Name);
|
||||
this.isEditable = !this.isSystem && !this.isDefaultNamespace;
|
||||
|
||||
await this.getEvents();
|
||||
await this.getApplications();
|
||||
|
||||
await this.getRegistries();
|
||||
|
||||
this.savedFormValues = angular.copy(this.formValues);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to load view data');
|
||||
} finally {
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* #endregion */
|
||||
|
||||
$onDestroy() {
|
||||
if (this.state.currentName !== this.$state.$current.name) {
|
||||
this.LocalStorage.storeActiveTab('resourcePool', 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesResourcePoolController;
|
||||
angular.module('portainer.kubernetes').controller('KubernetesResourcePoolController', KubernetesResourcePoolController);
|
||||
@@ -1,7 +0,0 @@
|
||||
<page-header ng-if="ctrl.state.viewReady" title="'Namespace list'" breadcrumbs="['Namespaces']" on-reload="(ctrl.onReload)" reload="true"></page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="ctrl.state.viewReady">
|
||||
<kubernetes-namespaces-datatable dataset="ctrl.resourcePools" on-remove="(ctrl.removeAction)" on-refresh="(ctrl.getResourcePools)"></kubernetes-namespaces-datatable>
|
||||
</div>
|
||||
@@ -1,8 +0,0 @@
|
||||
angular.module('portainer.kubernetes').component('kubernetesResourcePoolsView', {
|
||||
templateUrl: './resourcePools.html',
|
||||
controller: 'KubernetesResourcePoolsController',
|
||||
controllerAs: 'ctrl',
|
||||
bindings: {
|
||||
endpoint: '<',
|
||||
},
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import angular from 'angular';
|
||||
import { confirm } from '@@/modals/confirm';
|
||||
import { ModalType } from '@@/modals';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
import { dispatchCacheRefreshEvent } from '@/portainer/services/http-request.helper';
|
||||
|
||||
class KubernetesResourcePoolsController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, Notifications, KubernetesResourcePoolService, KubernetesNamespaceService) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.Notifications = Notifications;
|
||||
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
|
||||
this.KubernetesNamespaceService = KubernetesNamespaceService;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.getResourcePools = this.getResourcePools.bind(this);
|
||||
this.getResourcePoolsAsync = this.getResourcePoolsAsync.bind(this);
|
||||
this.removeAction = this.removeAction.bind(this);
|
||||
this.removeActionAsync = this.removeActionAsync.bind(this);
|
||||
this.onReload = this.onReload.bind(this);
|
||||
}
|
||||
|
||||
async onReload() {
|
||||
this.$state.reload(this.$state.current);
|
||||
}
|
||||
|
||||
async removeActionAsync(selectedItems) {
|
||||
let actionCount = selectedItems.length;
|
||||
for (const pool of selectedItems) {
|
||||
try {
|
||||
const isTerminating = pool.Namespace.Status === 'Terminating';
|
||||
if (isTerminating) {
|
||||
const ns = await this.KubernetesNamespaceService.getJSONAsync(pool.Namespace.Name);
|
||||
ns.$promise.then(async (namespace) => {
|
||||
const n = JSON.parse(namespace.data);
|
||||
if (n.spec && n.spec.finalizers) {
|
||||
delete n.spec.finalizers;
|
||||
}
|
||||
await this.KubernetesNamespaceService.updateFinalizeAsync(n);
|
||||
});
|
||||
} else {
|
||||
await this.KubernetesResourcePoolService.delete(pool);
|
||||
}
|
||||
this.Notifications.success('Namespace successfully removed', pool.Namespace.Name);
|
||||
const index = this.resourcePools.indexOf(pool);
|
||||
this.resourcePools.splice(index, 1);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to remove namespace');
|
||||
} finally {
|
||||
--actionCount;
|
||||
if (actionCount === 0) {
|
||||
this.$state.reload(this.$state.current);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeAction(selectedItems) {
|
||||
const isTerminatingNS = selectedItems.some((pool) => pool.Namespace.Status === 'Terminating');
|
||||
const message = isTerminatingNS
|
||||
? 'At least one namespace is in a terminating state. For terminating state namespaces, you may continue and force removal, but doing so without having properly cleaned up may lead to unstable and unpredictable behavior. Are you sure you wish to proceed?'
|
||||
: 'Do you want to remove the selected namespace(s)? All the resources associated to the selected namespace(s) will be removed too. Are you sure you wish to proceed?';
|
||||
confirm({
|
||||
title: isTerminatingNS ? 'Force namespace removal' : 'Are you sure?',
|
||||
message,
|
||||
confirmButton: buildConfirmButton('Remove', 'danger'),
|
||||
|
||||
modalType: ModalType.Destructive,
|
||||
}).then((confirmed) => {
|
||||
if (confirmed) {
|
||||
return this.$async(this.removeActionAsync, selectedItems);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getResourcePoolsAsync() {
|
||||
try {
|
||||
this.resourcePools = await this.KubernetesResourcePoolService.get('', { getQuota: true });
|
||||
// make sure table refreshes with fresh data when namespaces are in a terminating state
|
||||
if (this.resourcePools.some((namespace) => namespace.Namespace.Status === 'Terminating')) {
|
||||
dispatchCacheRefreshEvent();
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retreive namespaces');
|
||||
}
|
||||
}
|
||||
|
||||
getResourcePools() {
|
||||
return this.$async(this.getResourcePoolsAsync);
|
||||
}
|
||||
|
||||
async onInit() {
|
||||
this.state = {
|
||||
viewReady: false,
|
||||
};
|
||||
|
||||
await this.getResourcePools();
|
||||
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
return this.$async(this.onInit);
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesResourcePoolsController;
|
||||
angular.module('portainer.kubernetes').controller('KubernetesResourcePoolsController', KubernetesResourcePoolsController);
|
||||
@@ -15,6 +15,7 @@ const BoxSelectorReact = react2angular(BoxSelector, [
|
||||
'radioName',
|
||||
'slim',
|
||||
'hiddenSpacingCount',
|
||||
'error',
|
||||
]);
|
||||
|
||||
export const boxSelectorModule = angular
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user