Compare commits

..

14 Commits

Author SHA1 Message Date
Felix Han
6d6b277c18 fix(ldap): fixed default text in ldap custom admin group component 2021-10-12 19:55:34 +13:00
Felix Han
83b6159199 fix(ldap): removed testing code. 2021-10-12 19:50:54 +13:00
Felix Han
c54bac8d1a fix(ui): LDAP group search config label 2021-10-12 16:51:52 +13:00
Felix Han
8cd59f25e1 feat(ldap): added ldap custom admin group to LDAP and MS AD pages 2021-10-12 10:11:28 +13:00
Felix Han
a87cb8785c feat(ldap): added ldap custom admin group component 2021-10-12 10:10:29 +13:00
Richard Wei
685552a661 fix(wizard): fix wizard not visible in dark theme EE-1800 (#5822)
* fix wizard not visible in dark theme
2021-10-08 14:59:01 +13:00
Richard Wei
1b0e58a4e8 fix upload file not selectable on mac (#5808) 2021-10-08 12:17:22 +13:00
Chaim Lev-Ari
151dfe7e65 fix(compose): use tcp for agent proxy EE-1807 (#5854) 2021-10-08 11:59:50 +13:00
Chaim Lev-Ari
ed89587cb9 fix(ldap): enable user/group setting in custom ldap (#5855) 2021-10-08 10:43:04 +13:00
zees-dev
dad762de9f added swagger docs to websocketShellPodExec (#5840) 2021-10-07 15:32:07 +13:00
Richard Wei
661931d8b0 fix(template): add name validation for template name EE-1806 (#5823)
* add name validation for tempalte name
2021-10-07 13:02:56 +13:00
Richard Wei
84e57cebc9 fix set namespace to default-namespace (#5820) 2021-10-07 11:06:53 +13:00
Marcelo Rydel
fd9427cd0b remove default value for compose path (#5821) 2021-10-06 10:12:36 -03:00
Chaim Lev-Ari
e60dbba93b feat(app): highlight be provided value [EE-882] (#5703) 2021-10-06 09:24:26 +03:00
44 changed files with 316 additions and 364 deletions

View File

@@ -18,16 +18,13 @@ 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) (err error) {
func (store *Store) FailSafeMigrate(migrator *migrator.Migrator) error {
defer func() {
if e := recover(); e != nil {
if err := recover(); err != nil {
migrateLog.Info(fmt.Sprintf("Error during migration, recovering [%v]", err))
store.Rollback(true)
err = fmt.Errorf("%v", e)
}
}()
// !Important: we must use a named return value in the function definition and not a local
// !variable referenced from the closure or else the return value will be incorrectly set
return migrator.Migrate()
}

View File

@@ -301,7 +301,7 @@ func (m *Migrator) Migrate() error {
}
}
// Portainer 2.9.1, 2.9.2
// Portainer 2.9.1
if m.currentDBVersion < 33 {
err := m.migrateDBVersionToDB33()
if err != nil {
@@ -316,13 +316,6 @@ func (m *Migrator) Migrate() error {
}
}
// Portainer 2.9.3 (yep out of order, but 2.10 is EE only)
if m.currentDBVersion < 35 {
if err := m.migrateDBVersionToDB35(); err != nil {
return migrationError(err, "migrateDBVersionToDB35")
}
}
err = m.versionService.StoreDBVersion(portainer.DBVersion)
if err != nil {
return migrationError(err, "StoreDBVersion")

View File

@@ -100,32 +100,6 @@ func (m *Migrator) updateDockerhubToDB32() error {
RegistryAccesses: portainer.RegistryAccesses{},
}
// The following code will make this function idempotent.
// i.e. if run again, it will not change the data. It will ensure that
// we only have one migrated registry entry. Duplicates will be removed
// if they exist and which has been happening due to earlier migration bugs
migrated := false
registries, _ := m.registryService.Registries()
for _, r := range registries {
if r.Type == registry.Type &&
r.Name == registry.Name &&
r.URL == registry.URL &&
r.Authentication == registry.Authentication {
if !migrated {
// keep this one entry
migrated = true
} else {
// delete subsequent duplicates
m.registryService.DeleteRegistry(portainer.RegistryID(r.ID))
}
}
}
if migrated {
return nil
}
endpoints, err := m.endpointService.Endpoints()
if err != nil {
return err
@@ -244,12 +218,8 @@ func findResourcesToUpdateForDB32(dockerID string, volumesData map[string]interf
if !nameExist {
continue
}
createTime, createTimeExist := volume["CreatedAt"].(string)
if !createTimeExist {
continue
}
oldResourceID := fmt.Sprintf("%s%s", volumeName, createTime)
oldResourceID := fmt.Sprintf("%s%s", volumeName, volume["CreatedAt"].(string))
resourceControl, ok := volumeResourceControls[oldResourceID]
if ok {

View File

@@ -1,11 +0,0 @@
package migrator
func (m *Migrator) migrateDBVersionToDB35() error {
// These should have been migrated already, but due to an earlier bug and a bunch of duplicates,
// calling it again will now fix the issue as the function has been repaired.
err := m.updateDockerhubToDB32()
if err != nil {
return err
}
return nil
}

View File

@@ -1,108 +0,0 @@
package migrator
import (
"os"
"path"
"testing"
"time"
"github.com/boltdb/bolt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/dockerhub"
"github.com/portainer/portainer/api/bolt/endpoint"
"github.com/portainer/portainer/api/bolt/internal"
"github.com/portainer/portainer/api/bolt/registry"
"github.com/stretchr/testify/assert"
)
const (
db35TestFile = "portainer-mig-35.db"
username = "portainer"
password = "password"
)
func setupDB35Test(t *testing.T) *Migrator {
is := assert.New(t)
dbConn, err := bolt.Open(path.Join(t.TempDir(), db35TestFile), 0600, &bolt.Options{Timeout: 1 * time.Second})
is.NoError(err, "failed to init testing DB connection")
// Create an old style dockerhub authenticated account
dockerhubService, err := dockerhub.NewService(&internal.DbConnection{DB: dbConn})
is.NoError(err, "failed to init testing registry service")
err = dockerhubService.UpdateDockerHub(&portainer.DockerHub{true, username, password})
is.NoError(err, "failed to create dockerhub account")
registryService, err := registry.NewService(&internal.DbConnection{DB: dbConn})
is.NoError(err, "failed to init testing registry service")
endpointService, err := endpoint.NewService(&internal.DbConnection{DB: dbConn})
is.NoError(err, "failed to init endpoint service")
m := &Migrator{
db: dbConn,
dockerhubService: dockerhubService,
registryService: registryService,
endpointService: endpointService,
}
return m
}
// TestUpdateDockerhubToDB32 tests a normal upgrade
func TestUpdateDockerhubToDB32(t *testing.T) {
is := assert.New(t)
m := setupDB35Test(t)
defer m.db.Close()
defer os.Remove(db35TestFile)
if err := m.updateDockerhubToDB32(); err != nil {
t.Errorf("failed to update settings: %v", err)
}
// Verify we have a single registry were created
registries, err := m.registryService.Registries()
is.NoError(err, "failed to read registries from the RegistryService")
is.Equal(len(registries), 1, "only one migrated registry expected")
}
// TestUpdateDockerhubToDB32_with_duplicate_migrations tests an upgrade where in earlier versions a broken migration
// created a large number of duplicate "dockerhub migrated" registry entries.
func TestUpdateDockerhubToDB32_with_duplicate_migrations(t *testing.T) {
is := assert.New(t)
m := setupDB35Test(t)
defer m.db.Close()
defer os.Remove(db35TestFile)
// Create lots of duplicate entries...
registry := &portainer.Registry{
Type: portainer.DockerHubRegistry,
Name: "Dockerhub (authenticated - migrated)",
URL: "docker.io",
Authentication: true,
Username: "portainer",
Password: "password",
RegistryAccesses: portainer.RegistryAccesses{},
}
for i := 1; i < 150; i++ {
err := m.registryService.CreateRegistry(registry)
assert.NoError(t, err, "create registry failed")
}
// Verify they were created
registries, err := m.registryService.Registries()
is.NoError(err, "failed to read registries from the RegistryService")
is.Condition(func() bool {
return len(registries) > 1
}, "expected multiple duplicate registry entries")
// Now run the migrator
if err := m.updateDockerhubToDB32(); err != nil {
t.Errorf("failed to update settings: %v", err)
}
// Verify we have a single registry were created
registries, err = m.registryService.Registries()
is.NoError(err, "failed to read registries from the RegistryService")
is.Equal(len(registries), 1, "only one migrated registry expected")
}

View File

@@ -192,8 +192,8 @@ func (service *Service) RefreshableStacks() ([]portainer.Stack, error) {
bucket := tx.Bucket([]byte(BucketName))
cursor := bucket.Cursor()
var stack portainer.Stack
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
stack := portainer.Stack{}
err := internal.UnmarshalObject(v, &stack)
if err != nil {
return err

View File

@@ -74,7 +74,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.9.3
// @version 2.9.1
// @description.markdown api-description.md
// @termsOfService

View File

@@ -54,8 +54,11 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
if payload.TemplatesURL != nil && *payload.TemplatesURL != "" && !govalidator.IsURL(*payload.TemplatesURL) {
return errors.New("Invalid external templates URL. Must correspond to a valid URL format")
}
if payload.HelmRepositoryURL != nil && *payload.HelmRepositoryURL != "" && !govalidator.IsURL(*payload.HelmRepositoryURL) {
return errors.New("Invalid Helm repository URL. Must correspond to a valid URL format")
if payload.HelmRepositoryURL != nil && *payload.HelmRepositoryURL != "" {
err := libhelm.ValidateHelmRepositoryURL(*payload.HelmRepositoryURL)
if err != nil {
return errors.Wrap(err, "Invalid Helm repository URL. Must correspond to a valid URL format")
}
}
if payload.UserSessionTimeout != nil {
_, err := time.ParseDuration(*payload.UserSessionTimeout)
@@ -111,16 +114,7 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
}
if payload.HelmRepositoryURL != nil {
newHelmRepo := strings.TrimSuffix(strings.ToLower(*payload.HelmRepositoryURL), "/")
if newHelmRepo != settings.HelmRepositoryURL && newHelmRepo != portainer.DefaultHelmRepositoryURL {
err := libhelm.ValidateHelmRepositoryURL(*payload.HelmRepositoryURL)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid Helm repository URL. Must correspond to a valid URL format", err}
}
}
settings.HelmRepositoryURL = newHelmRepo
settings.HelmRepositoryURL = strings.TrimSuffix(strings.ToLower(*payload.HelmRepositoryURL), "/")
}
if payload.BlackListedLabels != nil {

View File

@@ -46,19 +46,5 @@ func (transport *baseTransport) proxyNamespaceDeleteOperation(request *http.Requ
}
}
}
stacks, err := transport.dataStore.Stack().Stacks()
if err != nil {
return nil, err
}
for _, s := range stacks {
if s.Namespace == namespace && s.EndpointID == transport.endpoint.ID {
if err := transport.dataStore.Stack().DeleteStack(s.ID); err != nil {
return nil, err
}
}
}
return transport.executeKubernetesRequest(request)
}

View File

@@ -9,7 +9,7 @@ import (
func getPortainerUserDefaultPolicies() []rbacv1.PolicyRule {
return []rbacv1.PolicyRule{
{
Verbs: []string{"list", "get"},
Verbs: []string{"list"},
Resources: []string{"namespaces", "nodes"},
APIGroups: []string{""},
},
@@ -18,11 +18,6 @@ func getPortainerUserDefaultPolicies() []rbacv1.PolicyRule {
Resources: []string{"storageclasses"},
APIGroups: []string{"storage.k8s.io"},
},
{
Verbs: []string{"list", "get"},
Resources: []string{"namespaces", "pods", "nodes"},
APIGroups: []string{"metrics.k8s.io"},
},
}
}

View File

@@ -1470,9 +1470,9 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.9.3"
APIVersion = "2.9.1"
// DBVersion is the version number of the Portainer database
DBVersion = 35
DBVersion = 32
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
ComposeSyntaxMaxVersion = "3.9"
// AssetsServerURL represents the URL of the Portainer asset server

View File

@@ -721,6 +721,10 @@ a[ng-click] {
.multiSelect .multiSelectItem:hover,
.multiSelect .multiSelectGroup:hover {
border-color: var(--grey-3);
}
.multiSelect .multiSelectItem:hover,
.multiSelect .multiSelectGroup:hover {
background-image: var(--bg-image-multiselect) !important;
color: var(--white-color) !important;
}

View File

@@ -62,6 +62,7 @@ html {
--grey-58: #ebf4f8;
--grey-59: #e6e6e6;
--grey-60: #cacaca;
--grey-61: rgb(231, 231, 231);
--blue-1: #219;
--blue-2: #337ab7;
@@ -158,6 +159,8 @@ html {
--bg-small-select-color: var(--white-color);
--bg-app-datatable-thead: var(--grey-23);
--bg-app-datatable-tbody: var(--grey-24);
--bg-stepper-item-active: var(--white-color);
--bg-stepper-item-counter: var(--grey-61);
--text-main-color: var(--grey-7);
--text-body-color: var(--grey-6);
@@ -328,6 +331,8 @@ html {
--bg-small-select-color: var(--grey-2);
--bg-app-datatable-thead: var(--grey-1);
--bg-app-datatable-tbody: var(--grey-1);
--bg-stepper-item-active: var(--grey-1);
--bg-stepper-item-counter: var(--grey-7);
--text-main-color: var(--white-color);
--text-body-color: var(--white-color);
@@ -497,6 +502,8 @@ html {
--bg-small-select-color: var(--black-color);
--bg-app-datatable-thead: var(--black-color);
--bg-app-datatable-tbody: var(--black-color);
--bg-stepper-item-active: var(--black-color);
--bg-stepper-item-counter: var(--grey-3);
--text-main-color: var(--white-color);
--text-body-color: var(--white-color);

View File

@@ -246,6 +246,12 @@ json-tree .branch-preview {
.pagination > li > span:focus {
background-color: var(--bg-pagination-hover-color);
border-color: var(--border-pagination-hover-color);
}
.pagination > li > a:hover,
.pagination > li > span:hover,
.pagination > li > a:focus,
.pagination > li > span:focus {
color: var(--text-pagination-span-hover-color);
}

View File

@@ -31,3 +31,4 @@ angular
export const PORTAINER_FADEOUT = 1500;
export const STACK_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$';
export const TEMPLATE_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$';

View File

@@ -69,19 +69,13 @@ class porImageRegistryController {
async reloadRegistries() {
return this.$async(async () => {
try {
let showDefaultRegistry = false;
this.registries = await this.EndpointService.registries(this.endpoint.Id, this.namespace);
// hide default(anonymous) dockerhub registry if user has an authenticated one
if (!this.registries.some((registry) => registry.Type === RegistryTypes.DOCKERHUB)) {
showDefaultRegistry = true;
this.registries.push(this.defaultRegistry);
}
const registries = await this.EndpointService.registries(this.endpoint.Id, this.namespace);
this.registries = _.concat(this.defaultRegistry, registries);
const id = this.model.Registry.Id;
const registry = _.find(this.registries, { Id: id });
if (!registry) {
this.model.Registry = showDefaultRegistry ? this.defaultRegistry : this.registries[0];
this.model.Registry = this.defaultRegistry;
}
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve registries');

View File

@@ -6,7 +6,7 @@
</label>
<div ng-class="$ctrl.inputClass">
<select
ng-options="registry as registry.Name for registry in $ctrl.registries track by registry.Id"
ng-options="registry as registry.Name for registry in $ctrl.registries track by registry.Name"
ng-model="$ctrl.model.Registry"
id="image_registry"
class="form-control"

View File

@@ -82,7 +82,7 @@
<!-- !tag-note -->
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.RegistryModel.Image" ng-click="tagImage()">Tag</button>
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!state.pullImageValidity || !formValues.RegistryModel.Image" ng-click="tagImage()">Tag</button>
</div>
</div>
</form>

View File

@@ -17,8 +17,6 @@ angular.module('portainer.docker').controller('ImageController', [
'FileSaver',
'Blob',
'endpoint',
'EndpointService',
'RegistryModalService',
function (
$async,
$q,
@@ -34,9 +32,7 @@ angular.module('portainer.docker').controller('ImageController', [
ModalService,
FileSaver,
Blob,
endpoint,
EndpointService,
RegistryModalService
endpoint
) {
$scope.endpoint = endpoint;
$scope.isAdmin = Authentication.isAdmin();
@@ -88,13 +84,11 @@ angular.module('portainer.docker').controller('ImageController', [
async function pushTag(repository) {
return $async(async () => {
$('#uploadResourceHint').show();
try {
const registryModel = await RegistryModalService.registryModal(repository, $scope.registries);
if (registryModel) {
$('#uploadResourceHint').show();
await ImageService.pushImage(registryModel);
Notifications.success('Image successfully pushed', repository);
}
const registryModel = await RegistryService.retrievePorRegistryModelFromRepository(repository, endpoint.Id);
await ImageService.pushImage(registryModel);
Notifications.success('Image successfully pushed', repository);
} catch (err) {
Notifications.error('Failure', err, 'Unable to push image to repository');
} finally {
@@ -106,13 +100,11 @@ angular.module('portainer.docker').controller('ImageController', [
$scope.pullTag = pullTag;
async function pullTag(repository) {
return $async(async () => {
$('#downloadResourceHint').show();
try {
const registryModel = await RegistryModalService.registryModal(repository, $scope.registries);
if (registryModel) {
$('#downloadResourceHint').show();
await ImageService.pullImage(registryModel);
Notifications.success('Image successfully pulled', repository);
}
const registryModel = await RegistryService.retrievePorRegistryModelFromRepository(repository, endpoint.Id);
await ImageService.pullImage(registryModel);
Notifications.success('Image successfully pulled', repository);
} catch (err) {
Notifications.error('Failure', err, 'Unable to pull image from repository');
} finally {
@@ -179,15 +171,8 @@ angular.module('portainer.docker').controller('ImageController', [
});
};
async function initView() {
function initView() {
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
try {
$scope.registries = await RegistryService.loadRegistriesForDropdown(endpoint.Id);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to load registries');
}
$q.all({
image: ImageService.image($transition$.params().id),
history: ImageService.history($transition$.params().id),

View File

@@ -1,4 +1,5 @@
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import _ from 'lodash-es';
export default class HelmTemplatesController {
/* @ngInject */
@@ -141,8 +142,8 @@ export default class HelmTemplatesController {
const resourcePools = await this.KubernetesResourcePoolService.get();
const nonSystemNamespaces = resourcePools.filter((resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name));
this.state.resourcePools = nonSystemNamespaces;
this.state.resourcePool = nonSystemNamespaces[0];
this.state.resourcePools = _.sortBy(nonSystemNamespaces, ({ Namespace }) => (Namespace.Name === 'default' ? 0 : 1));
this.state.resourcePool = this.state.resourcePools[0];
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve initial helm data.');
} finally {

View File

@@ -1771,7 +1771,7 @@
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
type="button"
class="btn btn-primary btn-sm"
ng-disabled="!kubernetesApplicationCreationForm.$valid || ctrl.isDeployUpdateButtonDisabled() || !ctrl.imageValidityIsValid()"
ng-disabled="!kubernetesApplicationCreationForm.$valid || ctrl.isDeployUpdateButtonDisabled() || !ctrl.state.pullImageValidity"
ng-click="ctrl.deployApplication()"
button-spinner="ctrl.state.actionInProgress"
data-cy="k8sAppCreate-deployButton"

View File

@@ -2,7 +2,6 @@ import angular from 'angular';
import _ from 'lodash-es';
import filesizeParser from 'filesize-parser';
import * as JsonPatch from 'fast-json-patch';
import { RegistryTypes } from '@/portainer/models/registryTypes';
import {
KubernetesApplicationDataAccessPolicies,
@@ -194,10 +193,6 @@ class KubernetesCreateApplicationController {
this.state.pullImageValidity = validity;
}
imageValidityIsValid() {
return this.state.pullImageValidity || this.formValues.ImageModel.Registry.Type !== RegistryTypes.DOCKERHUB;
}
onChangeName() {
const existingApplication = _.find(this.applications, { Name: this.formValues.Name });
this.state.alreadyExists = (this.state.isEdit && existingApplication && this.application.Id !== existingApplication.Id) || (!this.state.isEdit && existingApplication);
@@ -1079,7 +1074,10 @@ class KubernetesCreateApplicationController {
]);
this.nodesLimits = nodesLimits;
this.resourcePools = _.filter(resourcePools, (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name));
const nonSystemNamespaces = _.filter(resourcePools, (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name));
this.resourcePools = _.sortBy(nonSystemNamespaces, ({ Namespace }) => (Namespace.Name === 'default' ? 0 : 1));
this.formValues.ResourcePool = this.resourcePools[0];
if (!this.formValues.ResourcePool) {
return;

View File

@@ -5,7 +5,27 @@
Title
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" ng-model="$ctrl.formValues.Title" id="template_title" name="template_title" placeholder="e.g. mytemplate" auto-focus required />
<input
type="text"
class="form-control"
ng-model="$ctrl.formValues.Title"
ng-pattern="$ctrl.nameRegex"
id="template_title"
name="template_title"
placeholder="e.g. mytemplate"
auto-focus
required
/>
</div>
</div>
<div class="form-group" ng-show="commonCustomTemplateForm.template_title.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="commonCustomTemplateForm.template_title.$error">
<p ng-message="pattern">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
<span>This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123').</span>
</p>
</div>
</div>
</div>
<div class="form-group" ng-show="commonCustomTemplateForm.template_title.$invalid">

View File

@@ -7,5 +7,6 @@ angular.module('portainer.app').component('customTemplateCommonFields', {
formValues: '=',
showPlatformField: '<',
showTypeField: '<',
nameRegex: '<',
},
});

View File

@@ -8,6 +8,7 @@ angular.module('portainer.app').component('stackFromTemplateForm', {
state: '=',
createTemplate: '<',
unselectTemplate: '<',
nameRegex: '<',
},
transclude: {
advanced: '?advancedForm',

View File

@@ -2,7 +2,7 @@
<rd-widget>
<rd-widget-custom-header icon="$ctrl.template.Logo" title-text="$ctrl.template.Title"></rd-widget-custom-header>
<rd-widget-body classes="padding">
<form class="form-horizontal">
<form class="form-horizontal" name="stackTemplateForm">
<!-- description -->
<div ng-if="$ctrl.template.Note">
<div class="col-sm-12 form-section-title">
@@ -20,9 +20,19 @@
</div>
<!-- name-input -->
<div class="form-group">
<label for="container_name" class="col-sm-2 control-label text-left">Name</label>
<label for="template_name" class="col-sm-2 control-label text-left">Name</label>
<div class="col-sm-10">
<input type="text" name="container_name" class="form-control" ng-model="$ctrl.formValues.name" placeholder="e.g. myStack" required />
<input type="text" name="template_name" class="form-control" ng-model="$ctrl.formValues.name" ng-pattern="$ctrl.nameRegex" placeholder="e.g. myStack" required />
</div>
</div>
<div class="form-group" ng-show="stackTemplateForm.template_name.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="stackTemplateForm.template_name.$error">
<p ng-message="pattern">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
<span>This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123').</span>
</p>
</div>
</div>
</div>
<!-- !name-input -->

View File

@@ -22,8 +22,6 @@ angular.module('portainer.app').factory('RegistryService', [
createRegistry,
createGitlabRegistries,
retrievePorRegistryModelFromRepository,
retrievePorRegistryModelFromRepositoryWithRegistries,
loadRegistriesForDropdown,
};
function registries() {
@@ -109,45 +107,17 @@ angular.module('portainer.app').factory('RegistryService', [
return url;
}
// findBestMatchRegistry finds out the best match registry for repository
// matching precedence:
// 1. registryId matched
// 2. both domain name and username matched (for dockerhub only)
// 3. only URL matched
// 4. pick up the first dockerhub registry
function findBestMatchRegistry(repository, registries, registryId) {
let match2, match3, match4;
for (const registry of registries) {
if (registry.Id == registryId) {
return registry;
}
if (registry.Type === RegistryTypes.DOCKERHUB) {
// try to match repository examples:
// <USERNAME>/nginx:latest
// docker.io/<USERNAME>/nginx:latest
if (repository.startsWith(registry.Username + '/') || repository.startsWith(getURL(registry) + '/' + registry.Username + '/')) {
match2 = registry;
}
// try to match repository examples:
// portainer/portainer-ee:latest
// <NON-USERNAME>/portainer-ee:latest
match4 = match4 || registry;
}
if (_.includes(repository, getURL(registry))) {
match3 = registry;
}
}
return match2 || match3 || match4;
}
function retrievePorRegistryModelFromRepositoryWithRegistries(repository, registries, registryId) {
const model = new PorImageRegistryModel();
const registry = findBestMatchRegistry(repository, registries, registryId);
const registry = registries.find((reg) => {
if (registryId) {
return reg.Id === registryId;
}
if (reg.Type === RegistryTypes.DOCKERHUB) {
return _.includes(repository, reg.Username);
}
return _.includes(repository, getURL(reg));
});
if (registry) {
const url = getURL(registry);
let lastIndex = repository.lastIndexOf(url);
@@ -178,22 +148,5 @@ angular.module('portainer.app').factory('RegistryService', [
}
});
}
function loadRegistriesForDropdown(endpointId, namespace) {
return $async(async () => {
try {
const registries = await EndpointService.registries(endpointId, namespace);
// hide default(anonymous) dockerhub registry if user has an authenticated one
if (!registries.some((registry) => registry.Type === RegistryTypes.DOCKERHUB)) {
registries.push(new DockerHubViewModel());
}
return registries;
} catch (err) {
throw { msg: 'Unable to retrieve the registries', err: err };
}
});
}
},
]);

View File

@@ -308,17 +308,6 @@ angular.module('portainer.app').factory('ModalService', [
);
};
service.selectRegistry = function (options) {
var box = bootbox.prompt({
title: 'Which registry do you want to use?',
inputType: 'select',
value: options.defaultValue,
inputOptions: options.options,
callback: options.callback,
});
applyBoxCSS(box);
};
return service;
},
]);

View File

@@ -1,39 +0,0 @@
import _ from 'lodash';
angular.module('portainer.app').factory('RegistryModalService', ModalServiceFactory);
function ModalServiceFactory($q, ModalService, RegistryService) {
const service = {};
function registries2Options(registries) {
return registries.map((r) => ({
text: r.Name,
value: String(r.Id),
}));
}
service.registryModal = async function (repository, registries) {
const deferred = $q.defer();
const options = registries2Options(registries);
const registryModel = RegistryService.retrievePorRegistryModelFromRepositoryWithRegistries(repository, registries);
const defaultValue = String(_.get(registryModel, 'Registry.Id', '0'));
ModalService.selectRegistry({
options,
defaultValue,
callback: (registryId) => {
if (registryId) {
const registryModel = RegistryService.retrievePorRegistryModelFromRepositoryWithRegistries(repository, registries, registryId);
deferred.resolve(registryModel);
} else {
deferred.resolve(null);
}
},
});
return deferred.promise;
};
return service;
}

View File

@@ -153,5 +153,14 @@
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-group-search>
<ldap-custom-admin-group
style="margin-top: 5px;"
settings="$ctrl.settings"
on-search-click="($ctrl.onSearchAdminGroupsClick)"
selected-admin-groups="$ctrl.selectedAdminGroups"
default-admin-group-search-filter="'(objectClass=groupOfNames)'"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-custom-admin-group>
<ldap-settings-test-login settings="$ctrl.settings" limited-feature-id="$ctrl.limitedFeatureId"></ldap-settings-test-login>
</ng-form>

View File

@@ -14,6 +14,7 @@ import { ldapUserSearchItem } from './ldap-user-search-item';
import { ldapSettingsDnBuilder } from './ldap-settings-dn-builder';
import { ldapSettingsGroupDnBuilder } from './ldap-settings-group-dn-builder';
import { ldapCustomGroupSearch } from './ldap-custom-group-search';
import { ldapCustomAdminGroup } from './ldap-custom-admin-group';
import { ldapSettingsSecurity } from './ldap-settings-security';
import { ldapSettingsTestLogin } from './ldap-settings-test-login';
import { ldapCustomUserSearch } from './ldap-custom-user-search';
@@ -37,6 +38,7 @@ export default angular
.component('ldapSettingsDnBuilder', ldapSettingsDnBuilder)
.component('ldapSettingsGroupDnBuilder', ldapSettingsGroupDnBuilder)
.component('ldapCustomGroupSearch', ldapCustomGroupSearch)
.component('ldapCustomAdminGroup', ldapCustomAdminGroup)
.component('ldapSettingsOpenLdap', ldapSettingsOpenLdap)
.component('ldapSettingsSecurity', ldapSettingsSecurity)
.component('ldapSettingsTestLogin', ldapSettingsTestLogin)

View File

@@ -0,0 +1,13 @@
import controller from './ldap-custom-admin-group.controller';
export const ldapCustomAdminGroup = {
templateUrl: './ldap-custom-admin-group.html',
controller,
bindings: {
settings: '=',
selectedAdminGroups: '=',
defaultAdminGroupSearchFilter: '<',
onSearchClick: '<',
limitedFeatureId: '<',
},
};

View File

@@ -0,0 +1,49 @@
export default class LdapCustomAdminGroupController {
/* @ngInject */
constructor($async, Notifications, LDAPService) {
Object.assign(this, { $async, Notifications, LDAPService });
this.groups = null;
this.groupstest = null;
this.enableAssignAdminGroup = false;
this.onRemoveClick = this.onRemoveClick.bind(this);
this.onAddClick = this.onAddClick.bind(this);
this.search = this.search.bind(this);
}
onAddClick() {
this.settings.AdminGroupSearchSettings.push({ GroupBaseDN: '', GroupAttribute: '', GroupFilter: '' });
}
onRemoveClick(index) {
this.settings.AdminGroupSearchSettings.splice(index, 1);
}
search() {
return this.$async(async () => {
try {
this.groups = null;
this.groups = await this.onSearchClick();
this.enableAssignAdminGroup = this.groups && this.groups.length > 0;
} catch (error) {
this.Notifications.error('Failure', error, 'Failed to search groups');
}
});
}
async $onInit() {
if (this.settings.AdminAutoPopulate && this.settings.AdminGroups && this.settings.AdminGroups.length > 0) {
const settings = {
...this.settings,
AdminGroupSearchSettings: this.settings.AdminGroupSearchSettings.map((search) => ({ ...search, GroupFilter: search.GroupFilter || this.defaultAdminGroupSearchFilter })),
};
this.groups = await this.LDAPService.adminGroups(settings);
}
if (this.groups && this.groups.length > 0) {
this.enableAssignAdminGroup = true;
}
}
}

View File

@@ -0,0 +1,95 @@
<div class="col-sm-12 form-section-title" style="float: initial;">
Auto-populate team admins
</div>
<rd-widget ng-repeat="config in $ctrl.settings.AdminGroupSearchSettings | limitTo: (1 - $ctrl.settings.AdminGroupSearchSettings)" style="display: block; margin-bottom: 10px;">
<rd-widget-body>
<div class="form-group" ng-if="$index > 0" style="margin-bottom: 10px;">
<span class="col-sm-12 text-muted small">
Extra search configuration
</span>
</div>
<div class="form-group">
<label for="ldap_admin_group_basedn_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Group Base DN
<portainer-tooltip position="bottom" message="The distinguished name of the element from which the LDAP server will search for groups."></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-4">
<input type="text" class="form-control" id="ldap_admin_group_basedn_{{ $index }}" ng-model="config.GroupBaseDN" placeholder="dc=ldap,dc=domain,dc=tld" />
</div>
<label for="ldap_admin_group_att_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Group Membership Attribute
<portainer-tooltip position="bottom" message="LDAP attribute which denotes the group membership."></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-4">
<input type="text" class="form-control" id="ldap_admin_group_att_{{ $index }}" ng-model="config.GroupAttribute" placeholder="member" />
</div>
</div>
<div class="form-group">
<label for="ldap_admin_group_filter_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Group Filter
<portainer-tooltip position="bottom" message="The LDAP search filter used to select group elements, optional."></portainer-tooltip>
</label>
<div ng-class="{ 'col-sm-7 col-md-9': $index, 'col-sm-8 col-md-10': !$index }">
<input type="text" class="form-control" id="ldap_admin_group_filter_{{ $index }}" ng-model="config.GroupFilter" placeholder="(objectClass=groupOfNames)" />
</div>
<div class="col-sm-1" ng-if="$index > 0">
<button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.onRemoveClick($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</rd-widget-body>
</rd-widget>
<div class="form-group" style="margin-top: 10px;">
<div class="col-sm-12">
<button class="label label-default interactive" style="border: 0;" ng-click="$ctrl.onAddClick()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add group search configuration
</button>
</div>
<div class="col-sm-12" style="margin-top: 10px;">
<button class="btn btm-sm btn-primary" type="button" ng-click="$ctrl.search()" limited-feature-dir="{{ $ctrl.limitedFeatureId }}" limited-feature-tabindex="-1">
Fetch Admin Group(s)
</button>
<be-feature-indicator feature="$ctrl.limitedFeatureId" class="space-left"></be-feature-indicator>
<span ng-if="$ctrl.groups && $ctrl.groups.length === 0" style="margin-left: 30px;">
<i class="fa fa-exclamation-triangle text-warning" aria-hidden="true"></i> No groups found</span
>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<label for="admin-auto-populate" class="control-label text-left text-muted" ng-class="{ 'text-muted': !$ctrl.enableAssignAdminGroup }">
Assign admin rights to group(s)
</label>
<label class="switch" style="margin-left: 20px;">
<input id="admin-auto-populate" ng-disabled="!$ctrl.enableAssignAdminGroup" name="admin-auto-populate" type="checkbox" ng-model="$ctrl.settings.AdminAutoPopulate" /><i></i>
</label>
</div>
</div>
<div class="form-group" ng-if="$ctrl.settings.AdminAutoPopulate">
<div class="col-sm-12">
<label for="group-access" class="control-label text-left">
Select Group(s)
</label>
<span
isteven-multi-select
ng-if="$ctrl.enableAssignAdminGroup"
input-model="$ctrl.groups"
output-model="$ctrl.selectedAdminGroups"
button-label="name"
item-label="name"
tick-property="selected"
helper-elements="filter"
search-property="name"
translation="{nothingSelected: 'Select one or more groups', search: 'Search...'}"
style="margin-left: 20px;"
>
</span>
</div>
</div>

View File

@@ -114,6 +114,17 @@
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-custom-group-search>
<div limited-feature-dir="{{ $ctrl.limitedFeatureId }}" limited-feature-class="limited-be" style="margin-bottom: 20px;">
<ldap-custom-admin-group
style="margin-top: 5px;"
settings="$ctrl.settings"
on-search-click="($ctrl.onSearchAdminGroupsClick)"
selected-admin-groups="$ctrl.selectedAdminGroups"
default-admin-group-search-filter="'(objectClass=groupOfNames)'"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-custom-admin-group>
</div>
<div limited-feature-dir="{{ $ctrl.limitedFeatureId }}" limited-feature-class="limited-be">
<ldap-settings-test-login settings="$ctrl.settings" limited-feature-id="$ctrl.limitedFeatureId" show-be-indicator-if-needed="true"></ldap-settings-test-login>
</div>

View File

@@ -23,6 +23,13 @@ export function buildLdapSettingsModel() {
GroupAttribute: '',
},
],
AdminGroupSearchSettings: [
{
GroupBaseDN: '',
GroupFilter: '',
GroupAttribute: '',
},
],
AutoCreateUsers: true,
};
}

View File

@@ -8,7 +8,12 @@
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="customTemplateForm">
<custom-template-common-fields form-values="$ctrl.formValues" show-platform-field="true" show-type-field="true"></custom-template-common-fields>
<custom-template-common-fields
form-values="$ctrl.formValues"
show-platform-field="true"
show-type-field="true"
name-regex="$ctrl.state.templateNameRegex"
></custom-template-common-fields>
<!-- build-method -->
<div ng-if="!$ctrl.state.fromStack">

View File

@@ -1,5 +1,6 @@
import _ from 'lodash';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
class CreateCustomTemplateViewController {
/* @ngInject */
@@ -43,7 +44,9 @@ class CreateCustomTemplateViewController {
fromStack: false,
loading: true,
isEditorDirty: false,
templateNameRegex: TEMPLATE_NAME_VALIDATION_REGEX,
};
this.templates = [];
this.createCustomTemplate = this.createCustomTemplate.bind(this);

View File

@@ -11,6 +11,7 @@
ng-if="$ctrl.state.selectedTemplate"
template="$ctrl.state.selectedTemplate"
form-values="$ctrl.formValues"
name-regex="$ctrl.state.templateNameRegex"
state="$ctrl.state"
create-template="$ctrl.createStack"
unselect-template="$ctrl.unselectTemplate"

View File

@@ -1,5 +1,6 @@
import _ from 'lodash-es';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
class CustomTemplatesViewController {
/* @ngInject */
@@ -44,6 +45,7 @@ class CustomTemplatesViewController {
actionInProgress: false,
isEditorVisible: false,
deployable: false,
templateNameRegex: TEMPLATE_NAME_VALIDATION_REGEX,
};
this.currentUser = {

View File

@@ -167,7 +167,15 @@
<!-- select-file-input -->
<div class="form-group">
<div class="col-sm-12">
<button class="btn btn-sm btn-primary" ngf-select accept=".tar.gz,.encrypted" ng-model="formValues.BackupFile" auto-focus>Select file</button>
<button
class="btn btn-sm btn-primary"
ngf-select
accept=".gz,.encrypted"
ngf-accept="'application/x-tar,application/x-gzip'"
ng-model="formValues.BackupFile"
auto-focus
>Select file</button
>
<span style="margin-left: 5px;">
{{ formValues.BackupFile.name }}
<i class="fa fa-times red-icon" ng-if="!formValues.BackupFile" aria-hidden="true"></i>

View File

@@ -221,7 +221,7 @@ function SettingsAuthenticationController($q, $scope, $state, Notifications, Set
if (settings.LDAPSettings.ServerType === 2) {
$scope.formValues.ldap.adSettings = settings.LDAPSettings;
} else {
$scope.formValues.ldap.ldapSettings = settings.LDAPSettings;
$scope.formValues.ldap.ldapSettings = Object.assign($scope.formValues.ldap.ldapSettings, settings.LDAPSettings);
}
if (settings.LDAPSettings.URL) {

View File

@@ -30,17 +30,17 @@
.stepper-item::before {
position: absolute;
content: '';
border-bottom: 5px solid rgb(231, 231, 231);
width: 100%;
top: 20px;
left: -100%;
z-index: 2;
border-bottom: 5px solid var(--bg-stepper-item-counter);
}
.stepper-item::after {
position: absolute;
content: '';
border-bottom: 5px solid rgb(231, 231, 231);
border-bottom: 5px solid var(--bg-stepper-item-counter);
width: 100%;
top: 20px;
left: 0;
@@ -56,13 +56,13 @@
width: 40px;
height: 40px;
border-radius: 50%;
background: rgb(231, 231, 231);
background: var(--bg-stepper-item-counter);
margin-bottom: 6px;
}
.stepper-item.active {
font-weight: bold;
background: #fff;
background: var(--bg-stepper-item-active);
content: none;
}

View File

@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
"version": "2.9.3",
"version": "2.9.1",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"
@@ -176,4 +176,4 @@
"*.js": "eslint --cache --fix",
"*.{js,css,md,html}": "prettier --write"
}
}
}