Merge remote-tracking branch 'origin/develop' into feat/EE-189/EE-577/support-git-automated-sync-for-k8s-applications
This commit is contained in:
142
api/bolt/backup.go
Normal file
142
api/bolt/backup.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
plog "github.com/portainer/portainer/api/bolt/log"
|
||||
)
|
||||
|
||||
var backupDefaults = struct {
|
||||
backupDir string
|
||||
commonDir string
|
||||
databaseFileName string
|
||||
}{
|
||||
"backups",
|
||||
"common",
|
||||
databaseFileName,
|
||||
}
|
||||
|
||||
var backupLog = plog.NewScopedLog("bolt, backup")
|
||||
|
||||
//
|
||||
// Backup Helpers
|
||||
//
|
||||
|
||||
// createBackupFolders create initial folders for backups
|
||||
func (store *Store) createBackupFolders() {
|
||||
// create common dir
|
||||
commonDir := store.commonBackupDir()
|
||||
if exists, _ := store.fileService.FileExists(commonDir); !exists {
|
||||
if err := os.MkdirAll(commonDir, 0700); err != nil {
|
||||
backupLog.Error("Error while creating common backup folder", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) databasePath() string {
|
||||
return path.Join(store.path, databaseFileName)
|
||||
}
|
||||
|
||||
func (store *Store) commonBackupDir() string {
|
||||
return path.Join(store.path, backupDefaults.backupDir, backupDefaults.commonDir)
|
||||
}
|
||||
|
||||
func (store *Store) copyDBFile(from string, to string) error {
|
||||
backupLog.Info(fmt.Sprintf("Copying db file from %s to %s", from, to))
|
||||
err := store.fileService.Copy(from, to, true)
|
||||
if err != nil {
|
||||
backupLog.Error("Failed", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// BackupOptions provide a helper to inject backup options
|
||||
type BackupOptions struct {
|
||||
Version int
|
||||
BackupDir string
|
||||
BackupFileName string
|
||||
BackupPath string
|
||||
}
|
||||
|
||||
func (store *Store) setupOptions(options *BackupOptions) *BackupOptions {
|
||||
if options == nil {
|
||||
options = &BackupOptions{}
|
||||
}
|
||||
if options.Version == 0 {
|
||||
options.Version, _ = store.version()
|
||||
}
|
||||
if options.BackupDir == "" {
|
||||
options.BackupDir = store.commonBackupDir()
|
||||
}
|
||||
if options.BackupFileName == "" {
|
||||
options.BackupFileName = fmt.Sprintf("%s.%s.%s", backupDefaults.databaseFileName, fmt.Sprintf("%03d", options.Version), time.Now().Format("20060102150405"))
|
||||
}
|
||||
if options.BackupPath == "" {
|
||||
options.BackupPath = path.Join(options.BackupDir, options.BackupFileName)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// BackupWithOptions backup current database with options
|
||||
func (store *Store) BackupWithOptions(options *BackupOptions) (string, error) {
|
||||
backupLog.Info("creating db backup")
|
||||
store.createBackupFolders()
|
||||
|
||||
options = store.setupOptions(options)
|
||||
|
||||
return options.BackupPath, store.copyDBFile(store.databasePath(), options.BackupPath)
|
||||
}
|
||||
|
||||
// RestoreWithOptions previously saved backup for the current Edition with options
|
||||
// Restore strategies:
|
||||
// - default: restore latest from current edition
|
||||
// - restore a specific
|
||||
func (store *Store) RestoreWithOptions(options *BackupOptions) error {
|
||||
options = store.setupOptions(options)
|
||||
|
||||
// Check if backup file exist before restoring
|
||||
_, err := os.Stat(options.BackupPath)
|
||||
if os.IsNotExist(err) {
|
||||
backupLog.Error(fmt.Sprintf("Backup file to restore does not exist %s", options.BackupPath), err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = store.Close()
|
||||
if err != nil {
|
||||
backupLog.Error("Error while closing store before restore", err)
|
||||
return err
|
||||
}
|
||||
|
||||
backupLog.Info("Restoring db backup")
|
||||
err = store.copyDBFile(options.BackupPath, store.databasePath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return store.Open()
|
||||
}
|
||||
|
||||
// RemoveWithOptions removes backup database based on supplied options
|
||||
func (store *Store) RemoveWithOptions(options *BackupOptions) error {
|
||||
backupLog.Info("Removing db backup")
|
||||
|
||||
options = store.setupOptions(options)
|
||||
_, err := os.Stat(options.BackupPath)
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
backupLog.Error(fmt.Sprintf("Backup file to remove does not exist %s", options.BackupPath), err)
|
||||
return err
|
||||
}
|
||||
|
||||
backupLog.Info(fmt.Sprintf("Removing db file at %s", options.BackupPath))
|
||||
err = os.Remove(options.BackupPath)
|
||||
if err != nil {
|
||||
backupLog.Error("Failed", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
116
api/bolt/backup_test.go
Normal file
116
api/bolt/backup_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// isFileExist is helper function to check for file existence
|
||||
func isFileExist(path string) bool {
|
||||
matches, err := filepath.Glob(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return len(matches) > 0
|
||||
}
|
||||
|
||||
func TestCreateBackupFolders(t *testing.T) {
|
||||
store, teardown := MustNewTestStore(false)
|
||||
defer teardown()
|
||||
|
||||
backupPath := path.Join(store.path, backupDefaults.backupDir)
|
||||
|
||||
if isFileExist(backupPath) {
|
||||
t.Error("Expect backups folder to not exist")
|
||||
}
|
||||
|
||||
store.createBackupFolders()
|
||||
if !isFileExist(backupPath) {
|
||||
t.Error("Expect backups folder to exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreCreation(t *testing.T) {
|
||||
store, teardown := MustNewTestStore(true)
|
||||
defer teardown()
|
||||
|
||||
if store == nil {
|
||||
t.Error("Expect to create a store")
|
||||
}
|
||||
|
||||
if store.edition() != portainer.PortainerCE {
|
||||
t.Error("Expect to get CE Edition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackup(t *testing.T) {
|
||||
store, teardown := MustNewTestStore(true)
|
||||
defer teardown()
|
||||
|
||||
t.Run("Backup should create default db backup", func(t *testing.T) {
|
||||
store.VersionService.StoreDBVersion(portainer.DBVersion)
|
||||
store.BackupWithOptions(nil)
|
||||
|
||||
backupFileName := path.Join(store.path, "backups", "common", fmt.Sprintf("portainer.db.%03d.*", portainer.DBVersion))
|
||||
if !isFileExist(backupFileName) {
|
||||
t.Errorf("Expect backup file to be created %s", backupFileName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("BackupWithOption should create a name specific backup at common path", func(t *testing.T) {
|
||||
store.BackupWithOptions(&BackupOptions{
|
||||
BackupFileName: beforePortainerVersionUpgradeBackup,
|
||||
BackupDir: store.commonBackupDir(),
|
||||
})
|
||||
backupFileName := path.Join(store.path, "backups", "common", beforePortainerVersionUpgradeBackup)
|
||||
if !isFileExist(backupFileName) {
|
||||
t.Errorf("Expect backup file to be created %s", backupFileName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveWithOptions(t *testing.T) {
|
||||
store, teardown := MustNewTestStore(true)
|
||||
defer teardown()
|
||||
|
||||
t.Run("successfully removes file if existent", func(t *testing.T) {
|
||||
store.createBackupFolders()
|
||||
options := &BackupOptions{
|
||||
BackupDir: store.commonBackupDir(),
|
||||
BackupFileName: "test.txt",
|
||||
}
|
||||
|
||||
filePath := path.Join(options.BackupDir, options.BackupFileName)
|
||||
f, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("file should be created; err=%s", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
err = store.RemoveWithOptions(options)
|
||||
if err != nil {
|
||||
t.Errorf("RemoveWithOptions should successfully remove file; err=%w", err)
|
||||
}
|
||||
|
||||
if isFileExist(f.Name()) {
|
||||
t.Errorf("RemoveWithOptions should successfully remove file; file=%s", f.Name())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fails to removes file if non-existent", func(t *testing.T) {
|
||||
options := &BackupOptions{
|
||||
BackupDir: store.commonBackupDir(),
|
||||
BackupFileName: "test.txt",
|
||||
}
|
||||
|
||||
err := store.RemoveWithOptions(options)
|
||||
if err == nil {
|
||||
t.Error("RemoveWithOptions should fail for non-existent file")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package bolt
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
@@ -21,7 +20,6 @@ import (
|
||||
"github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/bolt/extension"
|
||||
"github.com/portainer/portainer/api/bolt/internal"
|
||||
"github.com/portainer/portainer/api/bolt/migrator"
|
||||
"github.com/portainer/portainer/api/bolt/registry"
|
||||
"github.com/portainer/portainer/api/bolt/resourcecontrol"
|
||||
"github.com/portainer/portainer/api/bolt/role"
|
||||
@@ -36,7 +34,6 @@ import (
|
||||
"github.com/portainer/portainer/api/bolt/user"
|
||||
"github.com/portainer/portainer/api/bolt/version"
|
||||
"github.com/portainer/portainer/api/bolt/webhook"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -76,6 +73,14 @@ type Store struct {
|
||||
WebhookService *webhook.Service
|
||||
}
|
||||
|
||||
func (store *Store) version() (int, error) {
|
||||
version, err := store.VersionService.DBVersion()
|
||||
if err == errors.ErrObjectNotFound {
|
||||
version = 0
|
||||
}
|
||||
return version, err
|
||||
}
|
||||
|
||||
func (store *Store) edition() portainer.SoftwareEdition {
|
||||
edition, err := store.VersionService.Edition()
|
||||
if err == errors.ErrObjectNotFound {
|
||||
@@ -94,15 +99,10 @@ func NewStore(storePath string, fileService portainer.FileService) (*Store, erro
|
||||
}
|
||||
|
||||
databasePath := path.Join(storePath, databaseFileName)
|
||||
databaseFileExists, err := fileService.FileExists(databasePath)
|
||||
if err != nil {
|
||||
if _, err := fileService.FileExists(databasePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if databaseFileExists {
|
||||
store.isNew = false
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
@@ -115,7 +115,18 @@ func (store *Store) Open() error {
|
||||
}
|
||||
store.connection.DB = db
|
||||
|
||||
return store.initServices()
|
||||
err = store.initServices()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//if failed to retrieve DBVersion from database
|
||||
//treat it as a new store
|
||||
if _, err := store.VersionService.DBVersion(); err != nil {
|
||||
store.isNew = true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the BoltDB database.
|
||||
@@ -133,64 +144,6 @@ func (store *Store) IsNew() bool {
|
||||
return store.isNew
|
||||
}
|
||||
|
||||
// CheckCurrentEdition checks if current edition is community edition
|
||||
func (store *Store) CheckCurrentEdition() error {
|
||||
if store.edition() != portainer.PortainerCE {
|
||||
return errors.ErrWrongDBEdition
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateData automatically migrate the data based on the DBVersion.
|
||||
// This process is only triggered on an existing database, not if the database was just created.
|
||||
// if force is true, then migrate regardless.
|
||||
func (store *Store) MigrateData(force bool) error {
|
||||
if store.isNew && !force {
|
||||
return store.VersionService.StoreDBVersion(portainer.DBVersion)
|
||||
}
|
||||
|
||||
version, err := store.VersionService.DBVersion()
|
||||
if err == errors.ErrObjectNotFound {
|
||||
version = 0
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if version < portainer.DBVersion {
|
||||
migratorParams := &migrator.Parameters{
|
||||
DB: store.connection.DB,
|
||||
DatabaseVersion: version,
|
||||
EndpointGroupService: store.EndpointGroupService,
|
||||
EndpointService: store.EndpointService,
|
||||
EndpointRelationService: store.EndpointRelationService,
|
||||
ExtensionService: store.ExtensionService,
|
||||
RegistryService: store.RegistryService,
|
||||
ResourceControlService: store.ResourceControlService,
|
||||
RoleService: store.RoleService,
|
||||
ScheduleService: store.ScheduleService,
|
||||
SettingsService: store.SettingsService,
|
||||
StackService: store.StackService,
|
||||
TagService: store.TagService,
|
||||
TeamMembershipService: store.TeamMembershipService,
|
||||
UserService: store.UserService,
|
||||
VersionService: store.VersionService,
|
||||
FileService: store.fileService,
|
||||
DockerhubService: store.DockerHubService,
|
||||
AuthorizationService: authorization.NewService(store),
|
||||
}
|
||||
migrator := migrator.NewMigrator(migratorParams)
|
||||
|
||||
log.Printf("Migrating database from version %v to %v.\n", version, portainer.DBVersion)
|
||||
err = migrator.Migrate()
|
||||
if err != nil {
|
||||
log.Printf("An error occurred during database migration: %s\n", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BackupTo backs up db to a provided writer.
|
||||
// It does hot backup and doesn't block other database reads and writes
|
||||
func (store *Store) BackupTo(w io.Writer) error {
|
||||
@@ -199,3 +152,11 @@ func (store *Store) BackupTo(w io.Writer) error {
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// CheckCurrentEdition checks if current edition is community edition
|
||||
func (store *Store) CheckCurrentEdition() error {
|
||||
if store.edition() != portainer.PortainerCE {
|
||||
return errors.ErrWrongDBEdition
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ func (store *Store) Init() error {
|
||||
HelmRepositoryURL: portainer.DefaultHelmRepositoryURL,
|
||||
UserSessionTimeout: portainer.DefaultUserSessionTimeout,
|
||||
KubeconfigExpiry: portainer.DefaultKubeconfigExpiry,
|
||||
KubectlShellImage: portainer.DefaultKubectlShellImage,
|
||||
}
|
||||
|
||||
err = store.SettingsService.UpdateSettings(defaultSettings)
|
||||
|
||||
146
api/bolt/migrate_data.go
Normal file
146
api/bolt/migrate_data.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/portainer/portainer/api/cli"
|
||||
|
||||
werrors "github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/errors"
|
||||
plog "github.com/portainer/portainer/api/bolt/log"
|
||||
"github.com/portainer/portainer/api/bolt/migrator"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
const beforePortainerVersionUpgradeBackup = "portainer.db.bak"
|
||||
|
||||
var migrateLog = plog.NewScopedLog("bolt, migrate")
|
||||
|
||||
// FailSafeMigrate backup and restore DB if migration fail
|
||||
func (store *Store) FailSafeMigrate(migrator *migrator.Migrator) error {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
migrateLog.Info(fmt.Sprintf("Error during migration, recovering [%v]", err))
|
||||
store.Rollback(true)
|
||||
}
|
||||
}()
|
||||
return migrator.Migrate()
|
||||
}
|
||||
|
||||
// MigrateData automatically migrate the data based on the DBVersion.
|
||||
// This process is only triggered on an existing database, not if the database was just created.
|
||||
// if force is true, then migrate regardless.
|
||||
func (store *Store) MigrateData(force bool) error {
|
||||
if store.isNew && !force {
|
||||
return store.VersionService.StoreDBVersion(portainer.DBVersion)
|
||||
}
|
||||
|
||||
migrator, err := store.newMigrator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// backup db file before upgrading DB to support rollback
|
||||
isUpdating, err := store.VersionService.IsUpdating()
|
||||
if err != nil && err != errors.ErrObjectNotFound {
|
||||
return err
|
||||
}
|
||||
|
||||
if !isUpdating && migrator.Version() != portainer.DBVersion {
|
||||
err = store.backupVersion(migrator)
|
||||
if err != nil {
|
||||
return werrors.Wrapf(err, "failed to backup database")
|
||||
}
|
||||
}
|
||||
|
||||
if migrator.Version() < portainer.DBVersion {
|
||||
migrateLog.Info(fmt.Sprintf("Migrating database from version %v to %v.\n", migrator.Version(), portainer.DBVersion))
|
||||
err = store.FailSafeMigrate(migrator)
|
||||
if err != nil {
|
||||
migrateLog.Error("An error occurred during database migration", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) newMigrator() (*migrator.Migrator, error) {
|
||||
version, err := store.version()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
migratorParams := &migrator.Parameters{
|
||||
DB: store.connection.DB,
|
||||
DatabaseVersion: version,
|
||||
EndpointGroupService: store.EndpointGroupService,
|
||||
EndpointService: store.EndpointService,
|
||||
EndpointRelationService: store.EndpointRelationService,
|
||||
ExtensionService: store.ExtensionService,
|
||||
RegistryService: store.RegistryService,
|
||||
ResourceControlService: store.ResourceControlService,
|
||||
RoleService: store.RoleService,
|
||||
ScheduleService: store.ScheduleService,
|
||||
SettingsService: store.SettingsService,
|
||||
StackService: store.StackService,
|
||||
TagService: store.TagService,
|
||||
TeamMembershipService: store.TeamMembershipService,
|
||||
UserService: store.UserService,
|
||||
VersionService: store.VersionService,
|
||||
FileService: store.fileService,
|
||||
DockerhubService: store.DockerHubService,
|
||||
AuthorizationService: authorization.NewService(store),
|
||||
}
|
||||
return migrator.NewMigrator(migratorParams), nil
|
||||
}
|
||||
|
||||
// getBackupRestoreOptions returns options to store db at common backup dir location; used by:
|
||||
// - db backup prior to version upgrade
|
||||
// - db rollback
|
||||
func getBackupRestoreOptions(store *Store) *BackupOptions {
|
||||
return &BackupOptions{
|
||||
BackupDir: store.commonBackupDir(),
|
||||
BackupFileName: beforePortainerVersionUpgradeBackup,
|
||||
}
|
||||
}
|
||||
|
||||
// backupVersion will backup the database or panic if any errors occur
|
||||
func (store *Store) backupVersion(migrator *migrator.Migrator) error {
|
||||
migrateLog.Info("Backing up database prior to version upgrade...")
|
||||
|
||||
options := getBackupRestoreOptions(store)
|
||||
|
||||
_, err := store.BackupWithOptions(options)
|
||||
if err != nil {
|
||||
migrateLog.Error("An error occurred during database backup", err)
|
||||
removalErr := store.RemoveWithOptions(options)
|
||||
if removalErr != nil {
|
||||
migrateLog.Error("An error occurred during store removal prior to backup", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rollback to a pre-upgrade backup copy/snapshot of portainer.db
|
||||
func (store *Store) Rollback(force bool) error {
|
||||
|
||||
if !force {
|
||||
confirmed, err := cli.Confirm("Are you sure you want to rollback your database to the previous backup?")
|
||||
if err != nil || !confirmed {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
options := getBackupRestoreOptions(store)
|
||||
|
||||
err := store.RestoreWithOptions(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return store.Close()
|
||||
}
|
||||
161
api/bolt/migrate_data_test.go
Normal file
161
api/bolt/migrate_data_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// testVersion is a helper which tests current store version against wanted version
|
||||
func testVersion(store *Store, versionWant int, t *testing.T) {
|
||||
if v, _ := store.version(); v != versionWant {
|
||||
t.Errorf("Expect store version to be %d but was %d", versionWant, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateData(t *testing.T) {
|
||||
t.Run("MigrateData for New Store", func(t *testing.T) {
|
||||
store, teardown := MustNewTestStore(false)
|
||||
defer teardown()
|
||||
store.MigrateData(false)
|
||||
testVersion(store, portainer.DBVersion, t)
|
||||
store.Close()
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
version int
|
||||
expectedVersion int
|
||||
}{
|
||||
{version: 2, expectedVersion: portainer.DBVersion},
|
||||
{version: 21, expectedVersion: portainer.DBVersion},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
store, teardown := MustNewTestStore(true)
|
||||
defer teardown()
|
||||
|
||||
// Setup data
|
||||
store.VersionService.StoreDBVersion(tc.version)
|
||||
|
||||
// Required roles by migrations 22.2
|
||||
store.RoleService.CreateRole(&portainer.Role{ID: 1})
|
||||
store.RoleService.CreateRole(&portainer.Role{ID: 2})
|
||||
store.RoleService.CreateRole(&portainer.Role{ID: 3})
|
||||
store.RoleService.CreateRole(&portainer.Role{ID: 4})
|
||||
|
||||
t.Run(fmt.Sprintf("MigrateData for version %d", tc.version), func(t *testing.T) {
|
||||
store.MigrateData(true)
|
||||
testVersion(store, tc.expectedVersion, t)
|
||||
})
|
||||
|
||||
t.Run(fmt.Sprintf("Restoring DB after migrateData for version %d", tc.version), func(t *testing.T) {
|
||||
store.Rollback(true)
|
||||
store.Open()
|
||||
testVersion(store, tc.version, t)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Error in MigrateData should restore backup before MigrateData", func(t *testing.T) {
|
||||
store, teardown := MustNewTestStore(false)
|
||||
defer teardown()
|
||||
|
||||
version := 2
|
||||
store.VersionService.StoreDBVersion(version)
|
||||
|
||||
store.MigrateData(true)
|
||||
|
||||
testVersion(store, version, t)
|
||||
})
|
||||
|
||||
t.Run("MigrateData should create backup file upon update", func(t *testing.T) {
|
||||
store, teardown := MustNewTestStore(false)
|
||||
defer teardown()
|
||||
store.VersionService.StoreDBVersion(0)
|
||||
|
||||
store.MigrateData(true)
|
||||
|
||||
options := store.setupOptions(getBackupRestoreOptions(store))
|
||||
|
||||
if !isFileExist(options.BackupPath) {
|
||||
t.Errorf("Backup file should exist; file=%s", options.BackupPath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MigrateData should fail to create backup if database file is set to updating", func(t *testing.T) {
|
||||
store, teardown := MustNewTestStore(false)
|
||||
defer teardown()
|
||||
|
||||
store.VersionService.StoreIsUpdating(true)
|
||||
|
||||
store.MigrateData(true)
|
||||
|
||||
options := store.setupOptions(getBackupRestoreOptions(store))
|
||||
|
||||
if isFileExist(options.BackupPath) {
|
||||
t.Errorf("Backup file should not exist for dirty database; file=%s", options.BackupPath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MigrateData should not create backup on startup if portainer version matches db", func(t *testing.T) {
|
||||
store, teardown := MustNewTestStore(false)
|
||||
defer teardown()
|
||||
|
||||
store.MigrateData(true)
|
||||
|
||||
options := store.setupOptions(getBackupRestoreOptions(store))
|
||||
|
||||
if isFileExist(options.BackupPath) {
|
||||
t.Errorf("Backup file should not exist for dirty database; file=%s", options.BackupPath)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func Test_getBackupRestoreOptions(t *testing.T) {
|
||||
store, teardown := MustNewTestStore(false)
|
||||
defer teardown()
|
||||
|
||||
options := getBackupRestoreOptions(store)
|
||||
|
||||
wantDir := store.commonBackupDir()
|
||||
if !strings.HasSuffix(options.BackupDir, wantDir) {
|
||||
log.Fatalf("incorrect backup dir; got=%s, want=%s", options.BackupDir, wantDir)
|
||||
}
|
||||
|
||||
wantFilename := "portainer.db.bak"
|
||||
if options.BackupFileName != wantFilename {
|
||||
log.Fatalf("incorrect backup file; got=%s, want=%s", options.BackupFileName, wantFilename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollback(t *testing.T) {
|
||||
t.Run("Rollback should restore upgrade after backup", func(t *testing.T) {
|
||||
version := 21
|
||||
store, teardown := MustNewTestStore(false)
|
||||
defer teardown()
|
||||
store.VersionService.StoreDBVersion(version)
|
||||
|
||||
_, err := store.BackupWithOptions(getBackupRestoreOptions(store))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Change the current edition
|
||||
err = store.VersionService.StoreDBVersion(version + 10)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = store.Rollback(true)
|
||||
if err != nil {
|
||||
t.Logf("Rollback failed: %s", err)
|
||||
t.Fail()
|
||||
return
|
||||
}
|
||||
|
||||
store.Open()
|
||||
testVersion(store, version, t)
|
||||
})
|
||||
}
|
||||
327
api/bolt/migrator/migrate_ce.go
Normal file
327
api/bolt/migrator/migrate_ce.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
werrors "github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func migrationError(err error, context string) error {
|
||||
return werrors.Wrap(err, "failed in "+context)
|
||||
}
|
||||
|
||||
// Migrate checks the database version and migrate the existing data to the most recent data model.
|
||||
func (m *Migrator) Migrate() error {
|
||||
// set DB to updating status
|
||||
err := m.versionService.StoreIsUpdating(true)
|
||||
if err != nil {
|
||||
return migrationError(err, "StoreIsUpdating")
|
||||
}
|
||||
|
||||
// Portainer < 1.12
|
||||
if m.currentDBVersion < 1 {
|
||||
err := m.updateAdminUserToDBVersion1()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateAdminUserToDBVersion1")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.12.x
|
||||
if m.currentDBVersion < 2 {
|
||||
err := m.updateResourceControlsToDBVersion2()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateResourceControlsToDBVersion2")
|
||||
}
|
||||
err = m.updateEndpointsToDBVersion2()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateEndpointsToDBVersion2")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.13.x
|
||||
if m.currentDBVersion < 3 {
|
||||
err := m.updateSettingsToDBVersion3()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateSettingsToDBVersion3")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.14.0
|
||||
if m.currentDBVersion < 4 {
|
||||
err := m.updateEndpointsToDBVersion4()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateEndpointsToDBVersion4")
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1235
|
||||
if m.currentDBVersion < 5 {
|
||||
err := m.updateSettingsToVersion5()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateSettingsToVersion5")
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1236
|
||||
if m.currentDBVersion < 6 {
|
||||
err := m.updateSettingsToVersion6()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateSettingsToVersion6")
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1449
|
||||
if m.currentDBVersion < 7 {
|
||||
err := m.updateSettingsToVersion7()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateSettingsToVersion7")
|
||||
}
|
||||
}
|
||||
|
||||
if m.currentDBVersion < 8 {
|
||||
err := m.updateEndpointsToVersion8()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateEndpointsToVersion8")
|
||||
}
|
||||
}
|
||||
|
||||
// https: //github.com/portainer/portainer/issues/1396
|
||||
if m.currentDBVersion < 9 {
|
||||
err := m.updateEndpointsToVersion9()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateEndpointsToVersion9")
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/461
|
||||
if m.currentDBVersion < 10 {
|
||||
err := m.updateEndpointsToVersion10()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateEndpointsToVersion10")
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1906
|
||||
if m.currentDBVersion < 11 {
|
||||
err := m.updateEndpointsToVersion11()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateEndpointsToVersion11")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.18.0
|
||||
if m.currentDBVersion < 12 {
|
||||
err := m.updateEndpointsToVersion12()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateEndpointsToVersion12")
|
||||
}
|
||||
|
||||
err = m.updateEndpointGroupsToVersion12()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateEndpointGroupsToVersion12")
|
||||
}
|
||||
|
||||
err = m.updateStacksToVersion12()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateStacksToVersion12")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.19.0
|
||||
if m.currentDBVersion < 13 {
|
||||
err := m.updateSettingsToVersion13()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateSettingsToVersion13")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.19.2
|
||||
if m.currentDBVersion < 14 {
|
||||
err := m.updateResourceControlsToDBVersion14()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateResourceControlsToDBVersion14")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.20.0
|
||||
if m.currentDBVersion < 15 {
|
||||
err := m.updateSettingsToDBVersion15()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateSettingsToDBVersion15")
|
||||
}
|
||||
|
||||
err = m.updateTemplatesToVersion15()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateTemplatesToVersion15")
|
||||
}
|
||||
}
|
||||
|
||||
if m.currentDBVersion < 16 {
|
||||
err := m.updateSettingsToDBVersion16()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateSettingsToDBVersion16")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.20.1
|
||||
if m.currentDBVersion < 17 {
|
||||
err := m.updateExtensionsToDBVersion17()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateExtensionsToDBVersion17")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.21.0
|
||||
if m.currentDBVersion < 18 {
|
||||
err := m.updateUsersToDBVersion18()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateUsersToDBVersion18")
|
||||
}
|
||||
|
||||
err = m.updateEndpointsToDBVersion18()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateEndpointsToDBVersion18")
|
||||
}
|
||||
|
||||
err = m.updateEndpointGroupsToDBVersion18()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateEndpointGroupsToDBVersion18")
|
||||
}
|
||||
|
||||
err = m.updateRegistriesToDBVersion18()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateRegistriesToDBVersion18")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.22.0
|
||||
if m.currentDBVersion < 19 {
|
||||
err := m.updateSettingsToDBVersion19()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateSettingsToDBVersion19")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.22.1
|
||||
if m.currentDBVersion < 20 {
|
||||
err := m.updateUsersToDBVersion20()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateUsersToDBVersion20")
|
||||
}
|
||||
|
||||
err = m.updateSettingsToDBVersion20()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateSettingsToDBVersion20")
|
||||
}
|
||||
|
||||
err = m.updateSchedulesToDBVersion20()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateSchedulesToDBVersion20")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.23.0
|
||||
// DBVersion 21 is missing as it was shipped as via hotfix 1.22.2
|
||||
if m.currentDBVersion < 22 {
|
||||
err := m.updateResourceControlsToDBVersion22()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateResourceControlsToDBVersion22")
|
||||
}
|
||||
|
||||
err = m.updateUsersAndRolesToDBVersion22()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateUsersAndRolesToDBVersion22")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.24.0
|
||||
if m.currentDBVersion < 23 {
|
||||
err := m.updateTagsToDBVersion23()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateTagsToDBVersion23")
|
||||
}
|
||||
|
||||
err = m.updateEndpointsAndEndpointGroupsToDBVersion23()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateEndpointsAndEndpointGroupsToDBVersion23")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.24.1
|
||||
if m.currentDBVersion < 24 {
|
||||
err := m.updateSettingsToDB24()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateSettingsToDB24")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.0.0
|
||||
if m.currentDBVersion < 25 {
|
||||
err := m.updateSettingsToDB25()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateSettingsToDB25")
|
||||
}
|
||||
|
||||
err = m.updateStacksToDB24()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateStacksToDB24")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.1.0
|
||||
if m.currentDBVersion < 26 {
|
||||
err := m.updateEndpointSettingsToDB25()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateEndpointSettingsToDB25")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.2.0
|
||||
if m.currentDBVersion < 27 {
|
||||
err := m.updateStackResourceControlToDB27()
|
||||
if err != nil {
|
||||
return migrationError(err, "updateStackResourceControlToDB27")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.6.0
|
||||
if m.currentDBVersion < 30 {
|
||||
err := m.migrateDBVersionToDB30()
|
||||
if err != nil {
|
||||
return migrationError(err, "migrateDBVersionToDB30")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.9.0
|
||||
if m.currentDBVersion < 32 {
|
||||
err := m.migrateDBVersionToDB32()
|
||||
if err != nil {
|
||||
return migrationError(err, "migrateDBVersionToDB32")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.9.1
|
||||
if m.currentDBVersion < 33 {
|
||||
err := m.migrateDBVersionToDB33()
|
||||
if err != nil {
|
||||
return migrationError(err, "migrateDBVersionToDB33")
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.10
|
||||
if m.currentDBVersion < 34 {
|
||||
if err := m.migrateDBVersionToDB34(); err != nil {
|
||||
return migrationError(err, "migrateDBVersionToDB34")
|
||||
}
|
||||
}
|
||||
|
||||
err = m.versionService.StoreDBVersion(portainer.DBVersion)
|
||||
if err != nil {
|
||||
return migrationError(err, "StoreDBVersion")
|
||||
}
|
||||
migrateLog.Info(fmt.Sprintf("Updated DB version to %d", portainer.DBVersion))
|
||||
|
||||
// reset DB updating status
|
||||
return m.versionService.StoreIsUpdating(false)
|
||||
}
|
||||
21
api/bolt/migrator/migrate_dbversion32.go
Normal file
21
api/bolt/migrator/migrate_dbversion32.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package migrator
|
||||
|
||||
import portainer "github.com/portainer/portainer/api"
|
||||
|
||||
func (m *Migrator) migrateDBVersionToDB33() error {
|
||||
if err := m.migrateSettingsToDB33(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) migrateSettingsToDB33() error {
|
||||
settings, err := m.settingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settings.KubectlShellImage = portainer.DefaultKubectlShellImage
|
||||
return m.settingsService.UpdateSettings(settings)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func (m *Migrator) migrateDBVersionTo33() error {
|
||||
func (m *Migrator) migrateDBVersionToDB34() error {
|
||||
err := migrateStackEntryPoint(m.stackService)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
func TestMigrateStackEntryPoint(t *testing.T) {
|
||||
dbConn, err := bolt.Open(path.Join(t.TempDir(), "portainer-ee-mig-33.db"), 0600, &bolt.Options{Timeout: 1 * time.Second})
|
||||
dbConn, err := bolt.Open(path.Join(t.TempDir(), "portainer-ee-mig-34.db"), 0600, &bolt.Options{Timeout: 1 * time.Second})
|
||||
assert.NoError(t, err, "failed to init testing DB connection")
|
||||
defer dbConn.Close()
|
||||
|
||||
|
||||
@@ -27,8 +27,9 @@ var migrateLog = plog.NewScopedLog("bolt, migrate")
|
||||
type (
|
||||
// Migrator defines a service to migrate data after a Portainer version update.
|
||||
Migrator struct {
|
||||
currentDBVersion int
|
||||
db *bolt.DB
|
||||
db *bolt.DB
|
||||
currentDBVersion int
|
||||
|
||||
endpointGroupService *endpointgroup.Service
|
||||
endpointService *endpoint.Service
|
||||
endpointRelationService *endpointrelation.Service
|
||||
@@ -97,295 +98,7 @@ func NewMigrator(parameters *Parameters) *Migrator {
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate checks the database version and migrate the existing data to the most recent data model.
|
||||
func (m *Migrator) Migrate() error {
|
||||
// Portainer < 1.12
|
||||
if m.currentDBVersion < 1 {
|
||||
err := m.updateAdminUserToDBVersion1()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.12.x
|
||||
if m.currentDBVersion < 2 {
|
||||
err := m.updateResourceControlsToDBVersion2()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.updateEndpointsToDBVersion2()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.13.x
|
||||
if m.currentDBVersion < 3 {
|
||||
err := m.updateSettingsToDBVersion3()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.14.0
|
||||
if m.currentDBVersion < 4 {
|
||||
err := m.updateEndpointsToDBVersion4()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1235
|
||||
if m.currentDBVersion < 5 {
|
||||
err := m.updateSettingsToVersion5()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1236
|
||||
if m.currentDBVersion < 6 {
|
||||
err := m.updateSettingsToVersion6()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1449
|
||||
if m.currentDBVersion < 7 {
|
||||
err := m.updateSettingsToVersion7()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if m.currentDBVersion < 8 {
|
||||
err := m.updateEndpointsToVersion8()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https: //github.com/portainer/portainer/issues/1396
|
||||
if m.currentDBVersion < 9 {
|
||||
err := m.updateEndpointsToVersion9()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/461
|
||||
if m.currentDBVersion < 10 {
|
||||
err := m.updateEndpointsToVersion10()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1906
|
||||
if m.currentDBVersion < 11 {
|
||||
err := m.updateEndpointsToVersion11()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.18.0
|
||||
if m.currentDBVersion < 12 {
|
||||
err := m.updateEndpointsToVersion12()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateEndpointGroupsToVersion12()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateStacksToVersion12()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.19.0
|
||||
if m.currentDBVersion < 13 {
|
||||
err := m.updateSettingsToVersion13()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.19.2
|
||||
if m.currentDBVersion < 14 {
|
||||
err := m.updateResourceControlsToDBVersion14()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.20.0
|
||||
if m.currentDBVersion < 15 {
|
||||
err := m.updateSettingsToDBVersion15()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateTemplatesToVersion15()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if m.currentDBVersion < 16 {
|
||||
err := m.updateSettingsToDBVersion16()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.20.1
|
||||
if m.currentDBVersion < 17 {
|
||||
err := m.updateExtensionsToDBVersion17()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.21.0
|
||||
if m.currentDBVersion < 18 {
|
||||
err := m.updateUsersToDBVersion18()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateEndpointsToDBVersion18()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateEndpointGroupsToDBVersion18()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateRegistriesToDBVersion18()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.22.0
|
||||
if m.currentDBVersion < 19 {
|
||||
err := m.updateSettingsToDBVersion19()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.22.1
|
||||
if m.currentDBVersion < 20 {
|
||||
err := m.updateUsersToDBVersion20()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateSettingsToDBVersion20()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateSchedulesToDBVersion20()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.23.0
|
||||
// DBVersion 21 is missing as it was shipped as via hotfix 1.22.2
|
||||
if m.currentDBVersion < 22 {
|
||||
err := m.updateResourceControlsToDBVersion22()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateUsersAndRolesToDBVersion22()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.24.0
|
||||
if m.currentDBVersion < 23 {
|
||||
err := m.updateTagsToDBVersion23()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateEndpointsAndEndpointGroupsToDBVersion23()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.24.1
|
||||
if m.currentDBVersion < 24 {
|
||||
err := m.updateSettingsToDB24()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.0.0
|
||||
if m.currentDBVersion < 25 {
|
||||
err := m.updateSettingsToDB25()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateStacksToDB24()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.1.0
|
||||
if m.currentDBVersion < 26 {
|
||||
err := m.updateEndpointSettingsToDB25()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.2.0
|
||||
if m.currentDBVersion < 27 {
|
||||
err := m.updateStackResourceControlToDB27()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.6.0
|
||||
if m.currentDBVersion < 30 {
|
||||
err := m.migrateDBVersionToDB30()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.9.0
|
||||
if m.currentDBVersion < 32 {
|
||||
err := m.migrateDBVersionToDB32()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if m.currentDBVersion < 33 {
|
||||
if err := m.migrateDBVersionTo33(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return m.versionService.StoreDBVersion(portainer.DBVersion)
|
||||
// Version exposes version of database
|
||||
func (migrator *Migrator) Version() int {
|
||||
return migrator.currentDBVersion
|
||||
}
|
||||
|
||||
@@ -4,18 +4,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api/bolt"
|
||||
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
|
||||
"github.com/portainer/portainer/api/bolt/bolttest"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func newGuidString(t *testing.T) string {
|
||||
@@ -35,7 +29,7 @@ func TestService_StackByWebhookID(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping test in short mode. Normally takes ~1s to run.")
|
||||
}
|
||||
store, teardown := bolttest.MustNewTestStore(true)
|
||||
store, teardown := bolt.MustNewTestStore(true)
|
||||
defer teardown()
|
||||
|
||||
b := stackBuilder{t: t, store: store}
|
||||
@@ -93,7 +87,7 @@ func Test_RefreshableStacks(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping test in short mode. Normally takes ~1s to run.")
|
||||
}
|
||||
store, teardown := bolttest.MustNewTestStore(true)
|
||||
store, teardown := bolt.MustNewTestStore(true)
|
||||
defer teardown()
|
||||
|
||||
staticStack := portainer.Stack{ID: 1}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package bolttest
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
@@ -6,13 +6,12 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/portainer/api/bolt"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
)
|
||||
|
||||
var errTempDir = errors.New("can't create a temp dir")
|
||||
|
||||
func MustNewTestStore(init bool) (*bolt.Store, func()) {
|
||||
func MustNewTestStore(init bool) (*Store, func()) {
|
||||
store, teardown, err := NewTestStore(init)
|
||||
if err != nil {
|
||||
if !errors.Is(err, errTempDir) {
|
||||
@@ -24,7 +23,7 @@ func MustNewTestStore(init bool) (*bolt.Store, func()) {
|
||||
return store, teardown
|
||||
}
|
||||
|
||||
func NewTestStore(init bool) (*bolt.Store, func(), error) {
|
||||
func NewTestStore(init bool) (*Store, func(), error) {
|
||||
// Creates unique temp directory in a concurrency friendly manner.
|
||||
dataStorePath, err := ioutil.TempDir("", "boltdb")
|
||||
if err != nil {
|
||||
@@ -36,7 +35,7 @@ func NewTestStore(init bool) (*bolt.Store, func(), error) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
store, err := bolt.NewStore(dataStorePath, fileService)
|
||||
store, err := NewStore(dataStorePath, fileService)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -60,7 +59,7 @@ func NewTestStore(init bool) (*bolt.Store, func(), error) {
|
||||
return store, teardown, nil
|
||||
}
|
||||
|
||||
func teardown(store *bolt.Store, dataStorePath string) {
|
||||
func teardown(store *Store, dataStorePath string) {
|
||||
err := store.Close()
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
@@ -15,6 +15,7 @@ const (
|
||||
versionKey = "DB_VERSION"
|
||||
instanceKey = "INSTANCE_ID"
|
||||
editionKey = "EDITION"
|
||||
updatingKey = "DB_UPDATING"
|
||||
)
|
||||
|
||||
// Service represents a service to manage stored versions.
|
||||
@@ -83,6 +84,21 @@ func (service *Service) StoreDBVersion(version int) error {
|
||||
})
|
||||
}
|
||||
|
||||
// IsUpdating retrieves the database updating status.
|
||||
func (service *Service) IsUpdating() (bool, error) {
|
||||
isUpdating, err := service.getKey(updatingKey)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return strconv.ParseBool(string(isUpdating))
|
||||
}
|
||||
|
||||
// StoreIsUpdating store the database updating status.
|
||||
func (service *Service) StoreIsUpdating(isUpdating bool) error {
|
||||
return service.setKey(updatingKey, strconv.FormatBool(isUpdating))
|
||||
}
|
||||
|
||||
// InstanceID retrieves the stored instance ID.
|
||||
func (service *Service) InstanceID() (string, error) {
|
||||
var data []byte
|
||||
|
||||
@@ -47,6 +47,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL (deprecated)").Default(defaultSSL).Bool(),
|
||||
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").String(),
|
||||
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").String(),
|
||||
Rollback: kingpin.Flag("rollback", "Rollback the database store to the previous version").Bool(),
|
||||
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each environment snapshot job").Default(defaultSnapshotInterval).String(),
|
||||
AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(),
|
||||
AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(),
|
||||
|
||||
24
api/cli/confirm.go
Normal file
24
api/cli/confirm.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Confirm starts a rollback db cli application
|
||||
func Confirm(message string) (bool, error) {
|
||||
log.Printf("%s [y/N]", message)
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
answer = strings.Replace(answer, "\n", "", -1)
|
||||
answer = strings.ToLower(answer)
|
||||
|
||||
return answer == "y" || answer == "yes", nil
|
||||
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func initFileService(dataStorePath string) portainer.FileService {
|
||||
return fileService
|
||||
}
|
||||
|
||||
func initDataStore(dataStorePath string, fileService portainer.FileService, shutdownCtx context.Context) portainer.DataStore {
|
||||
func initDataStore(dataStorePath string, rollback bool, fileService portainer.FileService, shutdownCtx context.Context) portainer.DataStore {
|
||||
store, err := bolt.NewStore(dataStorePath, fileService)
|
||||
if err != nil {
|
||||
log.Fatalf("failed creating data store: %v", err)
|
||||
@@ -67,6 +67,17 @@ func initDataStore(dataStorePath string, fileService portainer.FileService, shut
|
||||
log.Fatalf("failed opening store: %v", err)
|
||||
}
|
||||
|
||||
if rollback {
|
||||
err := store.Rollback(false)
|
||||
if err != nil {
|
||||
log.Fatalf("failed rolling back: %s", err)
|
||||
}
|
||||
|
||||
log.Println("Exiting rollback")
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
err = store.Init()
|
||||
if err != nil {
|
||||
log.Fatalf("failed initializing data store: %v", err)
|
||||
@@ -399,7 +410,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
fileService := initFileService(*flags.Data)
|
||||
|
||||
dataStore := initDataStore(*flags.Data, fileService, shutdownCtx)
|
||||
dataStore := initDataStore(*flags.Data, *flags.Rollback, fileService, shutdownCtx)
|
||||
|
||||
if err := dataStore.CheckCurrentEdition(); err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
23
api/exec/exectest/kubernetes_mocks.go
Normal file
23
api/exec/exectest/kubernetes_mocks.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package exectest
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type kubernetesMockDeployer struct{}
|
||||
|
||||
func NewKubernetesDeployer() portainer.KubernetesDeployer {
|
||||
return &kubernetesMockDeployer{}
|
||||
}
|
||||
|
||||
func (deployer *kubernetesMockDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (deployer *kubernetesMockDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (deployer *kubernetesMockDeployer) ConvertCompose(data []byte) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -95,9 +95,9 @@ func (deployer *KubernetesDeployer) command(operation string, userID portainer.U
|
||||
command = path.Join(deployer.binaryPath, "kubectl.exe")
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--token", token,
|
||||
"--namespace", namespace,
|
||||
args := []string{"--token", token}
|
||||
if namespace != "" {
|
||||
args = append(args, "--namespace", namespace)
|
||||
}
|
||||
|
||||
if endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
|
||||
@@ -38,7 +38,7 @@ require (
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20210909083948-8be0d98451a1
|
||||
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
|
||||
github.com/portainer/libhelm v0.0.0-20210913052337-365741c1c320
|
||||
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108
|
||||
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
@@ -46,6 +46,7 @@ require (
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
|
||||
gotest.tools v2.2.0+incompatible // indirect
|
||||
|
||||
@@ -210,8 +210,8 @@ github.com/portainer/docker-compose-wrapper v0.0.0-20210909083948-8be0d98451a1 h
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20210909083948-8be0d98451a1/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
|
||||
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a h1:qY8TbocN75n5PDl16o0uVr5MevtM5IhdwSelXEd4nFM=
|
||||
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a/go.mod h1:n54EEIq+MM0NNtqLeCby8ljL+l275VpolXO0ibHegLE=
|
||||
github.com/portainer/libhelm v0.0.0-20210913052337-365741c1c320 h1:wkmxoHYjWc7OB6JfSlt83mAVpnAo4/6TdL60PO4DlXk=
|
||||
github.com/portainer/libhelm v0.0.0-20210913052337-365741c1c320/go.mod h1:YvYAk7krKTzB+rFwDr0jQ3sQu2BtiXK1AR0sZH7nhJA=
|
||||
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108 h1:5e8KAnDa2G3cEHK7aV/ue8lOaoQwBZUzoALslwWkR04=
|
||||
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108/go.mod h1:YvYAk7krKTzB+rFwDr0jQ3sQu2BtiXK1AR0sZH7nhJA=
|
||||
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33 h1:H8HR2dHdBf8HANSkUyVw4o8+4tegGcd+zyKZ3e599II=
|
||||
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33/go.mod h1:Y2TfgviWI4rT2qaOTHr+hq6MdKIE5YjgQAu7qwptTV0=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
@@ -276,6 +276,8 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456 h1:ng0gs1AKnRRuEMZoTLLlbOd+C17zUDepwGQBb/n+JVg=
|
||||
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
||||
|
||||
@@ -27,15 +27,17 @@ type Handler struct {
|
||||
requestBouncer requestBouncer
|
||||
dataStore portainer.DataStore
|
||||
kubeConfigService kubernetes.KubeConfigService
|
||||
kubernetesDeployer portainer.KubernetesDeployer
|
||||
helmPackageManager libhelm.HelmPackageManager
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage environment(endpoint) group operations.
|
||||
func NewHandler(bouncer requestBouncer, dataStore portainer.DataStore, helmPackageManager libhelm.HelmPackageManager, kubeConfigService kubernetes.KubeConfigService) *Handler {
|
||||
// NewHandler creates a handler to manage endpoint group operations.
|
||||
func NewHandler(bouncer requestBouncer, dataStore portainer.DataStore, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelm.HelmPackageManager, kubeConfigService kubernetes.KubeConfigService) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
requestBouncer: bouncer,
|
||||
dataStore: dataStore,
|
||||
kubernetesDeployer: kubernetesDeployer,
|
||||
helmPackageManager: helmPackageManager,
|
||||
kubeConfigService: kubeConfigService,
|
||||
}
|
||||
|
||||
@@ -9,11 +9,12 @@ import (
|
||||
"github.com/portainer/libhelm/binary/test"
|
||||
"github.com/portainer/libhelm/options"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/exec/exectest"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
bolt "github.com/portainer/portainer/api/bolt/bolttest"
|
||||
"github.com/portainer/portainer/api/bolt"
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
)
|
||||
|
||||
@@ -29,9 +30,10 @@ func Test_helmDelete(t *testing.T) {
|
||||
err = store.User().CreateUser(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
|
||||
is.NoError(err, "Error creating a user")
|
||||
|
||||
kubernetesDeployer := exectest.NewKubernetesDeployer()
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
kubeConfigService := kubernetes.NewKubeConfigCAService("", "")
|
||||
h := NewHandler(helper.NewTestRequestBouncer(), store, helmPackageManager, kubeConfigService)
|
||||
h := NewHandler(helper.NewTestRequestBouncer(), store, kubernetesDeployer, helmPackageManager, kubeConfigService)
|
||||
|
||||
is.NotNil(h, "Handler should not fail")
|
||||
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/libhelm/options"
|
||||
"github.com/portainer/libhelm/release"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/api/kubernetes/validation"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type installChartPayload struct {
|
||||
@@ -131,5 +136,98 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = handler.updateHelmAppManifest(r, manifest, installOpts.Namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return release, nil
|
||||
}
|
||||
|
||||
// applyPortainerLabelsToHelmAppManifest will patch all the resources deployed in the helm release manifest
|
||||
// with portainer specific labels. This is to mark the resources as managed by portainer - hence the helm apps
|
||||
// wont appear external in the portainer UI.
|
||||
func (handler *Handler) applyPortainerLabelsToHelmAppManifest(r *http.Request, installOpts options.InstallOptions, manifest string) ([]byte, error) {
|
||||
// Patch helm release by adding with portainer labels to all deployed resources
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to retrieve user details from authentication token")
|
||||
}
|
||||
user, err := handler.dataStore.User().User(tokenData.ID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to load user information from the database")
|
||||
}
|
||||
|
||||
appLabels := kubernetes.GetHelmAppLabels(installOpts.Name, user.Username)
|
||||
labeledManifest, err := kubernetes.AddAppLabels([]byte(manifest), appLabels)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to label helm release manifest")
|
||||
}
|
||||
|
||||
return labeledManifest, nil
|
||||
}
|
||||
|
||||
// updateHelmAppManifest will update the resources of helm release manifest with portainer labels using kubectl.
|
||||
// The resources of the manifest will be updated in parallel and individuallly since resources of a chart
|
||||
// can be deployed to different namespaces.
|
||||
// NOTE: These updates will need to be re-applied when upgrading the helm release
|
||||
func (handler *Handler) updateHelmAppManifest(r *http.Request, manifest []byte, namespace string) error {
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to find an endpoint on request context")
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to retrieve user details from authentication token")
|
||||
}
|
||||
|
||||
// extract list of yaml resources from helm manifest
|
||||
yamlResources, err := kubernetes.ExtractDocuments(manifest, nil)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to extract documents from helm release manifest")
|
||||
}
|
||||
|
||||
// deploy individual resources in parallel
|
||||
g := new(errgroup.Group)
|
||||
for _, resource := range yamlResources {
|
||||
resource := resource // https://golang.org/doc/faq#closures_and_goroutines
|
||||
g.Go(func() error {
|
||||
tmpfile, err := ioutil.TempFile("", "helm-manifest-*")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create a tmp helm manifest file")
|
||||
}
|
||||
defer func() {
|
||||
tmpfile.Close()
|
||||
os.Remove(tmpfile.Name())
|
||||
}()
|
||||
|
||||
if _, err := tmpfile.Write(resource); err != nil {
|
||||
return errors.Wrap(err, "failed to write a tmp helm manifest file")
|
||||
}
|
||||
|
||||
// get resource namespace, fallback to provided namespace if not explicit on resource
|
||||
resourceNamespace, err := kubernetes.GetNamespace(resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resourceNamespace == "" {
|
||||
resourceNamespace = namespace
|
||||
}
|
||||
|
||||
_, err = handler.kubernetesDeployer.Deploy(tokenData.ID, endpoint, []string{tmpfile.Name()}, resourceNamespace)
|
||||
return err
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
return errors.Wrap(err, "unable to patch helm release using kubectl")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ import (
|
||||
"github.com/portainer/libhelm/options"
|
||||
"github.com/portainer/libhelm/release"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolt "github.com/portainer/portainer/api/bolt/bolttest"
|
||||
"github.com/portainer/portainer/api/bolt"
|
||||
"github.com/portainer/portainer/api/exec/exectest"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
@@ -31,9 +32,10 @@ func Test_helmInstall(t *testing.T) {
|
||||
err = store.User().CreateUser(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
|
||||
is.NoError(err, "error creating a user")
|
||||
|
||||
kubernetesDeployer := exectest.NewKubernetesDeployer()
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
kubeConfigService := kubernetes.NewKubeConfigCAService("", "")
|
||||
h := NewHandler(helper.NewTestRequestBouncer(), store, helmPackageManager, kubeConfigService)
|
||||
h := NewHandler(helper.NewTestRequestBouncer(), store, kubernetesDeployer, helmPackageManager, kubeConfigService)
|
||||
|
||||
is.NotNil(h, "Handler should not fail")
|
||||
|
||||
|
||||
@@ -11,10 +11,11 @@ import (
|
||||
"github.com/portainer/libhelm/options"
|
||||
"github.com/portainer/libhelm/release"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/exec/exectest"
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
bolt "github.com/portainer/portainer/api/bolt/bolttest"
|
||||
"github.com/portainer/portainer/api/bolt"
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
)
|
||||
|
||||
@@ -28,9 +29,10 @@ func Test_helmList(t *testing.T) {
|
||||
err = store.User().CreateUser(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
|
||||
assert.NoError(t, err, "error creating a user")
|
||||
|
||||
kubernetesDeployer := exectest.NewKubernetesDeployer()
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
kubeConfigService := kubernetes.NewKubeConfigCAService("", "")
|
||||
h := NewHandler(helper.NewTestRequestBouncer(), store, helmPackageManager, kubeConfigService)
|
||||
h := NewHandler(helper.NewTestRequestBouncer(), store, kubernetesDeployer, helmPackageManager, kubeConfigService)
|
||||
|
||||
// Install a single chart. We expect to get these values back
|
||||
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
|
||||
|
||||
@@ -40,6 +40,8 @@ type settingsUpdatePayload struct {
|
||||
EnableTelemetry *bool `example:"false"`
|
||||
// Helm repository URL
|
||||
HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
|
||||
// Kubectl Shell Image
|
||||
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
|
||||
}
|
||||
|
||||
func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
||||
@@ -178,6 +180,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
return tlsError
|
||||
}
|
||||
|
||||
if payload.KubectlShellImage != nil {
|
||||
settings.KubectlShellImage = *payload.KubectlShellImage
|
||||
}
|
||||
|
||||
err = handler.DataStore.Settings().UpdateSettings(settings)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist settings changes inside the database", err}
|
||||
|
||||
@@ -6,15 +6,13 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/portainer/portainer/api/bolt/bolttest"
|
||||
"github.com/portainer/portainer/api/bolt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHandler_webhookInvoke(t *testing.T) {
|
||||
store, teardown := bolttest.MustNewTestStore(true)
|
||||
store, teardown := bolt.MustNewTestStore(true)
|
||||
defer teardown()
|
||||
|
||||
webhookID := newGuidString(t)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
// @id UserInspect
|
||||
// @summary Inspect a user
|
||||
// @description Retrieve details about a user.
|
||||
// @description User passwords are filtered out, and should never be accessible.
|
||||
// @description **Access policy**: administrator
|
||||
// @tags users
|
||||
// @security jwt
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
// @summary List users
|
||||
// @description List Portainer users.
|
||||
// @description Non-administrator users will only be able to list other non-administrator user accounts.
|
||||
// @description User passwords are filtered out, and should never be accessible.
|
||||
// @description **Access policy**: restricted
|
||||
// @tags users
|
||||
// @security jwt
|
||||
|
||||
@@ -45,7 +45,12 @@ func (handler *Handler) websocketShellPodExec(w http.ResponseWriter, r *http.Req
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find serviceaccount associated with user", err}
|
||||
}
|
||||
|
||||
shellPod, err := cli.CreateUserShellPod(r.Context(), serviceAccount.Name)
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable read settings", err}
|
||||
}
|
||||
|
||||
shellPod, err := cli.CreateUserShellPod(r.Context(), serviceAccount.Name, settings.KubectlShellImage)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create user shell", err}
|
||||
}
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes/privateregistries"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
func (transport *baseTransport) proxySecretRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) {
|
||||
switch request.Method {
|
||||
case "POST":
|
||||
return transport.proxySecretCreationOperation(request)
|
||||
case "GET":
|
||||
if path.Base(requestPath) == "secrets" {
|
||||
return transport.proxySecretListOperation(request)
|
||||
}
|
||||
return transport.proxySecretInspectOperation(request)
|
||||
case "PUT":
|
||||
return transport.proxySecretUpdateOperation(request)
|
||||
case "DELETE":
|
||||
return transport.proxySecretDeleteOperation(request, namespace)
|
||||
default:
|
||||
return transport.executeKubernetesRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *baseTransport) proxySecretCreationOperation(request *http.Request) (*http.Response, error) {
|
||||
body, err := utils.GetRequestAsMap(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isSecretRepresentPrivateRegistry(body) {
|
||||
return utils.WriteAccessDeniedResponse()
|
||||
}
|
||||
|
||||
err = utils.RewriteRequest(request, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return transport.executeKubernetesRequest(request)
|
||||
}
|
||||
|
||||
func (transport *baseTransport) proxySecretListOperation(request *http.Request) (*http.Response, error) {
|
||||
response, err := transport.executeKubernetesRequest(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isAdmin, err := security.IsAdmin(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isAdmin {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
body, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := utils.GetArrayObject(body, "items")
|
||||
|
||||
if items == nil {
|
||||
utils.RewriteResponse(response, body, response.StatusCode)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
filteredItems := []interface{}{}
|
||||
for _, item := range items {
|
||||
itemObj := item.(map[string]interface{})
|
||||
if !isSecretRepresentPrivateRegistry(itemObj) {
|
||||
filteredItems = append(filteredItems, item)
|
||||
}
|
||||
}
|
||||
|
||||
body["items"] = filteredItems
|
||||
|
||||
utils.RewriteResponse(response, body, response.StatusCode)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (transport *baseTransport) proxySecretInspectOperation(request *http.Request) (*http.Response, error) {
|
||||
response, err := transport.executeKubernetesRequest(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isAdmin, err := security.IsAdmin(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isAdmin {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
body, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isSecretRepresentPrivateRegistry(body) {
|
||||
return utils.WriteAccessDeniedResponse()
|
||||
}
|
||||
|
||||
err = utils.RewriteResponse(response, body, response.StatusCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (transport *baseTransport) proxySecretUpdateOperation(request *http.Request) (*http.Response, error) {
|
||||
body, err := utils.GetRequestAsMap(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isSecretRepresentPrivateRegistry(body) {
|
||||
return utils.WriteAccessDeniedResponse()
|
||||
}
|
||||
|
||||
err = utils.RewriteRequest(request, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return transport.executeKubernetesRequest(request)
|
||||
}
|
||||
|
||||
func (transport *baseTransport) proxySecretDeleteOperation(request *http.Request, namespace string) (*http.Response, error) {
|
||||
kcl, err := transport.k8sClientFactory.GetKubeClient(transport.endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secretName := path.Base(request.RequestURI)
|
||||
|
||||
isRegistry, err := kcl.IsRegistrySecret(namespace, secretName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isRegistry {
|
||||
return utils.WriteAccessDeniedResponse()
|
||||
}
|
||||
|
||||
return transport.executeKubernetesRequest(request)
|
||||
}
|
||||
|
||||
func isSecretRepresentPrivateRegistry(secret map[string]interface{}) bool {
|
||||
if secret["type"] == nil || secret["type"].(string) != string(v1.SecretTypeDockerConfigJson) {
|
||||
return false
|
||||
}
|
||||
|
||||
metadata := utils.GetJSONObject(secret, "metadata")
|
||||
annotations := utils.GetJSONObject(metadata, "annotations")
|
||||
_, ok := annotations[privateregistries.RegistryIDLabel]
|
||||
|
||||
return ok
|
||||
}
|
||||
@@ -66,8 +66,6 @@ func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fu
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(requestPath, "secrets"):
|
||||
return transport.proxySecretRequest(request, namespace, requestPath)
|
||||
case requestPath == "" && request.Method == "DELETE":
|
||||
return transport.proxyNamespaceDeleteOperation(request, namespace)
|
||||
default:
|
||||
@@ -79,6 +77,18 @@ func (transport *baseTransport) executeKubernetesRequest(request *http.Request)
|
||||
|
||||
resp, err := transport.httpTransport.RoundTrip(request)
|
||||
|
||||
// This fix was made to resolve a k8s e2e test, more detailed investigation should be done later.
|
||||
if err == nil && resp.StatusCode == http.StatusMovedPermanently {
|
||||
oldLocation := resp.Header.Get("Location")
|
||||
if oldLocation != "" {
|
||||
stripedPrefix := strings.TrimSuffix(request.RequestURI, request.URL.Path)
|
||||
// local proxy strips "/kubernetes" but agent proxy and edge agent proxy do not
|
||||
stripedPrefix = strings.TrimSuffix(stripedPrefix, "/kubernetes")
|
||||
newLocation := stripedPrefix + "/kubernetes" + oldLocation
|
||||
resp.Header.Set("Location", newLocation)
|
||||
}
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ func (server *Server) Start() error {
|
||||
|
||||
var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"))
|
||||
|
||||
var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.HelmPackageManager, server.KubeConfigService)
|
||||
var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.KubernetesDeployer, server.HelmPackageManager, server.KubeConfigService)
|
||||
|
||||
var helmTemplatesHandler = helm.NewTemplateHandler(requestBouncer, server.HelmPackageManager)
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ func CreateTempK8SDeploymentFiles(stack *portainer.Stack, kubeDeployer portainer
|
||||
return nil, "", errors.Wrap(err, "failed to convert docker compose file to a kube manifest")
|
||||
}
|
||||
}
|
||||
manifestContent, err = k.AddAppLabels(manifestContent, appLabels)
|
||||
manifestContent, err = k.AddAppLabels(manifestContent, appLabels.ToMap())
|
||||
if err != nil {
|
||||
return nil, "", errors.Wrap(err, "failed to add application labels")
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func (d *datastore) Close() error { retur
|
||||
func (d *datastore) CheckCurrentEdition() error { return nil }
|
||||
func (d *datastore) IsNew() bool { return false }
|
||||
func (d *datastore) MigrateData(force bool) error { return nil }
|
||||
func (d *datastore) RollbackToCE() error { return nil }
|
||||
func (d *datastore) Rollback(force bool) error { return nil }
|
||||
func (d *datastore) CustomTemplate() portainer.CustomTemplateService { return d.customTemplate }
|
||||
func (d *datastore) EdgeGroup() portainer.EdgeGroupService { return d.edgeGroup }
|
||||
func (d *datastore) EdgeJob() portainer.EdgeJobService { return d.edgeJob }
|
||||
|
||||
@@ -12,15 +12,13 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
const shellPodImage = "portainer/kubectl-shell"
|
||||
|
||||
// CreateUserShellPod will create a kubectl based shell for the specified user by mounting their respective service account.
|
||||
// The lifecycle of the pod is managed in this function; this entails management of the following pod operations:
|
||||
// - The shell pod will be scoped to specified service accounts access permissions
|
||||
// - The shell pod will be automatically removed if it's not ready after specified period of time
|
||||
// - The shell pod will be automatically removed after a specified max life (prevent zombie pods)
|
||||
// - The shell pod will be automatically removed if request is cancelled (or client closes websocket connection)
|
||||
func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountName string) (*portainer.KubernetesShellPod, error) {
|
||||
func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*portainer.KubernetesShellPod, error) {
|
||||
maxPodKeepAliveSecondsStr := fmt.Sprintf("%d", int(portainer.WebSocketKeepAlive.Seconds()))
|
||||
|
||||
podPrefix := userShellPodPrefix(serviceAccountName)
|
||||
|
||||
@@ -11,6 +11,15 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
labelPortainerAppStack = "io.portainer.kubernetes.application.stack"
|
||||
labelPortainerAppStackID = "io.portainer.kubernetes.application.stackid"
|
||||
labelPortainerAppName = "io.portainer.kubernetes.application.name"
|
||||
labelPortainerAppOwner = "io.portainer.kubernetes.application.owner"
|
||||
labelPortainerAppKind = "io.portainer.kubernetes.application.kind"
|
||||
)
|
||||
|
||||
// KubeAppLabels are labels applied to all resources deployed in a kubernetes stack
|
||||
type KubeAppLabels struct {
|
||||
StackID int
|
||||
StackName string
|
||||
@@ -18,14 +27,50 @@ type KubeAppLabels struct {
|
||||
Kind string
|
||||
}
|
||||
|
||||
// ToMap converts KubeAppLabels to a map[string]string
|
||||
func (kal *KubeAppLabels) ToMap() map[string]string {
|
||||
return map[string]string{
|
||||
labelPortainerAppStackID: strconv.Itoa(kal.StackID),
|
||||
labelPortainerAppStack: kal.StackName,
|
||||
labelPortainerAppName: kal.StackName,
|
||||
labelPortainerAppOwner: kal.Owner,
|
||||
labelPortainerAppKind: kal.Kind,
|
||||
}
|
||||
}
|
||||
|
||||
// GetHelmAppLabels returns the labels to be applied to portainer deployed helm applications
|
||||
func GetHelmAppLabels(name, owner string) map[string]string {
|
||||
return map[string]string{
|
||||
labelPortainerAppName: name,
|
||||
labelPortainerAppOwner: owner,
|
||||
}
|
||||
}
|
||||
|
||||
// AddAppLabels adds required labels to "Resource"->metadata->labels.
|
||||
// It'll add those labels to all Resource (nodes with a kind property exluding a list) it can find in provided yaml.
|
||||
// Items in the yaml file could either be organised as a list or broken into multi documents.
|
||||
func AddAppLabels(manifestYaml []byte, appLabels KubeAppLabels) ([]byte, error) {
|
||||
func AddAppLabels(manifestYaml []byte, appLabels map[string]string) ([]byte, error) {
|
||||
if bytes.Equal(manifestYaml, []byte("")) {
|
||||
return manifestYaml, nil
|
||||
}
|
||||
|
||||
postProcessYaml := func(yamlDoc interface{}) error {
|
||||
addResourceLabels(yamlDoc, appLabels)
|
||||
return nil
|
||||
}
|
||||
|
||||
docs, err := ExtractDocuments(manifestYaml, postProcessYaml)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bytes.Join(docs, []byte("---\n")), nil
|
||||
}
|
||||
|
||||
// ExtractDocuments extracts all the documents from a yaml file
|
||||
// Optionally post-process each document with a function, which can modify the document in place.
|
||||
// Pass in nil for postProcessYaml to skip post-processing.
|
||||
func ExtractDocuments(manifestYaml []byte, postProcessYaml func(interface{}) error) ([][]byte, error) {
|
||||
docs := make([][]byte, 0)
|
||||
yamlDecoder := yaml.NewDecoder(bytes.NewReader(manifestYaml))
|
||||
|
||||
@@ -43,7 +88,12 @@ func AddAppLabels(manifestYaml []byte, appLabels KubeAppLabels) ([]byte, error)
|
||||
break
|
||||
}
|
||||
|
||||
addResourceLabels(m, appLabels)
|
||||
// optionally post-process yaml
|
||||
if postProcessYaml != nil {
|
||||
if err := postProcessYaml(m); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to post process yaml document")
|
||||
}
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
yamlEncoder := yaml.NewEncoder(&out)
|
||||
@@ -55,10 +105,29 @@ func AddAppLabels(manifestYaml []byte, appLabels KubeAppLabels) ([]byte, error)
|
||||
docs = append(docs, out.Bytes())
|
||||
}
|
||||
|
||||
return bytes.Join(docs, []byte("---\n")), nil
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
func addResourceLabels(yamlDoc interface{}, appLabels KubeAppLabels) {
|
||||
// GetNamespace returns the namespace of a kubernetes resource from its metadata
|
||||
// It returns an empty string if namespace is not found in the resource
|
||||
func GetNamespace(manifestYaml []byte) (string, error) {
|
||||
yamlDecoder := yaml.NewDecoder(bytes.NewReader(manifestYaml))
|
||||
m := make(map[string]interface{})
|
||||
err := yamlDecoder.Decode(&m)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "failed to unmarshal yaml manifest when obtaining namespace")
|
||||
}
|
||||
|
||||
if _, ok := m["metadata"]; ok {
|
||||
if namespace, ok := m["metadata"].(map[string]interface{})["namespace"]; ok {
|
||||
return namespace.(string), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func addResourceLabels(yamlDoc interface{}, appLabels map[string]string) {
|
||||
m, ok := yamlDoc.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
@@ -82,7 +151,7 @@ func addResourceLabels(yamlDoc interface{}, appLabels KubeAppLabels) {
|
||||
}
|
||||
}
|
||||
|
||||
func addLabels(obj map[string]interface{}, appLabels KubeAppLabels) {
|
||||
func addLabels(obj map[string]interface{}, appLabels map[string]string) {
|
||||
metadata := make(map[string]interface{})
|
||||
if m, ok := obj["metadata"]; ok {
|
||||
metadata = m.(map[string]interface{})
|
||||
@@ -95,11 +164,10 @@ func addLabels(obj map[string]interface{}, appLabels KubeAppLabels) {
|
||||
}
|
||||
}
|
||||
|
||||
labels["io.portainer.kubernetes.application.stackid"] = strconv.Itoa(appLabels.StackID)
|
||||
labels["io.portainer.kubernetes.application.name"] = appLabels.StackName
|
||||
labels["io.portainer.kubernetes.application.stack"] = appLabels.StackName
|
||||
labels["io.portainer.kubernetes.application.owner"] = appLabels.Owner
|
||||
labels["io.portainer.kubernetes.application.kind"] = appLabels.Kind
|
||||
// merge app labels with existing labels
|
||||
for k, v := range appLabels {
|
||||
labels[k] = v
|
||||
}
|
||||
|
||||
metadata["labels"] = labels
|
||||
obj["metadata"] = metadata
|
||||
|
||||
@@ -417,6 +417,170 @@ spec:
|
||||
Kind: "git",
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := AddAppLabels([]byte(tt.input), labels.ToMap())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wantOutput, string(result))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_AddAppLabels_HelmApp(t *testing.T) {
|
||||
labels := GetHelmAppLabels("best-name", "best-owner")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantOutput string
|
||||
}{
|
||||
{
|
||||
name: "bitnami nginx configmap",
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: nginx-test-server-block
|
||||
labels:
|
||||
app.kubernetes.io/name: nginx
|
||||
helm.sh/chart: nginx-9.5.4
|
||||
app.kubernetes.io/instance: nginx-test
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
data:
|
||||
server-blocks-paths.conf: |-
|
||||
include "/opt/bitnami/nginx/conf/server_blocks/ldap/*.conf";
|
||||
include "/opt/bitnami/nginx/conf/server_blocks/common/*.conf";
|
||||
`,
|
||||
wantOutput: `apiVersion: v1
|
||||
data:
|
||||
server-blocks-paths.conf: |-
|
||||
include "/opt/bitnami/nginx/conf/server_blocks/ldap/*.conf";
|
||||
include "/opt/bitnami/nginx/conf/server_blocks/common/*.conf";
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: nginx-test
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
app.kubernetes.io/name: nginx
|
||||
helm.sh/chart: nginx-9.5.4
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
name: nginx-test-server-block
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "bitnami nginx service",
|
||||
input: `apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: nginx-test
|
||||
labels:
|
||||
app.kubernetes.io/name: nginx
|
||||
helm.sh/chart: nginx-9.5.4
|
||||
app.kubernetes.io/instance: nginx-test
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
externalTrafficPolicy: "Cluster"
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
selector:
|
||||
app.kubernetes.io/name: nginx
|
||||
app.kubernetes.io/instance: nginx-test
|
||||
`,
|
||||
wantOutput: `apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: nginx-test
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
app.kubernetes.io/name: nginx
|
||||
helm.sh/chart: nginx-9.5.4
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
name: nginx-test
|
||||
spec:
|
||||
externalTrafficPolicy: Cluster
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
selector:
|
||||
app.kubernetes.io/instance: nginx-test
|
||||
app.kubernetes.io/name: nginx
|
||||
type: LoadBalancer
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "bitnami nginx deployment",
|
||||
input: `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nginx-test
|
||||
labels:
|
||||
app.kubernetes.io/name: nginx
|
||||
helm.sh/chart: nginx-9.5.4
|
||||
app.kubernetes.io/instance: nginx-test
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: nginx
|
||||
app.kubernetes.io/instance: nginx-test
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: nginx
|
||||
helm.sh/chart: nginx-9.5.4
|
||||
app.kubernetes.io/instance: nginx-test
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
shareProcessNamespace: false
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- name: nginx
|
||||
image: docker.io/bitnami/nginx:1.21.3-debian-10-r0
|
||||
imagePullPolicy: "IfNotPresent"
|
||||
`,
|
||||
wantOutput: `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: nginx-test
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
app.kubernetes.io/name: nginx
|
||||
helm.sh/chart: nginx-9.5.4
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
name: nginx-test
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: nginx-test
|
||||
app.kubernetes.io/name: nginx
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: nginx-test
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
app.kubernetes.io/name: nginx
|
||||
helm.sh/chart: nginx-9.5.4
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
containers:
|
||||
- image: docker.io/bitnami/nginx:1.21.3-debian-10-r0
|
||||
imagePullPolicy: IfNotPresent
|
||||
name: nginx
|
||||
serviceAccountName: default
|
||||
shareProcessNamespace: false
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := AddAppLabels([]byte(tt.input), labels)
|
||||
@@ -425,3 +589,125 @@ spec:
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_DocumentSeperator(t *testing.T) {
|
||||
labels := KubeAppLabels{
|
||||
StackID: 123,
|
||||
StackName: "best-name",
|
||||
Owner: "best-owner",
|
||||
Kind: "git",
|
||||
}
|
||||
|
||||
input := `apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
io.kompose.service: database
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
io.kompose.service: backend
|
||||
`
|
||||
expected := `apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
io.kompose.service: database
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
io.kompose.service: backend
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
`
|
||||
result, err := AddAppLabels([]byte(input), labels.ToMap())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, string(result))
|
||||
}
|
||||
|
||||
func Test_GetNamespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "valid namespace",
|
||||
input: `apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
namespace: test-namespace
|
||||
`,
|
||||
want: "test-namespace",
|
||||
},
|
||||
{
|
||||
name: "invalid namespace",
|
||||
input: `apiVersion: v1
|
||||
kind: Namespace
|
||||
`,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := GetNamespace([]byte(tt.input))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ExtractDocuments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "multiple documents",
|
||||
input: `apiVersion: v1
|
||||
kind: Namespace
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
`,
|
||||
want: []string{`apiVersion: v1
|
||||
kind: Namespace
|
||||
`, `apiVersion: v1
|
||||
kind: Service
|
||||
`},
|
||||
},
|
||||
{
|
||||
name: "single document",
|
||||
input: `apiVersion: v1
|
||||
kind: Namespace
|
||||
`,
|
||||
want: []string{`apiVersion: v1
|
||||
kind: Namespace
|
||||
`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
results, err := ExtractDocuments([]byte(tt.input), nil)
|
||||
assert.NoError(t, err)
|
||||
for i := range results {
|
||||
assert.Equal(t, tt.want[i], string(results[i]))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ type (
|
||||
SSL *bool
|
||||
SSLCert *string
|
||||
SSLKey *string
|
||||
Rollback *bool
|
||||
SnapshotInterval *string
|
||||
}
|
||||
|
||||
@@ -712,6 +713,8 @@ type (
|
||||
EnableTelemetry bool `json:"EnableTelemetry" example:"false"`
|
||||
// Helm repository URL, defaults to "https://charts.bitnami.com/bitnami"
|
||||
HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"`
|
||||
// KubectlImage, defaults to portainer/kubectl-shell
|
||||
KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"`
|
||||
|
||||
// Deprecated fields
|
||||
DisplayDonationHeader bool
|
||||
@@ -1021,7 +1024,7 @@ type (
|
||||
// User Identifier
|
||||
ID UserID `json:"Id" example:"1"`
|
||||
Username string `json:"Username" example:"bob"`
|
||||
Password string `json:"Password,omitempty" example:"passwd"`
|
||||
Password string `json:"Password,omitempty" swaggerignore:"true"`
|
||||
// User Theme
|
||||
UserTheme string `example:"dark"`
|
||||
// User role (1 for administrator account and 2 for regular account)
|
||||
@@ -1102,6 +1105,7 @@ type (
|
||||
Close() error
|
||||
IsNew() bool
|
||||
MigrateData(force bool) error
|
||||
Rollback(force bool) error
|
||||
CheckCurrentEdition() error
|
||||
BackupTo(w io.Writer) error
|
||||
|
||||
@@ -1203,6 +1207,7 @@ type (
|
||||
FileService interface {
|
||||
GetDockerConfigPath() string
|
||||
GetFileContent(filePath string) ([]byte, error)
|
||||
Copy(fromFilePath string, toFilePath string, deleteIfExists bool) error
|
||||
Rename(oldPath, newPath string) error
|
||||
RemoveDirectory(directoryPath string) error
|
||||
StoreTLSFileFromBytes(folder string, fileType TLSFileType, data []byte) (string, error)
|
||||
@@ -1260,7 +1265,7 @@ type (
|
||||
SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error
|
||||
GetServiceAccount(tokendata *TokenData) (*v1.ServiceAccount, error)
|
||||
GetServiceAccountBearerToken(userID int) (string, error)
|
||||
CreateUserShellPod(ctx context.Context, serviceAccountName string) (*KubernetesShellPod, error)
|
||||
CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error)
|
||||
StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error)
|
||||
NamespaceAccessPoliciesDeleteNamespace(namespace string) error
|
||||
GetNodesLimits() (K8sNodesLimits, error)
|
||||
@@ -1495,6 +1500,8 @@ const (
|
||||
DefaultUserSessionTimeout = "8h"
|
||||
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
|
||||
DefaultKubeconfigExpiry = "0"
|
||||
// DefaultKubectlShellImage represents the default image and tag for the kubectl shell
|
||||
DefaultKubectlShellImage = "portainer/kubectl-shell"
|
||||
// WebSocketKeepAlive web socket keep alive for edge environments
|
||||
WebSocketKeepAlive = 1 * time.Hour
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolt "github.com/portainer/portainer/api/bolt/bolttest"
|
||||
"github.com/portainer/portainer/api/bolt"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -88,6 +88,7 @@ html {
|
||||
|
||||
--green-1: #164;
|
||||
--green-2: #1ec863;
|
||||
--green-3: #23ae89;
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
@@ -287,7 +287,7 @@ json-tree .branch-preview {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
.bold {
|
||||
color: var(--text-summary-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -79,25 +79,42 @@ export function ContainerStatsViewModel(data) {
|
||||
if (data.memory_stats.stats === undefined || data.memory_stats.usage === undefined) {
|
||||
this.MemoryUsage = this.MemoryCache = 0;
|
||||
} else {
|
||||
this.MemoryUsage = data.memory_stats.usage - data.memory_stats.stats.cache;
|
||||
this.MemoryCache = data.memory_stats.stats.cache;
|
||||
this.MemoryCache = 0;
|
||||
if (data.memory_stats.stats.cache !== undefined) {
|
||||
// cgroups v1
|
||||
this.MemoryCache = data.memory_stats.stats.cache;
|
||||
}
|
||||
this.MemoryUsage = data.memory_stats.usage - this.MemoryCache;
|
||||
}
|
||||
}
|
||||
this.PreviousCPUTotalUsage = data.precpu_stats.cpu_usage.total_usage;
|
||||
this.PreviousCPUSystemUsage = data.precpu_stats.system_cpu_usage;
|
||||
this.CurrentCPUTotalUsage = data.cpu_stats.cpu_usage.total_usage;
|
||||
this.CurrentCPUSystemUsage = data.cpu_stats.system_cpu_usage;
|
||||
this.CPUCores = 1;
|
||||
if (data.cpu_stats.cpu_usage.percpu_usage) {
|
||||
this.CPUCores = data.cpu_stats.cpu_usage.percpu_usage.length;
|
||||
} else {
|
||||
if (data.cpu_stats.online_cpus !== undefined) {
|
||||
this.CPUCores = data.cpu_stats.online_cpus;
|
||||
}
|
||||
}
|
||||
this.Networks = _.values(data.networks);
|
||||
if (data.blkio_stats !== undefined) {
|
||||
//TODO: take care of multiple block devices
|
||||
var readData = data.blkio_stats.io_service_bytes_recursive.find((d) => d.op === 'Read');
|
||||
if (readData === undefined) {
|
||||
// try the cgroups v2 version
|
||||
readData = data.blkio_stats.io_service_bytes_recursive.find((d) => d.op === 'read');
|
||||
}
|
||||
if (readData !== undefined) {
|
||||
this.BytesRead = readData.value;
|
||||
}
|
||||
var writeData = data.blkio_stats.io_service_bytes_recursive.find((d) => d.op === 'Write');
|
||||
if (writeData === undefined) {
|
||||
// try the cgroups v2 version
|
||||
writeData = data.blkio_stats.io_service_bytes_recursive.find((d) => d.op === 'write');
|
||||
}
|
||||
if (writeData !== undefined) {
|
||||
this.BytesWrite = writeData.value;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
analytics-event="edge-stack-creation"
|
||||
analytics-category="edge"
|
||||
analytics-properties="$ctrl.buildAnalyticsProperties()"
|
||||
data-cy="edgeStackCreate-createStackButton"
|
||||
>
|
||||
<span ng-hide="$ctrl.state.actionInProgress">Deploy the stack</span>
|
||||
<span ng-show="$ctrl.state.actionInProgress">Deployment in progress...</span>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import _ from 'lodash-es';
|
||||
import { KubernetesComponentStatus } from './models';
|
||||
|
||||
export class KubernetesComponentStatusConverter {
|
||||
/**
|
||||
* Convert API data to KubernetesComponentStatus model
|
||||
*/
|
||||
static apiToModel(data) {
|
||||
const res = new KubernetesComponentStatus();
|
||||
res.ComponentName = data.metadata.name;
|
||||
|
||||
const healthyCondition = _.find(data.conditions, { type: 'Healthy' });
|
||||
if (healthyCondition && healthyCondition.status === 'True') {
|
||||
res.Healthy = true;
|
||||
} else if (healthyCondition && healthyCondition.status === 'False') {
|
||||
res.ErrorMessage = healthyCondition.message;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* KubernetesComponentStatus Model
|
||||
*/
|
||||
const _KubernetesComponentStatus = Object.freeze({
|
||||
ComponentName: '',
|
||||
Healthy: false,
|
||||
ErrorMessage: '',
|
||||
});
|
||||
|
||||
export class KubernetesComponentStatus {
|
||||
constructor() {
|
||||
Object.assign(this, JSON.parse(JSON.stringify(_KubernetesComponentStatus)));
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
angular.module('portainer.kubernetes').factory('KubernetesComponentStatus', [
|
||||
'$resource',
|
||||
'API_ENDPOINT_ENDPOINTS',
|
||||
'EndpointProvider',
|
||||
function KubernetesComponentStatusFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
|
||||
'use strict';
|
||||
return function () {
|
||||
const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/api/v1' + '/componentstatuses/:id';
|
||||
return $resource(
|
||||
url,
|
||||
{
|
||||
endpointId: EndpointProvider.endpointID,
|
||||
},
|
||||
{
|
||||
get: {
|
||||
method: 'GET',
|
||||
ignoreLoadingBar: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
},
|
||||
]);
|
||||
@@ -1,34 +0,0 @@
|
||||
import angular from 'angular';
|
||||
import PortainerError from 'Portainer/error';
|
||||
import _ from 'lodash-es';
|
||||
import { KubernetesComponentStatusConverter } from './converter';
|
||||
|
||||
class KubernetesComponentStatusService {
|
||||
/* @ngInject */
|
||||
constructor($async, KubernetesComponentStatus) {
|
||||
this.$async = $async;
|
||||
this.KubernetesComponentStatus = KubernetesComponentStatus;
|
||||
|
||||
this.getAsync = this.getAsync.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET
|
||||
*/
|
||||
async getAsync() {
|
||||
try {
|
||||
const data = await this.KubernetesComponentStatus().get().$promise;
|
||||
const res = _.map(data.items, (item) => KubernetesComponentStatusConverter.apiToModel(item));
|
||||
return res;
|
||||
} catch (err) {
|
||||
throw new PortainerError('Unable to retrieve cluster status', err);
|
||||
}
|
||||
}
|
||||
|
||||
get() {
|
||||
return this.$async(this.getAsync);
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesComponentStatusService;
|
||||
angular.module('portainer.kubernetes').service('KubernetesComponentStatusService', KubernetesComponentStatusService);
|
||||
@@ -201,6 +201,7 @@
|
||||
>
|
||||
<td>{{ item.ApplicationType | kubernetesApplicationTypeText }}</td>
|
||||
<td ng-if="item.ApplicationType !== $ctrl.KubernetesApplicationTypes.POD">
|
||||
<status-indicator ok="(item.TotalPodsCount > 0 && item.TotalPodsCount === item.RunningPodsCount) || item.Status === 'Ready'"></status-indicator>
|
||||
<span ng-if="item.DeploymentType === $ctrl.KubernetesApplicationDeploymentTypes.REPLICATED">Replicated</span>
|
||||
<span ng-if="item.DeploymentType === $ctrl.KubernetesApplicationDeploymentTypes.GLOBAL">Global</span>
|
||||
<span ng-if="item.RunningPodsCount >= 0 && item.TotalPodsCount >= 0">
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
<td>
|
||||
<!-- LB -->
|
||||
<span ng-if="item.ServiceType === $ctrl.KubernetesServiceTypes.LOAD_BALANCER">
|
||||
<span> <i class="fa fa-project-diagram" aria-hidden="true" style="margin-right: 2px;"></i> Load balancer </span>
|
||||
<span> <i class="fa fa-project-diagram" aria-hidden="true" style="margin-right: 2px;"></i> LoadBalancer </span>
|
||||
<span class="text-muted small" style="margin-left: 5px;">
|
||||
<span ng-if="item.LoadBalancerIPAddress">{{ item.LoadBalancerIPAddress }}</span>
|
||||
<span ng-if="!item.LoadBalancerIPAddress">pending</span>
|
||||
@@ -133,10 +133,10 @@
|
||||
</span>
|
||||
<!-- Internal -->
|
||||
<span ng-if="item.ServiceType === $ctrl.KubernetesServiceTypes.CLUSTER_IP">
|
||||
<i class="fa fa-list-alt" aria-hidden="true" style="margin-right: 2px;"></i> Internal
|
||||
<i class="fa fa-list-alt" aria-hidden="true" style="margin-right: 2px;"></i> ClusterIP
|
||||
</span>
|
||||
<!-- Cluster -->
|
||||
<span ng-if="item.ServiceType === $ctrl.KubernetesServiceTypes.NODE_PORT"> <i class="fa fa-list" aria-hidden="true" style="margin-right: 2px;"></i> Cluster </span>
|
||||
<span ng-if="item.ServiceType === $ctrl.KubernetesServiceTypes.NODE_PORT"> <i class="fa fa-list" aria-hidden="true" style="margin-right: 2px;"></i> NodePort </span>
|
||||
</td>
|
||||
<!-- Exposed port -->
|
||||
<td>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="kubernetes.configurations.new" data-cy="k8sConfig-addConfigWithFormButton">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add configuration with form
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add with form
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="kubernetes.deploy" data-cy="k8sConfig-deployFromManifestButton">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Create from manifest
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
class-name="sidebar-list"
|
||||
data-cy="k8sSidebar-configurations"
|
||||
>
|
||||
Configurations
|
||||
ConfigMaps & Secrets
|
||||
</sidebar-menu-item>
|
||||
|
||||
<sidebar-menu-item path="kubernetes.volumes" path-params="{ endpointId: $ctrl.endpointId }" icon-class="fa-database fa-fw" class-name="sidebar-list" data-cy="k8sSidebar-volumes">
|
||||
|
||||
@@ -299,11 +299,11 @@ class KubernetesApplicationConverter {
|
||||
if (app.ServiceType === KubernetesServiceTypes.LOAD_BALANCER) {
|
||||
res.PublishingType = KubernetesApplicationPublishingTypes.LOAD_BALANCER;
|
||||
} else if (app.ServiceType === KubernetesServiceTypes.NODE_PORT) {
|
||||
res.PublishingType = KubernetesApplicationPublishingTypes.CLUSTER;
|
||||
res.PublishingType = KubernetesApplicationPublishingTypes.NODE_PORT;
|
||||
} else if (app.ServiceType === KubernetesServiceTypes.CLUSTER_IP && isIngress) {
|
||||
res.PublishingType = KubernetesApplicationPublishingTypes.INGRESS;
|
||||
} else {
|
||||
res.PublishingType = KubernetesApplicationPublishingTypes.INTERNAL;
|
||||
res.PublishingType = KubernetesApplicationPublishingTypes.CLUSTER_IP;
|
||||
}
|
||||
|
||||
if (app.Pods && app.Pods.length) {
|
||||
|
||||
@@ -42,7 +42,7 @@ class KubernetesServiceConverter {
|
||||
res.StackName = formValues.StackName ? formValues.StackName : formValues.Name;
|
||||
res.ApplicationOwner = formValues.ApplicationOwner;
|
||||
res.ApplicationName = formValues.Name;
|
||||
if (formValues.PublishingType === KubernetesApplicationPublishingTypes.CLUSTER) {
|
||||
if (formValues.PublishingType === KubernetesApplicationPublishingTypes.NODE_PORT) {
|
||||
res.Type = KubernetesServiceTypes.NODE_PORT;
|
||||
} else if (formValues.PublishingType === KubernetesApplicationPublishingTypes.LOAD_BALANCER) {
|
||||
res.Type = KubernetesServiceTypes.LOAD_BALANCER;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import _ from 'lodash-es';
|
||||
import { KubernetesApplicationDataAccessPolicies } from 'Kubernetes/models/application/models';
|
||||
import { KubernetesServiceTypes } from 'Kubernetes/models/service/models';
|
||||
import { KubernetesApplicationTypes, KubernetesApplicationTypeStrings } from 'Kubernetes/models/application/models';
|
||||
import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models';
|
||||
|
||||
@@ -26,24 +25,11 @@ angular
|
||||
var status = _.toLower(text);
|
||||
switch (status) {
|
||||
case 'loadbalancer':
|
||||
return 'Load balancer';
|
||||
return 'LoadBalancer';
|
||||
case 'clusterip':
|
||||
return 'Internal';
|
||||
return 'ClusterIP';
|
||||
case 'nodeport':
|
||||
return 'Cluster';
|
||||
}
|
||||
};
|
||||
})
|
||||
.filter('kubernetesApplicationPortsTableHeaderText', function () {
|
||||
'use strict';
|
||||
return function (serviceType) {
|
||||
switch (serviceType) {
|
||||
case KubernetesServiceTypes.LOAD_BALANCER:
|
||||
return 'Load balancer';
|
||||
case KubernetesServiceTypes.CLUSTER_IP:
|
||||
return 'Application';
|
||||
case KubernetesServiceTypes.NODE_PORT:
|
||||
return 'Cluster node';
|
||||
return 'NodePort';
|
||||
}
|
||||
};
|
||||
})
|
||||
|
||||
@@ -5,9 +5,9 @@ angular.module('portainer.kubernetes').filter('kubernetesConfigurationTypeText',
|
||||
return function (type) {
|
||||
switch (type) {
|
||||
case KubernetesConfigurationTypes.SECRET:
|
||||
return 'Sensitive';
|
||||
return 'Secret';
|
||||
case KubernetesConfigurationTypes.CONFIGMAP:
|
||||
return 'Non-sensitive';
|
||||
return 'ConfigMap';
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -487,6 +487,7 @@ class KubernetesApplicationHelper {
|
||||
const helmApp = new HelmApplication();
|
||||
helmApp.Name = helmInstance;
|
||||
helmApp.ApplicationType = KubernetesApplicationTypes.HELM;
|
||||
helmApp.ApplicationOwner = applications[0].ApplicationOwner;
|
||||
helmApp.KubernetesApplications = applications;
|
||||
|
||||
// the status of helm app is `Ready` based on whether the underlying RunningPodsCount of the k8s app
|
||||
|
||||
@@ -22,7 +22,7 @@ export function KubernetesApplicationFormValues() {
|
||||
this.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.SHARED;
|
||||
this.PersistedFolders = []; // KubernetesApplicationPersistedFolderFormValue lis;
|
||||
this.Configurations = []; // KubernetesApplicationConfigurationFormValue lis;
|
||||
this.PublishingType = KubernetesApplicationPublishingTypes.INTERNAL;
|
||||
this.PublishingType = KubernetesApplicationPublishingTypes.CLUSTER_IP;
|
||||
this.PublishedPorts = []; // KubernetesApplicationPublishedPortFormValue lis;
|
||||
this.PlacementType = KubernetesApplicationPlacementTypes.PREFERRED;
|
||||
this.Placements = []; // KubernetesApplicationPlacementFormValue lis;
|
||||
|
||||
@@ -25,8 +25,8 @@ export const KubernetesApplicationTypeStrings = Object.freeze({
|
||||
});
|
||||
|
||||
export const KubernetesApplicationPublishingTypes = Object.freeze({
|
||||
INTERNAL: 1,
|
||||
CLUSTER: 2,
|
||||
CLUSTER_IP: 1,
|
||||
NODE_PORT: 2,
|
||||
LOAD_BALANCER: 3,
|
||||
INGRESS: 4,
|
||||
});
|
||||
|
||||
@@ -1241,498 +1241,515 @@
|
||||
</div>
|
||||
<!-- #region PUBLISHING OPTIONS -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
Select how you want to publish your application.
|
||||
<div class="col-sm-12">
|
||||
<label for="enable_port_publishing" class="control-label text-left">
|
||||
Enable publishing for this application
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" class="form-control" name="enable_port_publishing" ng-model="ctrl.formValues.IsPublishingService" />
|
||||
<i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- publishing options -->
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<div class="boxselector_wrapper">
|
||||
<div ng-style="{ color: ctrl.isPublishingTypeEditDisabled() ? '#767676' : '' }">
|
||||
<input
|
||||
type="radio"
|
||||
id="publishing_internal"
|
||||
ng-value="ctrl.ApplicationPublishingTypes.INTERNAL"
|
||||
ng-model="ctrl.formValues.PublishingType"
|
||||
ng-change="ctrl.onChangePublishedPorts()"
|
||||
ng-disabled="ctrl.isPublishingTypeEditDisabled()"
|
||||
data-cy="k8sAppCreate-internalPublishButton"
|
||||
/>
|
||||
<label
|
||||
for="publishing_internal"
|
||||
ng-if="
|
||||
!ctrl.isPublishingTypeEditDisabled() ||
|
||||
(ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INTERNAL)
|
||||
"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-list-alt" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Internal
|
||||
</div>
|
||||
<p>Internal communications inside the cluster only</p>
|
||||
</label>
|
||||
<label
|
||||
for="publishing_internal"
|
||||
ng-if="ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType !== ctrl.ApplicationPublishingTypes.INTERNAL"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
uib-tooltip="Changing the publishing mode is not allowed until you delete all previously existing ports"
|
||||
style="cursor: pointer; border-color: #767676;"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-list-alt" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Internal
|
||||
</div>
|
||||
<p>Internal communications inside the cluster only</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div ng-style="{ color: ctrl.isPublishingTypeEditDisabled() ? '#767676' : '' }">
|
||||
<input
|
||||
type="radio"
|
||||
id="publishing_cluster"
|
||||
ng-value="ctrl.ApplicationPublishingTypes.CLUSTER"
|
||||
ng-model="ctrl.formValues.PublishingType"
|
||||
ng-change="ctrl.onChangePublishedPorts()"
|
||||
ng-disabled="ctrl.isPublishingTypeEditDisabled()"
|
||||
data-cy="k8sAppCreate-clusterPublishButton"
|
||||
/>
|
||||
<label
|
||||
for="publishing_cluster"
|
||||
ng-if="
|
||||
!ctrl.isPublishingTypeEditDisabled() ||
|
||||
(ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.CLUSTER)
|
||||
"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-list" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Cluster
|
||||
</div>
|
||||
<p>Publish this application via a port on all nodes of the cluster</p>
|
||||
</label>
|
||||
<label
|
||||
for="publishing_cluster"
|
||||
ng-if="ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType !== ctrl.ApplicationPublishingTypes.CLUSTER"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
uib-tooltip="Changing the publishing mode is not allowed until you delete all previously existing ports"
|
||||
style="cursor: pointer; border-color: #767676;"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-list" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Cluster
|
||||
</div>
|
||||
<p>Publish this application via a port on all nodes of the cluster</p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="ctrl.publishViaIngressEnabled()" ng-style="{ color: ctrl.isPublishingTypeEditDisabled() ? '#767676' : '' }">
|
||||
<input
|
||||
type="radio"
|
||||
id="publishing_ingress"
|
||||
ng-value="ctrl.ApplicationPublishingTypes.INGRESS"
|
||||
ng-model="ctrl.formValues.PublishingType"
|
||||
ng-change="ctrl.onChangePublishedPorts()"
|
||||
ng-disabled="ctrl.isPublishingTypeEditDisabled()"
|
||||
data-cy="k8sAppCreate-ingressPublishButton"
|
||||
/>
|
||||
<label
|
||||
for="publishing_ingress"
|
||||
ng-if="
|
||||
!ctrl.isPublishingTypeEditDisabled() ||
|
||||
(ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS)
|
||||
"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Ingress
|
||||
</div>
|
||||
<p>Publish this application via a HTTP route</p>
|
||||
</label>
|
||||
<label
|
||||
for="publishing_ingress"
|
||||
ng-if="ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType !== ctrl.ApplicationPublishingTypes.INGRESS"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
uib-tooltip="Changing the publishing mode is not allowed until you delete all previously existing ports"
|
||||
style="cursor: pointer; border-color: #767676;"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Ingress
|
||||
</div>
|
||||
<p>Publish this application via a HTTP route</p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="ctrl.publishViaLoadBalancerEnabled()" ng-style="{ color: ctrl.isPublishingTypeEditDisabled() ? '#767676' : '' }">
|
||||
<input
|
||||
type="radio"
|
||||
id="publishing_loadbalancer"
|
||||
ng-value="ctrl.ApplicationPublishingTypes.LOAD_BALANCER"
|
||||
ng-model="ctrl.formValues.PublishingType"
|
||||
ng-change="ctrl.onChangePublishedPorts()"
|
||||
ng-disabled="ctrl.isPublishingTypeEditDisabled()"
|
||||
data-cy="k8sAppCreate-lbPublichButton"
|
||||
/>
|
||||
<label
|
||||
for="publishing_loadbalancer"
|
||||
ng-if="
|
||||
!ctrl.isPublishingTypeEditDisabled() ||
|
||||
(ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER)
|
||||
"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-project-diagram" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Load balancer
|
||||
</div>
|
||||
<p>Publish this application via a load balancer</p>
|
||||
</label>
|
||||
<label
|
||||
for="publishing_loadbalancer"
|
||||
ng-if="ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType !== ctrl.ApplicationPublishingTypes.LOAD_BALANCER"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
uib-tooltip="Changing the publishing mode is not allowed until you delete all previously existing ports"
|
||||
style="cursor: pointer; border-color: #767676;"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-project-diagram" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Load balancer
|
||||
</div>
|
||||
<p>Publish this application via a load balancer</p>
|
||||
</label>
|
||||
<span ng-if="ctrl.formValues.IsPublishingService">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
Select how you want to publish your application.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- #region PUBLISHED PORTS -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Published ports</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="ctrl.addPublishedPort()" data-cy="k8sAppCreate-addNewPortButton">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> publish a new port
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-sm-12 small text-muted"
|
||||
style="margin-top: 15px;"
|
||||
ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.CLUSTER && ctrl.formValues.PublishedPorts.length > 0"
|
||||
>
|
||||
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
When publishing a port in cluster mode, the node port is optional. If left empty Kubernetes will use a random port number. If you wish to specify a port, use a
|
||||
port number inside the default range <code>30000-32767</code>.
|
||||
</div>
|
||||
<div ng-if="ctrl.isNotInternalAndHasNoPublishedPorts()" class="col-sm-12 small text-warning" style="margin-top: 12px;">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> At least one published port must be defined.
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<!-- #region INPUTS -->
|
||||
<div
|
||||
ng-repeat-start="publishedPort in ctrl.formValues.PublishedPorts"
|
||||
style="margin-top: 2px;"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
tooltip-enable="ctrl.disableLoadBalancerEdit()"
|
||||
uib-tooltip="Edition is not allowed while the Load Balancer is in 'Pending' state"
|
||||
>
|
||||
<div
|
||||
class="input-group input-group-sm"
|
||||
ng-class="{
|
||||
striked: publishedPort.NeedsDeletion,
|
||||
'col-sm-2': ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS,
|
||||
'col-sm-3': ctrl.formValues.PublishingType !== ctrl.ApplicationPublishingTypes.INGRESS
|
||||
}"
|
||||
>
|
||||
<span class="input-group-addon">container port</span>
|
||||
<!-- publishing options -->
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<div class="boxselector_wrapper">
|
||||
<div ng-style="{ color: ctrl.isPublishingTypeEditDisabled() ? '#767676' : '' }">
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
name="container_port_{{ $index }}"
|
||||
ng-model="publishedPort.ContainerPort"
|
||||
placeholder="80"
|
||||
ng-min="1"
|
||||
ng-max="65535"
|
||||
ng-required="!publishedPort.NeedsDeletion"
|
||||
ng-change="ctrl.onChangePortMappingContainerPort()"
|
||||
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
|
||||
data-cy="k8sAppCreate-containerPort_{{ $index }}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-sm-3 input-group input-group-sm"
|
||||
ng-class="{ striked: publishedPort.NeedsDeletion }"
|
||||
ng-if="
|
||||
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.CLUSTER) ||
|
||||
(!publishedPort.IsNew && ctrl.savedFormValues.PublishingType === ctrl.ApplicationPublishingTypes.CLUSTER)
|
||||
"
|
||||
>
|
||||
<span class="input-group-addon">node port</span>
|
||||
<input
|
||||
name="published_node_port_{{ $index }}"
|
||||
type="number"
|
||||
class="form-control"
|
||||
ng-model="publishedPort.NodePort"
|
||||
placeholder="30080"
|
||||
ng-min="30000"
|
||||
ng-max="32767"
|
||||
ng-change="ctrl.onChangePortMappingNodePort()"
|
||||
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
|
||||
data-cy="k8sAppCreate-nodePort_{{ $index }}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-sm-3 input-group input-group-sm"
|
||||
ng-class="{ striked: publishedPort.NeedsDeletion }"
|
||||
ng-if="
|
||||
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER) ||
|
||||
(!publishedPort.IsNew && ctrl.savedFormValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER)
|
||||
"
|
||||
>
|
||||
<span class="input-group-addon">load balancer port</span>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
name="load_balancer_port_{{ $index }}"
|
||||
ng-model="publishedPort.LoadBalancerPort"
|
||||
placeholder="80"
|
||||
value="8080"
|
||||
ng-min="1"
|
||||
ng-max="65535"
|
||||
ng-required="!publishedPort.NeedsDeletion"
|
||||
ng-change="ctrl.onChangePortMappingLoadBalancer()"
|
||||
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
|
||||
data-cy="k8sAppCreate-lbPortInput_{{ $index }}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-sm-2 input-group input-group-sm"
|
||||
ng-class="{ striked: publishedPort.NeedsDeletion }"
|
||||
ng-if="
|
||||
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS) ||
|
||||
(!publishedPort.IsNew && ctrl.savedFormValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS)
|
||||
"
|
||||
>
|
||||
<span class="input-group-addon">ingress</span>
|
||||
<select
|
||||
class="form-control"
|
||||
name="ingress_class_{{ $index }}"
|
||||
ng-model="publishedPort.IngressName"
|
||||
ng-options="ingress.Name as ingress.Name for ingress in ctrl.ingresses"
|
||||
ng-required="!publishedPort.NeedsDeletion"
|
||||
ng-change="ctrl.onChangePortMappingIngress($index)"
|
||||
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
|
||||
data-cy="k8sAppCreate-ingressSelect_{{ $index }}"
|
||||
>
|
||||
<option selected disabled hidden value="">Select an ingress</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-sm-2 input-group input-group-sm"
|
||||
ng-class="{ striked: publishedPort.NeedsDeletion }"
|
||||
ng-if="
|
||||
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS) ||
|
||||
(!publishedPort.IsNew && ctrl.savedFormValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS)
|
||||
"
|
||||
>
|
||||
<span class="input-group-addon">hostname</span>
|
||||
<select
|
||||
class="form-control"
|
||||
name="ingress_hostname_{{ $index }}"
|
||||
ng-model="publishedPort.IngressHost"
|
||||
ng-options="host as (host | kubernetesApplicationIngressEmptyHostname) for host in publishedPort.IngressHosts"
|
||||
type="radio"
|
||||
id="publishing_internal"
|
||||
ng-value="ctrl.ApplicationPublishingTypes.CLUSTER_IP"
|
||||
ng-model="ctrl.formValues.PublishingType"
|
||||
ng-change="ctrl.onChangePublishedPorts()"
|
||||
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
|
||||
data-cy="k8sAppCreate-hostnameSelect_{{ $index }}"
|
||||
>
|
||||
<option selected disabled hidden value="">Select a hostname</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-sm-2 input-group input-group-sm"
|
||||
ng-class="{ striked: publishedPort.NeedsDeletion }"
|
||||
ng-if="
|
||||
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS) ||
|
||||
(!publishedPort.IsNew && ctrl.savedFormValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS)
|
||||
"
|
||||
>
|
||||
<span class="input-group-addon">route</span>
|
||||
<input
|
||||
class="form-control"
|
||||
name="ingress_route_{{ $index }}"
|
||||
ng-model="publishedPort.IngressRoute"
|
||||
placeholder="route"
|
||||
ng-required="!publishedPort.NeedsDeletion"
|
||||
ng-change="ctrl.onChangePortMappingIngressRoute()"
|
||||
ng-pattern="/^(\/?[a-zA-Z0-9]+([a-zA-Z0-9-/_]*[a-zA-Z0-9])?|[a-zA-Z0-9]+)|(\/){1}$/"
|
||||
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
|
||||
data-cy="k8sAppCreate-ingressRoute_{{ $index }}"
|
||||
ng-disabled="ctrl.isPublishingTypeEditDisabled()"
|
||||
data-cy="k8sAppCreate-internalPublishButton"
|
||||
/>
|
||||
<label
|
||||
for="publishing_internal"
|
||||
ng-if="
|
||||
!ctrl.isPublishingTypeEditDisabled() ||
|
||||
(ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.CLUSTER_IP)
|
||||
"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-list-alt" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
ClusterIP
|
||||
</div>
|
||||
<p>Internal communications inside the cluster only</p>
|
||||
</label>
|
||||
<label
|
||||
for="publishing_internal"
|
||||
ng-if="ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType !== ctrl.ApplicationPublishingTypes.CLUSTER_IP"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
uib-tooltip="Changing the publishing mode is not allowed until you delete all previously existing ports"
|
||||
style="cursor: pointer; border-color: #767676;"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-list-alt" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
ClusterIP
|
||||
</div>
|
||||
<p>Internal communications inside the cluster only</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="input-group col-sm-2 input-group-sm">
|
||||
<div class="btn-group btn-group-sm" ng-class="{ striked: publishedPort.NeedsDeletion }">
|
||||
<label
|
||||
class="btn btn-primary"
|
||||
ng-model="publishedPort.Protocol"
|
||||
uib-btn-radio="'TCP'"
|
||||
ng-change="ctrl.onChangePortProtocol($index)"
|
||||
ng-disabled="ctrl.isProtocolOptionDisabled($index, 'TCP')"
|
||||
data-cy="k8sAppCreate-TCPButton_{{ $index }}"
|
||||
>TCP</label
|
||||
>
|
||||
<label
|
||||
class="btn btn-primary"
|
||||
ng-model="publishedPort.Protocol"
|
||||
uib-btn-radio="'UDP'"
|
||||
ng-change="ctrl.onChangePortProtocol($index)"
|
||||
ng-disabled="ctrl.isProtocolOptionDisabled($index, 'UDP')"
|
||||
data-cy="k8sAppCreate-UDPButton_{{ $index }}"
|
||||
>UDP</label
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
ng-if="!ctrl.disableLoadBalancerEdit() && !publishedPort.NeedsDeletion"
|
||||
class="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
ng-click="ctrl.removePublishedPort($index)"
|
||||
data-cy="k8sAppCreate-rmPortButton_{{ $index }}"
|
||||
<div ng-style="{ color: ctrl.isPublishingTypeEditDisabled() ? '#767676' : '' }">
|
||||
<input
|
||||
type="radio"
|
||||
id="publishing_cluster"
|
||||
ng-value="ctrl.ApplicationPublishingTypes.NODE_PORT"
|
||||
ng-model="ctrl.formValues.PublishingType"
|
||||
ng-change="ctrl.onChangePublishedPorts()"
|
||||
ng-disabled="ctrl.isPublishingTypeEditDisabled()"
|
||||
data-cy="k8sAppCreate-clusterPublishButton"
|
||||
/>
|
||||
<label
|
||||
for="publishing_cluster"
|
||||
ng-if="
|
||||
!ctrl.isPublishingTypeEditDisabled() ||
|
||||
(ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.NODE_PORT)
|
||||
"
|
||||
>
|
||||
<i class="fa fa-trash-alt" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
ng-if="publishedPort.NeedsDeletion && ctrl.formValues.PublishingType === ctrl.savedFormValues.PublishingType"
|
||||
class="btn btn-sm btn-primary"
|
||||
type="button"
|
||||
ng-click="ctrl.restorePublishedPort($index)"
|
||||
data-cy="k8sAppCreate-restorePortButton_{{ $index }}"
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-list" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
NodePort
|
||||
</div>
|
||||
<p>Publish this application via a port on all nodes of the cluster</p>
|
||||
</label>
|
||||
<label
|
||||
for="publishing_cluster"
|
||||
ng-if="ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType !== ctrl.ApplicationPublishingTypes.NODE_PORT"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
uib-tooltip="Changing the publishing mode is not allowed until you delete all previously existing ports"
|
||||
style="cursor: pointer; border-color: #767676;"
|
||||
>
|
||||
<i class="fa fa-trash-restore" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-list" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
NodePort
|
||||
</div>
|
||||
<p>Publish this application via a port on all nodes of the cluster</p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="ctrl.publishViaIngressEnabled()" ng-style="{ color: ctrl.isPublishingTypeEditDisabled() ? '#767676' : '' }">
|
||||
<input
|
||||
type="radio"
|
||||
id="publishing_ingress"
|
||||
ng-value="ctrl.ApplicationPublishingTypes.INGRESS"
|
||||
ng-model="ctrl.formValues.PublishingType"
|
||||
ng-change="ctrl.onChangePublishedPorts()"
|
||||
ng-disabled="ctrl.isPublishingTypeEditDisabled()"
|
||||
data-cy="k8sAppCreate-ingressPublishButton"
|
||||
/>
|
||||
<label
|
||||
for="publishing_ingress"
|
||||
ng-if="
|
||||
!ctrl.isPublishingTypeEditDisabled() ||
|
||||
(ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS)
|
||||
"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Ingress
|
||||
</div>
|
||||
<p>Publish this application via a HTTP route</p>
|
||||
</label>
|
||||
<label
|
||||
for="publishing_ingress"
|
||||
ng-if="ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType !== ctrl.ApplicationPublishingTypes.INGRESS"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
uib-tooltip="Changing the publishing mode is not allowed until you delete all previously existing ports"
|
||||
style="cursor: pointer; border-color: #767676;"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Ingress
|
||||
</div>
|
||||
<p>Publish this application via a HTTP route</p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="ctrl.publishViaLoadBalancerEnabled()" ng-style="{ color: ctrl.isPublishingTypeEditDisabled() ? '#767676' : '' }">
|
||||
<input
|
||||
type="radio"
|
||||
id="publishing_loadbalancer"
|
||||
ng-value="ctrl.ApplicationPublishingTypes.LOAD_BALANCER"
|
||||
ng-model="ctrl.formValues.PublishingType"
|
||||
ng-change="ctrl.onChangePublishedPorts()"
|
||||
ng-disabled="ctrl.isPublishingTypeEditDisabled()"
|
||||
data-cy="k8sAppCreate-lbPublichButton"
|
||||
/>
|
||||
<label
|
||||
for="publishing_loadbalancer"
|
||||
ng-if="
|
||||
!ctrl.isPublishingTypeEditDisabled() ||
|
||||
(ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER)
|
||||
"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-project-diagram" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Load balancer
|
||||
</div>
|
||||
<p>Publish this application via a load balancer</p>
|
||||
</label>
|
||||
<label
|
||||
for="publishing_loadbalancer"
|
||||
ng-if="ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType !== ctrl.ApplicationPublishingTypes.LOAD_BALANCER"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
uib-tooltip="Changing the publishing mode is not allowed until you delete all previously existing ports"
|
||||
style="cursor: pointer; border-color: #767676;"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-project-diagram" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Load balancer
|
||||
</div>
|
||||
<p>Publish this application via a load balancer</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- #region PUBLISHED PORTS -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Published ports</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="ctrl.addPublishedPort()" data-cy="k8sAppCreate-addNewPortButton">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> publish a new port
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- #region VALIDATION -->
|
||||
<div
|
||||
ng-repeat-end
|
||||
ng-show="
|
||||
kubernetesApplicationCreationForm['container_port_' + $index].$invalid ||
|
||||
kubernetesApplicationCreationForm['published_node_port_' + $index].$invalid ||
|
||||
kubernetesApplicationCreationForm['load_balancer_port_' + $index].$invalid ||
|
||||
kubernetesApplicationCreationForm['ingress_class_' + $index].$invalid ||
|
||||
kubernetesApplicationCreationForm['ingress_route_' + $index].$invalid ||
|
||||
ctrl.state.duplicates.publishedPorts.containerPorts.refs[$index] !== undefined ||
|
||||
(ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.CLUSTER && ctrl.state.duplicates.publishedPorts.nodePorts.refs[$index] !== undefined) ||
|
||||
(ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS &&
|
||||
ctrl.state.duplicates.publishedPorts.ingressRoutes.refs[$index] !== undefined) ||
|
||||
(ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER &&
|
||||
ctrl.state.duplicates.publishedPorts.loadBalancerPorts.refs[$index] !== undefined)
|
||||
"
|
||||
class="col-sm-12 small text-muted"
|
||||
style="margin-top: 15px;"
|
||||
ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.NODE_PORT && ctrl.formValues.PublishedPorts.length > 0"
|
||||
>
|
||||
<div class="col-sm-3 input-group input-group-sm">
|
||||
<div
|
||||
class="small text-warning"
|
||||
style="margin-top: 5px;"
|
||||
ng-if="
|
||||
kubernetesApplicationCreationForm['container_port_' + $index].$invalid || ctrl.state.duplicates.publishedPorts.containerPorts.refs[$index] !== undefined
|
||||
"
|
||||
>
|
||||
<div ng-messages="kubernetesApplicationCreationForm['container_port_'+$index].$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Container port number is required.</p>
|
||||
<p ng-message="min"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Container port number must be inside the range 1-65535.</p>
|
||||
<p ng-message="max"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Container port number must be inside the range 1-65535.</p>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.duplicates.publishedPorts.containerPorts.refs[$index] !== undefined">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This port is already used.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
When publishing a port in cluster mode, the node port is optional. If left empty Kubernetes will use a random port number. If you wish to specify a port, use
|
||||
a port number inside the default range <code>30000-32767</code>.
|
||||
</div>
|
||||
<div ng-if="ctrl.hasNoPublishedPorts()" class="col-sm-12 small text-warning" style="margin-top: 12px;">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> At least one published port must be defined.
|
||||
</div>
|
||||
|
||||
<div class="col-sm-3 input-group input-group-sm" ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.CLUSTER">
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<!-- #region INPUTS -->
|
||||
<div
|
||||
ng-repeat-start="publishedPort in ctrl.formValues.PublishedPorts"
|
||||
style="margin-top: 2px;"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
tooltip-enable="ctrl.disableLoadBalancerEdit()"
|
||||
uib-tooltip="Edition is not allowed while the Load Balancer is in 'Pending' state"
|
||||
>
|
||||
<div
|
||||
class="small text-warning"
|
||||
style="margin-top: 5px;"
|
||||
ng-if="
|
||||
kubernetesApplicationCreationForm['published_node_port_' + $index].$invalid || ctrl.state.duplicates.publishedPorts.nodePorts.refs[$index] !== undefined
|
||||
"
|
||||
class="input-group input-group-sm"
|
||||
ng-class="{
|
||||
striked: publishedPort.NeedsDeletion,
|
||||
'col-sm-2': ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS,
|
||||
'col-sm-3': ctrl.formValues.PublishingType !== ctrl.ApplicationPublishingTypes.INGRESS
|
||||
}"
|
||||
>
|
||||
<div ng-messages="kubernetesApplicationCreationForm['published_node_port_'+$index].$error">
|
||||
<p ng-message="min"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Node port number must be inside the range 30000-32767.</p>
|
||||
<p ng-message="max"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Node port number must be inside the range 30000-32767.</p>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.duplicates.publishedPorts.nodePorts.refs[$index] !== undefined">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This port is already used.
|
||||
</p>
|
||||
<span class="input-group-addon">container port</span>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
name="container_port_{{ $index }}"
|
||||
ng-model="publishedPort.ContainerPort"
|
||||
placeholder="80"
|
||||
ng-min="1"
|
||||
ng-max="65535"
|
||||
ng-required="!publishedPort.NeedsDeletion"
|
||||
ng-change="ctrl.onChangePortMappingContainerPort()"
|
||||
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
|
||||
data-cy="k8sAppCreate-containerPort_{{ $index }}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-3 input-group input-group-sm" ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS">
|
||||
<div class="small text-warning" style="margin-top: 5px;" ng-if="kubernetesApplicationCreationForm['ingress_class_' + $index].$invalid">
|
||||
<div ng-messages="kubernetesApplicationCreationForm['ingress_class_'+$index].$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Ingress selection is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3 input-group input-group-sm" ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS">
|
||||
<div
|
||||
class="small text-warning"
|
||||
style="margin-top: 5px;"
|
||||
class="col-sm-3 input-group input-group-sm"
|
||||
ng-class="{ striked: publishedPort.NeedsDeletion }"
|
||||
ng-if="
|
||||
kubernetesApplicationCreationForm['ingress_route_' + $index].$invalid || ctrl.state.duplicates.publishedPorts.ingressRoutes.refs[$index] !== undefined
|
||||
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.NODE_PORT) ||
|
||||
(!publishedPort.IsNew && ctrl.savedFormValues.PublishingType === ctrl.ApplicationPublishingTypes.NODE_PORT)
|
||||
"
|
||||
>
|
||||
<div ng-messages="kubernetesApplicationCreationForm['ingress_route_'+$index].$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Route is required.</p>
|
||||
<p ng-message="pattern"
|
||||
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field must consist of alphanumeric characters or the special characters: '-', '_'
|
||||
or '/'. It must start and end with an alphanumeric character (e.g. 'my-route', or 'route-123').</p
|
||||
<span class="input-group-addon">node port</span>
|
||||
<input
|
||||
name="published_node_port_{{ $index }}"
|
||||
type="number"
|
||||
class="form-control"
|
||||
ng-model="publishedPort.NodePort"
|
||||
placeholder="30080"
|
||||
ng-min="30000"
|
||||
ng-max="32767"
|
||||
ng-change="ctrl.onChangePortMappingNodePort()"
|
||||
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
|
||||
data-cy="k8sAppCreate-nodePort_{{ $index }}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-sm-3 input-group input-group-sm"
|
||||
ng-class="{ striked: publishedPort.NeedsDeletion }"
|
||||
ng-if="
|
||||
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER) ||
|
||||
(!publishedPort.IsNew && ctrl.savedFormValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER)
|
||||
"
|
||||
>
|
||||
<span class="input-group-addon">load balancer port</span>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
name="load_balancer_port_{{ $index }}"
|
||||
ng-model="publishedPort.LoadBalancerPort"
|
||||
placeholder="80"
|
||||
value="8080"
|
||||
ng-min="1"
|
||||
ng-max="65535"
|
||||
ng-required="!publishedPort.NeedsDeletion"
|
||||
ng-change="ctrl.onChangePortMappingLoadBalancer()"
|
||||
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
|
||||
data-cy="k8sAppCreate-lbPortInput_{{ $index }}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-sm-2 input-group input-group-sm"
|
||||
ng-class="{ striked: publishedPort.NeedsDeletion }"
|
||||
ng-if="
|
||||
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS) ||
|
||||
(!publishedPort.IsNew && ctrl.savedFormValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS)
|
||||
"
|
||||
>
|
||||
<span class="input-group-addon">ingress</span>
|
||||
<select
|
||||
class="form-control"
|
||||
name="ingress_class_{{ $index }}"
|
||||
ng-model="publishedPort.IngressName"
|
||||
ng-options="ingress.Name as ingress.Name for ingress in ctrl.ingresses"
|
||||
ng-required="!publishedPort.NeedsDeletion"
|
||||
ng-change="ctrl.onChangePortMappingIngress($index)"
|
||||
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
|
||||
data-cy="k8sAppCreate-ingressSelect_{{ $index }}"
|
||||
>
|
||||
<option selected disabled hidden value="">Select an ingress</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-sm-2 input-group input-group-sm"
|
||||
ng-class="{ striked: publishedPort.NeedsDeletion }"
|
||||
ng-if="
|
||||
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS) ||
|
||||
(!publishedPort.IsNew && ctrl.savedFormValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS)
|
||||
"
|
||||
>
|
||||
<span class="input-group-addon">hostname</span>
|
||||
<select
|
||||
class="form-control"
|
||||
name="ingress_hostname_{{ $index }}"
|
||||
ng-model="publishedPort.IngressHost"
|
||||
ng-options="host as (host | kubernetesApplicationIngressEmptyHostname) for host in publishedPort.IngressHosts"
|
||||
ng-change="ctrl.onChangePublishedPorts()"
|
||||
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
|
||||
data-cy="k8sAppCreate-hostnameSelect_{{ $index }}"
|
||||
>
|
||||
<option selected disabled hidden value="">Select a hostname</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-sm-2 input-group input-group-sm"
|
||||
ng-class="{ striked: publishedPort.NeedsDeletion }"
|
||||
ng-if="
|
||||
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS) ||
|
||||
(!publishedPort.IsNew && ctrl.savedFormValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS)
|
||||
"
|
||||
>
|
||||
<span class="input-group-addon">route</span>
|
||||
<input
|
||||
class="form-control"
|
||||
name="ingress_route_{{ $index }}"
|
||||
ng-model="publishedPort.IngressRoute"
|
||||
placeholder="route"
|
||||
ng-required="!publishedPort.NeedsDeletion"
|
||||
ng-change="ctrl.onChangePortMappingIngressRoute()"
|
||||
ng-pattern="/^(\/?[a-zA-Z0-9]+([a-zA-Z0-9-/_]*[a-zA-Z0-9])?|[a-zA-Z0-9]+)|(\/){1}$/"
|
||||
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
|
||||
data-cy="k8sAppCreate-ingressRoute_{{ $index }}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group col-sm-2 input-group-sm">
|
||||
<div class="btn-group btn-group-sm" ng-class="{ striked: publishedPort.NeedsDeletion }">
|
||||
<label
|
||||
class="btn btn-primary"
|
||||
ng-model="publishedPort.Protocol"
|
||||
uib-btn-radio="'TCP'"
|
||||
ng-change="ctrl.onChangePortProtocol($index)"
|
||||
ng-disabled="ctrl.isProtocolOptionDisabled($index, 'TCP')"
|
||||
data-cy="k8sAppCreate-TCPButton_{{ $index }}"
|
||||
>TCP</label
|
||||
>
|
||||
<label
|
||||
class="btn btn-primary"
|
||||
ng-model="publishedPort.Protocol"
|
||||
uib-btn-radio="'UDP'"
|
||||
ng-change="ctrl.onChangePortProtocol($index)"
|
||||
ng-disabled="ctrl.isProtocolOptionDisabled($index, 'UDP')"
|
||||
data-cy="k8sAppCreate-UDPButton_{{ $index }}"
|
||||
>UDP</label
|
||||
>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.duplicates.publishedPorts.ingressRoutes.refs[$index] !== undefined">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This route is already used.
|
||||
</p>
|
||||
<button
|
||||
ng-if="!ctrl.disableLoadBalancerEdit() && !publishedPort.NeedsDeletion"
|
||||
class="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
ng-click="ctrl.removePublishedPort($index)"
|
||||
data-cy="k8sAppCreate-rmPortButton_{{ $index }}"
|
||||
>
|
||||
<i class="fa fa-trash-alt" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
ng-if="publishedPort.NeedsDeletion && ctrl.formValues.PublishingType === ctrl.savedFormValues.PublishingType"
|
||||
class="btn btn-sm btn-primary"
|
||||
type="button"
|
||||
ng-click="ctrl.restorePublishedPort($index)"
|
||||
data-cy="k8sAppCreate-restorePortButton_{{ $index }}"
|
||||
>
|
||||
<i class="fa fa-trash-restore" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
||||
<div class="col-sm-3 input-group input-group-sm" ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER">
|
||||
<div
|
||||
class="small text-warning"
|
||||
style="margin-top: 5px;"
|
||||
ng-if="
|
||||
kubernetesApplicationCreationForm['load_balancer_port_' + $index].$invalid ||
|
||||
ctrl.state.duplicates.publishedPorts.loadBalancerPorts.refs[$index] !== undefined
|
||||
"
|
||||
>
|
||||
<div ng-messages="kubernetesApplicationCreationForm['load_balancer_port_'+$index].$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Load balancer port number is required.</p>
|
||||
<p ng-message="min"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Load balancer port number must be inside the range 1-65535.</p>
|
||||
<p ng-message="max"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Load balancer port number must be inside the range 1-65535.</p>
|
||||
<!-- #region VALIDATION -->
|
||||
<div
|
||||
ng-repeat-end
|
||||
ng-show="
|
||||
kubernetesApplicationCreationForm['container_port_' + $index].$invalid ||
|
||||
kubernetesApplicationCreationForm['published_node_port_' + $index].$invalid ||
|
||||
kubernetesApplicationCreationForm['load_balancer_port_' + $index].$invalid ||
|
||||
kubernetesApplicationCreationForm['ingress_class_' + $index].$invalid ||
|
||||
kubernetesApplicationCreationForm['ingress_route_' + $index].$invalid ||
|
||||
ctrl.state.duplicates.publishedPorts.containerPorts.refs[$index] !== undefined ||
|
||||
(ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.NODE_PORT &&
|
||||
ctrl.state.duplicates.publishedPorts.nodePorts.refs[$index] !== undefined) ||
|
||||
(ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS &&
|
||||
ctrl.state.duplicates.publishedPorts.ingressRoutes.refs[$index] !== undefined) ||
|
||||
(ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER &&
|
||||
ctrl.state.duplicates.publishedPorts.loadBalancerPorts.refs[$index] !== undefined)
|
||||
"
|
||||
>
|
||||
<div class="col-sm-3 input-group input-group-sm">
|
||||
<div
|
||||
class="small text-warning"
|
||||
style="margin-top: 5px;"
|
||||
ng-if="
|
||||
kubernetesApplicationCreationForm['container_port_' + $index].$invalid ||
|
||||
ctrl.state.duplicates.publishedPorts.containerPorts.refs[$index] !== undefined
|
||||
"
|
||||
>
|
||||
<div ng-messages="kubernetesApplicationCreationForm['container_port_'+$index].$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Container port number is required.</p>
|
||||
<p ng-message="min"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Container port number must be inside the range 1-65535.</p>
|
||||
<p ng-message="max"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Container port number must be inside the range 1-65535.</p>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.duplicates.publishedPorts.containerPorts.refs[$index] !== undefined">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This port is already used.
|
||||
</p>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.duplicates.publishedPorts.loadBalancerPorts.refs[$index] !== undefined">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
This port is already used.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group col-sm-1 input-group-sm"> </div>
|
||||
<div class="col-sm-3 input-group input-group-sm" ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.NODE_PORT">
|
||||
<div
|
||||
class="small text-warning"
|
||||
style="margin-top: 5px;"
|
||||
ng-if="
|
||||
kubernetesApplicationCreationForm['published_node_port_' + $index].$invalid ||
|
||||
ctrl.state.duplicates.publishedPorts.nodePorts.refs[$index] !== undefined
|
||||
"
|
||||
>
|
||||
<div ng-messages="kubernetesApplicationCreationForm['published_node_port_'+$index].$error">
|
||||
<p ng-message="min"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Node port number must be inside the range 30000-32767.</p>
|
||||
<p ng-message="max"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Node port number must be inside the range 30000-32767.</p>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.duplicates.publishedPorts.nodePorts.refs[$index] !== undefined">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This port is already used.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-3 input-group input-group-sm" ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS">
|
||||
<div class="small text-warning" style="margin-top: 5px;" ng-if="kubernetesApplicationCreationForm['ingress_class_' + $index].$invalid">
|
||||
<div ng-messages="kubernetesApplicationCreationForm['ingress_class_'+$index].$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Ingress selection is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3 input-group input-group-sm" ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS">
|
||||
<div
|
||||
class="small text-warning"
|
||||
style="margin-top: 5px;"
|
||||
ng-if="
|
||||
kubernetesApplicationCreationForm['ingress_route_' + $index].$invalid || ctrl.state.duplicates.publishedPorts.ingressRoutes.refs[$index] !== undefined
|
||||
"
|
||||
>
|
||||
<div ng-messages="kubernetesApplicationCreationForm['ingress_route_'+$index].$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Route is required.</p>
|
||||
<p ng-message="pattern"
|
||||
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field must consist of alphanumeric characters or the special characters: '-',
|
||||
'_' or '/'. It must start and end with an alphanumeric character (e.g. 'my-route', or 'route-123').</p
|
||||
>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.duplicates.publishedPorts.ingressRoutes.refs[$index] !== undefined">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This route is already used.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-3 input-group input-group-sm" ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER">
|
||||
<div
|
||||
class="small text-warning"
|
||||
style="margin-top: 5px;"
|
||||
ng-if="
|
||||
kubernetesApplicationCreationForm['load_balancer_port_' + $index].$invalid ||
|
||||
ctrl.state.duplicates.publishedPorts.loadBalancerPorts.refs[$index] !== undefined
|
||||
"
|
||||
>
|
||||
<div ng-messages="kubernetesApplicationCreationForm['load_balancer_port_'+$index].$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Load balancer port number is required.</p>
|
||||
<p ng-message="min"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Load balancer port number must be inside the range 1-65535.</p>
|
||||
<p ng-message="max"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Load balancer port number must be inside the range 1-65535.</p>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.duplicates.publishedPorts.loadBalancerPorts.refs[$index] !== undefined">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
This port is already used.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group col-sm-1 input-group-sm"> </div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- summary -->
|
||||
|
||||
@@ -37,6 +37,7 @@ class KubernetesCreateApplicationController {
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$scope,
|
||||
$async,
|
||||
$state,
|
||||
Notifications,
|
||||
@@ -54,6 +55,7 @@ class KubernetesCreateApplicationController {
|
||||
StackService,
|
||||
KubernetesNodesLimitsService
|
||||
) {
|
||||
this.$scope = $scope;
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.Notifications = Notifications;
|
||||
@@ -140,6 +142,9 @@ class KubernetesCreateApplicationController {
|
||||
this.deployApplicationAsync = this.deployApplicationAsync.bind(this);
|
||||
this.setPullImageValidity = this.setPullImageValidity.bind(this);
|
||||
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
||||
this.onServicePublishChange = this.onServicePublishChange.bind(this);
|
||||
|
||||
this.$scope.$watch(() => this.formValues.IsPublishingService, this.onServicePublishChange);
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
@@ -416,6 +421,27 @@ class KubernetesCreateApplicationController {
|
||||
|
||||
/* #endregion */
|
||||
|
||||
onServicePublishChange() {
|
||||
// service creation
|
||||
if (this.formValues.PublishedPorts.length === 0) {
|
||||
if (this.formValues.IsPublishingService) {
|
||||
// toggle enabled
|
||||
this.addPublishedPort();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// service update
|
||||
if (this.formValues.IsPublishingService) {
|
||||
// toggle enabled
|
||||
this.formValues.PublishedPorts.forEach((port) => (port.NeedsDeletion = false));
|
||||
} else {
|
||||
// toggle disabled
|
||||
// all new ports need to be deleted, existing ports need to be marked as needing deletion
|
||||
this.formValues.PublishedPorts = this.formValues.PublishedPorts.filter((port) => !port.IsNew).map((port) => ({ ...port, NeedsDeletion: true }));
|
||||
}
|
||||
}
|
||||
|
||||
/* #region PUBLISHED PORTS UI MANAGEMENT */
|
||||
addPublishedPort() {
|
||||
const p = new KubernetesApplicationPublishedPortFormValue();
|
||||
@@ -476,7 +502,7 @@ class KubernetesCreateApplicationController {
|
||||
|
||||
onChangePortMappingNodePort() {
|
||||
const state = this.state.duplicates.publishedPorts.nodePorts;
|
||||
if (this.formValues.PublishingType === KubernetesApplicationPublishingTypes.CLUSTER) {
|
||||
if (this.formValues.PublishingType === KubernetesApplicationPublishingTypes.NODE_PORT) {
|
||||
const source = _.map(this.formValues.PublishedPorts, (p) => (p.NeedsDeletion ? undefined : p.NodePort));
|
||||
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
|
||||
state.refs = duplicates;
|
||||
@@ -736,10 +762,8 @@ class KubernetesCreateApplicationController {
|
||||
return this.state.isEdit && !this.formValues.PublishedPorts[index].IsNew;
|
||||
}
|
||||
|
||||
isNotInternalAndHasNoPublishedPorts() {
|
||||
const toDelPorts = _.filter(this.formValues.PublishedPorts, { NeedsDeletion: true });
|
||||
const toKeepPorts = _.without(this.formValues.PublishedPorts, ...toDelPorts);
|
||||
return this.formValues.PublishingType !== KubernetesApplicationPublishingTypes.INTERNAL && toKeepPorts.length === 0;
|
||||
hasNoPublishedPorts() {
|
||||
return this.formValues.PublishedPorts.filter((port) => !port.NeedsDeletion).length === 0;
|
||||
}
|
||||
|
||||
isEditAndNotNewPlacement(index) {
|
||||
@@ -771,8 +795,8 @@ class KubernetesCreateApplicationController {
|
||||
const invalid = !this.isValid();
|
||||
const hasNoChanges = this.isEditAndNoChangesMade();
|
||||
const nonScalable = this.isNonScalable();
|
||||
const notInternalNoPorts = this.isNotInternalAndHasNoPublishedPorts();
|
||||
return overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable || notInternalNoPorts;
|
||||
const isPublishingWithoutPorts = this.formValues.IsPublishingService && this.hasNoPublishedPorts();
|
||||
return overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable || isPublishingWithoutPorts;
|
||||
}
|
||||
|
||||
disableLoadBalancerEdit() {
|
||||
@@ -926,7 +950,7 @@ class KubernetesCreateApplicationController {
|
||||
if (this.savedFormValues) {
|
||||
this.formValues.PublishingType = this.savedFormValues.PublishingType;
|
||||
} else {
|
||||
this.formValues.PublishingType = this.ApplicationPublishingTypes.INTERNAL;
|
||||
this.formValues.PublishingType = this.ApplicationPublishingTypes.CLUSTER_IP;
|
||||
}
|
||||
}
|
||||
this.formValues.OriginalIngresses = this.ingresses;
|
||||
@@ -1114,6 +1138,8 @@ class KubernetesCreateApplicationController {
|
||||
this.nodesLimits.excludesPods(this.application.Pods, this.formValues.CpuLimit, KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit));
|
||||
}
|
||||
|
||||
this.formValues.IsPublishingService = this.formValues.PublishedPorts.length > 0;
|
||||
|
||||
this.updateNamespaceLimits();
|
||||
this.updateSliders();
|
||||
} catch (err) {
|
||||
|
||||
@@ -253,7 +253,8 @@
|
||||
<div ng-if="ctrl.application.ServiceType === ctrl.KubernetesServiceTypes.LOAD_BALANCER">
|
||||
<div class="small text-muted">
|
||||
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
This application is exposed through an external load balancer. Use the links below to access the different ports exposed.
|
||||
This application is exposed through a service of type <span class="bold">{{ ctrl.application.ServiceType }}</span
|
||||
>. Refer to the port configuration below to access it.
|
||||
</div>
|
||||
<div style="margin-top: 10px;" class="small text-muted">
|
||||
<span ng-if="!ctrl.application.LoadBalancerIPAddress">
|
||||
@@ -282,45 +283,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- cluster notice -->
|
||||
<!-- NodePort notice -->
|
||||
<div ng-if="ctrl.application.ServiceType === ctrl.KubernetesServiceTypes.NODE_PORT">
|
||||
<div class="small text-muted">
|
||||
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
This application is exposed globally on all nodes of your cluster. It can be reached using the IP address of any node in your cluster using the port configuration
|
||||
below.
|
||||
This application is exposed through a service of type <span class="bold">{{ ctrl.application.ServiceType }}</span
|
||||
>. It can be reached using the IP address of any node in your cluster using the port configuration below.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- internal notice -->
|
||||
<!-- ClusterIP notice -->
|
||||
<div ng-if="ctrl.application.ServiceType === ctrl.KubernetesServiceTypes.CLUSTER_IP && !ctrl.state.useIngress">
|
||||
<div class="small text-muted">
|
||||
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
This application is only available for internal usage inside the cluster via the application name <code>{{ ctrl.application.ServiceName }}</code>
|
||||
This application is exposed through a service of type <span class="bold">{{ ctrl.application.ServiceType }}</span
|
||||
>. It can be reached via the application name <code>{{ ctrl.application.ServiceName }}</code> and the port configuration below.
|
||||
<span class="btn btn-primary btn-xs" ng-click="ctrl.copyApplicationName()"><i class="fa fa-copy space-right" aria-hidden="true"></i>Copy</span>
|
||||
<span id="copyNotificationApplicationName" style="margin-left: 7px; display: none; color: #23ae89;" class="small"
|
||||
><i class="fa fa-check" aria-hidden="true"></i> copied</span
|
||||
>
|
||||
</div>
|
||||
<div class="small text-muted" style="margin-top: 2px;">
|
||||
<p>Refer to the below port configuration to access the application.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ingress notice -->
|
||||
<!-- Ingress notice -->
|
||||
<div ng-if="ctrl.application.ServiceType === ctrl.KubernetesServiceTypes.CLUSTER_IP && ctrl.state.useIngress">
|
||||
<div class="small text-muted">
|
||||
<p>
|
||||
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
This application is available for internal usage inside the cluster via the application name <code>{{ ctrl.application.ServiceName }}</code>
|
||||
This application is exposed through a service of type <span class="bold">{{ ctrl.application.ServiceType }}</span
|
||||
>. It can be reached via the application name <code>{{ ctrl.application.ServiceName }}</code> and the port configuration below.
|
||||
<span class="btn btn-primary btn-xs" ng-click="ctrl.copyApplicationName()"><i class="fa fa-copy space-right" aria-hidden="true"></i>Copy</span>
|
||||
<span id="copyNotificationApplicationName" style="margin-left: 7px; display: none; color: #23ae89;" class="small"
|
||||
><i class="fa fa-check" aria-hidden="true"></i> copied</span
|
||||
>
|
||||
</p>
|
||||
<p>It can also be accessed via specific HTTP route(s).</p>
|
||||
</div>
|
||||
<div class="small text-muted" style="margin-top: 2px;">
|
||||
<p>Refer to the below port configuration to access the application.</p>
|
||||
<p>It is also associated to an <span class="bold">Ingress</span> and can be accessed via specific HTTP route(s).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -330,7 +327,7 @@
|
||||
<tbody>
|
||||
<tr class="text-muted">
|
||||
<td style="width: 25%;">Container port</td>
|
||||
<td style="width: 25%;">{{ ctrl.application.ServiceType | kubernetesApplicationPortsTableHeaderText }} port</td>
|
||||
<td style="width: 25%;">Service port</td>
|
||||
<td style="width: 50%;">HTTP route</td>
|
||||
</tr>
|
||||
<tr ng-repeat-start="port in ctrl.application.PublishedPorts">
|
||||
|
||||
@@ -25,34 +25,6 @@
|
||||
</form>
|
||||
<!-- !resource-reservation -->
|
||||
|
||||
<!-- cluster-status -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Cluster status
|
||||
</div>
|
||||
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr class="text-muted">
|
||||
<td style="border-top: none; width: 25%;">Component</td>
|
||||
<td style="border-top: none; width: 25%;">Status</td>
|
||||
<td style="border-top: none; width: 50%;" ng-if="ctrl.hasUnhealthyComponentStatus">Error</td>
|
||||
</tr>
|
||||
<tr ng-repeat="cs in ctrl.componentStatuses">
|
||||
<td style="width: 25%;">
|
||||
{{ cs.ComponentName }}
|
||||
</td>
|
||||
<td style="width: 25%;">
|
||||
<span ng-if="cs.Healthy"><i class="fa fa-check green-icon" aria-hidden="true" style="margin-right: 2px;"></i> healthy</span>
|
||||
<span ng-if="!cs.Healthy"><i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i> unhealthy</span>
|
||||
</td>
|
||||
<td ng-if="ctrl.hasUnhealthyComponentStatus" style="width: 50%;">
|
||||
{{ cs.ErrorMessage !== '' ? cs.ErrorMessage : '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- !cluster-status -->
|
||||
|
||||
<!-- leader-status -->
|
||||
<div ng-if="ctrl.systemEndpoints.length > 0">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
|
||||
@@ -15,7 +15,6 @@ class KubernetesClusterController {
|
||||
KubernetesNodeService,
|
||||
KubernetesMetricsService,
|
||||
KubernetesApplicationService,
|
||||
KubernetesComponentStatusService,
|
||||
KubernetesEndpointService
|
||||
) {
|
||||
this.$async = $async;
|
||||
@@ -26,32 +25,16 @@ class KubernetesClusterController {
|
||||
this.KubernetesNodeService = KubernetesNodeService;
|
||||
this.KubernetesMetricsService = KubernetesMetricsService;
|
||||
this.KubernetesApplicationService = KubernetesApplicationService;
|
||||
this.KubernetesComponentStatusService = KubernetesComponentStatusService;
|
||||
this.KubernetesEndpointService = KubernetesEndpointService;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.getNodes = this.getNodes.bind(this);
|
||||
this.getNodesAsync = this.getNodesAsync.bind(this);
|
||||
this.getApplicationsAsync = this.getApplicationsAsync.bind(this);
|
||||
this.getComponentStatus = this.getComponentStatus.bind(this);
|
||||
this.getComponentStatusAsync = this.getComponentStatusAsync.bind(this);
|
||||
this.getEndpointsAsync = this.getEndpointsAsync.bind(this);
|
||||
this.hasResourceUsageAccess = this.hasResourceUsageAccess.bind(this);
|
||||
}
|
||||
|
||||
async getComponentStatusAsync() {
|
||||
try {
|
||||
this.componentStatuses = await this.KubernetesComponentStatusService.get();
|
||||
this.hasUnhealthyComponentStatus = _.find(this.componentStatuses, { Healthy: false }) ? true : false;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve cluster component statuses');
|
||||
}
|
||||
}
|
||||
|
||||
getComponentStatus() {
|
||||
return this.$async(this.getComponentStatusAsync);
|
||||
}
|
||||
|
||||
async getEndpointsAsync() {
|
||||
try {
|
||||
const endpoints = await this.KubernetesEndpointService.get();
|
||||
@@ -152,14 +135,12 @@ class KubernetesClusterController {
|
||||
this.state = {
|
||||
applicationsLoading: true,
|
||||
viewReady: false,
|
||||
hasUnhealthyComponentStatus: false,
|
||||
useServerMetrics,
|
||||
};
|
||||
|
||||
await this.getNodes();
|
||||
if (this.isAdmin) {
|
||||
await this.getEndpoints();
|
||||
await this.getComponentStatus();
|
||||
await this.getApplications();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<kubernetes-view-header title="Configuration list" state="kubernetes.configurations" view-ready="ctrl.state.viewReady">
|
||||
Configurations
|
||||
<kubernetes-view-header title="ConfigMaps & Secrets list" state="kubernetes.configurations" view-ready="ctrl.state.viewReady">
|
||||
ConfigMaps & Secrets
|
||||
</kubernetes-view-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<kubernetes-view-header title="Create configuration" state="kubernetes.configurations.new" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.configurations">Configurations</a> > Create a configuration
|
||||
<a ui-sref="kubernetes.configurations">ConfigMaps and Secrets</a> > Create a configuration
|
||||
</kubernetes-view-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
@@ -95,7 +95,7 @@
|
||||
<label for="type_basic" data-cy="k8sConfigCreate-nonSensitiveButton">
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-file-code" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Non-sensitive
|
||||
ConfigMap
|
||||
</div>
|
||||
<p>This configuration holds non-sensitive information</p>
|
||||
</label>
|
||||
@@ -105,7 +105,7 @@
|
||||
<label for="type_secret" data-cy="k8sConfigCreate-sensitiveButton">
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-user-secret" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Sensitive
|
||||
Secret
|
||||
</div>
|
||||
<p>This configuration holds sensitive information</p>
|
||||
</label>
|
||||
@@ -153,7 +153,7 @@
|
||||
button-spinner="ctrl.state.actionInProgress"
|
||||
data-cy="k8sConfigCreate-CreateConfigButton"
|
||||
>
|
||||
<span ng-hide="ctrl.state.actionInProgress">Create configuration</span>
|
||||
<span ng-hide="ctrl.state.actionInProgress">Create {{ ctrl.formValues.Type | kubernetesConfigurationTypeText }}</span>
|
||||
<span ng-show="ctrl.state.actionInProgress">Creation in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<kubernetes-view-header title="Configuration details" state="kubernetes.configurations.configuration" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.resourcePools">Namespaces</a> >
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.configuration.Namespace })">{{ ctrl.configuration.Namespace }}</a> >
|
||||
<a ui-sref="kubernetes.configurations">Configurations</a> > {{ ctrl.configuration.Name }}
|
||||
<a ui-sref="kubernetes.configurations">ConfigMaps and Secrets</a> > {{ ctrl.configuration.Name }}
|
||||
</kubernetes-view-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
@@ -106,7 +106,7 @@
|
||||
button-spinner="ctrl.state.actionInProgress"
|
||||
data-cy="k8sConfigDetail-updateConfig"
|
||||
>
|
||||
<span ng-hide="ctrl.state.actionInProgress">Update configuration</span>
|
||||
<span ng-hide="ctrl.state.actionInProgress">Update {{ ctrl.configuration.Type | kubernetesConfigurationTypeText }}</span>
|
||||
<span ng-show="ctrl.state.actionInProgress">Update in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<li ng-repeat="summary in $ctrl.state.resources" ng-if="summary.action && summary.kind && summary.name">
|
||||
{{ summary.action }}
|
||||
{{ $ctrl.getArticle(summary.kind, summary.action) }}
|
||||
<span class="summary">{{ summary.kind }}</span> named <code>{{ summary.name }}</code>
|
||||
<span class="bold">{{ summary.kind }}</span> named <code>{{ summary.name }}</code>
|
||||
<span ng-if="summary.type">
|
||||
of type <code>{{ summary.type }}</code></span
|
||||
>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
ng-value="$ctrl.option.value"
|
||||
ng-disabled="$ctrl.disabled"
|
||||
/>
|
||||
<label for="{{ $ctrl.option.id }}" ng-click="$ctrl.onChange($ctrl.option.value)" t>
|
||||
<label for="{{ $ctrl.option.id }}" ng-click="$ctrl.onChange($ctrl.option.value)" t data-cy="edgeStackCreate-deploymentSelector_{{ $ctrl.option.value }}">
|
||||
<div class="boxselector_header">
|
||||
<i ng-class="$ctrl.option.icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
{{ $ctrl.option.label }}
|
||||
|
||||
@@ -37,7 +37,7 @@ class EndpointItemController {
|
||||
const checkInInterval = this.model.EdgeCheckinInterval;
|
||||
|
||||
// give checkIn some wiggle room
|
||||
return this.endpointInitTime - this.model.LastCheckInDate <= checkInInterval * 2;
|
||||
return this.endpointInitTime - this.model.LastCheckInDate <= checkInInterval * 2 + 20;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
|
||||
@@ -271,6 +271,7 @@ ul.sidebar .sidebar-title .form-control {
|
||||
ul.sidebar .sidebar-list a,
|
||||
ul.sidebar .sidebar-list .sidebar-sublist a {
|
||||
line-height: 36px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list .menu-icon {
|
||||
|
||||
12
app/portainer/components/status-indicator/index.js
Normal file
12
app/portainer/components/status-indicator/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import angular from 'angular';
|
||||
|
||||
import './status-indicator.css';
|
||||
|
||||
export const statusIndicator = {
|
||||
templateUrl: './status-indicator.html',
|
||||
bindings: {
|
||||
ok: '<',
|
||||
},
|
||||
};
|
||||
|
||||
angular.module('portainer.app').component('statusIndicator', statusIndicator);
|
||||
@@ -0,0 +1,13 @@
|
||||
.status-indicator {
|
||||
padding: 0 !important;
|
||||
margin-right: 1ch;
|
||||
border-radius: 50%;
|
||||
background-color: var(--red-3);
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ok {
|
||||
background-color: var(--green-3);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<i aria-hidden="true" ng-class="['status-indicator', { ok: $ctrl.ok }]"></i>
|
||||
Reference in New Issue
Block a user