Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33cc29fa3c | ||
|
|
5e2eb667b4 | ||
|
|
1f9c9b082f | ||
|
|
722c1875af | ||
|
|
68471d0225 | ||
|
|
a6900545b0 | ||
|
|
808ceba848 | ||
|
|
a796a03a15 | ||
|
|
5a5dc67209 | ||
|
|
69ae54b523 | ||
|
|
b405227d51 | ||
|
|
44be39a9a4 | ||
|
|
5de0cc199c | ||
|
|
0c9e408eda | ||
|
|
1007f1f740 | ||
|
|
774e3d5948 | ||
|
|
4d866d066a | ||
|
|
da6544e981 | ||
|
|
3af9a7646d | ||
|
|
0e2cf82e3e | ||
|
|
97e69b9887 | ||
|
|
692f91263b | ||
|
|
8b61d8a9d2 | ||
|
|
25d51f9515 | ||
|
|
20b971dc1f | ||
|
|
7a76d749e3 | ||
|
|
123afd9462 | ||
|
|
ad83478b77 | ||
|
|
2ad0a65613 | ||
|
|
1f5762b8c8 | ||
|
|
0370b09ad0 | ||
|
|
5869a8948d | ||
|
|
56a840e207 | ||
|
|
a01dd005fd | ||
|
|
9ad6c16d43 | ||
|
|
9cc3e16db9 | ||
|
|
d02bcdba29 | ||
|
|
c708fe577c | ||
|
|
c92161bb22 | ||
|
|
138aa13fdc |
@@ -151,6 +151,7 @@ overrides:
|
||||
no-restricted-imports: off
|
||||
'react/jsx-props-no-spreading': off
|
||||
'@vitest/no-conditional-expect': warn
|
||||
'max-classes-per-file': off
|
||||
- files:
|
||||
- app/**/*.stories.*
|
||||
rules:
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -94,6 +94,7 @@ body:
|
||||
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
multiple: false
|
||||
options:
|
||||
- '2.39.0'
|
||||
- '2.38.1'
|
||||
- '2.38.0'
|
||||
- '2.37.0'
|
||||
@@ -142,8 +143,6 @@ body:
|
||||
- '2.21.4'
|
||||
- '2.21.3'
|
||||
- '2.21.2'
|
||||
- '2.21.1'
|
||||
- '2.21.0'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -54,8 +54,28 @@ linters:
|
||||
desc: github.com/ProtonMail/go-crypto/openpgp is not allowed because of FIPS mode
|
||||
- pkg: github.com/cosi-project/runtime
|
||||
desc: github.com/cosi-project/runtime is not allowed because of FIPS mode
|
||||
- pkg: gopkg.in/yaml.v2
|
||||
desc: use go.yaml.in/yaml/v3 instead
|
||||
- pkg: gopkg.in/yaml.v3
|
||||
desc: use go.yaml.in/yaml/v3 instead
|
||||
- pkg: github.com/golang-jwt/jwt/v4
|
||||
desc: use github.com/golang-jwt/jwt/v5 instead
|
||||
- pkg: github.com/mitchellh/mapstructure
|
||||
desc: use github.com/go-viper/mapstructure/v2 instead
|
||||
- pkg: gopkg.in/alecthomas/kingpin.v2
|
||||
desc: use github.com/alecthomas/kingpin/v2 instead
|
||||
- pkg: github.com/jcmturner/gokrb5$
|
||||
desc: use github.com/jcmturner/gokrb5/v8 instead
|
||||
- pkg: github.com/gofrs/uuid
|
||||
desc: use github.com/google/uuid
|
||||
- pkg: github.com/Masterminds/semver$
|
||||
desc: use github.com/Masterminds/semver/v3
|
||||
- pkg: github.com/blang/semver
|
||||
desc: use github.com/Masterminds/semver/v3
|
||||
- pkg: github.com/coreos/go-semver
|
||||
desc: use github.com/Masterminds/semver/v3
|
||||
- pkg: github.com/hashicorp/go-version
|
||||
desc: use github.com/Masterminds/semver/v3
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: ^tls\.Config$
|
||||
|
||||
@@ -21,7 +21,11 @@ The Portainer team takes the security of our products seriously. If you believe
|
||||
|
||||
### Disclosure Process
|
||||
|
||||
1. **Report**: Email your findings to security@portainer.io.
|
||||
1. **Report**: You can report in one of two ways:
|
||||
|
||||
- **GitHub**: Use the **Report a vulnerability** button on the **Security** tab of this repository.
|
||||
|
||||
- **Email**: Send your findings to security@portainer.io.
|
||||
|
||||
2. **Details**: To help us verify the issue, please include:
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ func CLIFlags() *portainer.CLIFlags {
|
||||
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
|
||||
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
|
||||
EndpointURL: kingpin.Flag("host", "Environment URL").Short('H').String(),
|
||||
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Strings(),
|
||||
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Envar(portainer.FeatureFlagEnvVar).Strings(),
|
||||
EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(),
|
||||
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(),
|
||||
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
|
||||
|
||||
@@ -55,7 +55,7 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libstack/compose"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -119,7 +119,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
||||
}
|
||||
|
||||
if isNew {
|
||||
instanceId, err := uuid.NewV4()
|
||||
instanceId, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed generating instance id")
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -29,7 +29,7 @@ func secretToEncryptionKey(passphrase string) []byte {
|
||||
func Test_MarshalObjectUnencrypted(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
uuid := uuid.Must(uuid.NewV4())
|
||||
uuid := uuid.New()
|
||||
|
||||
tests := []struct {
|
||||
object any
|
||||
|
||||
@@ -119,6 +119,19 @@ func (service *Service) Endpoints() ([]portainer.Endpoint, error) {
|
||||
return endpoints, nil
|
||||
}
|
||||
|
||||
// ReadAll retrieves all the elements that satisfy all the provided predicates.
|
||||
func (service *Service) ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) {
|
||||
var endpoints []portainer.Endpoint
|
||||
var err error
|
||||
|
||||
err = service.connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
endpoints, err = service.Tx(tx).ReadAll(predicates...)
|
||||
return err
|
||||
})
|
||||
|
||||
return endpoints, err
|
||||
}
|
||||
|
||||
// EndpointIDByEdgeID returns the EndpointID from the given EdgeID using an in-memory index
|
||||
func (service *Service) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
|
||||
service.mu.RLock()
|
||||
|
||||
@@ -89,6 +89,11 @@ func (service ServiceTx) Endpoints() ([]portainer.Endpoint, error) {
|
||||
)
|
||||
}
|
||||
|
||||
// ReadAll retrieves all the elements that satisfy all the provided predicates.
|
||||
func (service ServiceTx) ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) {
|
||||
return dataservices.BaseDataServiceTx[portainer.Endpoint, portainer.EndpointID]{Bucket: BucketName, Connection: service.service.connection, Tx: service.tx}.ReadAll(predicates...)
|
||||
}
|
||||
|
||||
func (service ServiceTx) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
|
||||
log.Error().Str("func", "EndpointIDByEdgeID").Msg("cannot be called inside a transaction")
|
||||
|
||||
|
||||
@@ -102,6 +102,9 @@ type (
|
||||
|
||||
// EndpointService represents a service for managing environment(endpoint) data
|
||||
EndpointService interface {
|
||||
// partial dataservices.BaseCRUD[portainer.Endpoint, portainer.EndpointID]
|
||||
ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error)
|
||||
|
||||
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)
|
||||
EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool)
|
||||
EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error)
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newGuidString(t *testing.T) string {
|
||||
uuid, err := uuid.NewV4()
|
||||
uuid, err := uuid.NewRandom()
|
||||
require.NoError(t, err)
|
||||
|
||||
return uuid.String()
|
||||
|
||||
@@ -9,15 +9,15 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/boltdb"
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
"github.com/portainer/portainer/api/datastore/migrator"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMigrateData(t *testing.T) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices/version"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package postinit
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
@@ -44,40 +46,65 @@ func NewPostInitMigrator(
|
||||
|
||||
// PostInitMigrate will run all post-init migrations, which require docker/kube clients for all edge or non-edge environments
|
||||
func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
|
||||
environments, err := postInitMigrator.dataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error getting environments")
|
||||
return err
|
||||
}
|
||||
var environments []portainer.Endpoint
|
||||
|
||||
for _, environment := range environments {
|
||||
// edge environments will run after the server starts, in pending actions
|
||||
if endpoints.IsEdgeEndpoint(&environment) {
|
||||
// Skip edge environments that do not have direct connectivity
|
||||
if !endpoints.HasDirectConnectivity(&environment) {
|
||||
if err := postInitMigrator.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
if environments, err = tx.Endpoint().ReadAll(func(endpoint portainer.Endpoint) bool {
|
||||
return endpoints.HasDirectConnectivity(&endpoint)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to retrieve environments: %w", err)
|
||||
}
|
||||
|
||||
var pendingActions []portainer.PendingAction
|
||||
if pendingActions, err = tx.PendingActions().ReadAll(func(action portainer.PendingAction) bool {
|
||||
return action.Action == actions.PostInitMigrateEnvironment
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to retrieve pending actions: %w", err)
|
||||
}
|
||||
|
||||
// Sort for the binary search in createPostInitMigrationPendingAction()
|
||||
slices.SortFunc(pendingActions, func(a, b portainer.PendingAction) int {
|
||||
return cmp.Compare(a.EndpointID, b.EndpointID)
|
||||
})
|
||||
|
||||
for _, environment := range environments {
|
||||
if !endpoints.IsEdgeEndpoint(&environment) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Edge environments will run after the server starts, in pending actions
|
||||
log.Info().
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("adding pending action 'PostInitMigrateEnvironment' for environment")
|
||||
|
||||
if err := postInitMigrator.createPostInitMigrationPendingAction(environment.ID); err != nil {
|
||||
if err := postInitMigrator.createPostInitMigrationPendingAction(tx, environment.ID, pendingActions); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error creating pending action for environment")
|
||||
}
|
||||
} else {
|
||||
// Non-edge environments will run before the server starts.
|
||||
if err := postInitMigrator.MigrateEnvironment(&environment); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error running post-init migrations for non-edge environment")
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("error running post-init migrations")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
for _, environment := range environments {
|
||||
if endpoints.IsEdgeEndpoint(&environment) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-edge environments will run before the server starts.
|
||||
if err := postInitMigrator.MigrateEnvironment(&environment); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error running post-init migrations for non-edge environment")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -85,44 +112,47 @@ func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
|
||||
|
||||
// try to create a post init migration pending action. If it already exists, do nothing
|
||||
// this function exists for readability, not reusability
|
||||
func (postInitMigrator *PostInitMigrator) createPostInitMigrationPendingAction(environmentID portainer.EndpointID) error {
|
||||
// pending actions must be passed in ascending order by endpoint ID
|
||||
func (postInitMigrator *PostInitMigrator) createPostInitMigrationPendingAction(tx dataservices.DataStoreTx, environmentID portainer.EndpointID, pendingActions []portainer.PendingAction) error {
|
||||
action := portainer.PendingAction{
|
||||
EndpointID: environmentID,
|
||||
Action: actions.PostInitMigrateEnvironment,
|
||||
}
|
||||
|
||||
pendingActions, err := postInitMigrator.dataStore.PendingActions().ReadAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve pending actions: %w", err)
|
||||
if _, found := slices.BinarySearchFunc(pendingActions, environmentID, func(e portainer.PendingAction, id portainer.EndpointID) int {
|
||||
return cmp.Compare(e.EndpointID, id)
|
||||
}); found {
|
||||
log.Debug().
|
||||
Str("action", action.Action).
|
||||
Int("endpoint_id", int(action.EndpointID)).
|
||||
Msg("pending action already exists for environment, skipping...")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, dba := range pendingActions {
|
||||
if dba.EndpointID == action.EndpointID && dba.Action == action.Action {
|
||||
log.Debug().
|
||||
Str("action", action.Action).
|
||||
Int("endpoint_id", int(action.EndpointID)).
|
||||
Msg("pending action already exists for environment, skipping...")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return postInitMigrator.dataStore.PendingActions().Create(&action)
|
||||
return tx.PendingActions().Create(&action)
|
||||
}
|
||||
|
||||
// MigrateEnvironment runs migrations on a single environment
|
||||
func (migrator *PostInitMigrator) MigrateEnvironment(environment *portainer.Endpoint) error {
|
||||
log.Info().Msgf("Executing post init migration for environment %d", environment.ID)
|
||||
log.Info().
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("executing post init migration for environment")
|
||||
|
||||
switch {
|
||||
case endpointutils.IsKubernetesEndpoint(environment):
|
||||
// get the kubeclient for the environment, and skip all kube migrations if there's an error
|
||||
kubeclient, err := migrator.kubeFactory.GetPrivilegedKubeClient(environment)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error creating kubeclient for environment: %d", environment.ID)
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error creating kubeclient for environment")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// if one environment fails, it is logged and the next migration runs. The error is returned at the end and handled by pending actions
|
||||
// If one environment fails, it is logged and the next migration runs. The error is returned at the end and handled by pending actions
|
||||
if err := migrator.MigrateIngresses(*environment, kubeclient); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -132,12 +162,21 @@ func (migrator *PostInitMigrator) MigrateEnvironment(environment *portainer.Endp
|
||||
// get the docker client for the environment, and skip all docker migrations if there's an error
|
||||
dockerClient, err := migrator.dockerFactory.CreateClient(environment, "", nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error creating docker client for environment: %d", environment.ID)
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error creating docker client for environment")
|
||||
|
||||
return err
|
||||
}
|
||||
defer logs.CloseAndLogErr(dockerClient)
|
||||
|
||||
if err := migrator.MigrateGPUs(*environment, dockerClient); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error migrating GPUs for environment")
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -150,13 +189,20 @@ func (migrator *PostInitMigrator) MigrateIngresses(environment portainer.Endpoin
|
||||
if !environment.PostInitMigrations.MigrateIngresses {
|
||||
return nil
|
||||
}
|
||||
log.Debug().Msgf("Migrating ingresses for environment %d", environment.ID)
|
||||
|
||||
err := migrator.kubeFactory.MigrateEndpointIngresses(&environment, migrator.dataStore, kubeclient)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error migrating ingresses for environment %d", environment.ID)
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("migrating ingresses for environment")
|
||||
|
||||
if err := migrator.kubeFactory.MigrateEndpointIngresses(&environment, migrator.dataStore, kubeclient); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error migrating ingresses for environment")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -166,29 +212,42 @@ func (migrator *PostInitMigrator) MigrateGPUs(e portainer.Endpoint, dockerClient
|
||||
return migrator.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
environment, err := tx.Endpoint().Endpoint(e.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error getting environment %d", e.ID)
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(e.ID)).
|
||||
Msg("error getting environment")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Early exit if we do not need to migrate!
|
||||
if !environment.PostInitMigrations.MigrateGPUs {
|
||||
return nil
|
||||
}
|
||||
log.Debug().Msgf("Migrating GPUs for environment %d", e.ID)
|
||||
|
||||
// get all containers
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(e.ID)).
|
||||
Msg("migrating GPUs for environment")
|
||||
|
||||
// Get all containers
|
||||
containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("failed to list containers for environment %d", environment.ID)
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("failed to list containers for environment")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole environment
|
||||
// Check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole environment
|
||||
containersLoop:
|
||||
for _, container := range containers {
|
||||
// https://www.sobyte.net/post/2022-10/go-docker/ has nice documentation on the docker client with GPUs
|
||||
containerDetails, err := dockerClient.ContainerInspect(context.Background(), container.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to inspect container")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -202,10 +261,14 @@ func (migrator *PostInitMigrator) MigrateGPUs(e portainer.Endpoint, dockerClient
|
||||
}
|
||||
}
|
||||
|
||||
// set the MigrateGPUs flag to false so we don't run this again
|
||||
// Set the MigrateGPUs flag to false so we don't run this again
|
||||
environment.PostInitMigrations.MigrateGPUs = false
|
||||
if err := tx.Endpoint().UpdateEndpoint(environment.ID, environment); err != nil {
|
||||
log.Error().Err(err).Msgf("Error updating EnableGPUManagement flag for environment %d", environment.ID)
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error updating EnableGPUManagement flag for environment")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"allowDeviceMappingForRegularUsers": true,
|
||||
"allowHostNamespaceForRegularUsers": true,
|
||||
"allowPrivilegedModeForRegularUsers": true,
|
||||
"allowSecurityOptForRegularUsers": false,
|
||||
"allowStackManagementForRegularUsers": true,
|
||||
"allowSysctlSettingForRegularUsers": false,
|
||||
"allowVolumeBrowserForRegularUsers": false,
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/portainer/portainer/api/docker/images"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/docker/docker/api/types"
|
||||
dockercontainer "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
@@ -812,7 +812,7 @@ func (service *Service) getEdgeJobTaskLogPath(edgeJobID string, taskID string) s
|
||||
|
||||
// GetTemporaryPath returns a temp folder
|
||||
func (service *Service) GetTemporaryPath() (string, error) {
|
||||
uid, err := uuid.NewV4()
|
||||
uid, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -223,3 +223,15 @@ func TestIsInConfigDir(t *testing.T) {
|
||||
f(DirEntry{Name: "edgestacktest/edge-configs/standalone-edge-agent-async"}, "edgestacktest/edge-configs", true)
|
||||
f(DirEntry{Name: "edgestacktest/edge-configs/abc.txt"}, "edgestacktest/edge-configs", true)
|
||||
}
|
||||
|
||||
func TestShouldIncludeDir(t *testing.T) {
|
||||
f := func(dirEntry DirEntry, deviceName, configPath string, expect bool) {
|
||||
t.Helper()
|
||||
|
||||
actual := shouldIncludeDir(dirEntry, deviceName, configPath)
|
||||
assert.Equal(t, expect, actual)
|
||||
}
|
||||
|
||||
f(DirEntry{Name: "app/blue-app", IsFile: false}, "blue-app", "app", true)
|
||||
f(DirEntry{Name: "app/blue-app/values.yaml", IsFile: true}, "blue-app", "app", true)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,14 @@ package customtemplates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/slicesx"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -33,11 +39,46 @@ func (handler *Handler) customTemplateFile(w http.ResponseWriter, r *http.Reques
|
||||
return httperror.BadRequest("Invalid custom template identifier route variable", err)
|
||||
}
|
||||
|
||||
customTemplate, err := handler.DataStore.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
var customTemplate *portainer.CustomTemplate
|
||||
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve a resource control associated to the custom template", err)
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
|
||||
}
|
||||
|
||||
canEdit := userCanEditTemplate(customTemplate, securityContext)
|
||||
hasAccess := false
|
||||
|
||||
if resourceControl != nil {
|
||||
customTemplate.ResourceControl = resourceControl
|
||||
|
||||
teamIDs := slicesx.Map(securityContext.UserMemberships, func(m portainer.TeamMembership) portainer.TeamID {
|
||||
return m.TeamID
|
||||
})
|
||||
|
||||
hasAccess = authorization.UserCanAccessResource(securityContext.UserID, teamIDs, resourceControl)
|
||||
}
|
||||
|
||||
if canEdit || hasAccess {
|
||||
return nil
|
||||
}
|
||||
|
||||
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
|
||||
}); err != nil {
|
||||
return response.TxErrorResponse(err)
|
||||
}
|
||||
|
||||
entryPath := customTemplate.EntryPoint
|
||||
|
||||
115
api/http/handler/customtemplates/customtemplate_file_test.go
Normal file
115
api/http/handler/customtemplates/customtemplate_file_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package customtemplates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCustomTemplateFile(t *testing.T) {
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
require.NotNil(t, ds)
|
||||
|
||||
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
templateContent := "some template content"
|
||||
templateEntrypoint := "entrypoint"
|
||||
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 3, Username: "std3", Role: portainer.StandardUserRole}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 4, Username: "std4", Role: portainer.StandardUserRole}))
|
||||
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1,
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{
|
||||
2: portainer.AccessPolicy{RoleID: 0},
|
||||
3: portainer.AccessPolicy{RoleID: 0},
|
||||
}}))
|
||||
require.NoError(t, tx.Team().Create(&portainer.Team{ID: 1}))
|
||||
require.NoError(t, tx.TeamMembership().Create(&portainer.TeamMembership{ID: 1, UserID: 3, TeamID: 1, Role: portainer.TeamMember}))
|
||||
|
||||
// template 1
|
||||
path, err := fs.StoreCustomTemplateFileFromBytes("1", templateEntrypoint, []byte(templateContent))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1, EntryPoint: templateEntrypoint, ProjectPath: path}))
|
||||
|
||||
// template 2
|
||||
path, err = fs.StoreCustomTemplateFileFromBytes("2", templateEntrypoint, []byte(templateContent))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 2, EntryPoint: templateEntrypoint, ProjectPath: path}))
|
||||
|
||||
require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{ID: 1, ResourceID: "2", Type: portainer.CustomTemplateResourceControl,
|
||||
UserAccesses: []portainer.UserResourceAccess{{UserID: 2}},
|
||||
TeamAccesses: []portainer.TeamResourceAccess{{TeamID: 1}},
|
||||
}))
|
||||
return nil
|
||||
}))
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
|
||||
|
||||
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID+"/file", nil)
|
||||
r = mux.SetURLVars(r, map[string]string{"id": templateID})
|
||||
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
|
||||
r = r.WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
return rr, handler.customTemplateFile(rr, r)
|
||||
}
|
||||
|
||||
t.Run("unknown id should get not found error", func(t *testing.T) {
|
||||
_, r := test("0", &security.RestrictedRequestContext{UserID: 1})
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusNotFound, r.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("admin should access adminonly template", func(t *testing.T) {
|
||||
rr, r := test("1", &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
|
||||
require.Nil(t, r)
|
||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||
var res struct{ FileContent string }
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
|
||||
require.Equal(t, templateContent, res.FileContent)
|
||||
})
|
||||
|
||||
t.Run("std should not access adminonly template", func(t *testing.T) {
|
||||
_, r := test("1", &security.RestrictedRequestContext{UserID: 2})
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("std should access template via direct user access", func(t *testing.T) {
|
||||
rr, r := test("2", &security.RestrictedRequestContext{UserID: 2})
|
||||
require.Nil(t, r)
|
||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||
var res struct{ FileContent string }
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
|
||||
require.Equal(t, templateContent, res.FileContent)
|
||||
})
|
||||
|
||||
t.Run("std should access template via team access", func(t *testing.T) {
|
||||
rr, r := test("2", &security.RestrictedRequestContext{UserID: 3, UserMemberships: []portainer.TeamMembership{{ID: 1, UserID: 3, TeamID: 1}}})
|
||||
require.Nil(t, r)
|
||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||
var res struct{ FileContent string }
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
|
||||
require.Equal(t, templateContent, res.FileContent)
|
||||
})
|
||||
|
||||
t.Run("std should not access template without access", func(t *testing.T) {
|
||||
_, r := test("2", &security.RestrictedRequestContext{UserID: 4})
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||
})
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Req
|
||||
var customTemplate *portainer.CustomTemplate
|
||||
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
@@ -20,6 +21,9 @@ func TestInspectHandler(t *testing.T) {
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
require.NotNil(t, ds)
|
||||
|
||||
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
|
||||
@@ -42,7 +46,7 @@ func TestInspectHandler(t *testing.T) {
|
||||
return nil
|
||||
}))
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, &TestFileService{}, nil)
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
|
||||
|
||||
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID, nil)
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type endpointCreatePayload struct {
|
||||
@@ -405,7 +405,7 @@ func (handler *Handler) createEdgeAgentEndpoint(tx dataservices.DataStoreTx, pay
|
||||
}
|
||||
|
||||
if settings.EnforceEdgeID {
|
||||
edgeID, err := uuid.NewV4()
|
||||
edgeID, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Cannot generate the Edge ID", err)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ const (
|
||||
// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags"
|
||||
// @param endpointIds query []int false "will return only these environments(endpoints)"
|
||||
// @param excludeIds query []int false "will exclude these environments(endpoints)"
|
||||
// @param excludeGroupIds query []int false "will exclude environments(endpoints) belonging to these endpoint groups"
|
||||
// @param provisioned query bool false "If true, will return environment(endpoint) that were provisioned"
|
||||
// @param agentVersions query []string false "will return only environments with on of these agent versions"
|
||||
// @param edgeAsync query bool false "if exists true show only edge async agents, false show only standard edge agents. if missing, will show both types (relevant only for edge agents)"
|
||||
|
||||
@@ -26,6 +26,8 @@ type endpointSettingsUpdatePayload struct {
|
||||
AllowContainerCapabilitiesForRegularUsers *bool `json:"allowContainerCapabilitiesForRegularUsers" example:"true"`
|
||||
// Whether non-administrator should be able to use sysctl settings
|
||||
AllowSysctlSettingForRegularUsers *bool `json:"allowSysctlSettingForRegularUsers" example:"true"`
|
||||
// Whether non-administrator should be able to use security-opt settings
|
||||
AllowSecurityOptForRegularUsers *bool `json:"allowSecurityOptForRegularUsers" example:"true"`
|
||||
// Whether host management features are enabled
|
||||
EnableHostManagementFeatures *bool `json:"enableHostManagementFeatures" example:"true"`
|
||||
|
||||
@@ -111,6 +113,12 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re
|
||||
securitySettings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures
|
||||
}
|
||||
|
||||
if payload.AllowSecurityOptForRegularUsers != nil {
|
||||
securitySettings.AllowSecurityOptForRegularUsers = *payload.AllowSecurityOptForRegularUsers
|
||||
}
|
||||
|
||||
endpoint.SecuritySettings = securitySettings
|
||||
|
||||
if payload.EnableGPUManagement != nil {
|
||||
endpoint.EnableGPUManagement = *payload.EnableGPUManagement
|
||||
}
|
||||
@@ -119,8 +127,6 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re
|
||||
endpoint.Gpus = payload.Gpus
|
||||
}
|
||||
|
||||
endpoint.SecuritySettings = securitySettings
|
||||
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Failed persisting environment in database", err)
|
||||
|
||||
@@ -38,6 +38,7 @@ type EnvironmentsQuery struct {
|
||||
edgeStackId portainer.EdgeStackID
|
||||
edgeStackStatus *portainer.EdgeStackStatusType
|
||||
excludeIds []portainer.EndpointID
|
||||
excludeGroupIds []portainer.EndpointGroupID
|
||||
edgeGroupIds []portainer.EdgeGroupID
|
||||
excludeEdgeGroupIds []portainer.EdgeGroupID
|
||||
}
|
||||
@@ -80,6 +81,11 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
excludeGroupIDs, err := getNumberArrayQueryParameter[portainer.EndpointGroupID](r, "excludeGroupIds")
|
||||
if err != nil {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
edgeGroupIDs, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "edgeGroupIds")
|
||||
if err != nil {
|
||||
return EnvironmentsQuery{}, err
|
||||
@@ -119,6 +125,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
tagIds: tagIDs,
|
||||
endpointIds: endpointIDs,
|
||||
excludeIds: excludeIDs,
|
||||
excludeGroupIds: excludeGroupIDs,
|
||||
tagsPartialMatch: tagsPartialMatch,
|
||||
groupIds: groupIDs,
|
||||
status: status,
|
||||
@@ -157,6 +164,12 @@ func (handler *Handler) filterEndpointsByQuery(
|
||||
})
|
||||
}
|
||||
|
||||
if len(query.excludeGroupIds) > 0 {
|
||||
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
|
||||
return !slices.Contains(query.excludeGroupIds, endpoint.GroupID)
|
||||
})
|
||||
}
|
||||
|
||||
if len(query.groupIds) > 0 {
|
||||
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
|
||||
}
|
||||
|
||||
@@ -151,6 +151,46 @@ func Test_Filter_excludeIDs(t *testing.T) {
|
||||
runTests(tests, t, handler, environments)
|
||||
}
|
||||
|
||||
func Test_Filter_excludeGroupIDs(t *testing.T) {
|
||||
groupA := portainer.EndpointGroupID(10)
|
||||
groupB := portainer.EndpointGroupID(20)
|
||||
groupC := portainer.EndpointGroupID(30)
|
||||
|
||||
endpoints := []portainer.Endpoint{
|
||||
{ID: 1, GroupID: groupA, Type: portainer.DockerEnvironment},
|
||||
{ID: 2, GroupID: groupA, Type: portainer.DockerEnvironment},
|
||||
{ID: 3, GroupID: groupB, Type: portainer.DockerEnvironment},
|
||||
{ID: 4, GroupID: groupB, Type: portainer.DockerEnvironment},
|
||||
{ID: 5, GroupID: groupC, Type: portainer.DockerEnvironment},
|
||||
}
|
||||
|
||||
handler := setupFilterTest(t, endpoints)
|
||||
|
||||
tests := []filterTest{
|
||||
{
|
||||
title: "should exclude endpoints in groupA",
|
||||
expected: []portainer.EndpointID{3, 4, 5},
|
||||
query: EnvironmentsQuery{
|
||||
excludeGroupIds: []portainer.EndpointGroupID{groupA},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "should exclude endpoints in groupA and groupB",
|
||||
expected: []portainer.EndpointID{5},
|
||||
query: EnvironmentsQuery{
|
||||
excludeGroupIds: []portainer.EndpointGroupID{groupA, groupB},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "should return all endpoints when excludeGroupIds is empty",
|
||||
expected: []portainer.EndpointID{1, 2, 3, 4, 5},
|
||||
query: EnvironmentsQuery{},
|
||||
},
|
||||
}
|
||||
|
||||
runTests(tests, t, handler, endpoints)
|
||||
}
|
||||
|
||||
func BenchmarkFilterEndpointsBySearchCriteria_PartialMatch(b *testing.B) {
|
||||
n := 10000
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/api/kubernetes/validation"
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type installChartPayload struct {
|
||||
@@ -108,6 +107,23 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry
|
||||
return nil, httperr.Err
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to retrieve user details from authentication token")
|
||||
}
|
||||
|
||||
var username string
|
||||
if err := handler.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
user, err := tx.User().Read(tokenData.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to load user information from the database")
|
||||
}
|
||||
username = user.Username
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
installOpts := options.InstallOptions{
|
||||
Name: p.Name,
|
||||
Chart: p.Chart,
|
||||
@@ -117,6 +133,7 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry
|
||||
Atomic: p.Atomic,
|
||||
DryRun: dryRun,
|
||||
KubernetesClusterAccess: clusterAccess,
|
||||
HelmAppLabels: kubernetes.GetHelmAppLabels(p.Name, username),
|
||||
}
|
||||
|
||||
if p.Values != "" {
|
||||
@@ -147,105 +164,5 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !installOpts.DryRun {
|
||||
manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); 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().Read(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 {
|
||||
g.Go(func() error {
|
||||
tmpfile, err := os.CreateTemp("", "helm-manifest-*.yaml")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create a tmp helm manifest file")
|
||||
}
|
||||
defer func() {
|
||||
if err := tmpfile.Close(); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to close tmp helm manifest file")
|
||||
}
|
||||
|
||||
if err := os.Remove(tmpfile.Name()); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to remove tmp helm manifest file")
|
||||
}
|
||||
}()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -13,13 +13,13 @@ import (
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStackUpdateGitWebhookUniqueness(t *testing.T) {
|
||||
webhook, err := uuid.NewV4()
|
||||
webhook, err := uuid.NewRandom()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// @id WebhookInvoke
|
||||
@@ -56,7 +56,7 @@ func retrieveUUIDRouteVariableValue(r *http.Request, name string) (uuid.UUID, er
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
uid, err := uuid.FromString(webhookID)
|
||||
uid, err := uuid.Parse(webhookID)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -52,7 +52,7 @@ func TestHandler_webhookInvoke(t *testing.T) {
|
||||
}
|
||||
|
||||
func newGuidString(t *testing.T) string {
|
||||
uuid, err := uuid.NewV4()
|
||||
uuid, err := uuid.NewRandom()
|
||||
require.NoError(t, err)
|
||||
|
||||
return uuid.String()
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/coreos/go-semver/semver"
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
@@ -109,5 +109,5 @@ func HasNewerVersion(currentVersion, latestVersion string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
return currentVersionSemver.LessThan(*latestVersionSemver)
|
||||
return currentVersionSemver.LessThan(latestVersionSemver)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type webhookCreatePayload struct {
|
||||
@@ -86,7 +86,7 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h
|
||||
}
|
||||
}
|
||||
|
||||
token, err := uuid.NewV4()
|
||||
token, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Error creating unique token", err)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ var (
|
||||
ErrPIDHostNamespaceForbidden = errors.New("forbidden to use pid host namespace")
|
||||
ErrDeviceMappingForbidden = errors.New("forbidden to use device mapping")
|
||||
ErrSysCtlSettingsForbidden = errors.New("forbidden to use sysctl settings")
|
||||
ErrSecurityOptSettingsForbidden = errors.New("forbidden to use security-opt settings")
|
||||
ErrContainerCapabilitiesForbidden = errors.New("forbidden to use container capabilities")
|
||||
ErrBindMountsForbidden = errors.New("forbidden to use bind mounts")
|
||||
)
|
||||
@@ -90,7 +91,7 @@ func (transport *Transport) containerListOperation(response *http.Response, exec
|
||||
// containerInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the container based on resource control and either rewrite an access denied response or a decorated container.
|
||||
func (transport *Transport) containerInspectOperation(response *http.Response, executor *operationExecutor) error {
|
||||
//ContainerInspect response is a JSON object
|
||||
// ContainerInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
@@ -116,6 +117,7 @@ func selectorContainerLabelsFromContainerInspectOperation(responseObject map[str
|
||||
containerConfigObject := utils.GetJSONObject(responseObject, "Config")
|
||||
if containerConfigObject != nil {
|
||||
containerLabelsObject := utils.GetJSONObject(containerConfigObject, "Labels")
|
||||
|
||||
return containerLabelsObject
|
||||
}
|
||||
|
||||
@@ -170,13 +172,14 @@ func containerHasBlackListedLabel(containerLabels map[string]any, labelBlackList
|
||||
func (transport *Transport) decorateContainerCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
type PartialContainer struct {
|
||||
HostConfig struct {
|
||||
Privileged bool `json:"Privileged"`
|
||||
PidMode string `json:"PidMode"`
|
||||
Devices []any `json:"Devices"`
|
||||
Sysctls map[string]any `json:"Sysctls"`
|
||||
CapAdd []string `json:"CapAdd"`
|
||||
CapDrop []string `json:"CapDrop"`
|
||||
Binds []string `json:"Binds"`
|
||||
Privileged bool `json:"Privileged"`
|
||||
PidMode string `json:"PidMode"`
|
||||
Devices []any `json:"Devices"`
|
||||
Sysctls map[string]any `json:"Sysctls"`
|
||||
SecurityOpt []string `json:"SecurityOpt"`
|
||||
CapAdd []string `json:"CapAdd"`
|
||||
CapDrop []string `json:"CapDrop"`
|
||||
Binds []string `json:"Binds"`
|
||||
} `json:"HostConfig"`
|
||||
}
|
||||
|
||||
@@ -226,6 +229,10 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
|
||||
return forbiddenResponse, ErrSysCtlSettingsForbidden
|
||||
}
|
||||
|
||||
if !securitySettings.AllowSecurityOptForRegularUsers && len(partialContainer.HostConfig.SecurityOpt) > 0 {
|
||||
return forbiddenResponse, ErrSecurityOptSettingsForbidden
|
||||
}
|
||||
|
||||
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(partialContainer.HostConfig.CapAdd) > 0 || len(partialContainer.HostConfig.CapDrop) > 0) {
|
||||
return nil, ErrContainerCapabilitiesForbidden
|
||||
}
|
||||
|
||||
@@ -262,6 +262,8 @@ func WithEndpointRelations(relations []portainer.EndpointRelation) datastoreOpti
|
||||
}
|
||||
|
||||
type stubEndpointService struct {
|
||||
dataservices.EndpointService
|
||||
|
||||
endpoints []portainer.Endpoint
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"github.com/portainer/portainer/api/apikey"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -185,7 +185,7 @@ func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt
|
||||
expiresAt = time.Now().Add(99 * year)
|
||||
}
|
||||
|
||||
uuid, err := uuid.NewV4()
|
||||
uuid, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to generate the JWT ID: %w", err)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -3,8 +3,10 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/rest"
|
||||
@@ -55,29 +57,43 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
|
||||
TTY: true,
|
||||
}, scheme.ParameterCodec)
|
||||
|
||||
streamOpts := remotecommand.StreamOptions{
|
||||
Stdin: stdin,
|
||||
Stdout: stdout,
|
||||
Tty: true,
|
||||
}
|
||||
|
||||
// Try WebSocket executor first, fall back to SPDY if it fails
|
||||
exec, err := remotecommand.NewWebSocketExecutorForProtocols(
|
||||
config,
|
||||
"GET", // WebSocket uses GET for the upgrade request
|
||||
req.URL().String(),
|
||||
channelProtocolList...,
|
||||
)
|
||||
if err != nil {
|
||||
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
if err == nil {
|
||||
err = exec.StreamWithContext(context.TODO(), streamOpts)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("context", "StartExecProcess").
|
||||
Msg("WebSocket exec failed, falling back to SPDY")
|
||||
}
|
||||
|
||||
err = exec.StreamWithContext(context.TODO(), remotecommand.StreamOptions{
|
||||
Stdin: stdin,
|
||||
Stdout: stdout,
|
||||
Tty: true,
|
||||
})
|
||||
// Fall back to SPDY executor
|
||||
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("unable to create SPDY executor: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = exec.StreamWithContext(context.TODO(), streamOpts)
|
||||
if err != nil {
|
||||
var exitError utilexec.ExitError
|
||||
if !errors.As(err, &exitError) {
|
||||
errChan <- errors.New("unable to start exec process")
|
||||
errChan <- fmt.Errorf("unable to start exec process: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,6 @@ func Test_GenerateYAML(t *testing.T) {
|
||||
name: portainer-ctx
|
||||
current-context: portainer-ctx
|
||||
kind: Config
|
||||
preferences: {}
|
||||
users:
|
||||
- name: test-user
|
||||
user:
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
@@ -87,7 +87,7 @@ func GetIdToken(token *oauth2.Token) (map[string]any, error) {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
jwtParser := jwt.Parser{SkipClaimsValidation: true}
|
||||
jwtParser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
||||
|
||||
t, _, err := jwtParser.ParseUnverified(idToken.(string), jwt.MapClaims{})
|
||||
if err != nil {
|
||||
@@ -131,9 +131,19 @@ func GetResource(ctx context.Context, token string, resourceURI string) (map[str
|
||||
}
|
||||
}
|
||||
|
||||
content, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||
// Some OAuth providers (e.g. Cloudflare Access) return malformed Content-Type headers
|
||||
// (e.g. "application/json; charset=utf-8, application/json") that mime.ParseMediaType
|
||||
// cannot parse. We intentionally ignore that error: if parsing fails, content is empty,
|
||||
// the urlencoded branch is skipped, and json.Unmarshal below acts as the final validator.
|
||||
originalContentType := resp.Header.Get("Content-Type")
|
||||
content, _, err := mime.ParseMediaType(originalContentType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Str("context", "OAuthResourceFetch").
|
||||
Str("original_content_type", originalContentType).
|
||||
Str("parsed_content_type", content).
|
||||
Msg("Failed to parse Content-Type header from resource endpoint, falling back to JSON")
|
||||
}
|
||||
|
||||
if content == "application/x-www-form-urlencoded" || content == "text/plain" {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -110,6 +112,39 @@ func Test_getResource(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func Test_getResource_malformedContentType(t *testing.T) {
|
||||
body := `{"username":"test-oauth-user"}`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
contentType string
|
||||
}{
|
||||
{
|
||||
name: "duplicate mime types separated by comma",
|
||||
contentType: "application/json; charset=utf-8, application/json",
|
||||
},
|
||||
{
|
||||
name: "missing mime type with only parameters",
|
||||
contentType: "; charset=utf-8",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", tc.contentType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
result, err := GetResource(t.Context(), "any-token", srv.URL)
|
||||
require.NoError(t, err, "GetResource should succeed despite malformed Content-Type header")
|
||||
assert.Equal(t, "test-oauth-user", result["username"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Authenticate(t *testing.T) {
|
||||
code := "valid-code"
|
||||
authService := NewService()
|
||||
|
||||
@@ -361,8 +361,6 @@ type (
|
||||
ChartPath string `json:"ChartPath,omitempty" example:"charts/my-app"`
|
||||
// Array of paths to Helm values YAML files for Helm git deployments
|
||||
ValuesFiles []string `json:"ValuesFiles,omitempty" example:"['values/prod.yaml', 'values/secrets.yaml']"`
|
||||
// Helm chart version from Chart.yaml (read-only, extracted during Git sync)
|
||||
Version string `json:"Version,omitempty" example:"1.2.3"`
|
||||
// Enable automatic rollback on deployment failure (equivalent to helm --atomic flag)
|
||||
Atomic bool `json:"Atomic" example:"true"`
|
||||
// Timeout for Helm operations (equivalent to helm --timeout flag)
|
||||
@@ -647,6 +645,8 @@ type (
|
||||
AllowContainerCapabilitiesForRegularUsers bool `json:"allowContainerCapabilitiesForRegularUsers" example:"true"`
|
||||
// Whether non-administrator should be able to use sysctl settings
|
||||
AllowSysctlSettingForRegularUsers bool `json:"allowSysctlSettingForRegularUsers" example:"true"`
|
||||
// Whether non-administrator should be able to use security-opt settings
|
||||
AllowSecurityOptForRegularUsers bool `json:"allowSecurityOptForRegularUsers" example:"true"`
|
||||
// Whether host management features are enabled
|
||||
EnableHostManagementFeatures bool `json:"enableHostManagementFeatures" example:"true"`
|
||||
}
|
||||
@@ -1930,6 +1930,8 @@ const (
|
||||
KubectlShellImageEnvVar = "KUBECTL_SHELL_IMAGE"
|
||||
// PullLimitCheckDisabledEnvVar is the environment variable used to disable the pull limit check
|
||||
PullLimitCheckDisabledEnvVar = "PULL_LIMIT_CHECK_DISABLED"
|
||||
// FeatureFlagEnvVar is the environment variable used to set the list of enabled feature flags
|
||||
FeatureFlagEnvVar = "FEATURE_FLAG"
|
||||
// LicenseServerBaseURL represents the base URL of the API used to validate
|
||||
// an extension license.
|
||||
LicenseServerBaseURL = "https://api.portainer.io"
|
||||
@@ -2450,14 +2452,15 @@ const (
|
||||
|
||||
const (
|
||||
// PolicyType constants
|
||||
RbacK8s PolicyType = "rbac-k8s"
|
||||
SecurityK8s PolicyType = "security-k8s"
|
||||
SetupK8s PolicyType = "setup-k8s"
|
||||
RegistryK8s PolicyType = "registry-k8s"
|
||||
RbacDocker PolicyType = "rbac-docker"
|
||||
SecurityDocker PolicyType = "security-docker"
|
||||
SetupDocker PolicyType = "setup-docker"
|
||||
RegistryDocker PolicyType = "registry-docker"
|
||||
RbacK8s PolicyType = "rbac-k8s"
|
||||
SecurityK8s PolicyType = "security-k8s"
|
||||
SetupK8s PolicyType = "setup-k8s"
|
||||
RegistryK8s PolicyType = "registry-k8s"
|
||||
RbacDocker PolicyType = "rbac-docker"
|
||||
SecurityDocker PolicyType = "security-docker"
|
||||
SetupDocker PolicyType = "setup-docker"
|
||||
RegistryDocker PolicyType = "registry-docker"
|
||||
ChangeConfirmation PolicyType = "change-confirmation"
|
||||
)
|
||||
|
||||
type HelmInstallStatus string
|
||||
@@ -2477,6 +2480,7 @@ func DefaultEndpointSecuritySettings() EndpointSecuritySettings {
|
||||
AllowHostNamespaceForRegularUsers: false,
|
||||
AllowPrivilegedModeForRegularUsers: false,
|
||||
AllowSysctlSettingForRegularUsers: false,
|
||||
AllowSecurityOptForRegularUsers: false,
|
||||
AllowVolumeBrowserForRegularUsers: false,
|
||||
EnableHostManagementFeatures: false,
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -8,7 +9,6 @@ import (
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type ComposeStackFileContentBuilder struct {
|
||||
@@ -55,7 +55,7 @@ func (b *ComposeStackFileContentBuilder) SetFileContent(payload *StackPayload) F
|
||||
stackFolder := strconv.Itoa(int(b.stack.ID))
|
||||
projectPath, err := b.fileService.StoreStackFileFromBytes(stackFolder, b.stack.EntryPoint, []byte(payload.StackFileContent))
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError("Unable to persist Compose file on disk", err)
|
||||
b.err = fmt.Errorf("Unable to persist Compose file on disk: %w", err)
|
||||
return b
|
||||
}
|
||||
b.stack.ProjectPath = projectPath
|
||||
@@ -70,7 +70,7 @@ func (b *ComposeStackFileContentBuilder) Deploy(payload *StackPayload, endpoint
|
||||
|
||||
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, false)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError(err.Error(), err)
|
||||
b.err = err
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type ComposeStackFileUploadBuilder struct {
|
||||
@@ -61,7 +60,7 @@ func (b *ComposeStackFileUploadBuilder) Deploy(payload *StackPayload, endpoint *
|
||||
|
||||
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, false)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError(err.Error(), err)
|
||||
b.err = err
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type ComposeStackGitBuilder struct {
|
||||
@@ -61,7 +60,7 @@ func (b *ComposeStackGitBuilder) Deploy(payload *StackPayload, endpoint *portain
|
||||
|
||||
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, false)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError(err.Error(), err)
|
||||
b.err = err
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
@@ -18,38 +18,76 @@ func NewStackBuilderDirector(b any) *StackBuilderDirector {
|
||||
}
|
||||
}
|
||||
|
||||
// Build executes the stack build process based on the builder type. It returns the
|
||||
// created stack and any error encountered during the process.
|
||||
// The returned error is of type *httperror.HandlerError, which could be a BadRequest
|
||||
// or InternalServerError depending on the error encountered during the stack build process.
|
||||
func (d *StackBuilderDirector) Build(payload *StackPayload, endpoint *portainer.Endpoint) (*portainer.Stack, *httperror.HandlerError) {
|
||||
|
||||
var (
|
||||
stack *portainer.Stack
|
||||
err error
|
||||
)
|
||||
// To align with the flow of the actual service deployment tools, we save
|
||||
// the stack before the deployment. This allows us to track the stack
|
||||
// metadata and partially created resources.
|
||||
switch builder := d.builder.(type) {
|
||||
case GitMethodStackBuildProcess:
|
||||
return builder.SetGeneralInfo(payload, endpoint).
|
||||
stack, err = builder.SetGeneralInfo(payload, endpoint).
|
||||
SetUniqueInfo(payload).
|
||||
SetGitRepository(payload).
|
||||
Deploy(payload, endpoint).
|
||||
SetAutoUpdate(payload).
|
||||
SaveStack()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Failed to save stack via Git repository method", err)
|
||||
}
|
||||
|
||||
// Since AutoUpdate job for stack is created after a successful
|
||||
// deployment, we need to update the stack with the new generated job ID
|
||||
stack, err = builder.Deploy(payload, endpoint).
|
||||
SetAutoUpdate(payload).
|
||||
UpdateStack(stack)
|
||||
|
||||
case FileUploadMethodStackBuildProcess:
|
||||
return builder.SetGeneralInfo(payload, endpoint).
|
||||
stack, err = builder.SetGeneralInfo(payload, endpoint).
|
||||
SetUniqueInfo(payload).
|
||||
SetUploadedFile(payload).
|
||||
Deploy(payload, endpoint).
|
||||
SaveStack()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Failed to save stack via File Upload method", err)
|
||||
}
|
||||
|
||||
builder.Deploy(payload, endpoint)
|
||||
err = builder.Error()
|
||||
|
||||
case FileContentMethodStackBuildProcess:
|
||||
return builder.SetGeneralInfo(payload, endpoint).
|
||||
stack, err = builder.SetGeneralInfo(payload, endpoint).
|
||||
SetUniqueInfo(payload).
|
||||
SetFileContent(payload).
|
||||
Deploy(payload, endpoint).
|
||||
SaveStack()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Failed to save stack via File Content method", err)
|
||||
}
|
||||
|
||||
builder.Deploy(payload, endpoint)
|
||||
err = builder.Error()
|
||||
|
||||
case UrlMethodStackBuildProcess:
|
||||
return builder.SetGeneralInfo(payload, endpoint).
|
||||
stack, err = builder.SetGeneralInfo(payload, endpoint).
|
||||
SetUniqueInfo(payload).
|
||||
SetURL(payload).
|
||||
Deploy(payload, endpoint).
|
||||
SaveStack()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Failed to save stack via URL method", err)
|
||||
}
|
||||
|
||||
builder.Deploy(payload, endpoint)
|
||||
err = builder.Error()
|
||||
|
||||
default:
|
||||
return nil, httperror.BadRequest("Invalid value for query parameter: method. Value must be one of: string or repository or url or file", errors.New(request.ErrInvalidQueryParameter))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Failed to deploy stack", err)
|
||||
}
|
||||
|
||||
return nil, httperror.BadRequest("Invalid value for query parameter: method. Value must be one of: string or repository or url or file", errors.New(request.ErrInvalidQueryParameter))
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
@@ -10,7 +11,6 @@ import (
|
||||
k "github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type K8sStackFileContentBuilder struct {
|
||||
@@ -66,7 +66,7 @@ func (b *K8sStackFileContentBuilder) SetFileContent(payload *StackPayload) FileC
|
||||
stackFolder := strconv.Itoa(int(b.stack.ID))
|
||||
projectPath, err := b.fileService.StoreStackFileFromBytes(stackFolder, b.stack.EntryPoint, []byte(payload.StackFileContent))
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError("Unable to persist Kubernetes Manifest file on disk", err)
|
||||
b.err = fmt.Errorf("Unable to persist Kubernetes Manifest file on disk: %w", err)
|
||||
|
||||
return b
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func (b *K8sStackFileContentBuilder) Deploy(payload *StackPayload, endpoint *por
|
||||
|
||||
k8sDeploymentConfig, err := deployments.CreateKubernetesStackDeploymentConfig(b.stack, b.KuberneteDeployer, k8sAppLabel, b.User, endpoint)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError("failed to create temp kub deployment files", err)
|
||||
b.err = fmt.Errorf("failed to create temp kub deployment files: %w", err)
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -9,7 +10,6 @@ import (
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type KubernetesStackGitBuilder struct {
|
||||
@@ -83,7 +83,7 @@ func (b *KubernetesStackGitBuilder) Deploy(payload *StackPayload, endpoint *port
|
||||
|
||||
k8sDeploymentConfig, err := deployments.CreateKubernetesStackDeploymentConfig(b.stack, b.KuberneteDeployer, k8sAppLabel, b.user, endpoint)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError("failed to create temp kub deployment files", err)
|
||||
b.err = fmt.Errorf("failed to create temp kub deployment files: %w", err)
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
@@ -11,7 +12,6 @@ import (
|
||||
k "github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type KubernetesStackUrlBuilder struct {
|
||||
@@ -65,7 +65,7 @@ func (b *KubernetesStackUrlBuilder) SetURL(payload *StackPayload) UrlMethodStack
|
||||
|
||||
manifestContent, err := client.Get(payload.ManifestURL, 30)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError("Unable to retrieve manifest from URL", err)
|
||||
b.err = fmt.Errorf("Unable to retrieve manifest from URL: %w", err)
|
||||
|
||||
return b
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func (b *KubernetesStackUrlBuilder) SetURL(payload *StackPayload) UrlMethodStack
|
||||
stackFolder := strconv.Itoa(int(b.stack.ID))
|
||||
projectPath, err := b.fileService.StoreStackFileFromBytes(stackFolder, b.stack.EntryPoint, manifestContent)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError("Unable to persist Kubernetes manifest file on disk", err)
|
||||
b.err = fmt.Errorf("Unable to persist Kubernetes manifest file on disk: %w", err)
|
||||
|
||||
return b
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func (b *KubernetesStackUrlBuilder) Deploy(payload *StackPayload, endpoint *port
|
||||
|
||||
k8sDeploymentConfig, err := deployments.CreateKubernetesStackDeploymentConfig(b.stack, b.KuberneteDeployer, k8sAppLabel, b.user, endpoint)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError("failed to create temp kub deployment files", err)
|
||||
b.err = fmt.Errorf("failed to create temp kub deployment files: %w", err)
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -15,7 +16,7 @@ type StackBuilder struct {
|
||||
fileService portainer.FileService
|
||||
stackDeployer deployments.StackDeployer
|
||||
deploymentConfiger deployments.StackDeploymentConfiger
|
||||
err *httperror.HandlerError
|
||||
err error
|
||||
doCleanUp bool
|
||||
}
|
||||
|
||||
@@ -29,7 +30,7 @@ func CreateStackBuilder(dataStore dataservices.DataStore, fileService portainer.
|
||||
}
|
||||
}
|
||||
|
||||
func (b *StackBuilder) SaveStack() (*portainer.Stack, *httperror.HandlerError) {
|
||||
func (b *StackBuilder) SaveStack() (*portainer.Stack, error) {
|
||||
defer func() { _ = b.cleanUp() }()
|
||||
|
||||
if b.hasError() {
|
||||
@@ -38,7 +39,7 @@ func (b *StackBuilder) SaveStack() (*portainer.Stack, *httperror.HandlerError) {
|
||||
|
||||
if err := b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
if err := tx.Stack().Create(b.stack); err != nil {
|
||||
b.err = httperror.InternalServerError("Unable to persist the stack inside the database", err)
|
||||
b.err = fmt.Errorf("Unable to persist the stack inside the database: %w", err)
|
||||
return b.err
|
||||
}
|
||||
|
||||
@@ -49,7 +50,11 @@ func (b *StackBuilder) SaveStack() (*portainer.Stack, *httperror.HandlerError) {
|
||||
|
||||
b.doCleanUp = false
|
||||
|
||||
return b.stack, b.err
|
||||
return b.stack, nil
|
||||
}
|
||||
|
||||
func (b *StackBuilder) Error() error {
|
||||
return b.err
|
||||
}
|
||||
|
||||
func (b *StackBuilder) cleanUp() error {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type FileContentMethodStackBuildProcess interface {
|
||||
@@ -15,11 +14,12 @@ type FileContentMethodStackBuildProcess interface {
|
||||
// Deploy stack based on the configuration
|
||||
Deploy(payload *StackPayload, endpoint *portainer.Endpoint) FileContentMethodStackBuildProcess
|
||||
// Save the stack information to database
|
||||
SaveStack() (*portainer.Stack, *httperror.HandlerError)
|
||||
SaveStack() (*portainer.Stack, error)
|
||||
// Get response from HTTP request. Use if it is needed
|
||||
GetResponse() string
|
||||
// Process the file content
|
||||
SetFileContent(payload *StackPayload) FileContentMethodStackBuildProcess
|
||||
Error() error
|
||||
}
|
||||
|
||||
type FileContentMethodStackBuilder struct {
|
||||
@@ -50,9 +50,7 @@ func (b *FileContentMethodStackBuilder) Deploy(payload *StackPayload, endpoint *
|
||||
}
|
||||
|
||||
// Deploy the stack
|
||||
if err := b.deploymentConfiger.Deploy(); err != nil {
|
||||
b.err = httperror.InternalServerError(err.Error(), err)
|
||||
}
|
||||
b.err = b.deploymentConfiger.Deploy()
|
||||
|
||||
return b
|
||||
}
|
||||
@@ -60,3 +58,7 @@ func (b *FileContentMethodStackBuilder) Deploy(payload *StackPayload, endpoint *
|
||||
func (b *FileContentMethodStackBuilder) GetResponse() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *FileContentMethodStackBuilder) Error() error {
|
||||
return b.err
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type FileUploadMethodStackBuildProcess interface {
|
||||
@@ -16,11 +16,12 @@ type FileUploadMethodStackBuildProcess interface {
|
||||
// Deploy stack based on the configuration
|
||||
Deploy(payload *StackPayload, endpoint *portainer.Endpoint) FileUploadMethodStackBuildProcess
|
||||
// Save the stack information to database
|
||||
SaveStack() (*portainer.Stack, *httperror.HandlerError)
|
||||
SaveStack() (*portainer.Stack, error)
|
||||
// Get response from HTTP request. Use if it is needed
|
||||
GetResponse() string
|
||||
// Process the upload file
|
||||
SetUploadedFile(payload *StackPayload) FileUploadMethodStackBuildProcess
|
||||
Error() error
|
||||
}
|
||||
|
||||
type FileUploadMethodStackBuilder struct {
|
||||
@@ -49,7 +50,7 @@ func (b *FileUploadMethodStackBuilder) SetUploadedFile(payload *StackPayload) Fi
|
||||
stackFolder := strconv.Itoa(int(b.stack.ID))
|
||||
projectPath, err := b.fileService.StoreStackFileFromBytes(stackFolder, b.stack.EntryPoint, payload.StackFileContentBytes)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError("Unable to persist Compose file on disk", err)
|
||||
b.err = fmt.Errorf("Unable to persist file on disk: %w", err)
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -65,7 +66,7 @@ func (b *FileUploadMethodStackBuilder) Deploy(payload *StackPayload, endpoint *p
|
||||
|
||||
// Deploy the stack
|
||||
if err := b.deploymentConfiger.Deploy(); err != nil {
|
||||
b.err = httperror.InternalServerError(err.Error(), err)
|
||||
b.err = err
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type GitMethodStackBuildProcess interface {
|
||||
@@ -21,14 +21,16 @@ type GitMethodStackBuildProcess interface {
|
||||
SetUniqueInfo(payload *StackPayload) GitMethodStackBuildProcess
|
||||
// Deploy stack based on the configuration
|
||||
Deploy(payload *StackPayload, endpoint *portainer.Endpoint) GitMethodStackBuildProcess
|
||||
// Save the stack information to database and return the stack object
|
||||
SaveStack() (*portainer.Stack, *httperror.HandlerError)
|
||||
// Save the stack information to database
|
||||
SaveStack() (*portainer.Stack, error)
|
||||
// Get response from HTTP request. Use if it is needed
|
||||
GetResponse() string
|
||||
// Set git repository configuration
|
||||
SetGitRepository(payload *StackPayload) GitMethodStackBuildProcess
|
||||
// Set auto update setting
|
||||
SetAutoUpdate(payload *StackPayload) GitMethodStackBuildProcess
|
||||
UpdateStack(stack *portainer.Stack) (*portainer.Stack, error)
|
||||
Error() error
|
||||
}
|
||||
|
||||
type GitMethodStackBuilder struct {
|
||||
@@ -91,7 +93,7 @@ func (b *GitMethodStackBuilder) SetGitRepository(payload *StackPayload) GitMetho
|
||||
|
||||
commitHash, err := stackutils.DownloadGitRepository(repoConfig, b.gitService, getProjectPath)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError(err.Error(), err)
|
||||
b.err = fmt.Errorf("failed to download git repository: %w", err)
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -108,15 +110,34 @@ func (b *GitMethodStackBuilder) Deploy(payload *StackPayload, endpoint *portaine
|
||||
}
|
||||
|
||||
// Deploy the stack
|
||||
err := b.deploymentConfiger.Deploy()
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError(err.Error(), err)
|
||||
return b
|
||||
}
|
||||
b.err = b.deploymentConfiger.Deploy()
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *GitMethodStackBuilder) UpdateStack(stack *portainer.Stack) (*portainer.Stack, error) {
|
||||
if b.hasError() {
|
||||
return nil, b.err
|
||||
}
|
||||
|
||||
b.stack = stack
|
||||
|
||||
// Ideally, we should replace b.dataStore with b.tx and manage the transaction
|
||||
// at a higher layer. However, that would require significant changes to other
|
||||
// logic unrelated to this builder.
|
||||
// To keep this change focused and minimize the scope, we will retain b.dataStore
|
||||
// and perform the update within a transaction here for now.
|
||||
b.err = b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
if err := tx.Stack().Update(b.stack.ID, b.stack); err != nil {
|
||||
return fmt.Errorf("Unable to update the stack inside the database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return b.stack, b.err
|
||||
}
|
||||
|
||||
func (b *GitMethodStackBuilder) SetAutoUpdate(payload *StackPayload) GitMethodStackBuildProcess {
|
||||
if b.hasError() {
|
||||
return b
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type UrlMethodStackBuildProcess interface {
|
||||
@@ -15,11 +14,12 @@ type UrlMethodStackBuildProcess interface {
|
||||
// Deploy stack based on the configuration
|
||||
Deploy(payload *StackPayload, endpoint *portainer.Endpoint) UrlMethodStackBuildProcess
|
||||
// Save the stack information to database
|
||||
SaveStack() (*portainer.Stack, *httperror.HandlerError)
|
||||
SaveStack() (*portainer.Stack, error)
|
||||
// Get reponse from http request. Use if it is needed
|
||||
GetResponse() string
|
||||
// Set manifest url
|
||||
SetURL(payload *StackPayload) UrlMethodStackBuildProcess
|
||||
Error() error
|
||||
}
|
||||
|
||||
type UrlMethodStackBuilder struct {
|
||||
@@ -55,7 +55,7 @@ func (b *UrlMethodStackBuilder) Deploy(payload *StackPayload, endpoint *portaine
|
||||
// Deploy the stack
|
||||
err := b.deploymentConfiger.Deploy()
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError(err.Error(), err)
|
||||
b.err = err
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -65,3 +65,7 @@ func (b *UrlMethodStackBuilder) Deploy(payload *StackPayload, endpoint *portaine
|
||||
func (b *UrlMethodStackBuilder) GetResponse() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *UrlMethodStackBuilder) Error() error {
|
||||
return b.err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -8,7 +9,6 @@ import (
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type SwarmStackFileContentBuilder struct {
|
||||
@@ -56,7 +56,7 @@ func (b *SwarmStackFileContentBuilder) SetFileContent(payload *StackPayload) Fil
|
||||
stackFolder := strconv.Itoa(int(b.stack.ID))
|
||||
projectPath, err := b.fileService.StoreStackFileFromBytes(stackFolder, b.stack.EntryPoint, []byte(payload.StackFileContent))
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError("Unable to persist Compose file on disk", err)
|
||||
b.err = fmt.Errorf("Unable to persist Swarm file on disk: %w", err)
|
||||
return b
|
||||
}
|
||||
b.stack.ProjectPath = projectPath
|
||||
@@ -71,7 +71,7 @@ func (b *SwarmStackFileContentBuilder) Deploy(payload *StackPayload, endpoint *p
|
||||
|
||||
swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, true)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError(err.Error(), err)
|
||||
b.err = err
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type SwarmStackFileUploadBuilder struct {
|
||||
@@ -65,7 +64,7 @@ func (b *SwarmStackFileUploadBuilder) Deploy(payload *StackPayload, endpoint *po
|
||||
|
||||
swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, true)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError(err.Error(), err)
|
||||
b.err = err
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type SwarmStackGitBuilder struct {
|
||||
@@ -63,7 +62,7 @@ func (b *SwarmStackGitBuilder) Deploy(payload *StackPayload, endpoint *portainer
|
||||
|
||||
swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, true)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError(err.Error(), err)
|
||||
b.err = err
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@ func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.Endpo
|
||||
return errors.New("sysctl setting disabled for non administrator users")
|
||||
}
|
||||
|
||||
if !securitySettings.AllowSecurityOptForRegularUsers && len(service.SecurityOpt) > 0 {
|
||||
return errors.New("security-opt setting disabled for non administrator users")
|
||||
}
|
||||
|
||||
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) {
|
||||
return errors.New("container capabilities disabled for non administrator users")
|
||||
}
|
||||
|
||||
@@ -81,7 +81,6 @@ func WriteReaderToWebSocket(websocketConn *websocket.Conn, mu *sync.Mutex, reade
|
||||
defer logs.CloseAndLogErr(websocketConn)
|
||||
|
||||
mu.Lock()
|
||||
websocketConn.SetReadLimit(ReaderBufferSize)
|
||||
websocketConn.SetPongHandler(func(string) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -287,11 +287,6 @@ input[type='checkbox'] {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -152,8 +152,8 @@ fieldset[disabled] .btn {
|
||||
|
||||
.btn.btn-link {
|
||||
@apply text-blue-8 hover:text-blue-9 disabled:text-gray-5;
|
||||
@apply th-dark:text-blue-8 th-dark:hover:text-blue-7;
|
||||
@apply th-highcontrast:text-blue-8 th-highcontrast:hover:text-blue-7;
|
||||
@apply th-dark:text-blue-7 th-dark:hover:text-blue-8;
|
||||
@apply th-highcontrast:text-blue-5 th-highcontrast:hover:text-blue-6;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
@@ -177,14 +177,14 @@ fieldset[disabled] .btn {
|
||||
|
||||
a.no-link,
|
||||
a[ng-click] {
|
||||
@apply text-current;
|
||||
@apply text-current th-dark:text-current th-highcontrast:text-current;
|
||||
@apply hover:text-current hover:no-underline;
|
||||
@apply focus:text-current focus:no-underline;
|
||||
}
|
||||
|
||||
a,
|
||||
a.hyperlink {
|
||||
@apply text-blue-8 hover:text-blue-9;
|
||||
@apply text-blue-8 hover:text-blue-9 th-dark:text-blue-7 th-dark:hover:text-blue-8 th-highcontrast:text-blue-5 th-highcontrast:hover:text-blue-6;
|
||||
@apply cursor-pointer hover:underline;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'bootstrap/dist/css/bootstrap.css';
|
||||
import 'toastr/build/toastr.css';
|
||||
import 'xterm/dist/xterm.css';
|
||||
import 'angularjs-slider/dist/rzslider.css';
|
||||
import 'angular-loading-bar/build/loading-bar.css';
|
||||
import 'angular-moment-picker/dist/angular-moment-picker.min.css';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Terminal } from 'xterm';
|
||||
import * as fit from 'xterm/lib/addons/fit/fit';
|
||||
import { csrfInterceptor, csrfTokenReaderInterceptorAngular } from './portainer/services/csrf';
|
||||
import { agentInterceptor } from './portainer/services/axios';
|
||||
import { dispatchCacheRefreshEventIfNeeded } from './portainer/services/http-request.helper';
|
||||
@@ -33,8 +31,6 @@ export function configApp($urlRouterProvider, $httpProvider, localStorageService
|
||||
request: csrfInterceptor,
|
||||
}));
|
||||
|
||||
Terminal.applyAddon(fit);
|
||||
|
||||
$uibTooltipProvider.setTriggers({
|
||||
mouseenter: 'mouseleave',
|
||||
click: 'click',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import tokenize from '@nxmix/tokenize-ansi';
|
||||
import { FontWeight } from 'xterm';
|
||||
import { FontWeight } from '@xterm/xterm';
|
||||
|
||||
import {
|
||||
colors,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FontWeight } from 'xterm';
|
||||
import { FontWeight } from '@xterm/xterm';
|
||||
|
||||
import { type TextColor } from './colors';
|
||||
|
||||
|
||||
@@ -53,6 +53,6 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<div id="terminal-container" class="terminal-container"></div>
|
||||
<shell-terminal url="shellUrl" connect="shellConnect" on-state-change="(onShellStateChange)" on-resize="(onShellResize)"></shell-terminal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { Terminal } from 'xterm';
|
||||
import { baseHref } from '@/portainer/helpers/pathHelper';
|
||||
import { commandStringToArray } from '@/docker/helpers/containers';
|
||||
import { isLinuxTerminalCommand, LINUX_SHELL_INIT_COMMANDS } from '@@/Terminal/Terminal';
|
||||
|
||||
angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
'$scope',
|
||||
'$state',
|
||||
'$transition$',
|
||||
'ContainerService',
|
||||
'ExecService',
|
||||
'ImageService',
|
||||
'Notifications',
|
||||
'ExecService',
|
||||
'HttpRequestHelper',
|
||||
'CONSOLE_COMMANDS_LABEL_PREFIX',
|
||||
'SidebarService',
|
||||
'endpoint',
|
||||
function ($scope, $state, $transition$, ContainerService, ImageService, Notifications, ExecService, HttpRequestHelper, CONSOLE_COMMANDS_LABEL_PREFIX, SidebarService, endpoint) {
|
||||
var socket, term;
|
||||
|
||||
let states = Object.freeze({
|
||||
function ($scope, $state, $transition$, ContainerService, ExecService, ImageService, Notifications, HttpRequestHelper, CONSOLE_COMMANDS_LABEL_PREFIX, endpoint) {
|
||||
const states = Object.freeze({
|
||||
disconnected: 0,
|
||||
connecting: 1,
|
||||
connected: 2,
|
||||
@@ -26,15 +23,29 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
$scope.loaded = false;
|
||||
$scope.states = states;
|
||||
$scope.state = states.disconnected;
|
||||
|
||||
$scope.formValues = {};
|
||||
$scope.containerCommands = [];
|
||||
|
||||
// Ensure the socket is closed before leaving the view
|
||||
$scope.shellUrl = '';
|
||||
$scope.shellConnect = false;
|
||||
$scope.onShellResize = null;
|
||||
$scope.shellInitCommands = null;
|
||||
|
||||
$scope.$on('$destroy', function () {
|
||||
$scope.disconnect();
|
||||
});
|
||||
|
||||
$scope.onShellStateChange = function (state) {
|
||||
$scope.$evalAsync(function () {
|
||||
if (state === 'connected') {
|
||||
$scope.state = states.connected;
|
||||
} else if (state === 'disconnected') {
|
||||
$scope.state = states.disconnected;
|
||||
$scope.shellConnect = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$scope.connectAttach = function () {
|
||||
if ($scope.state > states.disconnected) {
|
||||
return;
|
||||
@@ -42,7 +53,7 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
|
||||
$scope.state = states.connecting;
|
||||
|
||||
let attachId = $transition$.params().id;
|
||||
const attachId = $transition$.params().id;
|
||||
|
||||
ContainerService.container(endpoint.Id, attachId)
|
||||
.then((details) => {
|
||||
@@ -52,22 +63,13 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
return;
|
||||
}
|
||||
|
||||
const params = {
|
||||
endpointId: $state.params.endpointId,
|
||||
id: attachId,
|
||||
$scope.onShellResize = function ({ rows, cols }) {
|
||||
ContainerService.resizeTTY(endpoint.Id, attachId, cols, rows);
|
||||
};
|
||||
|
||||
const base = window.location.origin.startsWith('http') ? `${window.location.origin}${baseHref()}` : baseHref();
|
||||
var url =
|
||||
base +
|
||||
'api/websocket/attach?' +
|
||||
Object.keys(params)
|
||||
.map((k) => k + '=' + params[k])
|
||||
.join('&');
|
||||
|
||||
initTerm(url, ContainerService.resizeTTY.bind(this, endpoint.Id, attachId));
|
||||
$scope.shellUrl = buildShellUrl('api/websocket/attach', { endpointId: $state.params.endpointId, id: attachId });
|
||||
$scope.shellConnect = true;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
.catch(function (err) {
|
||||
Notifications.error('Error', err, 'Unable to retrieve container details');
|
||||
$scope.disconnect();
|
||||
});
|
||||
@@ -79,8 +81,9 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
}
|
||||
|
||||
$scope.state = states.connecting;
|
||||
var command = $scope.formValues.isCustomCommand ? $scope.formValues.customCommand : $scope.formValues.command;
|
||||
var execConfig = {
|
||||
|
||||
const command = $scope.formValues.isCustomCommand ? $scope.formValues.customCommand : $scope.formValues.command;
|
||||
const execConfig = {
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
@@ -90,171 +93,48 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
};
|
||||
|
||||
ContainerService.createExec(endpoint.Id, $transition$.params().id, execConfig)
|
||||
.then(function success(data) {
|
||||
const params = {
|
||||
endpointId: $state.params.endpointId,
|
||||
id: data.Id,
|
||||
.then(function (data) {
|
||||
$scope.onShellResize = function ({ rows, cols }) {
|
||||
ExecService.resizeTTY(data.Id, cols, rows);
|
||||
};
|
||||
|
||||
const base = window.location.origin.startsWith('http') ? `${window.location.origin}${baseHref()}` : baseHref();
|
||||
var url =
|
||||
base +
|
||||
'api/websocket/exec?' +
|
||||
Object.keys(params)
|
||||
.map((k) => k + '=' + params[k])
|
||||
.join('&');
|
||||
|
||||
const isLinuxCommand = execConfig.Cmd ? isLinuxTerminalCommand(execConfig.Cmd[0]) : false;
|
||||
initTerm(url, ExecService.resizeTTY.bind(this, params.id), isLinuxCommand);
|
||||
if (isLinuxTerminalCommand(execConfig.Cmd[0])) {
|
||||
$scope.shellInitCommands = LINUX_SHELL_INIT_COMMANDS;
|
||||
}
|
||||
$scope.shellUrl = buildShellUrl('api/websocket/exec', { endpointId: $state.params.endpointId, id: data.Id });
|
||||
$scope.shellConnect = true;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
.catch(function (err) {
|
||||
Notifications.error('Failure', err, 'Unable to exec into container');
|
||||
$scope.disconnect();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.disconnect = function () {
|
||||
if (socket) {
|
||||
socket.close();
|
||||
}
|
||||
if ($scope.state > states.disconnected) {
|
||||
$scope.state = states.disconnected;
|
||||
if (term) {
|
||||
term.write('\n\r(connection closed)');
|
||||
term.dispose();
|
||||
}
|
||||
}
|
||||
$scope.shellConnect = false;
|
||||
$scope.state = states.disconnected;
|
||||
$scope.onShellResize = null;
|
||||
$scope.shellInitCommands = null;
|
||||
};
|
||||
|
||||
$scope.autoconnectAttachView = function () {
|
||||
return $scope.initView().then(function success() {
|
||||
return $scope.initView().then(function () {
|
||||
if ($scope.container.State.Running) {
|
||||
$scope.connectAttach();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function resize(restcall, add) {
|
||||
if ($scope.state != states.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
add = add || 0;
|
||||
|
||||
term.fit();
|
||||
var termWidth = term.cols;
|
||||
var termHeight = 30;
|
||||
term.resize(termWidth, termHeight);
|
||||
|
||||
restcall(termWidth + add, termHeight + add, 1);
|
||||
}
|
||||
|
||||
function isLinuxTerminalCommand(command) {
|
||||
const validShellCommands = ['ash', 'bash', 'dash', 'sh'];
|
||||
return validShellCommands.includes(command);
|
||||
}
|
||||
|
||||
function initTerm(url, resizeRestCall, isLinuxTerm = false) {
|
||||
let resizefun = resize.bind(this, resizeRestCall);
|
||||
|
||||
if ($transition$.params().nodeName) {
|
||||
url += '&nodeName=' + $transition$.params().nodeName;
|
||||
}
|
||||
|
||||
if (url.indexOf('https') > -1) {
|
||||
url = url.replace('https://', 'wss://');
|
||||
} else {
|
||||
url = url.replace('http://', 'ws://');
|
||||
}
|
||||
|
||||
socket = new WebSocket(url);
|
||||
|
||||
socket.onopen = function () {
|
||||
let closeTerminal = false;
|
||||
let commandBuffer = '';
|
||||
|
||||
$scope.state = states.connected;
|
||||
term = new Terminal();
|
||||
|
||||
if (isLinuxTerm) {
|
||||
// linux terminals support xterm
|
||||
socket.send('export LANG=C.UTF-8\n');
|
||||
socket.send('export LC_ALL=C.UTF-8\n');
|
||||
socket.send('export TERM="xterm-256color"\n');
|
||||
socket.send('alias ls="ls --color=auto"\n');
|
||||
socket.send('echo -e "\\033[2J\\033[H"\n');
|
||||
}
|
||||
|
||||
term.onData(function (data) {
|
||||
socket.send(data);
|
||||
|
||||
// This code is detect whether the user has
|
||||
// typed CTRL+D or exit in the terminal
|
||||
if (data === '\x04') {
|
||||
// If the user types CTRL+D, close the terminal
|
||||
closeTerminal = true;
|
||||
} else if (data === '\r') {
|
||||
if (commandBuffer.trim() === 'exit') {
|
||||
closeTerminal = true;
|
||||
}
|
||||
commandBuffer = '';
|
||||
} else {
|
||||
commandBuffer += data;
|
||||
}
|
||||
});
|
||||
|
||||
var terminal_container = document.getElementById('terminal-container');
|
||||
term.open(terminal_container);
|
||||
term.focus();
|
||||
term.setOption('cursorBlink', true);
|
||||
|
||||
window.onresize = function () {
|
||||
resizefun();
|
||||
$scope.$apply();
|
||||
};
|
||||
|
||||
$scope.$watch(SidebarService.isSidebarOpen, function () {
|
||||
setTimeout(resizefun, 400);
|
||||
});
|
||||
|
||||
socket.onmessage = function (e) {
|
||||
term.write(e.data);
|
||||
};
|
||||
|
||||
socket.onerror = function (err) {
|
||||
if (closeTerminal) {
|
||||
$scope.disconnect();
|
||||
} else {
|
||||
Notifications.error('Failure', err, 'Connection error');
|
||||
}
|
||||
$scope.$apply();
|
||||
};
|
||||
|
||||
socket.onclose = function () {
|
||||
if (closeTerminal) {
|
||||
$scope.disconnect();
|
||||
}
|
||||
$scope.$apply();
|
||||
};
|
||||
|
||||
resizefun(1);
|
||||
$scope.$apply();
|
||||
};
|
||||
}
|
||||
|
||||
$scope.initView = function () {
|
||||
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
|
||||
return ContainerService.container(endpoint.Id, $transition$.params().id)
|
||||
.then(function success(data) {
|
||||
var container = data;
|
||||
$scope.container = container;
|
||||
return ImageService.image(container.Image);
|
||||
.then(function (data) {
|
||||
$scope.container = data;
|
||||
return ImageService.image(data.Image);
|
||||
})
|
||||
.then(function success(data) {
|
||||
var image = data;
|
||||
var containerLabels = $scope.container.Config.Labels;
|
||||
$scope.imageOS = image.Os;
|
||||
$scope.formValues.command = image.Os === 'windows' ? 'powershell' : 'bash';
|
||||
.then(function (data) {
|
||||
const containerLabels = $scope.container.Config.Labels;
|
||||
$scope.imageOS = data.Os;
|
||||
$scope.formValues.command = data.Os === 'windows' ? 'powershell' : 'bash';
|
||||
$scope.containerCommands = Object.keys(containerLabels)
|
||||
.filter(function (label) {
|
||||
return label.indexOf(CONSOLE_COMMANDS_LABEL_PREFIX) === 0;
|
||||
@@ -267,7 +147,7 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
});
|
||||
$scope.loaded = true;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
.catch(function (err) {
|
||||
Notifications.error('Error', err, 'Unable to retrieve container details');
|
||||
});
|
||||
};
|
||||
@@ -277,5 +157,20 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
$scope.formValues.isCustomCommand = enabled;
|
||||
});
|
||||
};
|
||||
|
||||
function buildShellUrl(path, params) {
|
||||
const base = window.location.origin.startsWith('http') ? `${window.location.origin}${baseHref()}` : baseHref();
|
||||
let url =
|
||||
base +
|
||||
path +
|
||||
'?' +
|
||||
Object.keys(params)
|
||||
.map((k) => k + '=' + params[k])
|
||||
.join('&');
|
||||
if ($transition$.params().nodeName) {
|
||||
url += '&nodeName=' + $transition$.params().nodeName;
|
||||
}
|
||||
return url.startsWith('https') ? url.replace('https://', 'wss://') : url.replace('http://', 'ws://');
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -96,6 +96,6 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<div id="terminal-container" class="terminal-container"></div>
|
||||
<shell-terminal url="shellUrl" connect="shellConnect" on-state-change="(onShellStateChange)" on-resize="(onShellResize)" initial-commands="shellInitCommands"></shell-terminal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ export default class DockerFeaturesConfigurationController {
|
||||
disableDeviceMappingForRegularUsers: false,
|
||||
disableContainerCapabilitiesForRegularUsers: false,
|
||||
disableSysctlSettingForRegularUsers: false,
|
||||
disableSecurityOptForRegularUsers: false,
|
||||
};
|
||||
|
||||
this.isAgent = false;
|
||||
@@ -48,6 +49,7 @@ export default class DockerFeaturesConfigurationController {
|
||||
this.onChangeDisableDeviceMappingForRegularUsers = this.onChangeField('disableDeviceMappingForRegularUsers');
|
||||
this.onChangeDisableContainerCapabilitiesForRegularUsers = this.onChangeField('disableContainerCapabilitiesForRegularUsers');
|
||||
this.onChangeDisableSysctlSettingForRegularUsers = this.onChangeField('disableSysctlSettingForRegularUsers');
|
||||
this.onChangeDisableSecurityOptForRegularUsers = this.onChangeField('disableSecurityOptForRegularUsers');
|
||||
}
|
||||
|
||||
onToggleAutoUpdate(value) {
|
||||
@@ -93,6 +95,7 @@ export default class DockerFeaturesConfigurationController {
|
||||
disableDeviceMappingForRegularUsers,
|
||||
disableContainerCapabilitiesForRegularUsers,
|
||||
disableSysctlSettingForRegularUsers,
|
||||
disableSecurityOptForRegularUsers,
|
||||
} = this.formValues;
|
||||
return (
|
||||
disableBindMountsForRegularUsers ||
|
||||
@@ -100,7 +103,8 @@ export default class DockerFeaturesConfigurationController {
|
||||
disablePrivilegedModeForRegularUsers ||
|
||||
disableDeviceMappingForRegularUsers ||
|
||||
disableContainerCapabilitiesForRegularUsers ||
|
||||
disableSysctlSettingForRegularUsers
|
||||
disableSysctlSettingForRegularUsers ||
|
||||
disableSecurityOptForRegularUsers
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,6 +126,7 @@ export default class DockerFeaturesConfigurationController {
|
||||
allowStackManagementForRegularUsers: !this.formValues.disableStackManagementForRegularUsers,
|
||||
allowContainerCapabilitiesForRegularUsers: !this.formValues.disableContainerCapabilitiesForRegularUsers,
|
||||
allowSysctlSettingForRegularUsers: !this.formValues.disableSysctlSettingForRegularUsers,
|
||||
allowSecurityOptForRegularUsers: !this.formValues.disableSecurityOptForRegularUsers,
|
||||
enableGPUManagement: this.state.enableGPUManagement,
|
||||
gpus,
|
||||
};
|
||||
@@ -159,6 +164,7 @@ export default class DockerFeaturesConfigurationController {
|
||||
disableStackManagementForRegularUsers: !securitySettings.allowStackManagementForRegularUsers,
|
||||
disableContainerCapabilitiesForRegularUsers: !securitySettings.allowContainerCapabilitiesForRegularUsers,
|
||||
disableSysctlSettingForRegularUsers: !securitySettings.allowSysctlSettingForRegularUsers,
|
||||
disableSecurityOptForRegularUsers: !securitySettings.allowSecurityOptForRegularUsers,
|
||||
};
|
||||
|
||||
// this.endpoint.Gpus could be null as it is Gpus: []Pair in the API
|
||||
|
||||
@@ -142,6 +142,17 @@
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
checked="$ctrl.formValues.disableSecurityOptForRegularUsers"
|
||||
name="'disableSecurityOptForRegularUsers'"
|
||||
label="'Hide security-opt for non-administrators'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
on-change="($ctrl.onChangeDisableSecurityOptForRegularUsers)"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="$ctrl.isContainerEditDisabled()">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
|
||||
@@ -36,6 +36,7 @@ export const BoxSelectorAngular: IComponentOptions = {
|
||||
options="$ctrl.options"
|
||||
radio-name="$ctrl.radioName"
|
||||
slim="$ctrl.slim"
|
||||
label="$ctrl.label"
|
||||
></box-selector-react>`,
|
||||
bindings: {
|
||||
value: '<',
|
||||
@@ -43,6 +44,7 @@ export const BoxSelectorAngular: IComponentOptions = {
|
||||
options: '<',
|
||||
radioName: '<',
|
||||
slim: '<',
|
||||
label: '<',
|
||||
},
|
||||
require: {
|
||||
formCtrl: '^form',
|
||||
|
||||
@@ -18,6 +18,7 @@ const BoxSelectorReact = react2angular(BoxSelector, [
|
||||
'error',
|
||||
'useGridLayout',
|
||||
'className',
|
||||
'label',
|
||||
]);
|
||||
|
||||
export const boxSelectorModule = angular
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
<div class="col-sm-12 form-section-title"> Provider </div>
|
||||
|
||||
<box-selector value="$ctrl.value" options="$ctrl.options" on-change="($ctrl.onChange)" radio-name="'oauth_provider'"></box-selector>
|
||||
<box-selector value="$ctrl.value" options="$ctrl.options" on-change="($ctrl.onChange)" radio-name="'oauth_provider'" label="'Provider'"></box-selector>
|
||||
|
||||
@@ -31,6 +31,13 @@ export default class OAuthSettingsController {
|
||||
this.removeTeamMembership = this.removeTeamMembership.bind(this);
|
||||
this.onToggleAutoTeamMembership = this.onToggleAutoTeamMembership.bind(this);
|
||||
this.onChangeAuthStyle = this.onChangeAuthStyle.bind(this);
|
||||
this.onAutoUserProvisionChange = this.onAutoUserProvisionChange.bind(this);
|
||||
}
|
||||
|
||||
onAutoUserProvisionChange(value) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.settings.OAuthAutoCreateUsers = value;
|
||||
});
|
||||
}
|
||||
|
||||
onMicrosoftTenantIDChange() {
|
||||
|
||||
@@ -29,12 +29,11 @@
|
||||
</div>
|
||||
<!-- !HideInternalAuth -->
|
||||
|
||||
<auto-user-provision-toggle ng-model="$ctrl.settings.OAuthAutoCreateUsers">
|
||||
<field-description>
|
||||
With automatic user provisioning enabled, Portainer will create user(s) automatically with the standard user role. If disabled, users must be created beforehand in Portainer
|
||||
in order to login.
|
||||
</field-description>
|
||||
</auto-user-provision-toggle>
|
||||
<auto-user-provision-toggle
|
||||
value="$ctrl.settings.OAuthAutoCreateUsers"
|
||||
on-change="($ctrl.onAutoUserProvisionChange)"
|
||||
description="'With automatic user provisioning enabled, Portainer will create user(s) automatically with the standard user role. If disabled, users must be created beforehand in Portainer in order to login.'"
|
||||
></auto-user-provision-toggle>
|
||||
|
||||
<div ng-if="$ctrl.settings.OAuthAutoCreateUsers">
|
||||
<div class="form-group">
|
||||
@@ -101,7 +100,7 @@
|
||||
|
||||
<div ng-if="$ctrl.settings.OAuthAutoMapTeamMemberships">
|
||||
<div class="form-group">
|
||||
<label class="col-sm-3 col-lg-2 control-label text-left">
|
||||
<label class="col-sm-3 col-lg-2 control-label text-left" for="oauth_token_claim_name">
|
||||
Claim name
|
||||
<portainer-tooltip message="'The OpenID Connect UserInfo Claim name that contains the team identifier the user belongs to.'"></portainer-tooltip>
|
||||
</label>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { FallbackImage } from '@@/FallbackImage';
|
||||
import { BadgeIcon } from '@@/BadgeIcon';
|
||||
import { TeamsSelector } from '@@/TeamsSelector';
|
||||
import { TerminalTooltip } from '@@/TerminalTooltip';
|
||||
import { Terminal } from '@@/Terminal/Terminal';
|
||||
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||
import { Slider } from '@@/form-components/Slider';
|
||||
import { TagButton } from '@@/TagButton';
|
||||
@@ -269,7 +270,17 @@ export const ngModule = angular
|
||||
'inlineLoader',
|
||||
r2a(InlineLoader, ['children', 'className', 'size'])
|
||||
)
|
||||
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, []));
|
||||
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, []))
|
||||
.component(
|
||||
'shellTerminal',
|
||||
r2a(Terminal, [
|
||||
'url',
|
||||
'connect',
|
||||
'onStateChange',
|
||||
'onResize',
|
||||
'initialCommands',
|
||||
])
|
||||
);
|
||||
|
||||
export const componentsModule = ngModule.name;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import angular from 'angular';
|
||||
|
||||
import { SettingsOpenAMT } from '@/react/portainer/settings/EdgeComputeView/SettingsOpenAMT';
|
||||
import { InternalAuth } from '@/react/portainer/settings/AuthenticationView/InternalAuth';
|
||||
import { AuthenticationMethodSelector } from '@/react/portainer/settings/AuthenticationView/AuthenticationMethodSelector';
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
@@ -13,14 +14,24 @@ import { HelmCertPanel } from '@/react/portainer/settings/SettingsView/HelmCertP
|
||||
import { HiddenContainersPanel } from '@/react/portainer/settings/SettingsView/HiddenContainersPanel/HiddenContainersPanel';
|
||||
import { SSLSettingsPanelWrapper } from '@/react/portainer/settings/SettingsView/SSLSettingsPanel/SSLSettingsPanel';
|
||||
import { AuthStyleField } from '@/react/portainer/settings/AuthenticationView/OAuth';
|
||||
import { AutoUserProvisionToggle } from '@/react/portainer/settings/AuthenticationView/AutoUserProvisionToggle/AutoUserProvisionToggle';
|
||||
import { SessionLifetimeSelect } from '@/react/portainer/settings/AuthenticationView/SessionLifetimeSelect';
|
||||
|
||||
export const settingsModule = angular
|
||||
.module('portainer.app.react.components.settings', [])
|
||||
.component('settingsOpenAmt', r2a(SettingsOpenAMT, ['onSubmit', 'settings']))
|
||||
.component(
|
||||
'sessionLifetimeSelect',
|
||||
r2a(SessionLifetimeSelect, ['value', 'onChange'])
|
||||
)
|
||||
.component(
|
||||
'internalAuth',
|
||||
r2a(InternalAuth, ['onSaveSettings', 'isLoading', 'value', 'onChange'])
|
||||
)
|
||||
.component(
|
||||
'authenticationMethodSelector',
|
||||
r2a(AuthenticationMethodSelector, ['value', 'onChange'])
|
||||
)
|
||||
.component('ldapUsersDatatable', r2a(LDAPUsersTable, ['dataset']))
|
||||
.component('ldapGroupsDatatable', r2a(LDAPGroupsTable, ['dataset']))
|
||||
.component(
|
||||
@@ -50,4 +61,13 @@ export const settingsModule = angular
|
||||
'readonly',
|
||||
'size',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'autoUserProvisionToggle',
|
||||
r2a(AutoUserProvisionToggle, [
|
||||
'value',
|
||||
'onChange',
|
||||
'description',
|
||||
'data-cy',
|
||||
])
|
||||
).name;
|
||||
|
||||
@@ -19,11 +19,14 @@ export function EndpointProvider() {
|
||||
pingInterval: null,
|
||||
};
|
||||
|
||||
environmentStore.subscribe((state) => {
|
||||
if (!state.environmentId) {
|
||||
setCurrentEndpoint(null);
|
||||
environmentStore.subscribe(
|
||||
(state) => state.environmentId,
|
||||
(environmentId) => {
|
||||
if (!environmentId) {
|
||||
setCurrentEndpoint(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
return { endpointID, setCurrentEndpoint, currentEndpoint, clean };
|
||||
|
||||
@@ -50,9 +53,7 @@ export function EndpointProvider() {
|
||||
);
|
||||
}
|
||||
|
||||
document.title = endpoint
|
||||
? `${DEFAULT_TITLE} | ${endpoint.Name}`
|
||||
: `${DEFAULT_TITLE}`;
|
||||
document.title = endpoint ? `${endpoint.Name}` : `${DEFAULT_TITLE}`;
|
||||
}
|
||||
|
||||
function currentEndpoint() {
|
||||
|
||||
@@ -42,7 +42,7 @@ export function notifyWarning(title: string, text: string) {
|
||||
toastr.warning(sanitize(_.escape(text)), sanitize(title), { timeOut: 6000 });
|
||||
}
|
||||
|
||||
export function notifyError(title: string, e?: Error, fallbackText = '') {
|
||||
export function notifyError(title: string, e?: unknown, fallbackText = '') {
|
||||
const msg = pickErrorMsg(e) || fallbackText;
|
||||
saveNotification(title, msg, 'error');
|
||||
|
||||
@@ -69,7 +69,7 @@ export function Notifications() {
|
||||
};
|
||||
}
|
||||
|
||||
function pickErrorMsg(e?: Error) {
|
||||
function pickErrorMsg(e?: unknown) {
|
||||
if (!e) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<div class="col-sm-12 form-section-title"> Automatic user provisioning </div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small" ng-transclude="description"> </span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 vertical-center">
|
||||
<label for="tls" class="control-label !pt-0 text-left"> Automatic user provisioning </label>
|
||||
<label class="switch my-0 ml-6">
|
||||
<input type="checkbox" ng-model="$ctrl.ngModel" data-cy="auto-user-provision-toggle" />
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,9 +0,0 @@
|
||||
export const autoUserProvisionToggle = {
|
||||
templateUrl: './auto-user-provision-toggle.html',
|
||||
transclude: {
|
||||
description: 'fieldDescription',
|
||||
},
|
||||
bindings: {
|
||||
ngModel: '=',
|
||||
},
|
||||
};
|
||||
@@ -1,10 +1,8 @@
|
||||
import angular from 'angular';
|
||||
import ldapModule from './ldap';
|
||||
import { autoUserProvisionToggle } from './auto-user-provision-toggle';
|
||||
import { saveAuthSettingsButton } from './save-auth-settings-button';
|
||||
|
||||
export default angular
|
||||
.module('portainer.settings.authentication', [ldapModule])
|
||||
|
||||
.component('saveAuthSettingsButton', saveAuthSettingsButton)
|
||||
.component('autoUserProvisionToggle', autoUserProvisionToggle).name;
|
||||
.component('saveAuthSettingsButton', saveAuthSettingsButton).name;
|
||||
|
||||
@@ -5,8 +5,9 @@ import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.ser
|
||||
|
||||
export default class AdSettingsController {
|
||||
/* @ngInject */
|
||||
constructor(LDAPService) {
|
||||
constructor(LDAPService, $scope) {
|
||||
this.LDAPService = LDAPService;
|
||||
this.$scope = $scope;
|
||||
|
||||
this.domainSuffix = '';
|
||||
this.limitedFeatureId = FeatureId.HIDE_INTERNAL_AUTH;
|
||||
@@ -15,6 +16,14 @@ export default class AdSettingsController {
|
||||
this.searchGroups = this.searchGroups.bind(this);
|
||||
this.parseDomainName = this.parseDomainName.bind(this);
|
||||
this.onAccountChange = this.onAccountChange.bind(this);
|
||||
this.onAutoUserProvisionChange = this.onAutoUserProvisionChange.bind(this);
|
||||
this.onAutoUserProvisionChange = this.onAutoUserProvisionChange.bind(this);
|
||||
}
|
||||
|
||||
onAutoUserProvisionChange(value) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.settings.AutoCreateUsers = value;
|
||||
});
|
||||
}
|
||||
|
||||
parseDomainName(account) {
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
<div class="be-indicator-container">
|
||||
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeatureId"></be-feature-indicator></div>
|
||||
<div class="limited-be-content">
|
||||
<auto-user-provision-toggle ng-model="$ctrl.settings.AutoCreateUsers">
|
||||
<field-description>
|
||||
With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role and assign them to team(s) which matches to LDAP group
|
||||
name(s). If disabled, users must be created in Portainer beforehand.
|
||||
</field-description>
|
||||
</auto-user-provision-toggle>
|
||||
<auto-user-provision-toggle
|
||||
value="$ctrl.settings.AutoCreateUsers"
|
||||
on-change="($ctrl.onAutoUserProvisionChange)"
|
||||
description="'With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role and assign them to team(s) which matches to LDAP group name(s). If disabled, users must be created in Portainer beforehand.'"
|
||||
></auto-user-provision-toggle>
|
||||
|
||||
<div>
|
||||
<div class="col-sm-12 form-section-title"> Information </div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="form-group">
|
||||
<label for="ldap_password" class="col-sm-3 col-lg-2 control-label vertical-center text-left">
|
||||
<label class="col-sm-3 col-lg-2 control-label vertical-center text-left">
|
||||
Connectivity check
|
||||
<pr-icon icon="'check'" mode="'success'" ng-if="$ctrl.state.successfulConnectivityCheck"></pr-icon>
|
||||
<pr-icon icon="'x'" mode="'danger'" ng-if="$ctrl.state.failedConnectivityCheck"></pr-icon>
|
||||
|
||||
@@ -1,84 +1,86 @@
|
||||
<div class="col-sm-12 form-section-title" style="float: initial"> Group search configurations </div>
|
||||
<section aria-label="Group search configurations">
|
||||
<div class="col-sm-12 form-section-title" style="float: initial"> Group search configurations </div>
|
||||
|
||||
<rd-widget ng-repeat="config in $ctrl.settings | limitTo: (1 - $ctrl.settings)" 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_group_basedn_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
|
||||
Group Base DN
|
||||
<portainer-tooltip 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_group_basedn_{{ $index }}"
|
||||
ng-model="config.GroupBaseDN"
|
||||
placeholder="dc=ldap,dc=domain,dc=tld"
|
||||
data-cy="ldap-group-basedn-input"
|
||||
/>
|
||||
<rd-widget ng-repeat="config in $ctrl.settings | limitTo: (1 - $ctrl.settings)" style="display: block; margin-bottom: 10px" data-cy="ldap-custom-group-search-config">
|
||||
<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>
|
||||
|
||||
<label for="ldap_group_att_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
|
||||
Group Membership Attribute
|
||||
<portainer-tooltip 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_group_att_{{ $index }}" ng-model="config.GroupAttribute" placeholder="member" data-cy="ldap-group-attribute-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ldap_group_filter_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
|
||||
Group Filter
|
||||
<portainer-tooltip message="'The LDAP search filter used to select group elements, optional.'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-8 col-md-10 vertical-center">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="ldap_group_filter_{{ $index }}"
|
||||
ng-model="config.GroupFilter"
|
||||
placeholder="(objectClass=account)"
|
||||
data-cy="ldap-group-filter-input"
|
||||
/>
|
||||
<button class="btn btn-md btn-danger" type="button" ng-click="$ctrl.onRemoveClick($index)" ng-if="$index > 0">
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 small" style="color: #ffa719">
|
||||
<pr-icon icon="'briefcase'" class-name="'icon icon-xs vertical-center'"></pr-icon>
|
||||
Users removal synchronize between groups and teams only available in
|
||||
<a href="https://www.portainer.io/features?from=custom-login-banner" target="_blank">business edition.</a>
|
||||
<portainer-tooltip
|
||||
class="text-muted align-bottom"
|
||||
message="'Groups allows users to automatically be added to Portainer teams. However, automatically removing users from teams to keep it fully in sync is available in the Business Edition.'"
|
||||
></portainer-tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
<div class="form-group">
|
||||
<label for="ldap_group_basedn_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
|
||||
Group Base DN
|
||||
<portainer-tooltip 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_group_basedn_{{ $index }}"
|
||||
ng-model="config.GroupBaseDN"
|
||||
placeholder="dc=ldap,dc=domain,dc=tld"
|
||||
data-cy="ldap-group-basedn-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 10px">
|
||||
<div class="col-sm-12">
|
||||
<button class="btn btn-sm btn-light vertical-center !ml-0" ng-click="$ctrl.onAddClick()">
|
||||
<pr-icon icon="'plus'"></pr-icon>
|
||||
Add group search configuration
|
||||
</button>
|
||||
<label for="ldap_group_att_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
|
||||
Group Membership Attribute
|
||||
<portainer-tooltip 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_group_att_{{ $index }}" ng-model="config.GroupAttribute" placeholder="member" data-cy="ldap-group-attribute-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ldap_group_filter_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
|
||||
Group Filter
|
||||
<portainer-tooltip message="'The LDAP search filter used to select group elements, optional.'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-8 col-md-10 vertical-center">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="ldap_group_filter_{{ $index }}"
|
||||
ng-model="config.GroupFilter"
|
||||
placeholder="(objectClass=account)"
|
||||
data-cy="ldap-group-filter-input"
|
||||
/>
|
||||
<button class="btn btn-md btn-danger" type="button" ng-click="$ctrl.onRemoveClick($index)" ng-if="$index > 0">
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 small" style="color: #ffa719">
|
||||
<pr-icon icon="'briefcase'" class-name="'icon icon-xs vertical-center'"></pr-icon>
|
||||
Users removal synchronize between groups and teams only available in
|
||||
<a href="https://www.portainer.io/features?from=custom-login-banner" target="_blank">business edition.</a>
|
||||
<portainer-tooltip
|
||||
class="text-muted align-bottom"
|
||||
message="'Groups allows users to automatically be added to Portainer teams. However, automatically removing users from teams to keep it fully in sync is available in the Business Edition.'"
|
||||
></portainer-tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
|
||||
<div class="form-group" style="margin-top: 10px">
|
||||
<div class="col-sm-12">
|
||||
<button class="btn btn-sm btn-light vertical-center !ml-0" ng-click="$ctrl.onAddClick()">
|
||||
<pr-icon icon="'plus'"></pr-icon>
|
||||
Add group search configuration
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-sm-12" style="margin-top: 10px">
|
||||
<be-teaser-button
|
||||
feature-id="$ctrl.limitedFeatureId"
|
||||
heading="'Display User/Group matching'"
|
||||
message="'Show the list of users and groups that match the Portainer search configurations.'"
|
||||
button-text="'Display User/Group matching'"
|
||||
button-class-name="'!ml-0'"
|
||||
></be-teaser-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12" style="margin-top: 10px">
|
||||
<be-teaser-button
|
||||
feature-id="$ctrl.limitedFeatureId"
|
||||
heading="'Display User/Group matching'"
|
||||
message="'Show the list of users and groups that match the Portainer search configurations.'"
|
||||
button-text="'Display User/Group matching'"
|
||||
button-class-name="'!ml-0'"
|
||||
></be-teaser-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ldap-groups-datatable ng-if="$ctrl.showTable" dataset="$ctrl.groups"></ldap-groups-datatable>
|
||||
<ldap-groups-datatable ng-if="$ctrl.showTable" dataset="$ctrl.groups"></ldap-groups-datatable>
|
||||
</section>
|
||||
|
||||
@@ -1,59 +1,68 @@
|
||||
<div class="col-sm-12 form-section-title" style="float: initial"> User search configurations </div>
|
||||
<section aria-label="User search configurations">
|
||||
<div class="col-sm-12 form-section-title" style="float: initial"> User search configurations </div>
|
||||
|
||||
<rd-widget ng-repeat="config in $ctrl.settings | limitTo: (1 - $ctrl.settings)" 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_basedn_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
|
||||
Base DN
|
||||
<portainer-tooltip message="'The distinguished name of the element from which the LDAP server will search for users.'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-8 col-md-4">
|
||||
<input type="text" class="form-control" id="ldap_basedn_{{ $index }}" ng-model="config.BaseDN" placeholder="dc=ldap,dc=domain,dc=tld" data-cy="ldap-basedn-input" />
|
||||
<rd-widget ng-repeat="config in $ctrl.settings | limitTo: (1 - $ctrl.settings)" style="display: block; margin-bottom: 10px" data-cy="ldap-custom-user-search-config">
|
||||
<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>
|
||||
|
||||
<label for="ldap_username_att_{{ $index }}" class="col-sm-4 col-md-3 col-lg-2 control-label text-left">
|
||||
Username attribute
|
||||
<portainer-tooltip message="'LDAP attribute which denotes the username.'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-8 col-md-3 col-lg-4">
|
||||
<input type="text" class="form-control" id="ldap_username_att_{{ $index }}" ng-model="config.UserNameAttribute" placeholder="uid" data-cy="ldap-username-attribute-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ldap_filter_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
|
||||
Filter
|
||||
<portainer-tooltip message="'The LDAP search filter used to select user elements, optional.'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-8 col-md-10 vertical-center">
|
||||
<input type="text" class="form-control" id="ldap_filter_{{ $index }}" ng-model="config.Filter" placeholder="(objectClass=account)" data-cy="ldap-filter-input" />
|
||||
<button class="btn btn-md btn-danger" type="button" ng-click="$ctrl.onRemoveClick($index)" ng-if="$index > 0">
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
<div class="form-group">
|
||||
<label for="ldap_basedn_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
|
||||
Base DN
|
||||
<portainer-tooltip message="'The distinguished name of the element from which the LDAP server will search for users.'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-8 col-md-4">
|
||||
<input type="text" class="form-control" id="ldap_basedn_{{ $index }}" ng-model="config.BaseDN" placeholder="dc=ldap,dc=domain,dc=tld" data-cy="ldap-basedn-input" />
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 10px">
|
||||
<div class="col-sm-12">
|
||||
<button class="btn btn-sm btn-light vertical-center !ml-0" ng-click="$ctrl.onAddClick()">
|
||||
<pr-icon icon="'plus'"></pr-icon>
|
||||
Add user search configuration
|
||||
</button>
|
||||
<label for="ldap_username_att_{{ $index }}" class="col-sm-4 col-md-3 col-lg-2 control-label text-left">
|
||||
Username attribute
|
||||
<portainer-tooltip message="'LDAP attribute which denotes the username.'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-8 col-md-3 col-lg-4">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="ldap_username_att_{{ $index }}"
|
||||
ng-model="config.UserNameAttribute"
|
||||
placeholder="uid"
|
||||
data-cy="ldap-username-attribute-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ldap_filter_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
|
||||
Filter
|
||||
<portainer-tooltip message="'The LDAP search filter used to select user elements, optional.'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-8 col-md-10 vertical-center">
|
||||
<input type="text" class="form-control" id="ldap_filter_{{ $index }}" ng-model="config.Filter" placeholder="(objectClass=account)" data-cy="ldap-filter-input" />
|
||||
<button class="btn btn-md btn-danger" type="button" ng-click="$ctrl.onRemoveClick($index)" ng-if="$index > 0">
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
|
||||
<div class="form-group" style="margin-top: 10px">
|
||||
<div class="col-sm-12">
|
||||
<button class="btn btn-sm btn-light vertical-center !ml-0" ng-click="$ctrl.onAddClick()">
|
||||
<pr-icon icon="'plus'"></pr-icon>
|
||||
Add user search configuration
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-sm-12" style="margin-top: 10px">
|
||||
<be-teaser-button
|
||||
feature-id="$ctrl.limitedFeatureId"
|
||||
heading="'Display Users'"
|
||||
message="'Allows you to display users from your LDAP server.'"
|
||||
button-text="'Display Users'"
|
||||
button-class-name="'!ml-0'"
|
||||
></be-teaser-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12" style="margin-top: 10px">
|
||||
<be-teaser-button
|
||||
feature-id="$ctrl.limitedFeatureId"
|
||||
heading="'Display Users'"
|
||||
message="'Allows you to display users from your LDAP server.'"
|
||||
button-text="'Display Users'"
|
||||
button-class-name="'!ml-0'"
|
||||
></be-teaser-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ldap-users-datatable ng-if="$ctrl.showTable" dataset="$ctrl.users"></ldap-users-datatable>
|
||||
<ldap-users-datatable ng-if="$ctrl.showTable" dataset="$ctrl.users"></ldap-users-datatable>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="w-full px-5 pt-3">
|
||||
<div class="w-full px-5 pt-3" data-cy="ldap-group-search-config">
|
||||
<div ng-if="$ctrl.index > 0" style="margin-bottom: 10px">
|
||||
<span class="text-muted small"> Extra search configuration </span>
|
||||
<button
|
||||
@@ -28,10 +28,10 @@
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 vertical-center" style="margin-bottom: 5px">
|
||||
<label class="control-label !pt-0 text-left">Groups</label>
|
||||
<span class="label label-default interactive vertical-center" style="margin-left: 10px" ng-click="$ctrl.addGroup()">
|
||||
<button class="label label-default interactive vertical-center border-0" style="margin-left: 10px" ng-click="$ctrl.addGroup()">
|
||||
<pr-icon icon="'plus-circle'"></pr-icon>
|
||||
add another group
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-sm-12" ng-if="$ctrl.groups.length">
|
||||
<div class="w-full px-5 pt-3">
|
||||
@@ -76,6 +76,6 @@
|
||||
|
||||
<div class="form-group no-margin-last-child">
|
||||
<label class="col-sm-4 col-md-2 control-label text-left"> Group Filter </label>
|
||||
<div class="col-sm-8 col-md-10"> {{ $ctrl.config.GroupFilter }} </div>
|
||||
<div class="col-sm-8 col-md-10" data-cy="ldap-group-filter-display"> {{ $ctrl.config.GroupFilter }} </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
<div class="col-sm-12 form-section-title" style="float: initial"> Group search configurations </div>
|
||||
<section aria-label="Group search configurations">
|
||||
<div class="col-sm-12 form-section-title" style="float: initial"> Group search configurations </div>
|
||||
|
||||
<div style="margin-top: 10px" ng-repeat="config in $ctrl.settings | limitTo: (1 - $ctrl.settings)">
|
||||
<ldap-group-search-item
|
||||
config="config"
|
||||
domain-suffix="{{ $ctrl.domainSuffix }}"
|
||||
index="$index"
|
||||
base-filter="{{ $ctrl.baseFilter }}"
|
||||
on-remove-click="($ctrl.onRemoveClick)"
|
||||
limited-feature-id="$ctrl.limitedFeatureId"
|
||||
></ldap-group-search-item>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 10px">
|
||||
<div class="col-sm-12">
|
||||
<button class="btn btn-sm btn-light vertical-center !ml-0" ng-click="$ctrl.onAddClick()" disabled>
|
||||
<pr-icon icon="'plus'"></pr-icon>
|
||||
Add group search configuration
|
||||
</button>
|
||||
<div style="margin-top: 10px" ng-repeat="config in $ctrl.settings | limitTo: (1 - $ctrl.settings)">
|
||||
<ldap-group-search-item
|
||||
config="config"
|
||||
domain-suffix="{{ $ctrl.domainSuffix }}"
|
||||
index="$index"
|
||||
base-filter="{{ $ctrl.baseFilter }}"
|
||||
on-remove-click="($ctrl.onRemoveClick)"
|
||||
limited-feature-id="$ctrl.limitedFeatureId"
|
||||
></ldap-group-search-item>
|
||||
</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">
|
||||
Display User/Group matching
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ldap-groups-datatable ng-if="$ctrl.showTable" dataset="$ctrl.groups"></ldap-groups-datatable>
|
||||
<div class="form-group" style="margin-top: 10px">
|
||||
<div class="col-sm-12">
|
||||
<button class="btn btn-sm btn-light vertical-center !ml-0" ng-click="$ctrl.onAddClick()" limited-feature-dir="{{::$ctrl.limitedFeatureId}}" limited-feature-tabindex="-1">
|
||||
<pr-icon icon="'plus'"></pr-icon>
|
||||
Add group search configuration
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-sm-12" style="margin-top: 10px">
|
||||
<button class="btn btm-sm btn-primary !ml-0" type="button" ng-click="$ctrl.search()" limited-feature-dir="{{::$ctrl.limitedFeatureId}}" limited-feature-tabindex="-1">
|
||||
Display User/Group matching
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ldap-groups-datatable ng-if="$ctrl.showTable" dataset="$ctrl.groups"></ldap-groups-datatable>
|
||||
</section>
|
||||
|
||||
@@ -1,64 +1,66 @@
|
||||
<div class="col-sm-12 form-section-title flex items-center" style="float: initial">
|
||||
Test login
|
||||
<be-feature-indicator
|
||||
ng-if="$ctrl.showBeIndicatorIfNeeded"
|
||||
feature="$ctrl.limitedFeatureId"
|
||||
class="space-left"
|
||||
ng-if="$ctrl.isLimitedFeatureSelfContained"
|
||||
></be-feature-indicator>
|
||||
</div>
|
||||
<section aria-label="Test login">
|
||||
<div class="col-sm-12 form-section-title flex items-center" style="float: initial">
|
||||
Test login
|
||||
<be-feature-indicator
|
||||
ng-if="$ctrl.showBeIndicatorIfNeeded"
|
||||
feature="$ctrl.limitedFeatureId"
|
||||
class="space-left"
|
||||
ng-if="$ctrl.isLimitedFeatureSelfContained"
|
||||
></be-feature-indicator>
|
||||
</div>
|
||||
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="form-inline">
|
||||
<div class="form-group" style="margin: 0">
|
||||
<label for="ldap_test_username" style="font-size: 0.9em; margin-right: 5px"> Username </label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="ldap_test_username"
|
||||
ng-model="$ctrl.username"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
|
||||
limited-feature-tabindex="-1"
|
||||
data-cy="ldap-test-username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group no-margin">
|
||||
<label for="ldap_test_password"> Password </label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="ldap_test_password"
|
||||
ng-model="$ctrl.password"
|
||||
autocomplete="new-password"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
|
||||
limited-feature-tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group !ml-0">
|
||||
<div class="vertical-center">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
ng-disabled="$ctrl.state.testStatus === $ctrl.TEST_STATUS.LOADING || !$ctrl.username || !$ctrl.password"
|
||||
ng-click="$ctrl.testLogin($ctrl.username, $ctrl.password)"
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="form-inline">
|
||||
<div class="form-group" style="margin: 0">
|
||||
<label for="ldap_test_username" style="font-size: 0.9em; margin-right: 5px"> Username </label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="ldap_test_username"
|
||||
ng-model="$ctrl.username"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
<span ng-if="$ctrl.state.testStatus !== $ctrl.TEST_STATUS.LOADING">Test</span>
|
||||
<span ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.LOADING">Testing...</span>
|
||||
</button>
|
||||
<pr-icon icon="'check'" class="icon-success" ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.SUCCESS"></pr-icon>
|
||||
<pr-icon icon="'x'" class="icon-danger" ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.FAILURE"></pr-icon>
|
||||
data-cy="ldap-test-username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group no-margin">
|
||||
<label for="ldap_test_password"> Password </label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="ldap_test_password"
|
||||
ng-model="$ctrl.password"
|
||||
autocomplete="new-password"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
|
||||
limited-feature-tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group !ml-0">
|
||||
<div class="vertical-center">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
ng-disabled="$ctrl.state.testStatus === $ctrl.TEST_STATUS.LOADING || !$ctrl.username || !$ctrl.password"
|
||||
ng-click="$ctrl.testLogin($ctrl.username, $ctrl.password)"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
<span ng-if="$ctrl.state.testStatus !== $ctrl.TEST_STATUS.LOADING">Test</span>
|
||||
<span ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.LOADING">Testing...</span>
|
||||
</button>
|
||||
<pr-icon icon="'check'" class="icon-success" ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.SUCCESS"></pr-icon>
|
||||
<pr-icon icon="'x'" class="icon-danger" ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.FAILURE"></pr-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</section>
|
||||
|
||||
@@ -12,8 +12,8 @@ const DEFAULT_USER_FILTER = '(objectClass=inetOrgPerson)';
|
||||
|
||||
export default class LdapSettingsController {
|
||||
/* @ngInject */
|
||||
constructor(LDAPService) {
|
||||
Object.assign(this, { LDAPService, SERVER_TYPES });
|
||||
constructor(LDAPService, $scope) {
|
||||
Object.assign(this, { LDAPService, SERVER_TYPES, $scope });
|
||||
|
||||
this.tlscaCert = null;
|
||||
this.settingsDrafts = {};
|
||||
@@ -24,8 +24,15 @@ export default class LdapSettingsController {
|
||||
this.searchUsers = this.searchUsers.bind(this);
|
||||
this.searchGroups = this.searchGroups.bind(this);
|
||||
this.onChangeServerType = this.onChangeServerType.bind(this);
|
||||
this.onAutoUserProvisionChange = this.onAutoUserProvisionChange.bind(this);
|
||||
this.onAutoUserProvisionChange = this.onAutoUserProvisionChange.bind(this);
|
||||
}
|
||||
|
||||
onAutoUserProvisionChange(value) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.settings.AutoCreateUsers = value;
|
||||
});
|
||||
}
|
||||
onTlscaCertChange(file) {
|
||||
this.tlscaCert = file;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
<div>
|
||||
<auto-user-provision-toggle ng-model="$ctrl.settings.AutoCreateUsers">
|
||||
<field-description>
|
||||
With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role and assign them to team(s) which matches to LDAP group name(s).
|
||||
If disabled, users must be created in Portainer beforehand.
|
||||
</field-description>
|
||||
</auto-user-provision-toggle>
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Server Type </div>
|
||||
<auto-user-provision-toggle
|
||||
value="$ctrl.settings.AutoCreateUsers"
|
||||
on-change="($ctrl.onAutoUserProvisionChange)"
|
||||
description="'With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role and assign them to team(s) which matches to LDAP group name(s). If disabled, users must be created in Portainer beforehand.'"
|
||||
></auto-user-provision-toggle>
|
||||
|
||||
<box-selector
|
||||
style="margin-bottom: 0"
|
||||
@@ -15,6 +12,7 @@
|
||||
options="$ctrl.boxSelectorOptions"
|
||||
on-change="($ctrl.onChangeServerType)"
|
||||
slim="true"
|
||||
label="'Server Type'"
|
||||
></box-selector>
|
||||
|
||||
<ldap-settings-custom
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="w-full px-5 pt-3">
|
||||
<div class="w-full px-5 pt-3" data-cy="ldap-user-search-config">
|
||||
<div ng-if="$ctrl.index > 0" style="margin-bottom: 10px">
|
||||
<span class="text-muted small"> Extra search configuration </span>
|
||||
<button
|
||||
@@ -92,7 +92,7 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-4 control-label text-left"> User Filter </label>
|
||||
<div class="col-sm-8">
|
||||
<div class="col-sm-8" data-cy="ldap-user-filter-display">
|
||||
{{ $ctrl.config.Filter }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,37 @@
|
||||
<div class="col-sm-12 form-section-title" style="float: initial"> User search configurations </div>
|
||||
<section aria-label="User search configurations">
|
||||
<div class="col-sm-12 form-section-title" style="float: initial"> User search configurations </div>
|
||||
|
||||
<div style="margin-top: 10px" ng-repeat="config in $ctrl.settings | limitTo: (1 - $ctrl.settings)">
|
||||
<ldap-user-search-item
|
||||
index="$index"
|
||||
config="config"
|
||||
domain-suffix="{{ $ctrl.domainSuffix }}"
|
||||
show-username-format="$ctrl.showUsernameFormat"
|
||||
base-filter="{{ $ctrl.baseFilter }}"
|
||||
on-remove-click="($ctrl.onRemoveClick)"
|
||||
limited-feature-id="$ctrl.limitedFeatureId"
|
||||
></ldap-user-search-item>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 10px">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
class="label label-default interactive vertical-center"
|
||||
style="border: 0"
|
||||
ng-click="$ctrl.onAddClick()"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
<pr-icon icon="'plus-circle'"></pr-icon>
|
||||
Add user search configuration
|
||||
</button>
|
||||
<div style="margin-top: 10px" ng-repeat="config in $ctrl.settings | limitTo: (1 - $ctrl.settings)">
|
||||
<ldap-user-search-item
|
||||
index="$index"
|
||||
config="config"
|
||||
domain-suffix="{{ $ctrl.domainSuffix }}"
|
||||
show-username-format="$ctrl.showUsernameFormat"
|
||||
base-filter="{{ $ctrl.baseFilter }}"
|
||||
on-remove-click="($ctrl.onRemoveClick)"
|
||||
limited-feature-id="$ctrl.limitedFeatureId"
|
||||
></ldap-user-search-item>
|
||||
</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">
|
||||
Display Users
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ldap-users-datatable ng-if="$ctrl.showTable" dataset="$ctrl.users"></ldap-users-datatable>
|
||||
<div class="form-group" style="margin-top: 10px">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
class="label label-default interactive vertical-center"
|
||||
style="border: 0"
|
||||
ng-click="$ctrl.onAddClick()"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
<pr-icon icon="'plus-circle'"></pr-icon>
|
||||
Add user 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">
|
||||
Display Users
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ldap-users-datatable ng-if="$ctrl.showTable" dataset="$ctrl.users"></ldap-users-datatable>
|
||||
</section>
|
||||
|
||||
241
app/portainer/views/settings/authentication/MIGRATION_PLAN.md
Normal file
241
app/portainer/views/settings/authentication/MIGRATION_PLAN.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# Migration Plan: Authentication Settings
|
||||
|
||||
**Linear Issue**: [BE-6604](https://linear.app/portainer/issue/BE-6604/migrate-portainersettingsauthentication)
|
||||
**Project**: Migrate portainer/settings views to react
|
||||
**Estimate**: 5 Points
|
||||
**Status**: Draft → Planning
|
||||
**Strategy**: See `.claude/skills/angular-react-migration-strategy/SKILL.md`
|
||||
**Original Component Path**: `package/[ce|ee]/app/portainer/views/settings/authentication/`
|
||||
**Target Migration Path**: `app/react/portainer/settings/AuthenticationView/`
|
||||
|
||||
## Current Structure Visualization
|
||||
|
||||
```
|
||||
PageHeader ✅ React component
|
||||
Widget ✅ React component
|
||||
Form
|
||||
SessionLifetimeSection
|
||||
Select (dropdown) - needs wrapper
|
||||
WarningMessage ✅ React component (Icon + text)
|
||||
|
||||
AuthenticationMethodSection
|
||||
BoxSelector ✅ React component
|
||||
|
||||
InternalAuth ✅ React component (already exists!)
|
||||
PasswordLengthSlider ✅
|
||||
SaveAuthSettingsButton ✅
|
||||
|
||||
LdapSettings (Angular - needs migration)
|
||||
AutoUserProvisionToggle (Angular - needs component)
|
||||
BoxSelector ✅ React component
|
||||
LdapSettingsCustom (Angular - needs migration)
|
||||
Multiple input fields, TLS config, connectivity check
|
||||
LdapSettingsDnBuilder (Angular - needs component)
|
||||
LdapSettingsGroupDnBuilder (Angular - needs component)
|
||||
LdapSettingsSecurity (Angular - needs component)
|
||||
LdapSettingsTestLogin (Angular - needs component)
|
||||
LdapSettingsOpenLdap (Angular - needs migration)
|
||||
Similar structure to Custom
|
||||
[EE] AdminGroupsMultiSelect (needs component)
|
||||
|
||||
AdSettings (Angular - needs migration)
|
||||
Similar to LdapSettings with AD-specific fields
|
||||
[EE] BindTypeSelector (kerberos vs simple)
|
||||
[EE] KerberosConfig (needs component)
|
||||
|
||||
OAuthSettings (Angular - needs migration)
|
||||
SwitchField ✅ React component
|
||||
TeamSelector (Angular - needs component)
|
||||
ClaimMappingsTable (Angular - needs component)
|
||||
[EE] AdminAutoPopulate (needs component)
|
||||
```
|
||||
|
||||
## EE vs CE Differences
|
||||
|
||||
**EE-specific features:**
|
||||
|
||||
- LDAP: `selected-admin-groups` binding (line 50, 62 in EE template)
|
||||
- LDAP: Admin auto-populate with group selection
|
||||
- AD: Kerberos bind type (simple vs kerberos)
|
||||
- AD: Kerberos configuration fields (Realm, Username, Password, Configuration)
|
||||
- OAuth: Admin group claims regex list mapping
|
||||
- OAuth: Admin auto-populate toggle
|
||||
|
||||
**Controller differences:**
|
||||
|
||||
- EE has additional validation: `isOAuthAdminMappingFormValid()` (lines 270-281)
|
||||
- EE has Kerberos initialization logic (lines 299-308)
|
||||
- EE has admin groups handling in `prepareLDAPSettings()` (lines 207-215)
|
||||
- EE has Kerberos settings cleanup (lines 190-203)
|
||||
- EE has OAuth admin settings cleanup (lines 135-137)
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
This is a **complex, multi-step migration** with the following phases:
|
||||
|
||||
### Phase 1: Session Lifetime Section (Simple)
|
||||
|
||||
- Migrate session lifetime dropdown to React
|
||||
- Reuse existing React Select component
|
||||
- Keep warning message as-is (already using React Icon component)
|
||||
|
||||
### Phase 2: LDAP Settings (Most Complex)
|
||||
|
||||
LDAP has deeply nested Angular components that need systematic migration:
|
||||
|
||||
- Break down into smallest possible units
|
||||
- Migrate shared subcomponents first (DN builders, security config)
|
||||
- Then migrate LDAP Custom and OpenLDAP variants
|
||||
- Handle EE-specific admin groups last
|
||||
|
||||
### Phase 3: AD Settings (Complex with EE divergence)
|
||||
|
||||
- Similar to LDAP but with AD-specific fields
|
||||
- EE has additional Kerberos configuration
|
||||
- Reuse components from Phase 2 where possible
|
||||
|
||||
### Phase 4: OAuth Settings (Moderate)
|
||||
|
||||
- Has team membership mappings
|
||||
- EE has admin auto-populate
|
||||
- Table-based claim mappings
|
||||
|
||||
### Phase 5: Final Integration
|
||||
|
||||
- Create container component with Formik
|
||||
- Wire up all authentication methods
|
||||
- Handle switching between auth methods
|
||||
- Update routing
|
||||
|
||||
## Issue Hierarchy
|
||||
|
||||
```
|
||||
BE-6604 (Parent: Authentication Settings Migration)
|
||||
├── Session & Page (Parallel)
|
||||
│ ├── BE-12583: SessionLifetimeSelect
|
||||
│ └── BE-12584: AuthenticationMethodSelector
|
||||
│
|
||||
├── BE-12585: AutoUserProvisionToggle (Shared by LDAP/AD/OAuth)
|
||||
│
|
||||
├── BE-12593: LdapSettings Container ⚠️ PARENT ISSUE
|
||||
│ ├── BE-12586: LdapSettingsDnBuilder
|
||||
│ ├── BE-12587: LdapSettingsGroupDnBuilder
|
||||
│ ├── BE-12588: Extend TLSFieldset with StartTLS
|
||||
│ ├── BE-12589: LdapSettingsTestLogin
|
||||
│ ├── BE-12590: LdapSettingsCustom (🔒 blocked by 12586-89)
|
||||
│ ├── BE-12591: LdapSettingsOpenLdap (🔒 blocked by 12586-89)
|
||||
│ └── BE-12592: [EE] AdminGroupsMultiSelect
|
||||
│
|
||||
├── BE-12596: AdSettings Container ⚠️ PARENT ISSUE (🔒 blocked by LDAP shared)
|
||||
│ ├── BE-12594: [EE] BindTypeSelector
|
||||
│ └── BE-12595: [EE] KerberosConfigFields
|
||||
│
|
||||
├── BE-12600: OAuthSettings Container ⚠️ PARENT ISSUE
|
||||
│ ├── BE-12597: TeamSelector
|
||||
│ ├── BE-12598: ClaimMappingsTable
|
||||
│ └── BE-12599: [EE] AdminAutoPopulateSection
|
||||
│
|
||||
├── BE-12601: AuthenticationView Formik Container (🔒 blocked by 12593, 12596, 12600)
|
||||
│
|
||||
└── BE-12602: Final Cleanup (🔒 blocked by 12601)
|
||||
```
|
||||
|
||||
## PRs (Nested Structure)
|
||||
|
||||
### Session & Page Structure (Parallel - No Dependencies)
|
||||
|
||||
- [x] PR 1: [BE-12583](https://linear.app/portainer/issue/BE-12583) SessionLifetimeSelect → react2angular bridge
|
||||
- [x] PR 2: [BE-12584](https://linear.app/portainer/issue/BE-12584) AuthenticationMethodSelector wrapper (BoxSelector is already React)
|
||||
|
||||
### Internal Auth (Already Done!)
|
||||
|
||||
- [x] ✅ Internal Auth is already fully migrated to React
|
||||
|
||||
### [BE-12593: LDAP Settings](https://linear.app/portainer/issue/BE-12593) (Parent Issue)
|
||||
|
||||
- [x] PR 3: [BE-12585](https://linear.app/portainer/issue/BE-12585) AutoUserProvisionToggle → shared component
|
||||
- **LDAP Shared Components** (Parallel - No Dependencies)
|
||||
- [ ] PR 4: [BE-12586](https://linear.app/portainer/issue/BE-12586) LdapSettingsDnBuilder
|
||||
- [ ] PR 5: [BE-12587](https://linear.app/portainer/issue/BE-12587) LdapSettingsGroupDnBuilder
|
||||
- [ ] PR 6: [BE-12588](https://linear.app/portainer/issue/BE-12588) Extend TLSFieldset with StartTLS
|
||||
- [ ] PR 7: [BE-12589](https://linear.app/portainer/issue/BE-12589) LdapSettingsTestLogin
|
||||
- **LDAP Variants** (Blocked by PR 4-7)
|
||||
- [ ] PR 8: [BE-12590](https://linear.app/portainer/issue/BE-12590) LdapSettingsCustom
|
||||
- [ ] PR 9: [BE-12591](https://linear.app/portainer/issue/BE-12591) LdapSettingsOpenLdap
|
||||
- **EE Features**
|
||||
- [ ] PR 10: [BE-12592](https://linear.app/portainer/issue/BE-12592) [EE] AdminGroupsMultiSelect
|
||||
- **Container** (Blocked by PR 3, 8, 9, 10)
|
||||
- [ ] PR 11: [BE-12593](https://linear.app/portainer/issue/BE-12593) LdapSettings container
|
||||
|
||||
### [BE-12596: AD Settings](https://linear.app/portainer/issue/BE-12596) (Parent Issue, Blocked by LDAP shared components)
|
||||
|
||||
- **AD-Specific Components** (Parallel)
|
||||
- [ ] PR 12: [BE-12594](https://linear.app/portainer/issue/BE-12594) [EE] BindTypeSelector
|
||||
- [ ] PR 13: [BE-12595](https://linear.app/portainer/issue/BE-12595) [EE] KerberosConfigFields
|
||||
- **Container** (Blocked by PR 3, 6, 7, 10, 12, 13)
|
||||
- [ ] PR 14: [BE-12596](https://linear.app/portainer/issue/BE-12596) AdSettings container
|
||||
|
||||
### [BE-12600: OAuth Settings](https://linear.app/portainer/issue/BE-12600) (Parent Issue)
|
||||
|
||||
- **OAuth Components** (Parallel)
|
||||
- [ ] PR 3: [BE-12585](https://linear.app/portainer/issue/BE-12585) AutoUserProvisionToggle (reused)
|
||||
- [ ] PR 15: [BE-12597](https://linear.app/portainer/issue/BE-12597) TeamSelector
|
||||
- [ ] PR 16: [BE-12598](https://linear.app/portainer/issue/BE-12598) ClaimMappingsTable
|
||||
- [ ] PR 17: [BE-12599](https://linear.app/portainer/issue/BE-12599) [EE] AdminAutoPopulateSection
|
||||
- **Container** (Blocked by PR 3, 15, 16, 17)
|
||||
- [ ] PR 18: [BE-12600](https://linear.app/portainer/issue/BE-12600) OAuthSettings container
|
||||
|
||||
### Final Integration (Blocked by All Parent Issues)
|
||||
|
||||
- [ ] PR 19: [BE-12601](https://linear.app/portainer/issue/BE-12601) AuthenticationView container (Formik) - **Blocked by BE-12593, BE-12596, BE-12600**
|
||||
- [ ] PR 20: [BE-12602](https://linear.app/portainer/issue/BE-12602) Complete React migration - **Blocked by PR 19**
|
||||
|
||||
## Linear Issue Comparison
|
||||
|
||||
The Linear issue BE-6604 provides a detailed breakdown of the component structure. Our migration plan aligns with it:
|
||||
|
||||
**Linear's structure notes:**
|
||||
|
||||
- ✅ PageHeader - already React
|
||||
- ✅ Widget - already React
|
||||
- ✅ InternalAuth - already React
|
||||
- 📋 Need to migrate: ldap-settings, ad-settings, oauth-settings
|
||||
- 📋 Need to create: oauth-team-memberships-fieldset, microsoft/google/github/custom-settings
|
||||
- 📋 Shared components: auto-user-provision-toggle, ldap-connectivity-check, ldap-settings-security, ldap-settings-test-login
|
||||
|
||||
**Our approach difference:**
|
||||
|
||||
- Linear suggests creating vs migrating OAuth provider settings (microsoft, google, github, custom)
|
||||
- We'll determine during implementation if these exist in Angular and need migration
|
||||
- Our plan emphasizes bottom-up approach: migrate shared components first (DN builders, security, test login) before composing larger components
|
||||
|
||||
## Decisions
|
||||
|
||||
- **InternalAuth**: Already fully migrated to React, can reuse as-is ✅
|
||||
- **BoxSelector**: Already React component, can reuse ✅
|
||||
- **TLSFieldset**: Existing component can be extended/wrapped to add StartTLS support ✅
|
||||
- LDAP needs: StartTLS toggle + TLS toggle + skip verify + CA cert only (not cert/key)
|
||||
- Existing TLSFieldset has: TLS + skip verify + CA cert + cert + key with validation
|
||||
- Solution: Extend TLSFieldset with optional StartTLS prop, make cert/key optional
|
||||
- **Bottom-up approach**: Start with smallest components (DN builders, toggles) before composing into larger components
|
||||
- **EE handling**: Each PR must work in both CE and EE builds
|
||||
- **Form validation**: Complex validation logic in controller needs to move into React components (each component exports its validation schema)
|
||||
- **File upload**: LDAP TLS certificate upload needs special handling
|
||||
- **Testing**: Focus on unit tests for each component (1-2 tests per file)
|
||||
|
||||
## Key Challenges
|
||||
|
||||
1. **Deep nesting**: LDAP settings has 3-4 levels of nested components
|
||||
2. **Shared logic**: DN builders and security config shared between LDAP variants
|
||||
3. **EE divergence**: Significant differences in LDAP/AD/OAuth between CE and EE
|
||||
4. **Validation complexity**: Form validation depends on multiple interdependent fields
|
||||
5. **State management**: Auth method switching, form state, and validation state
|
||||
6. **File upload**: TLS certificate upload for LDAP
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- **Validation**: `isLDAPFormValid()` has complex logic checking URLs, auth mode, TLS config, admin groups
|
||||
- **Password handling**: Special logic for edit mode to avoid requiring password re-entry
|
||||
- **URL formatting**: Automatically adds port (389 or 636) if not specified
|
||||
- **Server type switching**: Switching between LDAP and AD affects which fields are shown/validated
|
||||
- **Kerberos (EE)**: Bind type selector determines which credential fields are shown
|
||||
@@ -1,27 +1,15 @@
|
||||
<page-header title="'Authentication settings'" breadcrumbs="[{label:'Settings', link:'portainer.settings'}, 'Authentication']" reload="true"> </page-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="row" ng-if="settings">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="users" title-text="Authentication"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" name="authSettingsForm">
|
||||
<div class="col-sm-12 form-section-title"> Configuration </div>
|
||||
<div class="form-group">
|
||||
<label for="user_timeout" class="col-sm-2 control-label text-left">
|
||||
Session lifetime
|
||||
<portainer-tooltip message="'Time before users are forced to relogin.'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<select
|
||||
id="user_timeout"
|
||||
data-cy="user-timeout-select"
|
||||
class="form-control"
|
||||
ng-model="settings.UserSessionTimeout"
|
||||
ng-options="opt.value as opt.key for opt in state.availableUserSessionTimeoutOptions"
|
||||
></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<session-lifetime-select value="settings.UserSessionTimeout" on-change="(onChangeSessionLifetime)"> </session-lifetime-select>
|
||||
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small vertical-center">
|
||||
<pr-icon icon="'alert-triangle'" class-name="'icon-sm icon-yellow'"></pr-icon>
|
||||
@@ -29,9 +17,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Authentication method </div>
|
||||
|
||||
<box-selector radio-name="'authOptions'" value="authMethod" options="authOptions" on-change="(onChangeAuthMethod)"></box-selector>
|
||||
<authentication-method-selector value="authMethod" on-change="(onChangeAuthMethod)"></authentication-method-selector>
|
||||
|
||||
<internal-auth
|
||||
ng-if="authenticationMethodSelected(1)"
|
||||
|
||||
@@ -2,9 +2,9 @@ import angular from 'angular';
|
||||
import _ from 'lodash-es';
|
||||
|
||||
import { buildLdapSettingsModel, buildAdSettingsModel } from '@/portainer/settings/authentication/ldap/ldap-settings.model';
|
||||
import { options } from '@/react/portainer/settings/AuthenticationView/InternalAuth/options';
|
||||
import { SERVER_TYPES } from '@/react/portainer/settings/AuthenticationView/ldap-options';
|
||||
import { AuthenticationMethod } from '@/react/portainer/settings/types';
|
||||
import { getDefaultValue as getDefaultSessionValue } from '@/react/portainer/settings/AuthenticationView/SessionLifetimeSelect';
|
||||
|
||||
angular.module('portainer.app').controller('SettingsAuthenticationController', SettingsAuthenticationController);
|
||||
|
||||
@@ -14,36 +14,10 @@ function SettingsAuthenticationController($q, $scope, $state, Notifications, Set
|
||||
$scope.state = {
|
||||
uploadInProgress: false,
|
||||
actionInProgress: false,
|
||||
availableUserSessionTimeoutOptions: [
|
||||
{
|
||||
key: '30 minutes',
|
||||
value: '30m',
|
||||
},
|
||||
{
|
||||
key: '1 hour',
|
||||
value: '1h',
|
||||
},
|
||||
{
|
||||
key: '4 hours',
|
||||
value: '4h',
|
||||
},
|
||||
{
|
||||
key: '8 hours',
|
||||
value: '8h',
|
||||
},
|
||||
{
|
||||
key: '24 hours',
|
||||
value: '24h',
|
||||
},
|
||||
{ key: '1 week', value: `${24 * 7}h` },
|
||||
{ key: '1 month', value: `${24 * 30}h` },
|
||||
{ key: '6 months', value: `${24 * 30 * 6}h` },
|
||||
{ key: '1 year', value: `${24 * 30 * 12}h` },
|
||||
],
|
||||
};
|
||||
|
||||
$scope.formValues = {
|
||||
UserSessionTimeout: $scope.state.availableUserSessionTimeoutOptions[0],
|
||||
UserSessionTimeout: getDefaultSessionValue(),
|
||||
TLSCACert: '',
|
||||
ldap: {
|
||||
serverType: 0,
|
||||
@@ -52,24 +26,23 @@ function SettingsAuthenticationController($q, $scope, $state, Notifications, Set
|
||||
},
|
||||
};
|
||||
|
||||
$scope.authOptions = options;
|
||||
|
||||
$scope.onChangeAuthMethod = function onChangeAuthMethod(value) {
|
||||
$scope.authMethod = value;
|
||||
$scope.$evalAsync(() => {
|
||||
$scope.authMethod = value;
|
||||
|
||||
if (value === 4) {
|
||||
$scope.settings.AuthenticationMethod = AuthenticationMethod.LDAP;
|
||||
$scope.formValues.ldap.serverType = SERVER_TYPES.AD;
|
||||
return;
|
||||
}
|
||||
if (value === 4) {
|
||||
$scope.settings.AuthenticationMethod = AuthenticationMethod.LDAP;
|
||||
$scope.formValues.ldap.serverType = SERVER_TYPES.AD;
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === 2) {
|
||||
$scope.settings.AuthenticationMethod = AuthenticationMethod.LDAP;
|
||||
$scope.formValues.ldap.serverType = $scope.formValues.ldap.ldapSettings.ServerType;
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.settings.AuthenticationMethod = value;
|
||||
if (value === 2) {
|
||||
$scope.settings.AuthenticationMethod = AuthenticationMethod.LDAP;
|
||||
$scope.formValues.ldap.serverType = $scope.formValues.ldap.ldapSettings.ServerType;
|
||||
return;
|
||||
}
|
||||
$scope.settings.AuthenticationMethod = value;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.onChangePasswordLength = function onChangePasswordLength(value) {
|
||||
@@ -78,6 +51,12 @@ function SettingsAuthenticationController($q, $scope, $state, Notifications, Set
|
||||
});
|
||||
};
|
||||
|
||||
$scope.onChangeSessionLifetime = function onChangeSessionLifetime(value) {
|
||||
$scope.$evalAsync(() => {
|
||||
$scope.settings.UserSessionTimeout = value;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.authenticationMethodSelected = function authenticationMethodSelected(value) {
|
||||
if (!$scope.settings) {
|
||||
return false;
|
||||
|
||||
8
app/react-table-config.d.ts
vendored
8
app/react-table-config.d.ts
vendored
@@ -1,14 +1,10 @@
|
||||
import '@tanstack/react-table';
|
||||
|
||||
declare module '@tanstack/table-core' {
|
||||
declare module '@tanstack/react-table' {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
className?: string;
|
||||
filter?: Filter<TData, TValue>;
|
||||
width?: number | 'auto' | string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface TableMeta<TData extends RowData> {
|
||||
table?: string;
|
||||
minWidth?: string;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user