Merge remote-tracking branch 'origin/develop' into feat/EE-189/EE-577/support-git-automated-sync-for-k8s-applications

This commit is contained in:
Dmitry Salakhov
2021-09-29 17:18:14 +13:00
75 changed files with 2166 additions and 1256 deletions

142
api/bolt/backup.go Normal file
View 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
View 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")
}
})
}

View 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
}

View File

@@ -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
View 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()
}

View 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)
})
}

View 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)
}

View 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)
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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}

View File

@@ -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)

View File

@@ -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

View File

@@ -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
View 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
}

View File

@@ -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)

View 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
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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=

View File

@@ -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,
}

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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"}

View File

@@ -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}

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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")
}

View File

@@ -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 }

View File

@@ -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)

View File

@@ -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

View File

@@ -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]))
}
})
}
}

View File

@@ -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
)

View File

@@ -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"
)

View File

@@ -88,6 +88,7 @@ html {
--green-1: #164;
--green-2: #1ec863;
--green-3: #23ae89;
}
:root {

View File

@@ -287,7 +287,7 @@ json-tree .branch-preview {
margin-top: 15px;
}
.summary {
.bold {
color: var(--text-summary-color);
font-weight: 700;
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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)));
}
}

View File

@@ -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,
},
}
);
};
},
]);

View File

@@ -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);

View File

@@ -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">

View File

@@ -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>

View File

@@ -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

View File

@@ -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">

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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';
}
};
})

View File

@@ -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';
}
};
});

View File

@@ -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

View File

@@ -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;

View File

@@ -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,
});

View File

@@ -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 -->

View File

@@ -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) {

View File

@@ -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">

View File

@@ -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">

View File

@@ -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();
}

View File

@@ -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>

View File

@@ -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> &gt; Create a configuration
<a ui-sref="kubernetes.configurations">ConfigMaps and Secrets</a> &gt; 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>

View File

@@ -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> &gt;
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.configuration.Namespace })">{{ ctrl.configuration.Namespace }}</a> &gt;
<a ui-sref="kubernetes.configurations">Configurations</a> &gt; {{ ctrl.configuration.Name }}
<a ui-sref="kubernetes.configurations">ConfigMaps and Secrets</a> &gt; {{ 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>

View File

@@ -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
>

View File

@@ -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 }}

View File

@@ -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() {

View File

@@ -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 {

View 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);

View File

@@ -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);
}

View File

@@ -0,0 +1 @@
<i aria-hidden="true" ng-class="['status-indicator', { ok: $ctrl.ok }]"></i>