mirror of
https://github.com/portainer/portainer.git
synced 2025-07-25 16:29:44 +02:00
Compare commits
1 commit
Author | SHA1 | Date | |
---|---|---|---|
|
d12d694092 |
349 changed files with 2604 additions and 13903 deletions
8
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
8
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
@ -94,11 +94,6 @@ 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.
|
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
|
multiple: false
|
||||||
options:
|
options:
|
||||||
- '2.32.0'
|
|
||||||
- '2.31.3'
|
|
||||||
- '2.31.2'
|
|
||||||
- '2.31.1'
|
|
||||||
- '2.31.0'
|
|
||||||
- '2.30.1'
|
- '2.30.1'
|
||||||
- '2.30.0'
|
- '2.30.0'
|
||||||
- '2.29.2'
|
- '2.29.2'
|
||||||
|
@ -106,9 +101,6 @@ body:
|
||||||
- '2.29.0'
|
- '2.29.0'
|
||||||
- '2.28.1'
|
- '2.28.1'
|
||||||
- '2.28.0'
|
- '2.28.0'
|
||||||
- '2.27.9'
|
|
||||||
- '2.27.8'
|
|
||||||
- '2.27.7'
|
|
||||||
- '2.27.6'
|
- '2.27.6'
|
||||||
- '2.27.5'
|
- '2.27.5'
|
||||||
- '2.27.4'
|
- '2.27.4'
|
||||||
|
|
|
@ -61,8 +61,6 @@ func CLIFlags() *portainer.CLIFlags {
|
||||||
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
|
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
|
||||||
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
|
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
|
||||||
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
|
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
|
||||||
TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(),
|
|
||||||
CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -52,7 +52,6 @@ import (
|
||||||
"github.com/portainer/portainer/pkg/libhelm"
|
"github.com/portainer/portainer/pkg/libhelm"
|
||||||
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
|
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
|
||||||
"github.com/portainer/portainer/pkg/libstack/compose"
|
"github.com/portainer/portainer/pkg/libstack/compose"
|
||||||
"github.com/portainer/portainer/pkg/validate"
|
|
||||||
|
|
||||||
"github.com/gofrs/uuid"
|
"github.com/gofrs/uuid"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
@ -331,18 +330,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||||
featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags)
|
featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags)
|
||||||
}
|
}
|
||||||
|
|
||||||
trustedOrigins := []string{}
|
|
||||||
if *flags.TrustedOrigins != "" {
|
|
||||||
// validate if the trusted origins are valid urls
|
|
||||||
for _, origin := range strings.Split(*flags.TrustedOrigins, ",") {
|
|
||||||
if !validate.IsTrustedOrigin(origin) {
|
|
||||||
log.Fatal().Str("trusted_origin", origin).Msg("invalid url for trusted origin. Please check the trusted origins flag.")
|
|
||||||
}
|
|
||||||
|
|
||||||
trustedOrigins = append(trustedOrigins, origin)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileService := initFileService(*flags.Data)
|
fileService := initFileService(*flags.Data)
|
||||||
encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName)
|
encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName)
|
||||||
if encryptionKey == nil {
|
if encryptionKey == nil {
|
||||||
|
@ -383,8 +370,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||||
|
|
||||||
gitService := git.NewService(shutdownCtx)
|
gitService := git.NewService(shutdownCtx)
|
||||||
|
|
||||||
// Setting insecureSkipVerify to true to preserve the old behaviour.
|
openAMTService := openamt.NewService()
|
||||||
openAMTService := openamt.NewService(true)
|
|
||||||
|
|
||||||
cryptoService := &crypto.Service{}
|
cryptoService := &crypto.Service{}
|
||||||
|
|
||||||
|
@ -451,7 +437,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||||
|
|
||||||
snapshotService.Start()
|
snapshotService.Start()
|
||||||
|
|
||||||
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService, jwtService)
|
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
|
||||||
|
|
||||||
helmPackageManager, err := initHelmPackageManager()
|
helmPackageManager, err := initHelmPackageManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -559,7 +545,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||||
Status: applicationStatus,
|
Status: applicationStatus,
|
||||||
BindAddress: *flags.Addr,
|
BindAddress: *flags.Addr,
|
||||||
BindAddressHTTPS: *flags.AddrHTTPS,
|
BindAddressHTTPS: *flags.AddrHTTPS,
|
||||||
CSP: *flags.CSP,
|
|
||||||
HTTPEnabled: sslDBSettings.HTTPEnabled,
|
HTTPEnabled: sslDBSettings.HTTPEnabled,
|
||||||
AssetsPath: *flags.Assets,
|
AssetsPath: *flags.Assets,
|
||||||
DataStore: dataStore,
|
DataStore: dataStore,
|
||||||
|
@ -593,7 +578,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||||
PendingActionsService: pendingActionsService,
|
PendingActionsService: pendingActionsService,
|
||||||
PlatformService: platformService,
|
PlatformService: platformService,
|
||||||
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
|
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
|
||||||
TrustedOrigins: trustedOrigins,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -138,8 +138,6 @@ func (connection *DbConnection) Open() error {
|
||||||
db, err := bolt.Open(databasePath, 0600, &bolt.Options{
|
db, err := bolt.Open(databasePath, 0600, &bolt.Options{
|
||||||
Timeout: 1 * time.Second,
|
Timeout: 1 * time.Second,
|
||||||
InitialMmapSize: connection.InitialMmapSize,
|
InitialMmapSize: connection.InitialMmapSize,
|
||||||
FreelistType: bolt.FreelistMapType,
|
|
||||||
NoFreelistSync: true,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -10,7 +10,7 @@ type BaseCRUD[T any, I constraints.Integer] interface {
|
||||||
Create(element *T) error
|
Create(element *T) error
|
||||||
Read(ID I) (*T, error)
|
Read(ID I) (*T, error)
|
||||||
Exists(ID I) (bool, error)
|
Exists(ID I) (bool, error)
|
||||||
ReadAll(predicates ...func(T) bool) ([]T, error)
|
ReadAll() ([]T, error)
|
||||||
Update(ID I, element *T) error
|
Update(ID I, element *T) error
|
||||||
Delete(ID I) error
|
Delete(ID I) error
|
||||||
}
|
}
|
||||||
|
@ -56,13 +56,12 @@ func (service BaseDataService[T, I]) Exists(ID I) (bool, error) {
|
||||||
return exists, err
|
return exists, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadAll retrieves all the elements that satisfy all the provided predicates.
|
func (service BaseDataService[T, I]) ReadAll() ([]T, error) {
|
||||||
func (service BaseDataService[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) {
|
|
||||||
var collection = make([]T, 0)
|
var collection = make([]T, 0)
|
||||||
|
|
||||||
return collection, service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
return collection, service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
var err error
|
var err error
|
||||||
collection, err = service.Tx(tx).ReadAll(predicates...)
|
collection, err = service.Tx(tx).ReadAll()
|
||||||
|
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,92 +0,0 @@
|
||||||
package dataservices
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/slicesx"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
type testObject struct {
|
|
||||||
ID int
|
|
||||||
Value int
|
|
||||||
}
|
|
||||||
|
|
||||||
type mockConnection struct {
|
|
||||||
store map[int]testObject
|
|
||||||
|
|
||||||
portainer.Connection
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m mockConnection) UpdateObject(bucket string, key []byte, value interface{}) error {
|
|
||||||
obj := value.(*testObject)
|
|
||||||
|
|
||||||
m.store[obj.ID] = *obj
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m mockConnection) GetAll(bucketName string, obj any, appendFn func(o any) (any, error)) error {
|
|
||||||
for _, v := range m.store {
|
|
||||||
if _, err := appendFn(&v); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m mockConnection) UpdateTx(fn func(portainer.Transaction) error) error {
|
|
||||||
return fn(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m mockConnection) ViewTx(fn func(portainer.Transaction) error) error {
|
|
||||||
return fn(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m mockConnection) ConvertToKey(v int) []byte {
|
|
||||||
return []byte(strconv.Itoa(v))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadAll(t *testing.T) {
|
|
||||||
service := BaseDataService[testObject, int]{
|
|
||||||
Bucket: "testBucket",
|
|
||||||
Connection: mockConnection{store: make(map[int]testObject)},
|
|
||||||
}
|
|
||||||
|
|
||||||
data := []testObject{
|
|
||||||
{ID: 1, Value: 1},
|
|
||||||
{ID: 2, Value: 2},
|
|
||||||
{ID: 3, Value: 3},
|
|
||||||
{ID: 4, Value: 4},
|
|
||||||
{ID: 5, Value: 5},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range data {
|
|
||||||
err := service.Update(item.ID, &item)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadAll without predicates
|
|
||||||
result, err := service.ReadAll()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
expected := append([]testObject{}, data...)
|
|
||||||
|
|
||||||
require.ElementsMatch(t, expected, result)
|
|
||||||
|
|
||||||
// ReadAll with predicates
|
|
||||||
hasLowID := func(obj testObject) bool { return obj.ID < 3 }
|
|
||||||
isEven := func(obj testObject) bool { return obj.Value%2 == 0 }
|
|
||||||
|
|
||||||
result, err = service.ReadAll(hasLowID, isEven)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
expected = slicesx.Filter(expected, hasLowID)
|
|
||||||
expected = slicesx.Filter(expected, isEven)
|
|
||||||
|
|
||||||
require.ElementsMatch(t, expected, result)
|
|
||||||
}
|
|
|
@ -34,32 +34,13 @@ func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) {
|
||||||
return service.Tx.KeyExists(service.Bucket, identifier)
|
return service.Tx.KeyExists(service.Bucket, identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadAll retrieves all the elements that satisfy all the provided predicates.
|
func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) {
|
||||||
func (service BaseDataServiceTx[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) {
|
|
||||||
var collection = make([]T, 0)
|
var collection = make([]T, 0)
|
||||||
|
|
||||||
if len(predicates) == 0 {
|
|
||||||
return collection, service.Tx.GetAll(
|
|
||||||
service.Bucket,
|
|
||||||
new(T),
|
|
||||||
AppendFn(&collection),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
filterFn := func(element T) bool {
|
|
||||||
for _, p := range predicates {
|
|
||||||
if !p(element) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return collection, service.Tx.GetAll(
|
return collection, service.Tx.GetAll(
|
||||||
service.Bucket,
|
service.Bucket,
|
||||||
new(T),
|
new(T),
|
||||||
FilterFn(&collection, filterFn),
|
AppendFn(&collection),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,29 +17,11 @@ func (service ServiceTx) UpdateEdgeGroupFunc(ID portainer.EdgeGroupID, updateFun
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service ServiceTx) Create(group *portainer.EdgeGroup) error {
|
func (service ServiceTx) Create(group *portainer.EdgeGroup) error {
|
||||||
es := group.Endpoints
|
return service.Tx.CreateObject(
|
||||||
group.Endpoints = nil // Clear deprecated field
|
|
||||||
|
|
||||||
err := service.Tx.CreateObject(
|
|
||||||
BucketName,
|
BucketName,
|
||||||
func(id uint64) (int, any) {
|
func(id uint64) (int, any) {
|
||||||
group.ID = portainer.EdgeGroupID(id)
|
group.ID = portainer.EdgeGroupID(id)
|
||||||
return int(group.ID), group
|
return int(group.ID), group
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
group.Endpoints = es // Restore endpoints after create
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (service ServiceTx) Update(ID portainer.EdgeGroupID, group *portainer.EdgeGroup) error {
|
|
||||||
es := group.Endpoints
|
|
||||||
group.Endpoints = nil // Clear deprecated field
|
|
||||||
|
|
||||||
err := service.BaseDataServiceTx.Update(ID, group)
|
|
||||||
|
|
||||||
group.Endpoints = es // Restore endpoints after update
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -112,13 +112,13 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||||
return service.connection.UpdateTx(func(tx portainer.Transaction) error {
|
return service.connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
|
return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
func (service *Service) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||||
return service.connection.UpdateTx(func(tx portainer.Transaction) error {
|
return service.connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
|
return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -85,7 +85,6 @@ func (store *Store) newMigratorParameters(version *models.Version, flags *portai
|
||||||
EdgeStackService: store.EdgeStackService,
|
EdgeStackService: store.EdgeStackService,
|
||||||
EdgeStackStatusService: store.EdgeStackStatusService,
|
EdgeStackStatusService: store.EdgeStackStatusService,
|
||||||
EdgeJobService: store.EdgeJobService,
|
EdgeJobService: store.EdgeJobService,
|
||||||
EdgeGroupService: store.EdgeGroupService,
|
|
||||||
TunnelServerService: store.TunnelServerService,
|
TunnelServerService: store.TunnelServerService,
|
||||||
PendingActionsService: store.PendingActionsService,
|
PendingActionsService: store.PendingActionsService,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,33 +0,0 @@
|
||||||
package migrator
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
perrors "github.com/portainer/portainer/api/dataservices/errors"
|
|
||||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m *Migrator) addEndpointRelationForEdgeAgents_2_32_0() error {
|
|
||||||
endpoints, err := m.endpointService.Endpoints()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, endpoint := range endpoints {
|
|
||||||
if endpointutils.IsEdgeEndpoint(&endpoint) {
|
|
||||||
_, err := m.endpointRelationService.EndpointRelation(endpoint.ID)
|
|
||||||
if err != nil && errors.Is(err, perrors.ErrObjectNotFound) {
|
|
||||||
relation := &portainer.EndpointRelation{
|
|
||||||
EndpointID: endpoint.ID,
|
|
||||||
EdgeStacks: make(map[portainer.EdgeStackID]bool),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.endpointRelationService.Create(relation); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
package migrator
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/portainer/portainer/api/roar"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m *Migrator) migrateEdgeGroupEndpointsToRoars_2_33_0() error {
|
|
||||||
egs, err := m.edgeGroupService.ReadAll()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, eg := range egs {
|
|
||||||
eg.EndpointIDs = roar.FromSlice(eg.Endpoints)
|
|
||||||
eg.Endpoints = nil
|
|
||||||
|
|
||||||
if err := m.edgeGroupService.Update(eg.ID, &eg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -6,7 +6,6 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/database/models"
|
"github.com/portainer/portainer/api/database/models"
|
||||||
"github.com/portainer/portainer/api/dataservices/dockerhub"
|
"github.com/portainer/portainer/api/dataservices/dockerhub"
|
||||||
"github.com/portainer/portainer/api/dataservices/edgegroup"
|
|
||||||
"github.com/portainer/portainer/api/dataservices/edgejob"
|
"github.com/portainer/portainer/api/dataservices/edgejob"
|
||||||
"github.com/portainer/portainer/api/dataservices/edgestack"
|
"github.com/portainer/portainer/api/dataservices/edgestack"
|
||||||
"github.com/portainer/portainer/api/dataservices/edgestackstatus"
|
"github.com/portainer/portainer/api/dataservices/edgestackstatus"
|
||||||
|
@ -61,7 +60,6 @@ type (
|
||||||
edgeStackService *edgestack.Service
|
edgeStackService *edgestack.Service
|
||||||
edgeStackStatusService *edgestackstatus.Service
|
edgeStackStatusService *edgestackstatus.Service
|
||||||
edgeJobService *edgejob.Service
|
edgeJobService *edgejob.Service
|
||||||
edgeGroupService *edgegroup.Service
|
|
||||||
TunnelServerService *tunnelserver.Service
|
TunnelServerService *tunnelserver.Service
|
||||||
pendingActionsService *pendingactions.Service
|
pendingActionsService *pendingactions.Service
|
||||||
}
|
}
|
||||||
|
@ -91,7 +89,6 @@ type (
|
||||||
EdgeStackService *edgestack.Service
|
EdgeStackService *edgestack.Service
|
||||||
EdgeStackStatusService *edgestackstatus.Service
|
EdgeStackStatusService *edgestackstatus.Service
|
||||||
EdgeJobService *edgejob.Service
|
EdgeJobService *edgejob.Service
|
||||||
EdgeGroupService *edgegroup.Service
|
|
||||||
TunnelServerService *tunnelserver.Service
|
TunnelServerService *tunnelserver.Service
|
||||||
PendingActionsService *pendingactions.Service
|
PendingActionsService *pendingactions.Service
|
||||||
}
|
}
|
||||||
|
@ -123,13 +120,11 @@ func NewMigrator(parameters *MigratorParameters) *Migrator {
|
||||||
edgeStackService: parameters.EdgeStackService,
|
edgeStackService: parameters.EdgeStackService,
|
||||||
edgeStackStatusService: parameters.EdgeStackStatusService,
|
edgeStackStatusService: parameters.EdgeStackStatusService,
|
||||||
edgeJobService: parameters.EdgeJobService,
|
edgeJobService: parameters.EdgeJobService,
|
||||||
edgeGroupService: parameters.EdgeGroupService,
|
|
||||||
TunnelServerService: parameters.TunnelServerService,
|
TunnelServerService: parameters.TunnelServerService,
|
||||||
pendingActionsService: parameters.PendingActionsService,
|
pendingActionsService: parameters.PendingActionsService,
|
||||||
}
|
}
|
||||||
|
|
||||||
migrator.initMigrations()
|
migrator.initMigrations()
|
||||||
|
|
||||||
return migrator
|
return migrator
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -254,10 +249,6 @@ func (m *Migrator) initMigrations() {
|
||||||
|
|
||||||
m.addMigrations("2.31.0", m.migrateEdgeStacksStatuses_2_31_0)
|
m.addMigrations("2.31.0", m.migrateEdgeStacksStatuses_2_31_0)
|
||||||
|
|
||||||
m.addMigrations("2.32.0", m.addEndpointRelationForEdgeAgents_2_32_0)
|
|
||||||
|
|
||||||
m.addMigrations("2.33.0", m.migrateEdgeGroupEndpointsToRoars_2_33_0)
|
|
||||||
|
|
||||||
// Add new migrations above...
|
// Add new migrations above...
|
||||||
// One function per migration, each versions migration funcs in the same file.
|
// One function per migration, each versions migration funcs in the same file.
|
||||||
}
|
}
|
||||||
|
|
|
@ -121,10 +121,6 @@
|
||||||
"Ecr": {
|
"Ecr": {
|
||||||
"Region": ""
|
"Region": ""
|
||||||
},
|
},
|
||||||
"Github": {
|
|
||||||
"OrganisationName": "",
|
|
||||||
"UseOrganisation": false
|
|
||||||
},
|
|
||||||
"Gitlab": {
|
"Gitlab": {
|
||||||
"InstanceURL": "",
|
"InstanceURL": "",
|
||||||
"ProjectId": 0,
|
"ProjectId": 0,
|
||||||
|
@ -615,7 +611,7 @@
|
||||||
"RequiredPasswordLength": 12
|
"RequiredPasswordLength": 12
|
||||||
},
|
},
|
||||||
"KubeconfigExpiry": "0",
|
"KubeconfigExpiry": "0",
|
||||||
"KubectlShellImage": "portainer/kubectl-shell:2.32.0",
|
"KubectlShellImage": "portainer/kubectl-shell:2.31.0",
|
||||||
"LDAPSettings": {
|
"LDAPSettings": {
|
||||||
"AnonymousMode": true,
|
"AnonymousMode": true,
|
||||||
"AutoCreateUsers": true,
|
"AutoCreateUsers": true,
|
||||||
|
@ -780,7 +776,6 @@
|
||||||
"ImageCount": 9,
|
"ImageCount": 9,
|
||||||
"IsPodman": false,
|
"IsPodman": false,
|
||||||
"NodeCount": 0,
|
"NodeCount": 0,
|
||||||
"PerformanceMetrics": null,
|
|
||||||
"RunningContainerCount": 5,
|
"RunningContainerCount": 5,
|
||||||
"ServiceCount": 0,
|
"ServiceCount": 0,
|
||||||
"StackCount": 2,
|
"StackCount": 2,
|
||||||
|
@ -944,7 +939,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": {
|
"version": {
|
||||||
"VERSION": "{\"SchemaVersion\":\"2.32.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
"VERSION": "{\"SchemaVersion\":\"2.31.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||||
},
|
},
|
||||||
"webhooks": null
|
"webhooks": null
|
||||||
}
|
}
|
|
@ -58,15 +58,7 @@ func TestService_ClonePublicRepository_Azure(t *testing.T) {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
dst := t.TempDir()
|
dst := t.TempDir()
|
||||||
repositoryUrl := fmt.Sprintf(tt.args.repositoryURLFormat, tt.args.password)
|
repositoryUrl := fmt.Sprintf(tt.args.repositoryURLFormat, tt.args.password)
|
||||||
err := service.CloneRepository(
|
err := service.CloneRepository(dst, repositoryUrl, tt.args.referenceName, "", "", false)
|
||||||
dst,
|
|
||||||
repositoryUrl,
|
|
||||||
tt.args.referenceName,
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||||
})
|
})
|
||||||
|
@ -81,15 +73,7 @@ func TestService_ClonePrivateRepository_Azure(t *testing.T) {
|
||||||
|
|
||||||
dst := t.TempDir()
|
dst := t.TempDir()
|
||||||
|
|
||||||
err := service.CloneRepository(
|
err := service.CloneRepository(dst, privateAzureRepoURL, "refs/heads/main", "", pat, false)
|
||||||
dst,
|
|
||||||
privateAzureRepoURL,
|
|
||||||
"refs/heads/main",
|
|
||||||
"",
|
|
||||||
pat,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||||
}
|
}
|
||||||
|
@ -100,14 +84,7 @@ func TestService_LatestCommitID_Azure(t *testing.T) {
|
||||||
pat := getRequiredValue(t, "AZURE_DEVOPS_PAT")
|
pat := getRequiredValue(t, "AZURE_DEVOPS_PAT")
|
||||||
service := NewService(context.TODO())
|
service := NewService(context.TODO())
|
||||||
|
|
||||||
id, err := service.LatestCommitID(
|
id, err := service.LatestCommitID(privateAzureRepoURL, "refs/heads/main", "", pat, false)
|
||||||
privateAzureRepoURL,
|
|
||||||
"refs/heads/main",
|
|
||||||
"",
|
|
||||||
pat,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty")
|
assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty")
|
||||||
}
|
}
|
||||||
|
@ -119,14 +96,7 @@ func TestService_ListRefs_Azure(t *testing.T) {
|
||||||
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
|
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
|
||||||
service := NewService(context.TODO())
|
service := NewService(context.TODO())
|
||||||
|
|
||||||
refs, err := service.ListRefs(
|
refs, err := service.ListRefs(privateAzureRepoURL, username, accessToken, false, false)
|
||||||
privateAzureRepoURL,
|
|
||||||
username,
|
|
||||||
accessToken,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.GreaterOrEqual(t, len(refs), 1)
|
assert.GreaterOrEqual(t, len(refs), 1)
|
||||||
}
|
}
|
||||||
|
@ -138,8 +108,8 @@ func TestService_ListRefs_Azure_Concurrently(t *testing.T) {
|
||||||
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
|
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
|
||||||
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
|
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
|
||||||
|
|
||||||
go service.ListRefs(privateAzureRepoURL, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
|
go service.ListRefs(privateAzureRepoURL, username, accessToken, false, false)
|
||||||
service.ListRefs(privateAzureRepoURL, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
|
service.ListRefs(privateAzureRepoURL, username, accessToken, false, false)
|
||||||
|
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
}
|
}
|
||||||
|
@ -277,17 +247,7 @@ func TestService_ListFiles_Azure(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
paths, err := service.ListFiles(
|
paths, err := service.ListFiles(tt.args.repositoryUrl, tt.args.referenceName, tt.args.username, tt.args.password, false, false, tt.extensions, false)
|
||||||
tt.args.repositoryUrl,
|
|
||||||
tt.args.referenceName,
|
|
||||||
tt.args.username,
|
|
||||||
tt.args.password,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
tt.extensions,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
if tt.expect.shouldFail {
|
if tt.expect.shouldFail {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
if tt.expect.err != nil {
|
if tt.expect.err != nil {
|
||||||
|
@ -310,28 +270,8 @@ func TestService_ListFiles_Azure_Concurrently(t *testing.T) {
|
||||||
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
|
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
|
||||||
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
|
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
|
||||||
|
|
||||||
go service.ListFiles(
|
go service.ListFiles(privateAzureRepoURL, "refs/heads/main", username, accessToken, false, false, []string{}, false)
|
||||||
privateAzureRepoURL,
|
service.ListFiles(privateAzureRepoURL, "refs/heads/main", username, accessToken, false, false, []string{}, false)
|
||||||
"refs/heads/main",
|
|
||||||
username,
|
|
||||||
accessToken,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
[]string{},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
service.ListFiles(
|
|
||||||
privateAzureRepoURL,
|
|
||||||
"refs/heads/main",
|
|
||||||
username,
|
|
||||||
accessToken,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
[]string{},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
|
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,6 @@ type CloneOptions struct {
|
||||||
ReferenceName string
|
ReferenceName string
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
AuthType gittypes.GitCredentialAuthType
|
|
||||||
// TLSSkipVerify skips SSL verification when cloning the Git repository
|
// TLSSkipVerify skips SSL verification when cloning the Git repository
|
||||||
TLSSkipVerify bool `example:"false"`
|
TLSSkipVerify bool `example:"false"`
|
||||||
}
|
}
|
||||||
|
@ -43,15 +42,7 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File
|
||||||
|
|
||||||
cleanUp = true
|
cleanUp = true
|
||||||
|
|
||||||
if err := gitService.CloneRepository(
|
if err := gitService.CloneRepository(options.ProjectPath, options.URL, options.ReferenceName, options.Username, options.Password, options.TLSSkipVerify); err != nil {
|
||||||
options.ProjectPath,
|
|
||||||
options.URL,
|
|
||||||
options.ReferenceName,
|
|
||||||
options.Username,
|
|
||||||
options.Password,
|
|
||||||
options.AuthType,
|
|
||||||
options.TLSSkipVerify,
|
|
||||||
); err != nil {
|
|
||||||
cleanUp = false
|
cleanUp = false
|
||||||
if err := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath, false); err != nil {
|
if err := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath, false); err != nil {
|
||||||
log.Warn().Err(err).Msg("failed restoring backup folder")
|
log.Warn().Err(err).Msg("failed restoring backup folder")
|
||||||
|
|
|
@ -7,14 +7,12 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
gittypes "github.com/portainer/portainer/api/git/types"
|
gittypes "github.com/portainer/portainer/api/git/types"
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5"
|
"github.com/go-git/go-git/v5"
|
||||||
"github.com/go-git/go-git/v5/config"
|
"github.com/go-git/go-git/v5/config"
|
||||||
"github.com/go-git/go-git/v5/plumbing"
|
"github.com/go-git/go-git/v5/plumbing"
|
||||||
"github.com/go-git/go-git/v5/plumbing/filemode"
|
"github.com/go-git/go-git/v5/plumbing/filemode"
|
||||||
"github.com/go-git/go-git/v5/plumbing/object"
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
|
||||||
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||||
"github.com/go-git/go-git/v5/storage/memory"
|
"github.com/go-git/go-git/v5/storage/memory"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
@ -35,7 +33,7 @@ func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) e
|
||||||
URL: opt.repositoryUrl,
|
URL: opt.repositoryUrl,
|
||||||
Depth: opt.depth,
|
Depth: opt.depth,
|
||||||
InsecureSkipTLS: opt.tlsSkipVerify,
|
InsecureSkipTLS: opt.tlsSkipVerify,
|
||||||
Auth: getAuth(opt.authType, opt.username, opt.password),
|
Auth: getAuth(opt.username, opt.password),
|
||||||
Tags: git.NoTags,
|
Tags: git.NoTags,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,10 +51,7 @@ func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) e
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.preserveGitDirectory {
|
if !c.preserveGitDirectory {
|
||||||
err := os.RemoveAll(filepath.Join(dst, ".git"))
|
os.RemoveAll(filepath.Join(dst, ".git"))
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("failed to remove .git directory")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -69,7 +64,7 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string
|
||||||
})
|
})
|
||||||
|
|
||||||
listOptions := &git.ListOptions{
|
listOptions := &git.ListOptions{
|
||||||
Auth: getAuth(opt.authType, opt.username, opt.password),
|
Auth: getAuth(opt.username, opt.password),
|
||||||
InsecureSkipTLS: opt.tlsSkipVerify,
|
InsecureSkipTLS: opt.tlsSkipVerify,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,23 +94,7 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string
|
||||||
return "", errors.Errorf("could not find ref %q in the repository", opt.referenceName)
|
return "", errors.Errorf("could not find ref %q in the repository", opt.referenceName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAuth(authType gittypes.GitCredentialAuthType, username, password string) transport.AuthMethod {
|
func getAuth(username, password string) *githttp.BasicAuth {
|
||||||
if password == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch authType {
|
|
||||||
case gittypes.GitCredentialAuthType_Basic:
|
|
||||||
return getBasicAuth(username, password)
|
|
||||||
case gittypes.GitCredentialAuthType_Token:
|
|
||||||
return getTokenAuth(password)
|
|
||||||
default:
|
|
||||||
log.Warn().Msg("unknown git credentials authorization type, defaulting to None")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBasicAuth(username, password string) *githttp.BasicAuth {
|
|
||||||
if password != "" {
|
if password != "" {
|
||||||
if username == "" {
|
if username == "" {
|
||||||
username = "token"
|
username = "token"
|
||||||
|
@ -129,15 +108,6 @@ func getBasicAuth(username, password string) *githttp.BasicAuth {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTokenAuth(token string) *githttp.TokenAuth {
|
|
||||||
if token != "" {
|
|
||||||
return &githttp.TokenAuth{
|
|
||||||
Token: token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, error) {
|
func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, error) {
|
||||||
rem := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{
|
rem := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{
|
||||||
Name: "origin",
|
Name: "origin",
|
||||||
|
@ -145,7 +115,7 @@ func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, err
|
||||||
})
|
})
|
||||||
|
|
||||||
listOptions := &git.ListOptions{
|
listOptions := &git.ListOptions{
|
||||||
Auth: getAuth(opt.authType, opt.username, opt.password),
|
Auth: getAuth(opt.username, opt.password),
|
||||||
InsecureSkipTLS: opt.tlsSkipVerify,
|
InsecureSkipTLS: opt.tlsSkipVerify,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -173,7 +143,7 @@ func (c *gitClient) listFiles(ctx context.Context, opt fetchOption) ([]string, e
|
||||||
Depth: 1,
|
Depth: 1,
|
||||||
SingleBranch: true,
|
SingleBranch: true,
|
||||||
ReferenceName: plumbing.ReferenceName(opt.referenceName),
|
ReferenceName: plumbing.ReferenceName(opt.referenceName),
|
||||||
Auth: getAuth(opt.authType, opt.username, opt.password),
|
Auth: getAuth(opt.username, opt.password),
|
||||||
InsecureSkipTLS: opt.tlsSkipVerify,
|
InsecureSkipTLS: opt.tlsSkipVerify,
|
||||||
Tags: git.NoTags,
|
Tags: git.NoTags,
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,6 @@ package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
@ -26,15 +24,7 @@ func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
|
||||||
dst := t.TempDir()
|
dst := t.TempDir()
|
||||||
|
|
||||||
repositoryUrl := privateGitRepoURL
|
repositoryUrl := privateGitRepoURL
|
||||||
err := service.CloneRepository(
|
err := service.CloneRepository(dst, repositoryUrl, "refs/heads/main", username, accessToken, false)
|
||||||
dst,
|
|
||||||
repositoryUrl,
|
|
||||||
"refs/heads/main",
|
|
||||||
username,
|
|
||||||
accessToken,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||||
}
|
}
|
||||||
|
@ -47,14 +37,7 @@ func TestService_LatestCommitID_GitHub(t *testing.T) {
|
||||||
service := newService(context.TODO(), 0, 0)
|
service := newService(context.TODO(), 0, 0)
|
||||||
|
|
||||||
repositoryUrl := privateGitRepoURL
|
repositoryUrl := privateGitRepoURL
|
||||||
id, err := service.LatestCommitID(
|
id, err := service.LatestCommitID(repositoryUrl, "refs/heads/main", username, accessToken, false)
|
||||||
repositoryUrl,
|
|
||||||
"refs/heads/main",
|
|
||||||
username,
|
|
||||||
accessToken,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty")
|
assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty")
|
||||||
}
|
}
|
||||||
|
@ -67,7 +50,7 @@ func TestService_ListRefs_GitHub(t *testing.T) {
|
||||||
service := newService(context.TODO(), 0, 0)
|
service := newService(context.TODO(), 0, 0)
|
||||||
|
|
||||||
repositoryUrl := privateGitRepoURL
|
repositoryUrl := privateGitRepoURL
|
||||||
refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
|
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.GreaterOrEqual(t, len(refs), 1)
|
assert.GreaterOrEqual(t, len(refs), 1)
|
||||||
}
|
}
|
||||||
|
@ -80,8 +63,8 @@ func TestService_ListRefs_Github_Concurrently(t *testing.T) {
|
||||||
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
|
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
|
||||||
|
|
||||||
repositoryUrl := privateGitRepoURL
|
repositoryUrl := privateGitRepoURL
|
||||||
go service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
|
go service.ListRefs(repositoryUrl, username, accessToken, false, false)
|
||||||
service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
|
service.ListRefs(repositoryUrl, username, accessToken, false, false)
|
||||||
|
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
}
|
}
|
||||||
|
@ -219,17 +202,7 @@ func TestService_ListFiles_GitHub(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
paths, err := service.ListFiles(
|
paths, err := service.ListFiles(tt.args.repositoryUrl, tt.args.referenceName, tt.args.username, tt.args.password, false, false, tt.extensions, false)
|
||||||
tt.args.repositoryUrl,
|
|
||||||
tt.args.referenceName,
|
|
||||||
tt.args.username,
|
|
||||||
tt.args.password,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
tt.extensions,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
if tt.expect.shouldFail {
|
if tt.expect.shouldFail {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
if tt.expect.err != nil {
|
if tt.expect.err != nil {
|
||||||
|
@ -253,28 +226,8 @@ func TestService_ListFiles_Github_Concurrently(t *testing.T) {
|
||||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||||
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
|
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
|
||||||
|
|
||||||
go service.ListFiles(
|
go service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false)
|
||||||
repositoryUrl,
|
service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false)
|
||||||
"refs/heads/main",
|
|
||||||
username,
|
|
||||||
accessToken,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
[]string{},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
service.ListFiles(
|
|
||||||
repositoryUrl,
|
|
||||||
"refs/heads/main",
|
|
||||||
username,
|
|
||||||
accessToken,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
[]string{},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
|
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
}
|
}
|
||||||
|
@ -287,18 +240,8 @@ func TestService_purgeCache_Github(t *testing.T) {
|
||||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||||
service := NewService(context.TODO())
|
service := NewService(context.TODO())
|
||||||
|
|
||||||
service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
|
service.ListRefs(repositoryUrl, username, accessToken, false, false)
|
||||||
service.ListFiles(
|
service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false)
|
||||||
repositoryUrl,
|
|
||||||
"refs/heads/main",
|
|
||||||
username,
|
|
||||||
accessToken,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
[]string{},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||||
assert.Equal(t, 1, service.repoFileCache.Len())
|
assert.Equal(t, 1, service.repoFileCache.Len())
|
||||||
|
@ -318,18 +261,8 @@ func TestService_purgeCacheByTTL_Github(t *testing.T) {
|
||||||
// 40*timeout is designed for giving enough time for ListRefs and ListFiles to cache the result
|
// 40*timeout is designed for giving enough time for ListRefs and ListFiles to cache the result
|
||||||
service := newService(context.TODO(), 2, 40*timeout)
|
service := newService(context.TODO(), 2, 40*timeout)
|
||||||
|
|
||||||
service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
|
service.ListRefs(repositoryUrl, username, accessToken, false, false)
|
||||||
service.ListFiles(
|
service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false)
|
||||||
repositoryUrl,
|
|
||||||
"refs/heads/main",
|
|
||||||
username,
|
|
||||||
accessToken,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
[]string{},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||||
assert.Equal(t, 1, service.repoFileCache.Len())
|
assert.Equal(t, 1, service.repoFileCache.Len())
|
||||||
|
|
||||||
|
@ -360,12 +293,12 @@ func TestService_HardRefresh_ListRefs_GitHub(t *testing.T) {
|
||||||
service := newService(context.TODO(), 2, 0)
|
service := newService(context.TODO(), 2, 0)
|
||||||
|
|
||||||
repositoryUrl := privateGitRepoURL
|
repositoryUrl := privateGitRepoURL
|
||||||
refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
|
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.GreaterOrEqual(t, len(refs), 1)
|
assert.GreaterOrEqual(t, len(refs), 1)
|
||||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||||
|
|
||||||
_, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, false, false)
|
_, err = service.ListRefs(repositoryUrl, username, "fake-token", false, false)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||||
}
|
}
|
||||||
|
@ -378,46 +311,26 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
|
||||||
service := newService(context.TODO(), 2, 0)
|
service := newService(context.TODO(), 2, 0)
|
||||||
|
|
||||||
repositoryUrl := privateGitRepoURL
|
repositoryUrl := privateGitRepoURL
|
||||||
refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
|
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.GreaterOrEqual(t, len(refs), 1)
|
assert.GreaterOrEqual(t, len(refs), 1)
|
||||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||||
|
|
||||||
files, err := service.ListFiles(
|
files, err := service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false)
|
||||||
repositoryUrl,
|
|
||||||
"refs/heads/main",
|
|
||||||
username,
|
|
||||||
accessToken,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
[]string{},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.GreaterOrEqual(t, len(files), 1)
|
assert.GreaterOrEqual(t, len(files), 1)
|
||||||
assert.Equal(t, 1, service.repoFileCache.Len())
|
assert.Equal(t, 1, service.repoFileCache.Len())
|
||||||
|
|
||||||
files, err = service.ListFiles(
|
files, err = service.ListFiles(repositoryUrl, "refs/heads/test", username, accessToken, false, false, []string{}, false)
|
||||||
repositoryUrl,
|
|
||||||
"refs/heads/test",
|
|
||||||
username,
|
|
||||||
accessToken,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
[]string{},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.GreaterOrEqual(t, len(files), 1)
|
assert.GreaterOrEqual(t, len(files), 1)
|
||||||
assert.Equal(t, 2, service.repoFileCache.Len())
|
assert.Equal(t, 2, service.repoFileCache.Len())
|
||||||
|
|
||||||
_, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, false, false)
|
_, err = service.ListRefs(repositoryUrl, username, "fake-token", false, false)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||||
|
|
||||||
_, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, true, false)
|
_, err = service.ListRefs(repositoryUrl, username, "fake-token", true, false)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||||
// The relevant file caches should be removed too
|
// The relevant file caches should be removed too
|
||||||
|
@ -431,72 +344,12 @@ func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) {
|
||||||
accessToken := getRequiredValue(t, "GITHUB_PAT")
|
accessToken := getRequiredValue(t, "GITHUB_PAT")
|
||||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||||
repositoryUrl := privateGitRepoURL
|
repositoryUrl := privateGitRepoURL
|
||||||
files, err := service.ListFiles(
|
files, err := service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false)
|
||||||
repositoryUrl,
|
|
||||||
"refs/heads/main",
|
|
||||||
username,
|
|
||||||
accessToken,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
[]string{},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.GreaterOrEqual(t, len(files), 1)
|
assert.GreaterOrEqual(t, len(files), 1)
|
||||||
assert.Equal(t, 1, service.repoFileCache.Len())
|
assert.Equal(t, 1, service.repoFileCache.Len())
|
||||||
|
|
||||||
_, err = service.ListFiles(
|
_, err = service.ListFiles(repositoryUrl, "refs/heads/main", username, "fake-token", false, true, []string{}, false)
|
||||||
repositoryUrl,
|
|
||||||
"refs/heads/main",
|
|
||||||
username,
|
|
||||||
"fake-token",
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
[]string{},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Equal(t, 0, service.repoFileCache.Len())
|
assert.Equal(t, 0, service.repoFileCache.Len())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_CloneRepository_TokenAuth(t *testing.T) {
|
|
||||||
ensureIntegrationTest(t)
|
|
||||||
|
|
||||||
service := newService(context.TODO(), 2, 0)
|
|
||||||
var requests []*http.Request
|
|
||||||
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
requests = append(requests, r)
|
|
||||||
}))
|
|
||||||
accessToken := "test_access_token"
|
|
||||||
username := "test_username"
|
|
||||||
repositoryUrl := testServer.URL
|
|
||||||
|
|
||||||
// Since we aren't hitting a real git server we ignore the error
|
|
||||||
_ = service.CloneRepository(
|
|
||||||
"test_dir",
|
|
||||||
repositoryUrl,
|
|
||||||
"refs/heads/main",
|
|
||||||
username,
|
|
||||||
accessToken,
|
|
||||||
gittypes.GitCredentialAuthType_Token,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
|
|
||||||
testServer.Close()
|
|
||||||
|
|
||||||
if len(requests) != 1 {
|
|
||||||
t.Fatalf("expected 1 request sent but got %d", len(requests))
|
|
||||||
}
|
|
||||||
|
|
||||||
gotAuthHeader := requests[0].Header.Get("Authorization")
|
|
||||||
if gotAuthHeader == "" {
|
|
||||||
t.Fatal("no Authorization header in git request")
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedAuthHeader := "Bearer test_access_token"
|
|
||||||
if gotAuthHeader != expectedAuthHeader {
|
|
||||||
t.Fatalf("expected Authorization header %q but got %q", expectedAuthHeader, gotAuthHeader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -38,7 +38,7 @@ func Test_ClonePublicRepository_Shallow(t *testing.T) {
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Logf("Cloning into %s", dir)
|
t.Logf("Cloning into %s", dir)
|
||||||
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
|
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", false)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, 1, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth")
|
assert.Equal(t, 1, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth")
|
||||||
}
|
}
|
||||||
|
@ -50,7 +50,7 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Logf("Cloning into %s", dir)
|
t.Logf("Cloning into %s", dir)
|
||||||
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
|
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", false)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NoDirExists(t, filepath.Join(dir, ".git"))
|
assert.NoDirExists(t, filepath.Join(dir, ".git"))
|
||||||
}
|
}
|
||||||
|
@ -84,7 +84,7 @@ func Test_latestCommitID(t *testing.T) {
|
||||||
repositoryURL := setup(t)
|
repositoryURL := setup(t)
|
||||||
referenceName := "refs/heads/main"
|
referenceName := "refs/heads/main"
|
||||||
|
|
||||||
id, err := service.LatestCommitID(repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
|
id, err := service.LatestCommitID(repositoryURL, referenceName, "", "", false)
|
||||||
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id)
|
assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id)
|
||||||
|
@ -95,7 +95,7 @@ func Test_ListRefs(t *testing.T) {
|
||||||
|
|
||||||
repositoryURL := setup(t)
|
repositoryURL := setup(t)
|
||||||
|
|
||||||
fs, err := service.ListRefs(repositoryURL, "", "", gittypes.GitCredentialAuthType_Basic, false, false)
|
fs, err := service.ListRefs(repositoryURL, "", "", false, false)
|
||||||
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, []string{"refs/heads/main"}, fs)
|
assert.Equal(t, []string{"refs/heads/main"}, fs)
|
||||||
|
@ -107,17 +107,7 @@ func Test_ListFiles(t *testing.T) {
|
||||||
repositoryURL := setup(t)
|
repositoryURL := setup(t)
|
||||||
referenceName := "refs/heads/main"
|
referenceName := "refs/heads/main"
|
||||||
|
|
||||||
fs, err := service.ListFiles(
|
fs, err := service.ListFiles(repositoryURL, referenceName, "", "", false, false, []string{".yml"}, false)
|
||||||
repositoryURL,
|
|
||||||
referenceName,
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
[]string{".yml"},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, []string{"docker-compose.yml"}, fs)
|
assert.Equal(t, []string{"docker-compose.yml"}, fs)
|
||||||
|
@ -265,7 +255,7 @@ func Test_listFilesPrivateRepository(t *testing.T) {
|
||||||
name: "list tree with real repository and head ref but no credential",
|
name: "list tree with real repository and head ref but no credential",
|
||||||
args: fetchOption{
|
args: fetchOption{
|
||||||
baseOption: baseOption{
|
baseOption: baseOption{
|
||||||
repositoryUrl: privateGitRepoURL,
|
repositoryUrl: privateGitRepoURL + "fake",
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
},
|
},
|
||||||
|
|
|
@ -8,7 +8,6 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
lru "github.com/hashicorp/golang-lru"
|
lru "github.com/hashicorp/golang-lru"
|
||||||
gittypes "github.com/portainer/portainer/api/git/types"
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"golang.org/x/sync/singleflight"
|
"golang.org/x/sync/singleflight"
|
||||||
)
|
)
|
||||||
|
@ -23,7 +22,6 @@ type baseOption struct {
|
||||||
repositoryUrl string
|
repositoryUrl string
|
||||||
username string
|
username string
|
||||||
password string
|
password string
|
||||||
authType gittypes.GitCredentialAuthType
|
|
||||||
tlsSkipVerify bool
|
tlsSkipVerify bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -125,22 +123,13 @@ func (service *Service) timerHasStopped() bool {
|
||||||
|
|
||||||
// CloneRepository clones a git repository using the specified URL in the specified
|
// CloneRepository clones a git repository using the specified URL in the specified
|
||||||
// destination folder.
|
// destination folder.
|
||||||
func (service *Service) CloneRepository(
|
func (service *Service) CloneRepository(destination, repositoryURL, referenceName, username, password string, tlsSkipVerify bool) error {
|
||||||
destination,
|
|
||||||
repositoryURL,
|
|
||||||
referenceName,
|
|
||||||
username,
|
|
||||||
password string,
|
|
||||||
authType gittypes.GitCredentialAuthType,
|
|
||||||
tlsSkipVerify bool,
|
|
||||||
) error {
|
|
||||||
options := cloneOption{
|
options := cloneOption{
|
||||||
fetchOption: fetchOption{
|
fetchOption: fetchOption{
|
||||||
baseOption: baseOption{
|
baseOption: baseOption{
|
||||||
repositoryUrl: repositoryURL,
|
repositoryUrl: repositoryURL,
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
authType: authType,
|
|
||||||
tlsSkipVerify: tlsSkipVerify,
|
tlsSkipVerify: tlsSkipVerify,
|
||||||
},
|
},
|
||||||
referenceName: referenceName,
|
referenceName: referenceName,
|
||||||
|
@ -166,20 +155,12 @@ func (service *Service) cloneRepository(destination string, options cloneOption)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LatestCommitID returns SHA1 of the latest commit of the specified reference
|
// LatestCommitID returns SHA1 of the latest commit of the specified reference
|
||||||
func (service *Service) LatestCommitID(
|
func (service *Service) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) {
|
||||||
repositoryURL,
|
|
||||||
referenceName,
|
|
||||||
username,
|
|
||||||
password string,
|
|
||||||
authType gittypes.GitCredentialAuthType,
|
|
||||||
tlsSkipVerify bool,
|
|
||||||
) (string, error) {
|
|
||||||
options := fetchOption{
|
options := fetchOption{
|
||||||
baseOption: baseOption{
|
baseOption: baseOption{
|
||||||
repositoryUrl: repositoryURL,
|
repositoryUrl: repositoryURL,
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
authType: authType,
|
|
||||||
tlsSkipVerify: tlsSkipVerify,
|
tlsSkipVerify: tlsSkipVerify,
|
||||||
},
|
},
|
||||||
referenceName: referenceName,
|
referenceName: referenceName,
|
||||||
|
@ -189,14 +170,7 @@ func (service *Service) LatestCommitID(
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRefs will list target repository's references without cloning the repository
|
// ListRefs will list target repository's references without cloning the repository
|
||||||
func (service *Service) ListRefs(
|
func (service *Service) ListRefs(repositoryURL, username, password string, hardRefresh bool, tlsSkipVerify bool) ([]string, error) {
|
||||||
repositoryURL,
|
|
||||||
username,
|
|
||||||
password string,
|
|
||||||
authType gittypes.GitCredentialAuthType,
|
|
||||||
hardRefresh bool,
|
|
||||||
tlsSkipVerify bool,
|
|
||||||
) ([]string, error) {
|
|
||||||
refCacheKey := generateCacheKey(repositoryURL, username, password, strconv.FormatBool(tlsSkipVerify))
|
refCacheKey := generateCacheKey(repositoryURL, username, password, strconv.FormatBool(tlsSkipVerify))
|
||||||
if service.cacheEnabled && hardRefresh {
|
if service.cacheEnabled && hardRefresh {
|
||||||
// Should remove the cache explicitly, so that the following normal list can show the correct result
|
// Should remove the cache explicitly, so that the following normal list can show the correct result
|
||||||
|
@ -222,7 +196,6 @@ func (service *Service) ListRefs(
|
||||||
repositoryUrl: repositoryURL,
|
repositoryUrl: repositoryURL,
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
authType: authType,
|
|
||||||
tlsSkipVerify: tlsSkipVerify,
|
tlsSkipVerify: tlsSkipVerify,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -242,62 +215,18 @@ var singleflightGroup = &singleflight.Group{}
|
||||||
|
|
||||||
// ListFiles will list all the files of the target repository with specific extensions.
|
// ListFiles will list all the files of the target repository with specific extensions.
|
||||||
// If extension is not provided, it will list all the files under the target repository
|
// If extension is not provided, it will list all the files under the target repository
|
||||||
func (service *Service) ListFiles(
|
func (service *Service) ListFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, includedExts []string, tlsSkipVerify bool) ([]string, error) {
|
||||||
repositoryURL,
|
repoKey := generateCacheKey(repositoryURL, referenceName, username, password, strconv.FormatBool(tlsSkipVerify), strconv.FormatBool(dirOnly))
|
||||||
referenceName,
|
|
||||||
username,
|
|
||||||
password string,
|
|
||||||
authType gittypes.GitCredentialAuthType,
|
|
||||||
dirOnly,
|
|
||||||
hardRefresh bool,
|
|
||||||
includedExts []string,
|
|
||||||
tlsSkipVerify bool,
|
|
||||||
) ([]string, error) {
|
|
||||||
repoKey := generateCacheKey(
|
|
||||||
repositoryURL,
|
|
||||||
referenceName,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
strconv.FormatBool(tlsSkipVerify),
|
|
||||||
strconv.Itoa(int(authType)),
|
|
||||||
strconv.FormatBool(dirOnly),
|
|
||||||
)
|
|
||||||
|
|
||||||
fs, err, _ := singleflightGroup.Do(repoKey, func() (any, error) {
|
fs, err, _ := singleflightGroup.Do(repoKey, func() (any, error) {
|
||||||
return service.listFiles(
|
return service.listFiles(repositoryURL, referenceName, username, password, dirOnly, hardRefresh, tlsSkipVerify)
|
||||||
repositoryURL,
|
|
||||||
referenceName,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
authType,
|
|
||||||
dirOnly,
|
|
||||||
hardRefresh,
|
|
||||||
tlsSkipVerify,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return filterFiles(fs.([]string), includedExts), err
|
return filterFiles(fs.([]string), includedExts), err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) listFiles(
|
func (service *Service) listFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, tlsSkipVerify bool) ([]string, error) {
|
||||||
repositoryURL,
|
repoKey := generateCacheKey(repositoryURL, referenceName, username, password, strconv.FormatBool(tlsSkipVerify), strconv.FormatBool(dirOnly))
|
||||||
referenceName,
|
|
||||||
username,
|
|
||||||
password string,
|
|
||||||
authType gittypes.GitCredentialAuthType,
|
|
||||||
dirOnly,
|
|
||||||
hardRefresh bool,
|
|
||||||
tlsSkipVerify bool,
|
|
||||||
) ([]string, error) {
|
|
||||||
repoKey := generateCacheKey(
|
|
||||||
repositoryURL,
|
|
||||||
referenceName,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
strconv.FormatBool(tlsSkipVerify),
|
|
||||||
strconv.Itoa(int(authType)),
|
|
||||||
strconv.FormatBool(dirOnly),
|
|
||||||
)
|
|
||||||
|
|
||||||
if service.cacheEnabled && hardRefresh {
|
if service.cacheEnabled && hardRefresh {
|
||||||
// Should remove the cache explicitly, so that the following normal list can show the correct result
|
// Should remove the cache explicitly, so that the following normal list can show the correct result
|
||||||
|
@ -318,7 +247,6 @@ func (service *Service) listFiles(
|
||||||
repositoryUrl: repositoryURL,
|
repositoryUrl: repositoryURL,
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
authType: authType,
|
|
||||||
tlsSkipVerify: tlsSkipVerify,
|
tlsSkipVerify: tlsSkipVerify,
|
||||||
},
|
},
|
||||||
referenceName: referenceName,
|
referenceName: referenceName,
|
||||||
|
|
|
@ -1,21 +1,12 @@
|
||||||
package gittypes
|
package gittypes
|
||||||
|
|
||||||
import (
|
import "errors"
|
||||||
"errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrIncorrectRepositoryURL = errors.New("git repository could not be found, please ensure that the URL is correct")
|
ErrIncorrectRepositoryURL = errors.New("git repository could not be found, please ensure that the URL is correct")
|
||||||
ErrAuthenticationFailure = errors.New("authentication failed, please ensure that the git credentials are correct")
|
ErrAuthenticationFailure = errors.New("authentication failed, please ensure that the git credentials are correct")
|
||||||
)
|
)
|
||||||
|
|
||||||
type GitCredentialAuthType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
GitCredentialAuthType_Basic GitCredentialAuthType = iota
|
|
||||||
GitCredentialAuthType_Token
|
|
||||||
)
|
|
||||||
|
|
||||||
// RepoConfig represents a configuration for a repo
|
// RepoConfig represents a configuration for a repo
|
||||||
type RepoConfig struct {
|
type RepoConfig struct {
|
||||||
// The repo url
|
// The repo url
|
||||||
|
@ -33,11 +24,10 @@ type RepoConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitAuthentication struct {
|
type GitAuthentication struct {
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
AuthorizationType GitCredentialAuthType
|
|
||||||
// Git credentials identifier when the value is not 0
|
// Git credentials identifier when the value is not 0
|
||||||
// When the value is 0, Username, Password, and Authtype are set without using saved credential
|
// When the value is 0, Username and Password are set without using saved credential
|
||||||
// This is introduced since 2.15.0
|
// This is introduced since 2.15.0
|
||||||
GitCredentialID int `example:"0"`
|
GitCredentialID int `example:"0"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,14 +29,7 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
|
||||||
return false, "", errors.WithMessagef(err, "failed to get credentials for %v", objId)
|
return false, "", errors.WithMessagef(err, "failed to get credentials for %v", objId)
|
||||||
}
|
}
|
||||||
|
|
||||||
newHash, err := gitService.LatestCommitID(
|
newHash, err := gitService.LatestCommitID(gitConfig.URL, gitConfig.ReferenceName, username, password, gitConfig.TLSSkipVerify)
|
||||||
gitConfig.URL,
|
|
||||||
gitConfig.ReferenceName,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
gitConfig.TLSSkipVerify,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "", errors.WithMessagef(err, "failed to fetch latest commit id of %v", objId)
|
return false, "", errors.WithMessagef(err, "failed to fetch latest commit id of %v", objId)
|
||||||
}
|
}
|
||||||
|
@ -69,7 +62,6 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
|
||||||
cloneParams.auth = &gitAuth{
|
cloneParams.auth = &gitAuth{
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
authType: gitConfig.Authentication.AuthorizationType,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -97,31 +89,14 @@ type cloneRepositoryParameters struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type gitAuth struct {
|
type gitAuth struct {
|
||||||
authType gittypes.GitCredentialAuthType
|
|
||||||
username string
|
username string
|
||||||
password string
|
password string
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneGitRepository(gitService portainer.GitService, cloneParams *cloneRepositoryParameters) error {
|
func cloneGitRepository(gitService portainer.GitService, cloneParams *cloneRepositoryParameters) error {
|
||||||
if cloneParams.auth != nil {
|
if cloneParams.auth != nil {
|
||||||
return gitService.CloneRepository(
|
return gitService.CloneRepository(cloneParams.toDir, cloneParams.url, cloneParams.ref, cloneParams.auth.username, cloneParams.auth.password, cloneParams.tlsSkipVerify)
|
||||||
cloneParams.toDir,
|
|
||||||
cloneParams.url,
|
|
||||||
cloneParams.ref,
|
|
||||||
cloneParams.auth.username,
|
|
||||||
cloneParams.auth.password,
|
|
||||||
cloneParams.auth.authType,
|
|
||||||
cloneParams.tlsSkipVerify,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return gitService.CloneRepository(
|
return gitService.CloneRepository(cloneParams.toDir, cloneParams.url, cloneParams.ref, "", "", cloneParams.tlsSkipVerify)
|
||||||
cloneParams.toDir,
|
|
||||||
cloneParams.url,
|
|
||||||
cloneParams.ref,
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
cloneParams.tlsSkipVerify,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,9 +32,9 @@ type Service struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService initializes a new service.
|
// NewService initializes a new service.
|
||||||
func NewService(insecureSkipVerify bool) *Service {
|
func NewService() *Service {
|
||||||
tlsConfig := crypto.CreateTLSConfiguration()
|
tlsConfig := crypto.CreateTLSConfiguration()
|
||||||
tlsConfig.InsecureSkipVerify = insecureSkipVerify
|
tlsConfig.InsecureSkipVerify = true
|
||||||
|
|
||||||
return &Service{
|
return &Service{
|
||||||
httpsClient: &http.Client{
|
httpsClient: &http.Client{
|
||||||
|
|
|
@ -2,7 +2,6 @@ package csrf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
@ -10,8 +9,7 @@ import (
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
|
||||||
gcsrf "github.com/gorilla/csrf"
|
gorillacsrf "github.com/gorilla/csrf"
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"github.com/urfave/negroni"
|
"github.com/urfave/negroni"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -21,7 +19,7 @@ func SkipCSRFToken(w http.ResponseWriter) {
|
||||||
w.Header().Set(csrfSkipHeader, "1")
|
w.Header().Set(csrfSkipHeader, "1")
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, error) {
|
func WithProtect(handler http.Handler) (http.Handler, error) {
|
||||||
// IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck)
|
// IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck)
|
||||||
// DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml
|
// DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml
|
||||||
isDockerDesktopExtension := false
|
isDockerDesktopExtension := false
|
||||||
|
@ -36,12 +34,10 @@ func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, e
|
||||||
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
|
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler = gcsrf.Protect(
|
handler = gorillacsrf.Protect(
|
||||||
token,
|
token,
|
||||||
gcsrf.Path("/"),
|
gorillacsrf.Path("/"),
|
||||||
gcsrf.Secure(false),
|
gorillacsrf.Secure(false),
|
||||||
gcsrf.TrustedOrigins(trustedOrigins),
|
|
||||||
gcsrf.ErrorHandler(withErrorHandler(trustedOrigins)),
|
|
||||||
)(handler)
|
)(handler)
|
||||||
|
|
||||||
return withSkipCSRF(handler, isDockerDesktopExtension), nil
|
return withSkipCSRF(handler, isDockerDesktopExtension), nil
|
||||||
|
@ -59,7 +55,7 @@ func withSendCSRFToken(handler http.Handler) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 {
|
if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 {
|
||||||
sw.Header().Set("X-CSRF-Token", gcsrf.Token(r))
|
sw.Header().Set("X-CSRF-Token", gorillacsrf.Token(r))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -77,33 +73,9 @@ func withSkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Hand
|
||||||
}
|
}
|
||||||
|
|
||||||
if skip {
|
if skip {
|
||||||
r = gcsrf.UnsafeSkipCheck(r)
|
r = gorillacsrf.UnsafeSkipCheck(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func withErrorHandler(trustedOrigins []string) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
err := gcsrf.FailureReason(r)
|
|
||||||
|
|
||||||
if errors.Is(err, gcsrf.ErrBadOrigin) || errors.Is(err, gcsrf.ErrBadReferer) || errors.Is(err, gcsrf.ErrNoReferer) {
|
|
||||||
log.Error().Err(err).
|
|
||||||
Str("request_url", r.URL.String()).
|
|
||||||
Str("host", r.Host).
|
|
||||||
Str("x_forwarded_proto", r.Header.Get("X-Forwarded-Proto")).
|
|
||||||
Str("forwarded", r.Header.Get("Forwarded")).
|
|
||||||
Str("origin", r.Header.Get("Origin")).
|
|
||||||
Str("referer", r.Header.Get("Referer")).
|
|
||||||
Strs("trusted_origins", trustedOrigins).
|
|
||||||
Msg("Failed to validate Origin or Referer")
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Error(
|
|
||||||
w,
|
|
||||||
http.StatusText(http.StatusForbidden)+" - "+err.Error(),
|
|
||||||
http.StatusForbidden,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
@ -2,7 +2,6 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
@ -83,11 +82,6 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear any existing user caches
|
|
||||||
if user != nil {
|
|
||||||
handler.KubernetesClientFactory.ClearUserClientCache(strconv.Itoa(int(user.ID)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if user != nil && isUserInitialAdmin(user) || settings.AuthenticationMethod == portainer.AuthenticationInternal {
|
if user != nil && isUserInitialAdmin(user) || settings.AuthenticationMethod == portainer.AuthenticationInternal {
|
||||||
return handler.authenticateInternal(rw, user, payload.Password)
|
return handler.authenticateInternal(rw, user, payload.Password)
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,6 @@ import (
|
||||||
"github.com/portainer/portainer/api/http/proxy"
|
"github.com/portainer/portainer/api/http/proxy"
|
||||||
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
@ -24,18 +23,16 @@ type Handler struct {
|
||||||
OAuthService portainer.OAuthService
|
OAuthService portainer.OAuthService
|
||||||
ProxyManager *proxy.Manager
|
ProxyManager *proxy.Manager
|
||||||
KubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
KubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||||
KubernetesClientFactory *cli.ClientFactory
|
|
||||||
passwordStrengthChecker security.PasswordStrengthChecker
|
passwordStrengthChecker security.PasswordStrengthChecker
|
||||||
bouncer security.BouncerService
|
bouncer security.BouncerService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a handler to manage authentication operations.
|
// NewHandler creates a handler to manage authentication operations.
|
||||||
func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimiter, passwordStrengthChecker security.PasswordStrengthChecker, kubernetesClientFactory *cli.ClientFactory) *Handler {
|
func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimiter, passwordStrengthChecker security.PasswordStrengthChecker) *Handler {
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
Router: mux.NewRouter(),
|
Router: mux.NewRouter(),
|
||||||
passwordStrengthChecker: passwordStrengthChecker,
|
passwordStrengthChecker: passwordStrengthChecker,
|
||||||
bouncer: bouncer,
|
bouncer: bouncer,
|
||||||
KubernetesClientFactory: kubernetesClientFactory,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h.Handle("/auth/oauth/validate",
|
h.Handle("/auth/oauth/validate",
|
||||||
|
|
|
@ -2,7 +2,6 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
"github.com/portainer/portainer/api/logoutcontext"
|
"github.com/portainer/portainer/api/logoutcontext"
|
||||||
|
@ -24,7 +23,6 @@ func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperro
|
||||||
|
|
||||||
if tokenData != nil {
|
if tokenData != nil {
|
||||||
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID)
|
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID)
|
||||||
handler.KubernetesClientFactory.ClearUserClientCache(strconv.Itoa(int(tokenData.ID)))
|
|
||||||
logoutcontext.Cancel(tokenData.Token)
|
logoutcontext.Cancel(tokenData.Token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -33,28 +33,13 @@ type TestGitService struct {
|
||||||
targetFilePath string
|
targetFilePath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *TestGitService) CloneRepository(
|
func (g *TestGitService) CloneRepository(destination string, repositoryURL, referenceName string, username, password string, tlsSkipVerify bool) error {
|
||||||
destination string,
|
|
||||||
repositoryURL,
|
|
||||||
referenceName string,
|
|
||||||
username,
|
|
||||||
password string,
|
|
||||||
authType gittypes.GitCredentialAuthType,
|
|
||||||
tlsSkipVerify bool,
|
|
||||||
) error {
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
return createTestFile(g.targetFilePath)
|
return createTestFile(g.targetFilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *TestGitService) LatestCommitID(
|
func (g *TestGitService) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) {
|
||||||
repositoryURL,
|
|
||||||
referenceName,
|
|
||||||
username,
|
|
||||||
password string,
|
|
||||||
authType gittypes.GitCredentialAuthType,
|
|
||||||
tlsSkipVerify bool,
|
|
||||||
) (string, error) {
|
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,26 +56,11 @@ type InvalidTestGitService struct {
|
||||||
targetFilePath string
|
targetFilePath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *InvalidTestGitService) CloneRepository(
|
func (g *InvalidTestGitService) CloneRepository(dest, repoUrl, refName, username, password string, tlsSkipVerify bool) error {
|
||||||
dest,
|
|
||||||
repoUrl,
|
|
||||||
refName,
|
|
||||||
username,
|
|
||||||
password string,
|
|
||||||
authType gittypes.GitCredentialAuthType,
|
|
||||||
tlsSkipVerify bool,
|
|
||||||
) error {
|
|
||||||
return errors.New("simulate network error")
|
return errors.New("simulate network error")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *InvalidTestGitService) LatestCommitID(
|
func (g *InvalidTestGitService) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) {
|
||||||
repositoryURL,
|
|
||||||
referenceName,
|
|
||||||
username,
|
|
||||||
password string,
|
|
||||||
authType gittypes.GitCredentialAuthType,
|
|
||||||
tlsSkipVerify bool,
|
|
||||||
) (string, error) {
|
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -71,7 +71,7 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
|
||||||
customTemplates = filterByType(customTemplates, templateTypes)
|
customTemplates = filterByType(customTemplates, templateTypes)
|
||||||
|
|
||||||
if edge != nil {
|
if edge != nil {
|
||||||
customTemplates = slicesx.FilterInPlace(customTemplates, func(customTemplate portainer.CustomTemplate) bool {
|
customTemplates = slicesx.Filter(customTemplates, func(customTemplate portainer.CustomTemplate) bool {
|
||||||
return customTemplate.EdgeTemplate == *edge
|
return customTemplate.EdgeTemplate == *edge
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,16 +37,14 @@ type customTemplateUpdatePayload struct {
|
||||||
RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"`
|
RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"`
|
||||||
// Reference name of a Git repository hosting the Stack file
|
// Reference name of a Git repository hosting the Stack file
|
||||||
RepositoryReferenceName string `example:"refs/heads/master"`
|
RepositoryReferenceName string `example:"refs/heads/master"`
|
||||||
// Use authentication to clone the Git repository
|
// Use basic authentication to clone the Git repository
|
||||||
RepositoryAuthentication bool `example:"true"`
|
RepositoryAuthentication bool `example:"true"`
|
||||||
// Username used in basic authentication. Required when RepositoryAuthentication is true
|
// Username used in basic authentication. Required when RepositoryAuthentication is true
|
||||||
// and RepositoryGitCredentialID is 0. Ignored if RepositoryAuthType is token
|
// and RepositoryGitCredentialID is 0
|
||||||
RepositoryUsername string `example:"myGitUsername"`
|
RepositoryUsername string `example:"myGitUsername"`
|
||||||
// Password used in basic authentication or token used in token authentication.
|
// Password used in basic authentication. Required when RepositoryAuthentication is true
|
||||||
// Required when RepositoryAuthentication is true and RepositoryGitCredentialID is 0
|
// and RepositoryGitCredentialID is 0
|
||||||
RepositoryPassword string `example:"myGitPassword"`
|
RepositoryPassword string `example:"myGitPassword"`
|
||||||
// RepositoryAuthorizationType is the authorization type to use
|
|
||||||
RepositoryAuthorizationType gittypes.GitCredentialAuthType `example:"0"`
|
|
||||||
// GitCredentialID used to identify the bound git credential. Required when RepositoryAuthentication
|
// GitCredentialID used to identify the bound git credential. Required when RepositoryAuthentication
|
||||||
// is true and RepositoryUsername/RepositoryPassword are not provided
|
// is true and RepositoryUsername/RepositoryPassword are not provided
|
||||||
RepositoryGitCredentialID int `example:"0"`
|
RepositoryGitCredentialID int `example:"0"`
|
||||||
|
@ -184,15 +182,12 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
|
||||||
|
|
||||||
repositoryUsername := ""
|
repositoryUsername := ""
|
||||||
repositoryPassword := ""
|
repositoryPassword := ""
|
||||||
repositoryAuthType := gittypes.GitCredentialAuthType_Basic
|
|
||||||
if payload.RepositoryAuthentication {
|
if payload.RepositoryAuthentication {
|
||||||
repositoryUsername = payload.RepositoryUsername
|
repositoryUsername = payload.RepositoryUsername
|
||||||
repositoryPassword = payload.RepositoryPassword
|
repositoryPassword = payload.RepositoryPassword
|
||||||
repositoryAuthType = payload.RepositoryAuthorizationType
|
|
||||||
gitConfig.Authentication = &gittypes.GitAuthentication{
|
gitConfig.Authentication = &gittypes.GitAuthentication{
|
||||||
Username: payload.RepositoryUsername,
|
Username: payload.RepositoryUsername,
|
||||||
Password: payload.RepositoryPassword,
|
Password: payload.RepositoryPassword,
|
||||||
AuthorizationType: payload.RepositoryAuthorizationType,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -202,7 +197,6 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
|
||||||
ReferenceName: gitConfig.ReferenceName,
|
ReferenceName: gitConfig.ReferenceName,
|
||||||
Username: repositoryUsername,
|
Username: repositoryUsername,
|
||||||
Password: repositoryPassword,
|
Password: repositoryPassword,
|
||||||
AuthType: repositoryAuthType,
|
|
||||||
TLSSkipVerify: gitConfig.TLSSkipVerify,
|
TLSSkipVerify: gitConfig.TLSSkipVerify,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -211,14 +205,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
|
||||||
|
|
||||||
defer cleanBackup()
|
defer cleanBackup()
|
||||||
|
|
||||||
commitHash, err := handler.GitService.LatestCommitID(
|
commitHash, err := handler.GitService.LatestCommitID(gitConfig.URL, gitConfig.ReferenceName, repositoryUsername, repositoryPassword, gitConfig.TLSSkipVerify)
|
||||||
gitConfig.URL,
|
|
||||||
gitConfig.ReferenceName,
|
|
||||||
repositoryUsername,
|
|
||||||
repositoryPassword,
|
|
||||||
repositoryAuthType,
|
|
||||||
gitConfig.TLSSkipVerify,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable get latest commit id", fmt.Errorf("failed to fetch latest commit id of the template %v: %w", customTemplate.ID, err))
|
return httperror.InternalServerError("Unable get latest commit id", fmt.Errorf("failed to fetch latest commit id of the template %v: %w", customTemplate.ID, err))
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
"github.com/portainer/portainer/api/roar"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type endpointSetType map[portainer.EndpointID]bool
|
type endpointSetType map[portainer.EndpointID]bool
|
||||||
|
@ -50,29 +49,22 @@ func GetEndpointsByTags(tx dataservices.DataStoreTx, tagIDs []portainer.TagID, p
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTrustedEndpoints(tx dataservices.DataStoreTx, endpointIDs roar.Roar[portainer.EndpointID]) ([]portainer.EndpointID, error) {
|
func getTrustedEndpoints(tx dataservices.DataStoreTx, endpointIDs []portainer.EndpointID) ([]portainer.EndpointID, error) {
|
||||||
var innerErr error
|
|
||||||
|
|
||||||
results := []portainer.EndpointID{}
|
results := []portainer.EndpointID{}
|
||||||
|
for _, endpointID := range endpointIDs {
|
||||||
endpointIDs.Iterate(func(endpointID portainer.EndpointID) bool {
|
|
||||||
endpoint, err := tx.Endpoint().Endpoint(endpointID)
|
endpoint, err := tx.Endpoint().Endpoint(endpointID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
innerErr = err
|
return nil, err
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !endpoint.UserTrusted {
|
if !endpoint.UserTrusted {
|
||||||
return true
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
results = append(results, endpoint.ID)
|
results = append(results, endpoint.ID)
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return results, nil
|
||||||
})
|
|
||||||
|
|
||||||
return results, innerErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapEndpointGroupToEndpoints(endpoints []portainer.Endpoint) map[portainer.EndpointGroupID]endpointSetType {
|
func mapEndpointGroupToEndpoints(endpoints []portainer.Endpoint) map[portainer.EndpointGroupID]endpointSetType {
|
||||||
|
|
|
@ -7,7 +7,6 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
"github.com/portainer/portainer/api/roar"
|
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
)
|
)
|
||||||
|
@ -53,7 +52,6 @@ func calculateEndpointsOrTags(tx dataservices.DataStoreTx, edgeGroup *portainer.
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeGroup.Endpoints = endpointIDs
|
edgeGroup.Endpoints = endpointIDs
|
||||||
edgeGroup.EndpointIDs = roar.FromSlice(endpointIDs)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -96,7 +94,6 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request)
|
||||||
Dynamic: payload.Dynamic,
|
Dynamic: payload.Dynamic,
|
||||||
TagIDs: []portainer.TagID{},
|
TagIDs: []portainer.TagID{},
|
||||||
Endpoints: []portainer.EndpointID{},
|
Endpoints: []portainer.EndpointID{},
|
||||||
EndpointIDs: roar.Roar[portainer.EndpointID]{},
|
|
||||||
PartialMatch: payload.PartialMatch,
|
PartialMatch: payload.PartialMatch,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,5 +108,5 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
return txResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err)
|
return txResponse(w, edgeGroup, err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,62 +0,0 @@
|
||||||
package edgegroups
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/datastore"
|
|
||||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
|
||||||
|
|
||||||
"github.com/segmentio/encoding/json"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestEdgeGroupCreateHandler(t *testing.T) {
|
|
||||||
_, store := datastore.MustNewTestStore(t, true, true)
|
|
||||||
|
|
||||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
|
||||||
handler.DataStore = store
|
|
||||||
|
|
||||||
err := store.EndpointGroup().Create(&portainer.EndpointGroup{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Test Group",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for i := range 3 {
|
|
||||||
err = store.Endpoint().Create(&portainer.Endpoint{
|
|
||||||
ID: portainer.EndpointID(i + 1),
|
|
||||||
Name: "Test Endpoint " + strconv.Itoa(i+1),
|
|
||||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
|
||||||
GroupID: 1,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = store.EndpointRelation().Create(&portainer.EndpointRelation{
|
|
||||||
EndpointID: portainer.EndpointID(i + 1),
|
|
||||||
EdgeStacks: map[portainer.EdgeStackID]bool{},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(
|
|
||||||
http.MethodPost,
|
|
||||||
"/edge_groups",
|
|
||||||
strings.NewReader(`{"Name": "New Edge Group", "Endpoints": [1, 2, 3]}`),
|
|
||||||
)
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
|
||||||
|
|
||||||
var responseGroup portainer.EdgeGroup
|
|
||||||
err = json.NewDecoder(rr.Body).Decode(&responseGroup)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints)
|
|
||||||
}
|
|
|
@ -5,7 +5,6 @@ import (
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/roar"
|
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
)
|
)
|
||||||
|
@ -34,9 +33,7 @@ func (handler *Handler) edgeGroupInspect(w http.ResponseWriter, r *http.Request)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
edgeGroup.Endpoints = edgeGroup.EndpointIDs.ToSlice()
|
return txResponse(w, edgeGroup, err)
|
||||||
|
|
||||||
return txResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) (*portainer.EdgeGroup, error) {
|
func getEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) (*portainer.EdgeGroup, error) {
|
||||||
|
@ -53,7 +50,7 @@ func getEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) (*porta
|
||||||
return nil, httperror.InternalServerError("Unable to retrieve environments and environment groups for Edge group", err)
|
return nil, httperror.InternalServerError("Unable to retrieve environments and environment groups for Edge group", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeGroup.EndpointIDs = roar.FromSlice(endpoints)
|
edgeGroup.Endpoints = endpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
return edgeGroup, err
|
return edgeGroup, err
|
||||||
|
|
|
@ -1,176 +0,0 @@
|
||||||
package edgegroups
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/datastore"
|
|
||||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
|
||||||
"github.com/portainer/portainer/api/roar"
|
|
||||||
|
|
||||||
"github.com/segmentio/encoding/json"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestEdgeGroupInspectHandler(t *testing.T) {
|
|
||||||
_, store := datastore.MustNewTestStore(t, true, true)
|
|
||||||
|
|
||||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
|
||||||
handler.DataStore = store
|
|
||||||
|
|
||||||
err := store.EndpointGroup().Create(&portainer.EndpointGroup{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Test Group",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for i := range 3 {
|
|
||||||
err = store.Endpoint().Create(&portainer.Endpoint{
|
|
||||||
ID: portainer.EndpointID(i + 1),
|
|
||||||
Name: "Test Endpoint " + strconv.Itoa(i+1),
|
|
||||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
|
||||||
GroupID: 1,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = store.EndpointRelation().Create(&portainer.EndpointRelation{
|
|
||||||
EndpointID: portainer.EndpointID(i + 1),
|
|
||||||
EdgeStacks: map[portainer.EdgeStackID]bool{},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Test Edge Group",
|
|
||||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1, 2, 3}),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(
|
|
||||||
http.MethodGet,
|
|
||||||
"/edge_groups/1",
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
|
||||||
|
|
||||||
var responseGroup portainer.EdgeGroup
|
|
||||||
err = json.NewDecoder(rr.Body).Decode(&responseGroup)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmptyEdgeGroupInspectHandler(t *testing.T) {
|
|
||||||
_, store := datastore.MustNewTestStore(t, true, true)
|
|
||||||
|
|
||||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
|
||||||
handler.DataStore = store
|
|
||||||
|
|
||||||
err := store.EndpointGroup().Create(&portainer.EndpointGroup{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Test Group",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Test Edge Group",
|
|
||||||
EndpointIDs: roar.Roar[portainer.EndpointID]{},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(
|
|
||||||
http.MethodGet,
|
|
||||||
"/edge_groups/1",
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
|
||||||
|
|
||||||
var responseGroup portainer.EdgeGroup
|
|
||||||
err = json.NewDecoder(rr.Body).Decode(&responseGroup)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Make sure the frontend does not get a null value but a [] instead
|
|
||||||
require.NotNil(t, responseGroup.Endpoints)
|
|
||||||
require.Len(t, responseGroup.Endpoints, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDynamicEdgeGroupInspectHandler(t *testing.T) {
|
|
||||||
_, store := datastore.MustNewTestStore(t, true, true)
|
|
||||||
|
|
||||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
|
||||||
handler.DataStore = store
|
|
||||||
|
|
||||||
err := store.EndpointGroup().Create(&portainer.EndpointGroup{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Test Group",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = store.Tag().Create(&portainer.Tag{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Test Tag",
|
|
||||||
Endpoints: map[portainer.EndpointID]bool{
|
|
||||||
1: true,
|
|
||||||
2: true,
|
|
||||||
3: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for i := range 3 {
|
|
||||||
err = store.Endpoint().Create(&portainer.Endpoint{
|
|
||||||
ID: portainer.EndpointID(i + 1),
|
|
||||||
Name: "Test Endpoint " + strconv.Itoa(i+1),
|
|
||||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
|
||||||
GroupID: 1,
|
|
||||||
TagIDs: []portainer.TagID{1},
|
|
||||||
UserTrusted: true,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = store.EndpointRelation().Create(&portainer.EndpointRelation{
|
|
||||||
EndpointID: portainer.EndpointID(i + 1),
|
|
||||||
EdgeStacks: map[portainer.EdgeStackID]bool{},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Test Edge Group",
|
|
||||||
Dynamic: true,
|
|
||||||
TagIDs: []portainer.TagID{1},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(
|
|
||||||
http.MethodGet,
|
|
||||||
"/edge_groups/1",
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
|
||||||
|
|
||||||
var responseGroup portainer.EdgeGroup
|
|
||||||
err = json.NewDecoder(rr.Body).Decode(&responseGroup)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints)
|
|
||||||
}
|
|
|
@ -7,17 +7,11 @@ import (
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/roar"
|
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
)
|
)
|
||||||
|
|
||||||
type shadowedEdgeGroup struct {
|
|
||||||
portainer.EdgeGroup
|
|
||||||
EndpointIds int `json:"EndpointIds,omitempty"` // Shadow to avoid exposing in the API
|
|
||||||
}
|
|
||||||
|
|
||||||
type decoratedEdgeGroup struct {
|
type decoratedEdgeGroup struct {
|
||||||
shadowedEdgeGroup
|
portainer.EdgeGroup
|
||||||
HasEdgeStack bool `json:"HasEdgeStack"`
|
HasEdgeStack bool `json:"HasEdgeStack"`
|
||||||
HasEdgeJob bool `json:"HasEdgeJob"`
|
HasEdgeJob bool `json:"HasEdgeJob"`
|
||||||
EndpointTypes []portainer.EndpointType
|
EndpointTypes []portainer.EndpointType
|
||||||
|
@ -82,8 +76,8 @@ func getEdgeGroupList(tx dataservices.DataStoreTx) ([]decoratedEdgeGroup, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeGroup := decoratedEdgeGroup{
|
edgeGroup := decoratedEdgeGroup{
|
||||||
shadowedEdgeGroup: shadowedEdgeGroup{EdgeGroup: orgEdgeGroup},
|
EdgeGroup: orgEdgeGroup,
|
||||||
EndpointTypes: []portainer.EndpointType{},
|
EndpointTypes: []portainer.EndpointType{},
|
||||||
}
|
}
|
||||||
if edgeGroup.Dynamic {
|
if edgeGroup.Dynamic {
|
||||||
endpointIDs, err := GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch)
|
endpointIDs, err := GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch)
|
||||||
|
@ -94,16 +88,15 @@ func getEdgeGroupList(tx dataservices.DataStoreTx) ([]decoratedEdgeGroup, error)
|
||||||
edgeGroup.Endpoints = endpointIDs
|
edgeGroup.Endpoints = endpointIDs
|
||||||
edgeGroup.TrustedEndpoints = endpointIDs
|
edgeGroup.TrustedEndpoints = endpointIDs
|
||||||
} else {
|
} else {
|
||||||
trustedEndpoints, err := getTrustedEndpoints(tx, edgeGroup.EndpointIDs)
|
trustedEndpoints, err := getTrustedEndpoints(tx, edgeGroup.Endpoints)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, httperror.InternalServerError("Unable to retrieve environments for Edge group", err)
|
return nil, httperror.InternalServerError("Unable to retrieve environments for Edge group", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeGroup.Endpoints = edgeGroup.EndpointIDs.ToSlice()
|
|
||||||
edgeGroup.TrustedEndpoints = trustedEndpoints
|
edgeGroup.TrustedEndpoints = trustedEndpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
endpointTypes, err := getEndpointTypes(tx, edgeGroup.EndpointIDs)
|
endpointTypes, err := getEndpointTypes(tx, edgeGroup.Endpoints)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, httperror.InternalServerError("Unable to retrieve environment types for Edge group", err)
|
return nil, httperror.InternalServerError("Unable to retrieve environment types for Edge group", err)
|
||||||
}
|
}
|
||||||
|
@ -118,26 +111,15 @@ func getEdgeGroupList(tx dataservices.DataStoreTx) ([]decoratedEdgeGroup, error)
|
||||||
return decoratedEdgeGroups, nil
|
return decoratedEdgeGroups, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEndpointTypes(tx dataservices.DataStoreTx, endpointIds roar.Roar[portainer.EndpointID]) ([]portainer.EndpointType, error) {
|
func getEndpointTypes(tx dataservices.DataStoreTx, endpointIds []portainer.EndpointID) ([]portainer.EndpointType, error) {
|
||||||
var innerErr error
|
|
||||||
|
|
||||||
typeSet := map[portainer.EndpointType]bool{}
|
typeSet := map[portainer.EndpointType]bool{}
|
||||||
|
for _, endpointID := range endpointIds {
|
||||||
endpointIds.Iterate(func(endpointID portainer.EndpointID) bool {
|
|
||||||
endpoint, err := tx.Endpoint().Endpoint(endpointID)
|
endpoint, err := tx.Endpoint().Endpoint(endpointID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
innerErr = fmt.Errorf("failed fetching environment: %w", err)
|
return nil, fmt.Errorf("failed fetching environment: %w", err)
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
typeSet[endpoint.Type] = true
|
typeSet[endpoint.Type] = true
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if innerErr != nil {
|
|
||||||
return nil, innerErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
endpointTypes := make([]portainer.EndpointType, 0, len(typeSet))
|
endpointTypes := make([]portainer.EndpointType, 0, len(typeSet))
|
||||||
|
|
|
@ -1,19 +1,11 @@
|
||||||
package edgegroups
|
package edgegroups
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/datastore"
|
|
||||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
"github.com/portainer/portainer/api/roar"
|
|
||||||
|
|
||||||
"github.com/segmentio/encoding/json"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_getEndpointTypes(t *testing.T) {
|
func Test_getEndpointTypes(t *testing.T) {
|
||||||
|
@ -46,7 +38,7 @@ func Test_getEndpointTypes(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
ans, err := getEndpointTypes(datastore, roar.FromSlice(test.endpointIds))
|
ans, err := getEndpointTypes(datastore, test.endpointIds)
|
||||||
assert.NoError(t, err, "getEndpointTypes shouldn't fail")
|
assert.NoError(t, err, "getEndpointTypes shouldn't fail")
|
||||||
|
|
||||||
assert.ElementsMatch(t, test.expected, ans, "getEndpointTypes expected to return %b for %v, but returned %b", test.expected, test.endpointIds, ans)
|
assert.ElementsMatch(t, test.expected, ans, "getEndpointTypes expected to return %b for %v, but returned %b", test.expected, test.endpointIds, ans)
|
||||||
|
@ -56,61 +48,6 @@ func Test_getEndpointTypes(t *testing.T) {
|
||||||
func Test_getEndpointTypes_failWhenEndpointDontExist(t *testing.T) {
|
func Test_getEndpointTypes_failWhenEndpointDontExist(t *testing.T) {
|
||||||
datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{}))
|
datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{}))
|
||||||
|
|
||||||
_, err := getEndpointTypes(datastore, roar.FromSlice([]portainer.EndpointID{1}))
|
_, err := getEndpointTypes(datastore, []portainer.EndpointID{1})
|
||||||
assert.Error(t, err, "getEndpointTypes should fail")
|
assert.Error(t, err, "getEndpointTypes should fail")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEdgeGroupListHandler(t *testing.T) {
|
|
||||||
_, store := datastore.MustNewTestStore(t, true, true)
|
|
||||||
|
|
||||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
|
||||||
handler.DataStore = store
|
|
||||||
|
|
||||||
err := store.EndpointGroup().Create(&portainer.EndpointGroup{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Test Group",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for i := range 3 {
|
|
||||||
err = store.Endpoint().Create(&portainer.Endpoint{
|
|
||||||
ID: portainer.EndpointID(i + 1),
|
|
||||||
Name: "Test Endpoint " + strconv.Itoa(i+1),
|
|
||||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
|
||||||
GroupID: 1,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = store.EndpointRelation().Create(&portainer.EndpointRelation{
|
|
||||||
EndpointID: portainer.EndpointID(i + 1),
|
|
||||||
EdgeStacks: map[portainer.EdgeStackID]bool{},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Test Edge Group",
|
|
||||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1, 2, 3}),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(
|
|
||||||
http.MethodGet,
|
|
||||||
"/edge_groups",
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
|
||||||
|
|
||||||
var responseGroups []decoratedEdgeGroup
|
|
||||||
err = json.NewDecoder(rr.Body).Decode(&responseGroups)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.Len(t, responseGroups, 1)
|
|
||||||
require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroups[0].Endpoints)
|
|
||||||
require.Len(t, responseGroups[0].TrustedEndpoints, 0)
|
|
||||||
}
|
|
||||||
|
|
|
@ -158,12 +158,12 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
return txResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err)
|
return txResponse(w, edgeGroup, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
|
func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
|
||||||
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
||||||
if err != nil {
|
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -179,6 +179,12 @@ func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoi
|
||||||
edgeStackSet[edgeStackID] = true
|
edgeStackSet[edgeStackID] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if relation == nil {
|
||||||
|
relation = &portainer.EndpointRelation{
|
||||||
|
EndpointID: endpoint.ID,
|
||||||
|
EdgeStacks: make(map[portainer.EdgeStackID]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
relation.EdgeStacks = edgeStackSet
|
relation.EdgeStacks = edgeStackSet
|
||||||
|
|
||||||
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)
|
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)
|
||||||
|
|
|
@ -1,70 +0,0 @@
|
||||||
package edgegroups
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/datastore"
|
|
||||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
|
||||||
"github.com/portainer/portainer/api/roar"
|
|
||||||
|
|
||||||
"github.com/segmentio/encoding/json"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestEdgeGroupUpdateHandler(t *testing.T) {
|
|
||||||
_, store := datastore.MustNewTestStore(t, true, true)
|
|
||||||
|
|
||||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
|
||||||
handler.DataStore = store
|
|
||||||
|
|
||||||
err := store.EndpointGroup().Create(&portainer.EndpointGroup{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Test Group",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for i := range 3 {
|
|
||||||
err = store.Endpoint().Create(&portainer.Endpoint{
|
|
||||||
ID: portainer.EndpointID(i + 1),
|
|
||||||
Name: "Test Endpoint " + strconv.Itoa(i+1),
|
|
||||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
|
||||||
GroupID: 1,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = store.EndpointRelation().Create(&portainer.EndpointRelation{
|
|
||||||
EndpointID: portainer.EndpointID(i + 1),
|
|
||||||
EdgeStacks: map[portainer.EdgeStackID]bool{},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Test Edge Group",
|
|
||||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(
|
|
||||||
http.MethodPut,
|
|
||||||
"/edge_groups/1",
|
|
||||||
strings.NewReader(`{"Endpoints": [1, 2, 3]}`),
|
|
||||||
)
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
|
||||||
|
|
||||||
var responseGroup portainer.EdgeGroup
|
|
||||||
err = json.NewDecoder(rr.Body).Decode(&responseGroup)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints)
|
|
||||||
}
|
|
|
@ -33,8 +33,6 @@ type edgeStackFromGitRepositoryPayload struct {
|
||||||
RepositoryUsername string `example:"myGitUsername"`
|
RepositoryUsername string `example:"myGitUsername"`
|
||||||
// Password used in basic authentication. Required when RepositoryAuthentication is true.
|
// Password used in basic authentication. Required when RepositoryAuthentication is true.
|
||||||
RepositoryPassword string `example:"myGitPassword"`
|
RepositoryPassword string `example:"myGitPassword"`
|
||||||
// RepositoryAuthorizationType is the authorization type to use
|
|
||||||
RepositoryAuthorizationType gittypes.GitCredentialAuthType `example:"0"`
|
|
||||||
// Path to the Stack file inside the Git repository
|
// Path to the Stack file inside the Git repository
|
||||||
FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
|
FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
|
||||||
// List of identifiers of EdgeGroups
|
// List of identifiers of EdgeGroups
|
||||||
|
@ -127,9 +125,8 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat
|
||||||
|
|
||||||
if payload.RepositoryAuthentication {
|
if payload.RepositoryAuthentication {
|
||||||
repoConfig.Authentication = &gittypes.GitAuthentication{
|
repoConfig.Authentication = &gittypes.GitAuthentication{
|
||||||
Username: payload.RepositoryUsername,
|
Username: payload.RepositoryUsername,
|
||||||
Password: payload.RepositoryPassword,
|
Password: payload.RepositoryPassword,
|
||||||
AuthorizationType: payload.RepositoryAuthorizationType,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -148,22 +145,12 @@ func (handler *Handler) storeManifestFromGitRepository(tx dataservices.DataStore
|
||||||
projectPath = handler.FileService.GetEdgeStackProjectPath(stackFolder)
|
projectPath = handler.FileService.GetEdgeStackProjectPath(stackFolder)
|
||||||
repositoryUsername := ""
|
repositoryUsername := ""
|
||||||
repositoryPassword := ""
|
repositoryPassword := ""
|
||||||
repositoryAuthType := gittypes.GitCredentialAuthType_Basic
|
|
||||||
if repositoryConfig.Authentication != nil && repositoryConfig.Authentication.Password != "" {
|
if repositoryConfig.Authentication != nil && repositoryConfig.Authentication.Password != "" {
|
||||||
repositoryUsername = repositoryConfig.Authentication.Username
|
repositoryUsername = repositoryConfig.Authentication.Username
|
||||||
repositoryPassword = repositoryConfig.Authentication.Password
|
repositoryPassword = repositoryConfig.Authentication.Password
|
||||||
repositoryAuthType = repositoryConfig.Authentication.AuthorizationType
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := handler.GitService.CloneRepository(
|
if err := handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryConfig.TLSSkipVerify); err != nil {
|
||||||
projectPath,
|
|
||||||
repositoryConfig.URL,
|
|
||||||
repositoryConfig.ReferenceName,
|
|
||||||
repositoryUsername,
|
|
||||||
repositoryPassword,
|
|
||||||
repositoryAuthType,
|
|
||||||
repositoryConfig.TLSSkipVerify,
|
|
||||||
); err != nil {
|
|
||||||
return "", "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,10 +8,9 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/roar"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/segmentio/encoding/json"
|
"github.com/segmentio/encoding/json"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create
|
// Create
|
||||||
|
@ -25,7 +24,7 @@ func TestCreateAndInspect(t *testing.T) {
|
||||||
Name: "EdgeGroup 1",
|
Name: "EdgeGroup 1",
|
||||||
Dynamic: false,
|
Dynamic: false,
|
||||||
TagIDs: nil,
|
TagIDs: nil,
|
||||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpoint.ID}),
|
Endpoints: []portainer.EndpointID{endpoint.ID},
|
||||||
PartialMatch: false,
|
PartialMatch: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,39 +3,10 @@ package edgestacks
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
|
||||||
"github.com/portainer/portainer/api/slicesx"
|
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
|
||||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
type aggregatedStatusesMap map[portainer.EdgeStackStatusType]int
|
|
||||||
|
|
||||||
type SummarizedStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
sumStatusUnavailable SummarizedStatus = "Unavailable"
|
|
||||||
sumStatusDeploying SummarizedStatus = "Deploying"
|
|
||||||
sumStatusFailed SummarizedStatus = "Failed"
|
|
||||||
sumStatusPaused SummarizedStatus = "Paused"
|
|
||||||
sumStatusPartiallyRunning SummarizedStatus = "PartiallyRunning"
|
|
||||||
sumStatusCompleted SummarizedStatus = "Completed"
|
|
||||||
sumStatusRunning SummarizedStatus = "Running"
|
|
||||||
)
|
|
||||||
|
|
||||||
type edgeStackStatusSummary struct {
|
|
||||||
AggregatedStatus aggregatedStatusesMap
|
|
||||||
Status SummarizedStatus
|
|
||||||
Reason string
|
|
||||||
}
|
|
||||||
|
|
||||||
type edgeStackListResponseItem struct {
|
|
||||||
portainer.EdgeStack
|
|
||||||
StatusSummary edgeStackStatusSummary
|
|
||||||
}
|
|
||||||
|
|
||||||
// @id EdgeStackList
|
// @id EdgeStackList
|
||||||
// @summary Fetches the list of EdgeStacks
|
// @summary Fetches the list of EdgeStacks
|
||||||
// @description **Access policy**: administrator
|
// @description **Access policy**: administrator
|
||||||
|
@ -43,122 +14,22 @@ type edgeStackListResponseItem struct {
|
||||||
// @security ApiKeyAuth
|
// @security ApiKeyAuth
|
||||||
// @security jwt
|
// @security jwt
|
||||||
// @produce json
|
// @produce json
|
||||||
// @param summarizeStatuses query boolean false "will summarize the statuses"
|
|
||||||
// @success 200 {array} portainer.EdgeStack
|
// @success 200 {array} portainer.EdgeStack
|
||||||
// @failure 500
|
// @failure 500
|
||||||
// @failure 400
|
// @failure 400
|
||||||
// @failure 503 "Edge compute features are disabled"
|
// @failure 503 "Edge compute features are disabled"
|
||||||
// @router /edge_stacks [get]
|
// @router /edge_stacks [get]
|
||||||
func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
summarizeStatuses, _ := request.RetrieveBooleanQueryParameter(r, "summarizeStatuses", true)
|
|
||||||
|
|
||||||
edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks()
|
edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err)
|
return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
res := make([]edgeStackListResponseItem, len(edgeStacks))
|
|
||||||
|
|
||||||
for i := range edgeStacks {
|
for i := range edgeStacks {
|
||||||
res[i].EdgeStack = edgeStacks[i]
|
if err := fillEdgeStackStatus(handler.DataStore, &edgeStacks[i]); err != nil {
|
||||||
|
|
||||||
if summarizeStatuses {
|
|
||||||
if err := fillStatusSummary(handler.DataStore, &res[i]); err != nil {
|
|
||||||
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
|
|
||||||
}
|
|
||||||
} else if err := fillEdgeStackStatus(handler.DataStore, &res[i].EdgeStack); err != nil {
|
|
||||||
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
|
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.JSON(w, res)
|
return response.JSON(w, edgeStacks)
|
||||||
}
|
|
||||||
|
|
||||||
func fillStatusSummary(tx dataservices.DataStoreTx, edgeStack *edgeStackListResponseItem) error {
|
|
||||||
statuses, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
aggregated := make(aggregatedStatusesMap)
|
|
||||||
|
|
||||||
for _, envStatus := range statuses {
|
|
||||||
for _, status := range envStatus.Status {
|
|
||||||
aggregated[status.Type]++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
status, reason := SummarizeStatuses(statuses, edgeStack.NumDeployments)
|
|
||||||
|
|
||||||
edgeStack.StatusSummary = edgeStackStatusSummary{
|
|
||||||
AggregatedStatus: aggregated,
|
|
||||||
Status: status,
|
|
||||||
Reason: reason,
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeStack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SummarizeStatuses(statuses []portainer.EdgeStackStatusForEnv, numDeployments int) (SummarizedStatus, string) {
|
|
||||||
if numDeployments == 0 {
|
|
||||||
return sumStatusUnavailable, "Your edge stack is currently unavailable due to the absence of an available environment in your edge group"
|
|
||||||
}
|
|
||||||
|
|
||||||
allStatuses := slicesx.FlatMap(statuses, func(x portainer.EdgeStackStatusForEnv) []portainer.EdgeStackDeploymentStatus {
|
|
||||||
return x.Status
|
|
||||||
})
|
|
||||||
|
|
||||||
lastStatuses := slicesx.Map(
|
|
||||||
slicesx.Filter(
|
|
||||||
statuses,
|
|
||||||
func(s portainer.EdgeStackStatusForEnv) bool {
|
|
||||||
return len(s.Status) > 0
|
|
||||||
},
|
|
||||||
),
|
|
||||||
func(x portainer.EdgeStackStatusForEnv) portainer.EdgeStackDeploymentStatus {
|
|
||||||
return x.Status[len(x.Status)-1]
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(lastStatuses) == 0 {
|
|
||||||
return sumStatusDeploying, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if allFailed := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool {
|
|
||||||
return s.Type == portainer.EdgeStackStatusError
|
|
||||||
}); allFailed {
|
|
||||||
return sumStatusFailed, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasPaused := slicesx.Some(allStatuses, func(s portainer.EdgeStackDeploymentStatus) bool {
|
|
||||||
return s.Type == portainer.EdgeStackStatusPausedDeploying
|
|
||||||
}); hasPaused {
|
|
||||||
return sumStatusPaused, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(lastStatuses) < numDeployments {
|
|
||||||
return sumStatusDeploying, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
hasDeploying := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusDeploying })
|
|
||||||
hasRunning := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusRunning })
|
|
||||||
hasFailed := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusError })
|
|
||||||
|
|
||||||
if hasRunning && hasFailed && !hasDeploying {
|
|
||||||
return sumStatusPartiallyRunning, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if allCompleted := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusCompleted }); allCompleted {
|
|
||||||
return sumStatusCompleted, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if allRunning := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool {
|
|
||||||
return s.Type == portainer.EdgeStackStatusRunning
|
|
||||||
}); allRunning {
|
|
||||||
return sumStatusRunning, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return sumStatusDeploying, ""
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -133,9 +133,7 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, stack
|
||||||
}
|
}
|
||||||
|
|
||||||
environmentStatus, err := tx.EdgeStackStatus().Read(stackID, payload.EndpointID)
|
environmentStatus, err := tx.EdgeStackStatus().Read(stackID, payload.EndpointID)
|
||||||
if err != nil && !tx.IsErrObjectNotFound(err) {
|
if err != nil {
|
||||||
return err
|
|
||||||
} else if tx.IsErrObjectNotFound(err) {
|
|
||||||
environmentStatus = &portainer.EdgeStackStatusForEnv{
|
environmentStatus = &portainer.EdgeStackStatusForEnv{
|
||||||
EndpointID: payload.EndpointID,
|
EndpointID: payload.EndpointID,
|
||||||
Status: []portainer.EdgeStackDeploymentStatus{},
|
Status: []portainer.EdgeStackDeploymentStatus{},
|
||||||
|
|
|
@ -15,7 +15,6 @@ import (
|
||||||
"github.com/portainer/portainer/api/internal/edge/edgestacks"
|
"github.com/portainer/portainer/api/internal/edge/edgestacks"
|
||||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
"github.com/portainer/portainer/api/jwt"
|
"github.com/portainer/portainer/api/jwt"
|
||||||
"github.com/portainer/portainer/api/roar"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
@ -104,7 +103,7 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
|
||||||
Name: "EdgeGroup 1",
|
Name: "EdgeGroup 1",
|
||||||
Dynamic: false,
|
Dynamic: false,
|
||||||
TagIDs: nil,
|
TagIDs: nil,
|
||||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpointID}),
|
Endpoints: []portainer.EndpointID{endpointID},
|
||||||
PartialMatch: false,
|
PartialMatch: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,10 +9,9 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/roar"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/segmentio/encoding/json"
|
"github.com/segmentio/encoding/json"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
|
@ -44,7 +43,7 @@ func TestUpdateAndInspect(t *testing.T) {
|
||||||
Name: "EdgeGroup 2",
|
Name: "EdgeGroup 2",
|
||||||
Dynamic: false,
|
Dynamic: false,
|
||||||
TagIDs: nil,
|
TagIDs: nil,
|
||||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{newEndpoint.ID}),
|
Endpoints: []portainer.EndpointID{newEndpoint.ID},
|
||||||
PartialMatch: false,
|
PartialMatch: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -113,7 +112,7 @@ func TestUpdateWithInvalidEdgeGroups(t *testing.T) {
|
||||||
Name: "EdgeGroup 2",
|
Name: "EdgeGroup 2",
|
||||||
Dynamic: false,
|
Dynamic: false,
|
||||||
TagIDs: nil,
|
TagIDs: nil,
|
||||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{8889}),
|
Endpoints: []portainer.EndpointID{8889},
|
||||||
PartialMatch: false,
|
PartialMatch: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -264,6 +264,9 @@ func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpointID p
|
||||||
func (handler *Handler) buildEdgeStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) ([]stackStatusResponse, *httperror.HandlerError) {
|
func (handler *Handler) buildEdgeStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) ([]stackStatusResponse, *httperror.HandlerError) {
|
||||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if tx.IsErrObjectNotFound(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
return nil, httperror.InternalServerError("Unable to retrieve relation object from the database", err)
|
return nil, httperror.InternalServerError("Unable to retrieve relation object from the database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,6 @@ import (
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
"github.com/portainer/portainer/api/jwt"
|
"github.com/portainer/portainer/api/jwt"
|
||||||
"github.com/portainer/portainer/api/roar"
|
|
||||||
|
|
||||||
"github.com/segmentio/encoding/json"
|
"github.com/segmentio/encoding/json"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
@ -367,8 +366,8 @@ func TestEdgeJobsResponse(t *testing.T) {
|
||||||
unrelatedEndpoint := localCreateEndpoint(80, nil)
|
unrelatedEndpoint := localCreateEndpoint(80, nil)
|
||||||
|
|
||||||
staticEdgeGroup := portainer.EdgeGroup{
|
staticEdgeGroup := portainer.EdgeGroup{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpointFromStaticEdgeGroup.ID}),
|
Endpoints: []portainer.EndpointID{endpointFromStaticEdgeGroup.ID},
|
||||||
}
|
}
|
||||||
err := handler.DataStore.EdgeGroup().Create(&staticEdgeGroup)
|
err := handler.DataStore.EdgeGroup().Create(&staticEdgeGroup)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
|
@ -21,10 +21,17 @@ func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, end
|
||||||
}
|
}
|
||||||
|
|
||||||
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
||||||
if err != nil {
|
if err != nil && !tx.IsErrObjectNotFound(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if endpointRelation == nil {
|
||||||
|
endpointRelation = &portainer.EndpointRelation{
|
||||||
|
EndpointID: endpoint.ID,
|
||||||
|
EdgeStacks: make(map[portainer.EdgeStackID]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
edgeGroups, err := tx.EdgeGroup().ReadAll()
|
edgeGroups, err := tx.EdgeGroup().ReadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -563,10 +563,6 @@ func (handler *Handler) saveEndpointAndUpdateAuthorizations(tx dataservices.Data
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := endpointutils.InitializeEdgeEndpointRelation(endpoint, tx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tagID := range endpoint.TagIDs {
|
for _, tagID := range endpoint.TagIDs {
|
||||||
if err := tx.Tag().UpdateTagFunc(tagID, func(tag *portainer.Tag) {
|
if err := tx.Tag().UpdateTagFunc(tagID, func(tag *portainer.Tag) {
|
||||||
tag.Endpoints[endpoint.ID] = true
|
tag.Endpoints[endpoint.ID] = true
|
||||||
|
|
|
@ -3,6 +3,7 @@ package endpoints
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
@ -199,7 +200,9 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, edgeGroup := range edgeGroups {
|
for _, edgeGroup := range edgeGroups {
|
||||||
edgeGroup.EndpointIDs.Remove(endpoint.ID)
|
edgeGroup.Endpoints = slices.DeleteFunc(edgeGroup.Endpoints, func(e portainer.EndpointID) bool {
|
||||||
|
return e == endpoint.ID
|
||||||
|
})
|
||||||
|
|
||||||
if err := tx.EdgeGroup().Update(edgeGroup.ID, &edgeGroup); err != nil {
|
if err := tx.EdgeGroup().Update(edgeGroup.ID, &edgeGroup); err != nil {
|
||||||
log.Warn().Err(err).Msg("Unable to update edge group")
|
log.Warn().Err(err).Msg("Unable to update edge group")
|
||||||
|
|
|
@ -11,7 +11,6 @@ import (
|
||||||
"github.com/portainer/portainer/api/datastore"
|
"github.com/portainer/portainer/api/datastore"
|
||||||
"github.com/portainer/portainer/api/http/proxy"
|
"github.com/portainer/portainer/api/http/proxy"
|
||||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
"github.com/portainer/portainer/api/roar"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) {
|
func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) {
|
||||||
|
@ -22,7 +21,7 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) {
|
||||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||||
handler.DataStore = store
|
handler.DataStore = store
|
||||||
handler.ProxyManager = proxy.NewManager(nil)
|
handler.ProxyManager = proxy.NewManager(nil)
|
||||||
handler.ProxyManager.NewProxyFactory(nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
handler.ProxyManager.NewProxyFactory(nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
// Create all the environments and add them to the same edge group
|
// Create all the environments and add them to the same edge group
|
||||||
|
|
||||||
|
@ -43,9 +42,9 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.EdgeGroup().Create(&portainer.EdgeGroup{
|
if err := store.EdgeGroup().Create(&portainer.EdgeGroup{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Name: "edgegroup-1",
|
Name: "edgegroup-1",
|
||||||
EndpointIDs: roar.FromSlice(endpointIDs),
|
Endpoints: endpointIDs,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal("could not create edge group:", err)
|
t.Fatal("could not create edge group:", err)
|
||||||
}
|
}
|
||||||
|
@ -79,7 +78,7 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) {
|
||||||
t.Fatal("could not retrieve the edge group:", err)
|
t.Fatal("could not retrieve the edge group:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if edgeGroup.EndpointIDs.Len() > 0 {
|
if len(edgeGroup.Endpoints) > 0 {
|
||||||
t.Fatal("the edge group is not consistent")
|
t.Fatal("the edge group is not consistent")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -95,11 +95,12 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||||
return httperror.BadRequest("Invalid query parameters", err)
|
return httperror.BadRequest("Invalid query parameters", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(endpoints, query, endpointGroups, edgeGroups, settings, securityContext)
|
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
|
||||||
|
|
||||||
|
filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(filteredEndpoints, query, endpointGroups, edgeGroups, settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to filter endpoints", err)
|
return httperror.InternalServerError("Unable to filter endpoints", err)
|
||||||
}
|
}
|
||||||
filteredEndpoints = security.FilterEndpoints(filteredEndpoints, endpointGroups, securityContext)
|
|
||||||
|
|
||||||
sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc")
|
sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc")
|
||||||
|
|
||||||
|
|
|
@ -75,7 +75,7 @@ func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Requ
|
||||||
return nil, httperror.InternalServerError("Unable to retrieve registries from the database", err)
|
return nil, httperror.InternalServerError("Unable to retrieve registries from the database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
registries, handleError := handler.filterRegistriesByAccess(tx, r, registries, endpoint, user, securityContext.UserMemberships)
|
registries, handleError := handler.filterRegistriesByAccess(r, registries, endpoint, user, securityContext.UserMemberships)
|
||||||
if handleError != nil {
|
if handleError != nil {
|
||||||
return nil, handleError
|
return nil, handleError
|
||||||
}
|
}
|
||||||
|
@ -87,15 +87,15 @@ func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Requ
|
||||||
return registries, err
|
return registries, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) filterRegistriesByAccess(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
|
func (handler *Handler) filterRegistriesByAccess(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
|
||||||
if !endpointutils.IsKubernetesEndpoint(endpoint) {
|
if !endpointutils.IsKubernetesEndpoint(endpoint) {
|
||||||
return security.FilterRegistries(registries, user, memberships, endpoint.ID), nil
|
return security.FilterRegistries(registries, user, memberships, endpoint.ID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return handler.filterKubernetesEndpointRegistries(tx, r, registries, endpoint, user, memberships)
|
return handler.filterKubernetesEndpointRegistries(r, registries, endpoint, user, memberships)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) filterKubernetesEndpointRegistries(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
|
func (handler *Handler) filterKubernetesEndpointRegistries(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
|
||||||
namespaceParam, _ := request.RetrieveQueryParameter(r, "namespace", true)
|
namespaceParam, _ := request.RetrieveQueryParameter(r, "namespace", true)
|
||||||
isAdmin, err := security.IsAdmin(r)
|
isAdmin, err := security.IsAdmin(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -116,7 +116,7 @@ func (handler *Handler) filterKubernetesEndpointRegistries(tx dataservices.DataS
|
||||||
return registries, nil
|
return registries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return handler.filterKubernetesRegistriesByUserRole(tx, r, registries, endpoint, user)
|
return handler.filterKubernetesRegistriesByUserRole(r, registries, endpoint, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership, isAdmin bool) (bool, error) {
|
func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership, isAdmin bool) (bool, error) {
|
||||||
|
@ -169,7 +169,7 @@ func registryAccessPoliciesContainsNamespace(registryAccess portainer.RegistryAc
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) {
|
func (handler *Handler) filterKubernetesRegistriesByUserRole(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) {
|
||||||
err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||||
if errors.Is(err, security.ErrAuthorizationRequired) {
|
if errors.Is(err, security.ErrAuthorizationRequired) {
|
||||||
return nil, httperror.Forbidden("User is not authorized", err)
|
return nil, httperror.Forbidden("User is not authorized", err)
|
||||||
|
@ -178,7 +178,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.Dat
|
||||||
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
|
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
userNamespaces, err := handler.userNamespaces(tx, endpoint, user)
|
userNamespaces, err := handler.userNamespaces(endpoint, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, httperror.InternalServerError("unable to retrieve user namespaces", err)
|
return nil, httperror.InternalServerError("unable to retrieve user namespaces", err)
|
||||||
}
|
}
|
||||||
|
@ -186,7 +186,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.Dat
|
||||||
return filterRegistriesByNamespaces(registries, endpoint.ID, userNamespaces), nil
|
return filterRegistriesByNamespaces(registries, endpoint.ID, userNamespaces), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) userNamespaces(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) {
|
func (handler *Handler) userNamespaces(endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) {
|
||||||
kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
|
kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -197,7 +197,7 @@ func (handler *Handler) userNamespaces(tx dataservices.DataStoreTx, endpoint *po
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
|
userMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,10 +11,9 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/http/handler/edgegroups"
|
"github.com/portainer/portainer/api/http/handler/edgegroups"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
|
||||||
"github.com/portainer/portainer/api/internal/edge"
|
"github.com/portainer/portainer/api/internal/edge"
|
||||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
"github.com/portainer/portainer/api/roar"
|
"github.com/portainer/portainer/api/slicesx"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
@ -141,14 +140,11 @@ func (handler *Handler) filterEndpointsByQuery(
|
||||||
groups []portainer.EndpointGroup,
|
groups []portainer.EndpointGroup,
|
||||||
edgeGroups []portainer.EdgeGroup,
|
edgeGroups []portainer.EdgeGroup,
|
||||||
settings *portainer.Settings,
|
settings *portainer.Settings,
|
||||||
context *security.RestrictedRequestContext,
|
|
||||||
) ([]portainer.Endpoint, int, error) {
|
) ([]portainer.Endpoint, int, error) {
|
||||||
totalAvailableEndpoints := len(filteredEndpoints)
|
totalAvailableEndpoints := len(filteredEndpoints)
|
||||||
|
|
||||||
if len(query.endpointIds) > 0 {
|
if len(query.endpointIds) > 0 {
|
||||||
endpointIDs := roar.FromSlice(query.endpointIds)
|
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, query.endpointIds)
|
||||||
|
|
||||||
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, endpointIDs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(query.excludeIds) > 0 {
|
if len(query.excludeIds) > 0 {
|
||||||
|
@ -185,16 +181,11 @@ func (handler *Handler) filterEndpointsByQuery(
|
||||||
}
|
}
|
||||||
|
|
||||||
// filter edge environments by trusted/untrusted
|
// filter edge environments by trusted/untrusted
|
||||||
// only portainer admins are allowed to see untrusted environments
|
|
||||||
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
|
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
|
||||||
if !endpointutils.IsEdgeEndpoint(&endpoint) {
|
if !endpointutils.IsEdgeEndpoint(&endpoint) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if query.edgeDeviceUntrusted {
|
|
||||||
return !endpoint.UserTrusted && context.IsAdmin
|
|
||||||
}
|
|
||||||
|
|
||||||
return endpoint.UserTrusted == !query.edgeDeviceUntrusted
|
return endpoint.UserTrusted == !query.edgeDeviceUntrusted
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -277,7 +268,7 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port
|
||||||
return nil, errors.WithMessage(err, "Unable to retrieve edge stack from the database")
|
return nil, errors.WithMessage(err, "Unable to retrieve edge stack from the database")
|
||||||
}
|
}
|
||||||
|
|
||||||
envIds := roar.Roar[portainer.EndpointID]{}
|
envIds := make([]portainer.EndpointID, 0)
|
||||||
for _, edgeGroupdId := range stack.EdgeGroups {
|
for _, edgeGroupdId := range stack.EdgeGroups {
|
||||||
edgeGroup, err := datastore.EdgeGroup().Read(edgeGroupdId)
|
edgeGroup, err := datastore.EdgeGroup().Read(edgeGroupdId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -289,37 +280,30 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.WithMessage(err, "Unable to retrieve environments and environment groups for Edge group")
|
return nil, errors.WithMessage(err, "Unable to retrieve environments and environment groups for Edge group")
|
||||||
}
|
}
|
||||||
edgeGroup.EndpointIDs = roar.FromSlice(endpointIDs)
|
edgeGroup.Endpoints = endpointIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
envIds.Union(edgeGroup.EndpointIDs)
|
envIds = append(envIds, edgeGroup.Endpoints...)
|
||||||
}
|
}
|
||||||
|
|
||||||
if statusFilter != nil {
|
if statusFilter != nil {
|
||||||
var innerErr error
|
n := 0
|
||||||
|
for _, envId := range envIds {
|
||||||
envIds.Iterate(func(envId portainer.EndpointID) bool {
|
|
||||||
edgeStackStatus, err := datastore.EdgeStackStatus().Read(edgeStackId, envId)
|
edgeStackStatus, err := datastore.EdgeStackStatus().Read(edgeStackId, envId)
|
||||||
if dataservices.IsErrObjectNotFound(err) {
|
if err != nil {
|
||||||
return true
|
return nil, errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId)
|
||||||
} else if err != nil {
|
|
||||||
innerErr = errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId)
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !endpointStatusInStackMatchesFilter(edgeStackStatus, portainer.EndpointID(envId), *statusFilter) {
|
if endpointStatusInStackMatchesFilter(edgeStackStatus, envId, *statusFilter) {
|
||||||
envIds.Remove(envId)
|
envIds[n] = envId
|
||||||
|
n++
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if innerErr != nil {
|
|
||||||
return nil, innerErr
|
|
||||||
}
|
}
|
||||||
|
envIds = envIds[:n]
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredEndpoints := filteredEndpointsByIds(endpoints, envIds)
|
uniqueIds := slicesx.Unique(envIds)
|
||||||
|
filteredEndpoints := filteredEndpointsByIds(endpoints, uniqueIds)
|
||||||
|
|
||||||
return filteredEndpoints, nil
|
return filteredEndpoints, nil
|
||||||
}
|
}
|
||||||
|
@ -351,14 +335,16 @@ func filterEndpointsByEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGroups []
|
||||||
}
|
}
|
||||||
edgeGroups = edgeGroups[:n]
|
edgeGroups = edgeGroups[:n]
|
||||||
|
|
||||||
endpointIDSet := roar.Roar[portainer.EndpointID]{}
|
endpointIDSet := make(map[portainer.EndpointID]struct{})
|
||||||
for _, edgeGroup := range edgeGroups {
|
for _, edgeGroup := range edgeGroups {
|
||||||
endpointIDSet.Union(edgeGroup.EndpointIDs)
|
for _, endpointID := range edgeGroup.Endpoints {
|
||||||
|
endpointIDSet[endpointID] = struct{}{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
n = 0
|
n = 0
|
||||||
for _, endpoint := range endpoints {
|
for _, endpoint := range endpoints {
|
||||||
if endpointIDSet.Contains(endpoint.ID) {
|
if _, exists := endpointIDSet[endpoint.ID]; exists {
|
||||||
endpoints[n] = endpoint
|
endpoints[n] = endpoint
|
||||||
n++
|
n++
|
||||||
}
|
}
|
||||||
|
@ -374,11 +360,12 @@ func filterEndpointsByExcludeEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGr
|
||||||
}
|
}
|
||||||
|
|
||||||
n := 0
|
n := 0
|
||||||
excludeEndpointIDSet := roar.Roar[portainer.EndpointID]{}
|
excludeEndpointIDSet := make(map[portainer.EndpointID]struct{})
|
||||||
|
|
||||||
for _, edgeGroup := range edgeGroups {
|
for _, edgeGroup := range edgeGroups {
|
||||||
if _, ok := excludeEdgeGroupIDSet[edgeGroup.ID]; ok {
|
if _, ok := excludeEdgeGroupIDSet[edgeGroup.ID]; ok {
|
||||||
excludeEndpointIDSet.Union(edgeGroup.EndpointIDs)
|
for _, endpointID := range edgeGroup.Endpoints {
|
||||||
|
excludeEndpointIDSet[endpointID] = struct{}{}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
edgeGroups[n] = edgeGroup
|
edgeGroups[n] = edgeGroup
|
||||||
n++
|
n++
|
||||||
|
@ -388,7 +375,7 @@ func filterEndpointsByExcludeEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGr
|
||||||
|
|
||||||
n = 0
|
n = 0
|
||||||
for _, endpoint := range endpoints {
|
for _, endpoint := range endpoints {
|
||||||
if !excludeEndpointIDSet.Contains(endpoint.ID) {
|
if _, ok := excludeEndpointIDSet[endpoint.ID]; !ok {
|
||||||
endpoints[n] = endpoint
|
endpoints[n] = endpoint
|
||||||
n++
|
n++
|
||||||
}
|
}
|
||||||
|
@ -613,10 +600,15 @@ func endpointFullMatchTags(endpoint portainer.Endpoint, endpointGroup portainer.
|
||||||
return len(missingTags) == 0
|
return len(missingTags) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids roar.Roar[portainer.EndpointID]) []portainer.Endpoint {
|
func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids []portainer.EndpointID) []portainer.Endpoint {
|
||||||
|
idsSet := make(map[portainer.EndpointID]bool, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
idsSet[id] = true
|
||||||
|
}
|
||||||
|
|
||||||
n := 0
|
n := 0
|
||||||
for _, endpoint := range endpoints {
|
for _, endpoint := range endpoints {
|
||||||
if ids.Contains(endpoint.ID) {
|
if idsSet[endpoint.ID] {
|
||||||
endpoints[n] = endpoint
|
endpoints[n] = endpoint
|
||||||
n++
|
n++
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,13 +6,10 @@ import (
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/datastore"
|
"github.com/portainer/portainer/api/datastore"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
|
||||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
"github.com/portainer/portainer/api/roar"
|
|
||||||
"github.com/portainer/portainer/api/slicesx"
|
"github.com/portainer/portainer/api/slicesx"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type filterTest struct {
|
type filterTest struct {
|
||||||
|
@ -177,7 +174,7 @@ func BenchmarkFilterEndpointsBySearchCriteria_PartialMatch(b *testing.B) {
|
||||||
edgeGroups = append(edgeGroups, portainer.EdgeGroup{
|
edgeGroups = append(edgeGroups, portainer.EdgeGroup{
|
||||||
ID: portainer.EdgeGroupID(i + 1),
|
ID: portainer.EdgeGroupID(i + 1),
|
||||||
Name: "edge-group-" + strconv.Itoa(i+1),
|
Name: "edge-group-" + strconv.Itoa(i+1),
|
||||||
EndpointIDs: roar.FromSlice(endpointIDs),
|
Endpoints: append([]portainer.EndpointID{}, endpointIDs...),
|
||||||
Dynamic: true,
|
Dynamic: true,
|
||||||
TagIDs: []portainer.TagID{1, 2, 3},
|
TagIDs: []portainer.TagID{1, 2, 3},
|
||||||
PartialMatch: true,
|
PartialMatch: true,
|
||||||
|
@ -224,11 +221,11 @@ func BenchmarkFilterEndpointsBySearchCriteria_FullMatch(b *testing.B) {
|
||||||
edgeGroups := []portainer.EdgeGroup{}
|
edgeGroups := []portainer.EdgeGroup{}
|
||||||
for i := range 1000 {
|
for i := range 1000 {
|
||||||
edgeGroups = append(edgeGroups, portainer.EdgeGroup{
|
edgeGroups = append(edgeGroups, portainer.EdgeGroup{
|
||||||
ID: portainer.EdgeGroupID(i + 1),
|
ID: portainer.EdgeGroupID(i + 1),
|
||||||
Name: "edge-group-" + strconv.Itoa(i+1),
|
Name: "edge-group-" + strconv.Itoa(i+1),
|
||||||
EndpointIDs: roar.FromSlice(endpointIDs),
|
Endpoints: append([]portainer.EndpointID{}, endpointIDs...),
|
||||||
Dynamic: true,
|
Dynamic: true,
|
||||||
TagIDs: []portainer.TagID{1},
|
TagIDs: []portainer.TagID{1},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -266,7 +263,6 @@ func runTest(t *testing.T, test filterTest, handler *Handler, endpoints []portai
|
||||||
[]portainer.EndpointGroup{},
|
[]portainer.EndpointGroup{},
|
||||||
[]portainer.EdgeGroup{},
|
[]portainer.EdgeGroup{},
|
||||||
&portainer.Settings{},
|
&portainer.Settings{},
|
||||||
&security.RestrictedRequestContext{IsAdmin: true},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
is.NoError(err)
|
is.NoError(err)
|
||||||
|
@ -302,127 +298,3 @@ func setupFilterTest(t *testing.T, endpoints []portainer.Endpoint) *Handler {
|
||||||
|
|
||||||
return handler
|
return handler
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFilterEndpointsByEdgeStack(t *testing.T) {
|
|
||||||
_, store := datastore.MustNewTestStore(t, false, false)
|
|
||||||
|
|
||||||
endpoints := []portainer.Endpoint{
|
|
||||||
{ID: 1, Name: "Endpoint 1"},
|
|
||||||
{ID: 2, Name: "Endpoint 2"},
|
|
||||||
{ID: 3, Name: "Endpoint 3"},
|
|
||||||
{ID: 4, Name: "Endpoint 4"},
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeStackId := portainer.EdgeStackID(1)
|
|
||||||
|
|
||||||
err := store.EdgeStack().Create(edgeStackId, &portainer.EdgeStack{
|
|
||||||
ID: edgeStackId,
|
|
||||||
Name: "Test Edge Stack",
|
|
||||||
EdgeGroups: []portainer.EdgeGroupID{1, 2},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Edge Group 1",
|
|
||||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
|
|
||||||
ID: 2,
|
|
||||||
Name: "Edge Group 2",
|
|
||||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{2, 3}),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
es, err := filterEndpointsByEdgeStack(endpoints, edgeStackId, nil, store)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, es, 3)
|
|
||||||
require.Contains(t, es, endpoints[0]) // Endpoint 1
|
|
||||||
require.Contains(t, es, endpoints[1]) // Endpoint 2
|
|
||||||
require.Contains(t, es, endpoints[2]) // Endpoint 3
|
|
||||||
require.NotContains(t, es, endpoints[3]) // Endpoint 4
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterEndpointsByEdgeGroup(t *testing.T) {
|
|
||||||
_, store := datastore.MustNewTestStore(t, false, false)
|
|
||||||
|
|
||||||
endpoints := []portainer.Endpoint{
|
|
||||||
{ID: 1, Name: "Endpoint 1"},
|
|
||||||
{ID: 2, Name: "Endpoint 2"},
|
|
||||||
{ID: 3, Name: "Endpoint 3"},
|
|
||||||
{ID: 4, Name: "Endpoint 4"},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := store.EdgeGroup().Create(&portainer.EdgeGroup{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Edge Group 1",
|
|
||||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
|
|
||||||
ID: 2,
|
|
||||||
Name: "Edge Group 2",
|
|
||||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{2, 3}),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
edgeGroups, err := store.EdgeGroup().ReadAll()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
es, egs := filterEndpointsByEdgeGroupIDs(endpoints, edgeGroups, []portainer.EdgeGroupID{1, 2})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.Len(t, es, 3)
|
|
||||||
require.Contains(t, es, endpoints[0]) // Endpoint 1
|
|
||||||
require.Contains(t, es, endpoints[1]) // Endpoint 2
|
|
||||||
require.Contains(t, es, endpoints[2]) // Endpoint 3
|
|
||||||
require.NotContains(t, es, endpoints[3]) // Endpoint 4
|
|
||||||
|
|
||||||
require.Len(t, egs, 2)
|
|
||||||
require.Equal(t, egs[0].ID, portainer.EdgeGroupID(1))
|
|
||||||
require.Equal(t, egs[1].ID, portainer.EdgeGroupID(2))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterEndpointsByExcludeEdgeGroupIDs(t *testing.T) {
|
|
||||||
_, store := datastore.MustNewTestStore(t, false, false)
|
|
||||||
|
|
||||||
endpoints := []portainer.Endpoint{
|
|
||||||
{ID: 1, Name: "Endpoint 1"},
|
|
||||||
{ID: 2, Name: "Endpoint 2"},
|
|
||||||
{ID: 3, Name: "Endpoint 3"},
|
|
||||||
{ID: 4, Name: "Endpoint 4"},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := store.EdgeGroup().Create(&portainer.EdgeGroup{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Edge Group 1",
|
|
||||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
|
|
||||||
ID: 2,
|
|
||||||
Name: "Edge Group 2",
|
|
||||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{2, 3}),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
edgeGroups, err := store.EdgeGroup().ReadAll()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
es, egs := filterEndpointsByExcludeEdgeGroupIDs(endpoints, edgeGroups, []portainer.EdgeGroupID{1})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.Len(t, es, 3)
|
|
||||||
require.Equal(t, es, []portainer.Endpoint{
|
|
||||||
{ID: 2, Name: "Endpoint 2"},
|
|
||||||
{ID: 3, Name: "Endpoint 3"},
|
|
||||||
{ID: 4, Name: "Endpoint 4"},
|
|
||||||
})
|
|
||||||
|
|
||||||
require.Len(t, egs, 1)
|
|
||||||
require.Equal(t, egs[0].ID, portainer.EdgeGroupID(2))
|
|
||||||
}
|
|
||||||
|
|
|
@ -17,7 +17,17 @@ func (handler *Handler) updateEdgeRelations(tx dataservices.DataStoreTx, endpoin
|
||||||
|
|
||||||
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.WithMessage(err, "Unable to retrieve environment relation inside the database")
|
if !tx.IsErrObjectNotFound(err) {
|
||||||
|
return errors.WithMessage(err, "Unable to retrieve environment relation inside the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
relation = &portainer.EndpointRelation{
|
||||||
|
EndpointID: endpoint.ID,
|
||||||
|
EdgeStacks: map[portainer.EdgeStackID]bool{},
|
||||||
|
}
|
||||||
|
if err := tx.EndpointRelation().Create(relation); err != nil {
|
||||||
|
return errors.WithMessage(err, "Unable to create environment relation inside the database")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID)
|
endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID)
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
package endpoints
|
package endpoints
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/set"
|
"github.com/portainer/portainer/api/set"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func updateEnvironmentEdgeGroups(tx dataservices.DataStoreTx, newEdgeGroups []portainer.EdgeGroupID, environmentID portainer.EndpointID) (bool, error) {
|
func updateEnvironmentEdgeGroups(tx dataservices.DataStoreTx, newEdgeGroups []portainer.EdgeGroupID, environmentID portainer.EndpointID) (bool, error) {
|
||||||
|
@ -18,8 +19,10 @@ func updateEnvironmentEdgeGroups(tx dataservices.DataStoreTx, newEdgeGroups []po
|
||||||
|
|
||||||
environmentEdgeGroupsSet := set.Set[portainer.EdgeGroupID]{}
|
environmentEdgeGroupsSet := set.Set[portainer.EdgeGroupID]{}
|
||||||
for _, edgeGroup := range edgeGroups {
|
for _, edgeGroup := range edgeGroups {
|
||||||
if edgeGroup.EndpointIDs.Contains(environmentID) {
|
for _, eID := range edgeGroup.Endpoints {
|
||||||
environmentEdgeGroupsSet[edgeGroup.ID] = true
|
if eID == environmentID {
|
||||||
|
environmentEdgeGroupsSet[edgeGroup.ID] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,16 +52,20 @@ func updateEnvironmentEdgeGroups(tx dataservices.DataStoreTx, newEdgeGroups []po
|
||||||
}
|
}
|
||||||
|
|
||||||
removeEdgeGroups := environmentEdgeGroupsSet.Difference(newEdgeGroupsSet)
|
removeEdgeGroups := environmentEdgeGroupsSet.Difference(newEdgeGroupsSet)
|
||||||
if err := updateSet(removeEdgeGroups, func(edgeGroup *portainer.EdgeGroup) {
|
err = updateSet(removeEdgeGroups, func(edgeGroup *portainer.EdgeGroup) {
|
||||||
edgeGroup.EndpointIDs.Remove(environmentID)
|
edgeGroup.Endpoints = slices.DeleteFunc(edgeGroup.Endpoints, func(eID portainer.EndpointID) bool {
|
||||||
}); err != nil {
|
return eID == environmentID
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
addToEdgeGroups := newEdgeGroupsSet.Difference(environmentEdgeGroupsSet)
|
addToEdgeGroups := newEdgeGroupsSet.Difference(environmentEdgeGroupsSet)
|
||||||
if err := updateSet(addToEdgeGroups, func(edgeGroup *portainer.EdgeGroup) {
|
err = updateSet(addToEdgeGroups, func(edgeGroup *portainer.EdgeGroup) {
|
||||||
edgeGroup.EndpointIDs.Add(environmentID)
|
edgeGroup.Endpoints = append(edgeGroup.Endpoints, environmentID)
|
||||||
}); err != nil {
|
})
|
||||||
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,6 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/datastore"
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -15,9 +14,10 @@ func Test_updateEdgeGroups(t *testing.T) {
|
||||||
groups := make([]portainer.EdgeGroup, len(names))
|
groups := make([]portainer.EdgeGroup, len(names))
|
||||||
for index, name := range names {
|
for index, name := range names {
|
||||||
group := &portainer.EdgeGroup{
|
group := &portainer.EdgeGroup{
|
||||||
Name: name,
|
Name: name,
|
||||||
Dynamic: false,
|
Dynamic: false,
|
||||||
TagIDs: make([]portainer.TagID, 0),
|
TagIDs: make([]portainer.TagID, 0),
|
||||||
|
Endpoints: make([]portainer.EndpointID, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.EdgeGroup().Create(group); err != nil {
|
if err := store.EdgeGroup().Create(group); err != nil {
|
||||||
|
@ -35,8 +35,13 @@ func Test_updateEdgeGroups(t *testing.T) {
|
||||||
group, err := store.EdgeGroup().Read(groupID)
|
group, err := store.EdgeGroup().Read(groupID)
|
||||||
is.NoError(err)
|
is.NoError(err)
|
||||||
|
|
||||||
is.True(group.EndpointIDs.Contains(endpointID),
|
for _, endpoint := range group.Endpoints {
|
||||||
"expected endpoint to be in group")
|
if endpoint == endpointID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is.Fail("expected endpoint to be in group")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,7 +81,7 @@ func Test_updateEdgeGroups(t *testing.T) {
|
||||||
|
|
||||||
endpointGroups := groupsByName(groups, testCase.endpointGroupNames)
|
endpointGroups := groupsByName(groups, testCase.endpointGroupNames)
|
||||||
for _, group := range endpointGroups {
|
for _, group := range endpointGroups {
|
||||||
group.EndpointIDs.Add(testCase.endpoint.ID)
|
group.Endpoints = append(group.Endpoints, testCase.endpoint.ID)
|
||||||
|
|
||||||
err = store.EdgeGroup().Update(group.ID, &group)
|
err = store.EdgeGroup().Update(group.ID, &group)
|
||||||
is.NoError(err)
|
is.NoError(err)
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_updateTags(t *testing.T) {
|
func Test_updateTags(t *testing.T) {
|
||||||
|
|
||||||
createTags := func(store *datastore.Store, tagNames []string) ([]portainer.Tag, error) {
|
createTags := func(store *datastore.Store, tagNames []string) ([]portainer.Tag, error) {
|
||||||
tags := make([]portainer.Tag, len(tagNames))
|
tags := make([]portainer.Tag, len(tagNames))
|
||||||
for index, tagName := range tagNames {
|
for index, tagName := range tagNames {
|
||||||
|
|
|
@ -17,12 +17,12 @@ type Handler struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a handler to serve static files.
|
// NewHandler creates a handler to serve static files.
|
||||||
func NewHandler(assetPublicPath string, csp bool, wasInstanceDisabled func() bool) *Handler {
|
func NewHandler(assetPublicPath string, wasInstanceDisabled func() bool) *Handler {
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
Handler: security.MWSecureHeaders(
|
Handler: security.MWSecureHeaders(
|
||||||
gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))),
|
gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))),
|
||||||
featureflags.IsEnabled("hsts"),
|
featureflags.IsEnabled("hsts"),
|
||||||
csp,
|
featureflags.IsEnabled("csp"),
|
||||||
),
|
),
|
||||||
wasInstanceDisabled: wasInstanceDisabled,
|
wasInstanceDisabled: wasInstanceDisabled,
|
||||||
}
|
}
|
||||||
|
@ -36,7 +36,6 @@ func isHTML(acceptContent []string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,13 +43,11 @@ func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
if handler.wasInstanceDisabled() {
|
if handler.wasInstanceDisabled() {
|
||||||
if r.RequestURI == "/" || r.RequestURI == "/index.html" {
|
if r.RequestURI == "/" || r.RequestURI == "/index.html" {
|
||||||
http.Redirect(w, r, "/timeout.html", http.StatusTemporaryRedirect)
|
http.Redirect(w, r, "/timeout.html", http.StatusTemporaryRedirect)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if strings.HasPrefix(r.RequestURI, "/timeout.html") {
|
if strings.HasPrefix(r.RequestURI, "/timeout.html") {
|
||||||
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,11 +17,10 @@ type fileResponse struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type repositoryFilePreviewPayload struct {
|
type repositoryFilePreviewPayload struct {
|
||||||
Repository string `json:"repository" example:"https://github.com/openfaas/faas" validate:"required"`
|
Repository string `json:"repository" example:"https://github.com/openfaas/faas" validate:"required"`
|
||||||
Reference string `json:"reference" example:"refs/heads/master"`
|
Reference string `json:"reference" example:"refs/heads/master"`
|
||||||
Username string `json:"username" example:"myGitUsername"`
|
Username string `json:"username" example:"myGitUsername"`
|
||||||
Password string `json:"password" example:"myGitPassword"`
|
Password string `json:"password" example:"myGitPassword"`
|
||||||
AuthorizationType gittypes.GitCredentialAuthType `json:"authorizationType"`
|
|
||||||
// Path to file whose content will be read
|
// Path to file whose content will be read
|
||||||
TargetFile string `json:"targetFile" example:"docker-compose.yml"`
|
TargetFile string `json:"targetFile" example:"docker-compose.yml"`
|
||||||
// TLSSkipVerify skips SSL verification when cloning the Git repository
|
// TLSSkipVerify skips SSL verification when cloning the Git repository
|
||||||
|
@ -69,15 +68,7 @@ func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *ht
|
||||||
return httperror.InternalServerError("Unable to create temporary folder", err)
|
return httperror.InternalServerError("Unable to create temporary folder", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.gitService.CloneRepository(
|
err = handler.gitService.CloneRepository(projectPath, payload.Repository, payload.Reference, payload.Username, payload.Password, payload.TLSSkipVerify)
|
||||||
projectPath,
|
|
||||||
payload.Repository,
|
|
||||||
payload.Reference,
|
|
||||||
payload.Username,
|
|
||||||
payload.Password,
|
|
||||||
payload.AuthorizationType,
|
|
||||||
payload.TLSSkipVerify,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gittypes.ErrAuthenticationFailure) {
|
if errors.Is(err, gittypes.ErrAuthenticationFailure) {
|
||||||
return httperror.BadRequest("Invalid git credential", err)
|
return httperror.BadRequest("Invalid git credential", err)
|
||||||
|
|
|
@ -81,7 +81,7 @@ type Handler struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// @title PortainerCE API
|
// @title PortainerCE API
|
||||||
// @version 2.32.0
|
// @version 2.31.0
|
||||||
// @description.markdown api-description.md
|
// @description.markdown api-description.md
|
||||||
// @termsOfService
|
// @termsOfService
|
||||||
|
|
||||||
|
|
|
@ -46,24 +46,18 @@ var errChartNameInvalid = errors.New("invalid chart name. " +
|
||||||
// @produce json
|
// @produce json
|
||||||
// @param id path int true "Environment(Endpoint) identifier"
|
// @param id path int true "Environment(Endpoint) identifier"
|
||||||
// @param payload body installChartPayload true "Chart details"
|
// @param payload body installChartPayload true "Chart details"
|
||||||
// @param dryRun query bool false "Dry run"
|
|
||||||
// @success 201 {object} release.Release "Created"
|
// @success 201 {object} release.Release "Created"
|
||||||
// @failure 401 "Unauthorized"
|
// @failure 401 "Unauthorized"
|
||||||
// @failure 404 "Environment(Endpoint) or ServiceAccount not found"
|
// @failure 404 "Environment(Endpoint) or ServiceAccount not found"
|
||||||
// @failure 500 "Server error"
|
// @failure 500 "Server error"
|
||||||
// @router /endpoints/{id}/kubernetes/helm [post]
|
// @router /endpoints/{id}/kubernetes/helm [post]
|
||||||
func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
dryRun, err := request.RetrieveBooleanQueryParameter(r, "dryRun", true)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.BadRequest("Invalid dryRun query parameter", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload installChartPayload
|
var payload installChartPayload
|
||||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||||
return httperror.BadRequest("Invalid Helm install payload", err)
|
return httperror.BadRequest("Invalid Helm install payload", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
release, err := handler.installChart(r, payload, dryRun)
|
release, err := handler.installChart(r, payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to install a chart", err)
|
return httperror.InternalServerError("Unable to install a chart", err)
|
||||||
}
|
}
|
||||||
|
@ -100,7 +94,7 @@ func (p *installChartPayload) Validate(_ *http.Request) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) installChart(r *http.Request, p installChartPayload, dryRun bool) (*release.Release, error) {
|
func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*release.Release, error) {
|
||||||
clusterAccess, httperr := handler.getHelmClusterAccess(r)
|
clusterAccess, httperr := handler.getHelmClusterAccess(r)
|
||||||
if httperr != nil {
|
if httperr != nil {
|
||||||
return nil, httperr.Err
|
return nil, httperr.Err
|
||||||
|
@ -113,7 +107,6 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry
|
||||||
Namespace: p.Namespace,
|
Namespace: p.Namespace,
|
||||||
Repo: p.Repo,
|
Repo: p.Repo,
|
||||||
Atomic: p.Atomic,
|
Atomic: p.Atomic,
|
||||||
DryRun: dryRun,
|
|
||||||
KubernetesClusterAccess: clusterAccess,
|
KubernetesClusterAccess: clusterAccess,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -141,14 +134,13 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !installOpts.DryRun {
|
manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest)
|
||||||
manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest)
|
if err != nil {
|
||||||
if err != nil {
|
return nil, err
|
||||||
return nil, err
|
}
|
||||||
}
|
|
||||||
if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); err != nil {
|
if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return release, nil
|
return release, nil
|
||||||
|
|
|
@ -2,10 +2,8 @@ package kubernetes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/http/middlewares"
|
"github.com/portainer/portainer/api/http/middlewares"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
|
||||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
@ -27,13 +25,7 @@ func (handler *Handler) prepareKubeClient(r *http.Request) (*cli.KubeClient, *ht
|
||||||
return nil, httperror.NotFound("Unable to find the Kubernetes endpoint associated to the request.", err)
|
return nil, httperror.NotFound("Unable to find the Kubernetes endpoint associated to the request.", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenData, err := security.RetrieveTokenData(r)
|
pcli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to retrieve token data associated to the request.")
|
|
||||||
return nil, httperror.InternalServerError("Unable to retrieve token data associated to the request.", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pcli, err := handler.KubernetesClientFactory.GetPrivilegedUserKubeClient(endpoint, strconv.Itoa(int(tokenData.ID)))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to get a privileged Kubernetes client for the user.")
|
log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to get a privileged Kubernetes client for the user.")
|
||||||
return nil, httperror.InternalServerError("Unable to get a privileged Kubernetes client for the user.", err)
|
return nil, httperror.InternalServerError("Unable to get a privileged Kubernetes client for the user.", err)
|
||||||
|
|
|
@ -20,7 +20,7 @@ import (
|
||||||
// @param id path int true "Environment identifier"
|
// @param id path int true "Environment identifier"
|
||||||
// @param namespace path string true "The namespace name the events are associated to"
|
// @param namespace path string true "The namespace name the events are associated to"
|
||||||
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
|
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
|
||||||
// @success 200 {object} []kubernetes.K8sEvent "Success"
|
// @success 200 {object} models.Event[] "Success"
|
||||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||||
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
||||||
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
||||||
|
@ -68,7 +68,7 @@ func (handler *Handler) getKubernetesEventsForNamespace(w http.ResponseWriter, r
|
||||||
// @produce json
|
// @produce json
|
||||||
// @param id path int true "Environment identifier"
|
// @param id path int true "Environment identifier"
|
||||||
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
|
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
|
||||||
// @success 200 {object} []kubernetes.K8sEvent "Success"
|
// @success 200 {object} models.Event[] "Success"
|
||||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||||
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
||||||
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
||||||
|
|
|
@ -146,7 +146,7 @@ func (h *Handler) getProxyKubeClient(r *http.Request) (portainer.KubeClient, *ht
|
||||||
return nil, httperror.Forbidden(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, permission denied to access the environment /api/kubernetes/%d. Error: ", endpointID), err)
|
return nil, httperror.Forbidden(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, permission denied to access the environment /api/kubernetes/%d. Error: ", endpointID), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), strconv.Itoa(int(tokenData.ID)))
|
cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, httperror.InternalServerError("an error occurred during the getProxyKubeClient operation,failed to get proxy KubeClient", nil)
|
return nil, httperror.InternalServerError("an error occurred during the getProxyKubeClient operation,failed to get proxy KubeClient", nil)
|
||||||
}
|
}
|
||||||
|
@ -179,7 +179,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have a kubeclient against this auth token already, otherwise generate a new one
|
// Check if we have a kubeclient against this auth token already, otherwise generate a new one
|
||||||
_, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), strconv.Itoa(int(tokenData.ID)))
|
_, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token)
|
||||||
if ok {
|
if ok {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
|
@ -269,7 +269,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), strconv.Itoa(int(tokenData.ID)), kubeCli)
|
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), tokenData.Token, kubeCli)
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,6 @@ import (
|
||||||
// @produce json
|
// @produce json
|
||||||
// @param id path int true "Environment identifier"
|
// @param id path int true "Environment identifier"
|
||||||
// @param withResourceQuota query boolean true "When set to true, include the resource quota information as part of the Namespace information. Default is false"
|
// @param withResourceQuota query boolean true "When set to true, include the resource quota information as part of the Namespace information. Default is false"
|
||||||
// @param withUnhealthyEvents query boolean true "When set to true, include the unhealthy events information as part of the Namespace information. Default is false"
|
|
||||||
// @success 200 {array} portainer.K8sNamespaceInfo "Success"
|
// @success 200 {array} portainer.K8sNamespaceInfo "Success"
|
||||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||||
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
||||||
|
@ -37,12 +36,6 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R
|
||||||
return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withResourceQuota. Error: ", err)
|
return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withResourceQuota. Error: ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
withUnhealthyEvents, err := request.RetrieveBooleanQueryParameter(r, "withUnhealthyEvents", true)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Invalid query parameter withUnhealthyEvents")
|
|
||||||
return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withUnhealthyEvents. Error: ", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cli, httpErr := handler.prepareKubeClient(r)
|
cli, httpErr := handler.prepareKubeClient(r)
|
||||||
if httpErr != nil {
|
if httpErr != nil {
|
||||||
log.Error().Err(httpErr).Str("context", "GetKubernetesNamespaces").Msg("Unable to get a Kubernetes client for the user")
|
log.Error().Err(httpErr).Str("context", "GetKubernetesNamespaces").Msg("Unable to get a Kubernetes client for the user")
|
||||||
|
@ -55,14 +48,6 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R
|
||||||
return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to retrieve namespaces from the Kubernetes cluster. Error: ", err)
|
return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to retrieve namespaces from the Kubernetes cluster. Error: ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if withUnhealthyEvents {
|
|
||||||
namespaces, err = cli.CombineNamespacesWithUnhealthyEvents(namespaces)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Unable to combine namespaces with unhealthy events")
|
|
||||||
return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to combine namespaces with unhealthy events. Error: ", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if withResourceQuota {
|
if withResourceQuota {
|
||||||
return cli.CombineNamespacesWithResourceQuotas(namespaces, w)
|
return cli.CombineNamespacesWithResourceQuotas(namespaces, w)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,10 +5,10 @@ import (
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
|
||||||
"github.com/portainer/portainer/api/http/proxy"
|
"github.com/portainer/portainer/api/http/proxy"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
"github.com/portainer/portainer/api/internal/registryutils/access"
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
|
"github.com/portainer/portainer/api/kubernetes"
|
||||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||||
"github.com/portainer/portainer/api/pendingactions"
|
"github.com/portainer/portainer/api/pendingactions"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
@ -17,7 +17,6 @@ import (
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func hideFields(registry *portainer.Registry, hideAccesses bool) {
|
func hideFields(registry *portainer.Registry, hideAccesses bool) {
|
||||||
|
@ -57,20 +56,17 @@ func newHandler(bouncer security.BouncerService) *Handler {
|
||||||
func (handler *Handler) initRouter(bouncer accessGuard) {
|
func (handler *Handler) initRouter(bouncer accessGuard) {
|
||||||
adminRouter := handler.NewRoute().Subrouter()
|
adminRouter := handler.NewRoute().Subrouter()
|
||||||
adminRouter.Use(bouncer.AdminAccess)
|
adminRouter.Use(bouncer.AdminAccess)
|
||||||
|
|
||||||
|
authenticatedRouter := handler.NewRoute().Subrouter()
|
||||||
|
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
|
||||||
|
|
||||||
adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryList)).Methods(http.MethodGet)
|
adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryList)).Methods(http.MethodGet)
|
||||||
adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryCreate)).Methods(http.MethodPost)
|
adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryCreate)).Methods(http.MethodPost)
|
||||||
adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryUpdate)).Methods(http.MethodPut)
|
adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryUpdate)).Methods(http.MethodPut)
|
||||||
adminRouter.Handle("/registries/{id}/configure", httperror.LoggerHandler(handler.registryConfigure)).Methods(http.MethodPost)
|
adminRouter.Handle("/registries/{id}/configure", httperror.LoggerHandler(handler.registryConfigure)).Methods(http.MethodPost)
|
||||||
adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryDelete)).Methods(http.MethodDelete)
|
adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryDelete)).Methods(http.MethodDelete)
|
||||||
|
|
||||||
// Use registry-specific access bouncer for inspect and repositories endpoints
|
authenticatedRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryInspect)).Methods(http.MethodGet)
|
||||||
registryAccessRouter := handler.NewRoute().Subrouter()
|
|
||||||
registryAccessRouter.Use(bouncer.AuthenticatedAccess, handler.RegistryAccess)
|
|
||||||
registryAccessRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryInspect)).Methods(http.MethodGet)
|
|
||||||
|
|
||||||
// Keep the gitlab proxy on the regular authenticated router as it doesn't require specific registry access
|
|
||||||
authenticatedRouter := handler.NewRoute().Subrouter()
|
|
||||||
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
|
|
||||||
authenticatedRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry))
|
authenticatedRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,7 +88,9 @@ func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Re
|
||||||
}
|
}
|
||||||
|
|
||||||
// this function validates that
|
// this function validates that
|
||||||
|
//
|
||||||
// 1. user has the appropriate authorizations to perform the request
|
// 1. user has the appropriate authorizations to perform the request
|
||||||
|
//
|
||||||
// 2. user has a direct or indirect access to the registry
|
// 2. user has a direct or indirect access to the registry
|
||||||
func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portainer.Registry) (hasAccess bool, isAdmin bool, err error) {
|
func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portainer.Registry) (hasAccess bool, isAdmin bool, err error) {
|
||||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||||
|
@ -100,6 +98,11 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain
|
||||||
return false, false, err
|
return false, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, err := handler.DataStore.User().Read(securityContext.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return false, false, err
|
||||||
|
}
|
||||||
|
|
||||||
// Portainer admins always have access to everything
|
// Portainer admins always have access to everything
|
||||||
if securityContext.IsAdmin {
|
if securityContext.IsAdmin {
|
||||||
return true, true, nil
|
return true, true, nil
|
||||||
|
@ -125,68 +128,47 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain
|
||||||
return false, false, err
|
return false, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the enhanced registry access utility function that includes namespace validation
|
memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||||
_, err = access.GetAccessibleRegistry(
|
|
||||||
handler.DataStore,
|
|
||||||
handler.K8sClientFactory,
|
|
||||||
securityContext.UserID,
|
|
||||||
endpointId,
|
|
||||||
registry.ID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, nil // No access
|
return false, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, false, nil
|
// validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces)
|
||||||
}
|
if endpointutils.IsKubernetesEndpoint(endpoint) {
|
||||||
|
kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||||
// RegistryAccess defines a security check for registry-specific API endpoints.
|
if err != nil {
|
||||||
// Authentication is required to access these endpoints.
|
return false, false, errors.Wrap(err, "unable to retrieve kubernetes client to validate registry access")
|
||||||
// The user must have direct or indirect access to the specific registry being requested.
|
}
|
||||||
// This bouncer validates registry access using the userHasRegistryAccess logic.
|
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
|
||||||
func (handler *Handler) RegistryAccess(next http.Handler) http.Handler {
|
if err != nil {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return false, false, errors.Wrap(err, "unable to retrieve environment's namespaces policies to validate registry access")
|
||||||
// First ensure the user is authenticated
|
}
|
||||||
tokenData, err := security.RetrieveTokenData(r)
|
|
||||||
if err != nil {
|
authorizedNamespaces := registry.RegistryAccesses[endpointId].Namespaces
|
||||||
httperror.WriteError(w, http.StatusUnauthorized, "Authentication required", httperrors.ErrUnauthorized)
|
|
||||||
return
|
for _, namespace := range authorizedNamespaces {
|
||||||
}
|
// when the default namespace is authorized to use a registry, all users have the ability to use it
|
||||||
|
// unless the default namespace is restricted: in this case continue to search for other potential accesses authorizations
|
||||||
// Extract registry ID from the route
|
if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace {
|
||||||
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
return true, false, nil
|
||||||
if err != nil {
|
}
|
||||||
httperror.WriteError(w, http.StatusBadRequest, "Invalid registry identifier route variable", err)
|
|
||||||
return
|
namespacePolicy := accessPolicies[namespace]
|
||||||
}
|
if security.AuthorizedAccess(user.ID, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) {
|
||||||
|
return true, false, nil
|
||||||
// Get the registry from the database
|
}
|
||||||
registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID))
|
}
|
||||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
return false, false, nil
|
||||||
httperror.WriteError(w, http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err)
|
}
|
||||||
return
|
|
||||||
} else if err != nil {
|
// validate access for docker environments
|
||||||
httperror.WriteError(w, http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err)
|
// leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access)
|
||||||
return
|
// and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams)
|
||||||
}
|
if security.AuthorizedRegistryAccess(registry, user, memberships, endpoint.ID) {
|
||||||
|
return true, false, nil
|
||||||
// Check if user has access to this registry
|
}
|
||||||
hasAccess, _, err := handler.userHasRegistryAccess(r, registry)
|
|
||||||
if err != nil {
|
// when user has no access via their role, direct grant or indirect grant
|
||||||
httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve info from request context", err)
|
// then they don't have access to the registry
|
||||||
return
|
return false, false, nil
|
||||||
}
|
|
||||||
if !hasAccess {
|
|
||||||
log.Debug().
|
|
||||||
Int("registry_id", registryID).
|
|
||||||
Str("registry_name", registry.Name).
|
|
||||||
Int("user_id", int(tokenData.ID)).
|
|
||||||
Str("context", "RegistryAccessBouncer").
|
|
||||||
Msg("User access denied to registry")
|
|
||||||
httperror.WriteError(w, http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,89 +0,0 @@
|
||||||
package registries
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/datastore"
|
|
||||||
"github.com/portainer/portainer/api/http/security"
|
|
||||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Test_RegistryAccess_RequiresAuthentication(t *testing.T) {
|
|
||||||
_, store := datastore.MustNewTestStore(t, true, true)
|
|
||||||
registry := &portainer.Registry{
|
|
||||||
ID: 1,
|
|
||||||
Name: "test-registry",
|
|
||||||
URL: "https://registry.test.com",
|
|
||||||
}
|
|
||||||
err := store.Registry().Create(registry)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
handler := &Handler{
|
|
||||||
DataStore: store,
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/registries/1", nil)
|
|
||||||
req = mux.SetURLVars(req, map[string]string{"id": "1"})
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
bouncer := handler.RegistryAccess(testHandler)
|
|
||||||
bouncer.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_RegistryAccess_InvalidRegistryID(t *testing.T) {
|
|
||||||
_, store := datastore.MustNewTestStore(t, true, true)
|
|
||||||
user := &portainer.User{ID: 1, Username: "test", Role: portainer.StandardUserRole}
|
|
||||||
err := store.User().Create(user)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
handler := &Handler{
|
|
||||||
DataStore: store,
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/registries/invalid", nil)
|
|
||||||
req = mux.SetURLVars(req, map[string]string{"id": "invalid"})
|
|
||||||
tokenData := &portainer.TokenData{ID: 1, Role: portainer.StandardUserRole}
|
|
||||||
req = req.WithContext(security.StoreTokenData(req, tokenData))
|
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
bouncer := handler.RegistryAccess(testHandler)
|
|
||||||
bouncer.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_RegistryAccess_RegistryNotFound(t *testing.T) {
|
|
||||||
_, store := datastore.MustNewTestStore(t, true, true)
|
|
||||||
user := &portainer.User{ID: 1, Username: "test", Role: portainer.StandardUserRole}
|
|
||||||
err := store.User().Create(user)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
handler := &Handler{
|
|
||||||
DataStore: store,
|
|
||||||
requestBouncer: testhelpers.NewTestRequestBouncer(),
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/registries/999", nil)
|
|
||||||
req = mux.SetURLVars(req, map[string]string{"id": "999"})
|
|
||||||
tokenData := &portainer.TokenData{ID: 1, Role: portainer.StandardUserRole}
|
|
||||||
req = req.WithContext(security.StoreTokenData(req, tokenData))
|
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
bouncer := handler.RegistryAccess(testHandler)
|
|
||||||
bouncer.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusNotFound, rr.Code)
|
|
||||||
}
|
|
|
@ -4,12 +4,10 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// @id RegistryInspect
|
// @id RegistryInspect
|
||||||
|
@ -33,11 +31,6 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request)
|
||||||
return httperror.BadRequest("Invalid registry identifier route variable", err)
|
return httperror.BadRequest("Invalid registry identifier route variable", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debug().
|
|
||||||
Int("registry_id", registryID).
|
|
||||||
Str("context", "RegistryInspectHandler").
|
|
||||||
Msg("Starting registry inspection")
|
|
||||||
|
|
||||||
registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID))
|
registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID))
|
||||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||||
return httperror.NotFound("Unable to find a registry with the specified identifier inside the database", err)
|
return httperror.NotFound("Unable to find a registry with the specified identifier inside the database", err)
|
||||||
|
@ -45,12 +38,14 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request)
|
||||||
return httperror.InternalServerError("Unable to find a registry with the specified identifier inside the database", err)
|
return httperror.InternalServerError("Unable to find a registry with the specified identifier inside the database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is admin to determine if we should hide sensitive fields
|
hasAccess, isAdmin, err := handler.userHasRegistryAccess(r, registry)
|
||||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||||
}
|
}
|
||||||
|
if !hasAccess {
|
||||||
|
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
|
||||||
|
}
|
||||||
|
|
||||||
hideFields(registry, !securityContext.IsAdmin)
|
hideFields(registry, !isAdmin)
|
||||||
return response.JSON(w, registry)
|
return response.JSON(w, registry)
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,15 +19,14 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type stackGitUpdatePayload struct {
|
type stackGitUpdatePayload struct {
|
||||||
AutoUpdate *portainer.AutoUpdateSettings
|
AutoUpdate *portainer.AutoUpdateSettings
|
||||||
Env []portainer.Pair
|
Env []portainer.Pair
|
||||||
Prune bool
|
Prune bool
|
||||||
RepositoryReferenceName string
|
RepositoryReferenceName string
|
||||||
RepositoryAuthentication bool
|
RepositoryAuthentication bool
|
||||||
RepositoryUsername string
|
RepositoryUsername string
|
||||||
RepositoryPassword string
|
RepositoryPassword string
|
||||||
RepositoryAuthorizationType gittypes.GitCredentialAuthType
|
TLSSkipVerify bool
|
||||||
TLSSkipVerify bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *stackGitUpdatePayload) Validate(r *http.Request) error {
|
func (payload *stackGitUpdatePayload) Validate(r *http.Request) error {
|
||||||
|
@ -152,19 +151,11 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
|
||||||
}
|
}
|
||||||
|
|
||||||
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
|
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
|
||||||
Username: payload.RepositoryUsername,
|
Username: payload.RepositoryUsername,
|
||||||
Password: password,
|
Password: password,
|
||||||
AuthorizationType: payload.RepositoryAuthorizationType,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := handler.GitService.LatestCommitID(
|
if _, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, stack.GitConfig.Authentication.Username, stack.GitConfig.Authentication.Password, stack.GitConfig.TLSSkipVerify); err != nil {
|
||||||
stack.GitConfig.URL,
|
|
||||||
stack.GitConfig.ReferenceName,
|
|
||||||
stack.GitConfig.Authentication.Username,
|
|
||||||
stack.GitConfig.Authentication.Password,
|
|
||||||
stack.GitConfig.Authentication.AuthorizationType,
|
|
||||||
stack.GitConfig.TLSSkipVerify,
|
|
||||||
); err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to fetch git repository", err)
|
return httperror.InternalServerError("Unable to fetch git repository", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -6,7 +6,6 @@ import (
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/git"
|
"github.com/portainer/portainer/api/git"
|
||||||
gittypes "github.com/portainer/portainer/api/git/types"
|
|
||||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
k "github.com/portainer/portainer/api/kubernetes"
|
k "github.com/portainer/portainer/api/kubernetes"
|
||||||
|
@ -20,13 +19,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type stackGitRedployPayload struct {
|
type stackGitRedployPayload struct {
|
||||||
RepositoryReferenceName string
|
RepositoryReferenceName string
|
||||||
RepositoryAuthentication bool
|
RepositoryAuthentication bool
|
||||||
RepositoryUsername string
|
RepositoryUsername string
|
||||||
RepositoryPassword string
|
RepositoryPassword string
|
||||||
RepositoryAuthorizationType gittypes.GitCredentialAuthType
|
Env []portainer.Pair
|
||||||
Env []portainer.Pair
|
Prune bool
|
||||||
Prune bool
|
|
||||||
// Force a pulling to current image with the original tag though the image is already the latest
|
// Force a pulling to current image with the original tag though the image is already the latest
|
||||||
PullImage bool `example:"false"`
|
PullImage bool `example:"false"`
|
||||||
|
|
||||||
|
@ -137,16 +135,13 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||||
|
|
||||||
repositoryUsername := ""
|
repositoryUsername := ""
|
||||||
repositoryPassword := ""
|
repositoryPassword := ""
|
||||||
repositoryAuthType := gittypes.GitCredentialAuthType_Basic
|
|
||||||
if payload.RepositoryAuthentication {
|
if payload.RepositoryAuthentication {
|
||||||
repositoryPassword = payload.RepositoryPassword
|
repositoryPassword = payload.RepositoryPassword
|
||||||
repositoryAuthType = payload.RepositoryAuthorizationType
|
|
||||||
|
|
||||||
// When the existing stack is using the custom username/password and the password is not updated,
|
// When the existing stack is using the custom username/password and the password is not updated,
|
||||||
// the stack should keep using the saved username/password
|
// the stack should keep using the saved username/password
|
||||||
if repositoryPassword == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil {
|
if repositoryPassword == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil {
|
||||||
repositoryPassword = stack.GitConfig.Authentication.Password
|
repositoryPassword = stack.GitConfig.Authentication.Password
|
||||||
repositoryAuthType = stack.GitConfig.Authentication.AuthorizationType
|
|
||||||
}
|
}
|
||||||
repositoryUsername = payload.RepositoryUsername
|
repositoryUsername = payload.RepositoryUsername
|
||||||
}
|
}
|
||||||
|
@ -157,7 +152,6 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||||
ReferenceName: stack.GitConfig.ReferenceName,
|
ReferenceName: stack.GitConfig.ReferenceName,
|
||||||
Username: repositoryUsername,
|
Username: repositoryUsername,
|
||||||
Password: repositoryPassword,
|
Password: repositoryPassword,
|
||||||
AuthType: repositoryAuthType,
|
|
||||||
TLSSkipVerify: stack.GitConfig.TLSSkipVerify,
|
TLSSkipVerify: stack.GitConfig.TLSSkipVerify,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -172,7 +166,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
newHash, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryAuthType, stack.GitConfig.TLSSkipVerify)
|
newHash, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, repositoryUsername, repositoryPassword, stack.GitConfig.TLSSkipVerify)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable get latest commit id", errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID))
|
return httperror.InternalServerError("Unable get latest commit id", errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID))
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,13 +27,12 @@ type kubernetesFileStackUpdatePayload struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type kubernetesGitStackUpdatePayload struct {
|
type kubernetesGitStackUpdatePayload struct {
|
||||||
RepositoryReferenceName string
|
RepositoryReferenceName string
|
||||||
RepositoryAuthentication bool
|
RepositoryAuthentication bool
|
||||||
RepositoryUsername string
|
RepositoryUsername string
|
||||||
RepositoryPassword string
|
RepositoryPassword string
|
||||||
RepositoryAuthorizationType gittypes.GitCredentialAuthType
|
AutoUpdate *portainer.AutoUpdateSettings
|
||||||
AutoUpdate *portainer.AutoUpdateSettings
|
TLSSkipVerify bool
|
||||||
TLSSkipVerify bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error {
|
func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error {
|
||||||
|
@ -77,19 +76,11 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
|
||||||
}
|
}
|
||||||
|
|
||||||
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
|
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
|
||||||
Username: payload.RepositoryUsername,
|
Username: payload.RepositoryUsername,
|
||||||
Password: password,
|
Password: password,
|
||||||
AuthorizationType: payload.RepositoryAuthorizationType,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := handler.GitService.LatestCommitID(
|
if _, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, stack.GitConfig.Authentication.Username, stack.GitConfig.Authentication.Password, stack.GitConfig.TLSSkipVerify); err != nil {
|
||||||
stack.GitConfig.URL,
|
|
||||||
stack.GitConfig.ReferenceName,
|
|
||||||
stack.GitConfig.Authentication.Username,
|
|
||||||
stack.GitConfig.Authentication.Password,
|
|
||||||
stack.GitConfig.Authentication.AuthorizationType,
|
|
||||||
stack.GitConfig.TLSSkipVerify,
|
|
||||||
); err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to fetch git repository", err)
|
return httperror.InternalServerError("Unable to fetch git repository", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,6 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/internal/edge"
|
"github.com/portainer/portainer/api/internal/edge"
|
||||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
|
@ -59,9 +58,6 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error {
|
||||||
|
|
||||||
for endpointID := range tag.Endpoints {
|
for endpointID := range tag.Endpoints {
|
||||||
endpoint, err := tx.Endpoint().Endpoint(endpointID)
|
endpoint, err := tx.Endpoint().Endpoint(endpointID)
|
||||||
if tx.IsErrObjectNotFound(err) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to retrieve environment from the database", err)
|
return httperror.InternalServerError("Unable to retrieve environment from the database", err)
|
||||||
}
|
}
|
||||||
|
@ -107,10 +103,15 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error {
|
||||||
return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err)
|
return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeJobs, err := tx.EdgeJob().ReadAll()
|
for _, endpoint := range endpoints {
|
||||||
if err != nil {
|
if (tag.Endpoints[endpoint.ID] || tag.EndpointGroups[endpoint.GroupID]) && (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) {
|
||||||
return httperror.InternalServerError("Unable to retrieve edge job configurations from the database", err)
|
err = updateEndpointRelations(tx, endpoint, edgeGroups, edgeStacks)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to update environment relations in the database", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, edgeGroup := range edgeGroups {
|
for _, edgeGroup := range edgeGroups {
|
||||||
edgeGroup.TagIDs = slices.DeleteFunc(edgeGroup.TagIDs, func(t portainer.TagID) bool {
|
edgeGroup.TagIDs = slices.DeleteFunc(edgeGroup.TagIDs, func(t portainer.TagID) bool {
|
||||||
return t == tagID
|
return t == tagID
|
||||||
|
@ -122,16 +123,6 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, endpoint := range endpoints {
|
|
||||||
if (!tag.Endpoints[endpoint.ID] && !tag.EndpointGroups[endpoint.GroupID]) || !endpointutils.IsEdgeEndpoint(&endpoint) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := updateEndpointRelations(tx, endpoint, edgeGroups, edgeStacks, edgeJobs); err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to update environment relations in the database", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tx.Tag().Delete(tagID)
|
err = tx.Tag().Delete(tagID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to remove the tag from the database", err)
|
return httperror.InternalServerError("Unable to remove the tag from the database", err)
|
||||||
|
@ -140,12 +131,19 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack, edgeJobs []portainer.EdgeJob) error {
|
func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
|
||||||
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
||||||
if err != nil {
|
if err != nil && !tx.IsErrObjectNotFound(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if endpointRelation == nil {
|
||||||
|
endpointRelation = &portainer.EndpointRelation{
|
||||||
|
EndpointID: endpoint.ID,
|
||||||
|
EdgeStacks: make(map[portainer.EdgeStackID]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID)
|
endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -159,25 +157,5 @@ func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.End
|
||||||
|
|
||||||
endpointRelation.EdgeStacks = stacksSet
|
endpointRelation.EdgeStacks = stacksSet
|
||||||
|
|
||||||
if err := tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation); err != nil {
|
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, edgeJob := range edgeJobs {
|
|
||||||
endpoints, err := edge.GetEndpointsFromEdgeGroups(edgeJob.EdgeGroups, tx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if slices.Contains(endpoints, endpoint.ID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(edgeJob.GroupLogsCollection, endpoint.ID)
|
|
||||||
|
|
||||||
if err := tx.EdgeJob().Update(edgeJob.ID, &edgeJob); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,20 +8,23 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
|
||||||
portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors"
|
|
||||||
"github.com/portainer/portainer/api/datastore"
|
"github.com/portainer/portainer/api/datastore"
|
||||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
"github.com/portainer/portainer/api/roar"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) {
|
func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) {
|
||||||
const tagsCount = 100
|
const tagsCount = 100
|
||||||
|
|
||||||
handler, store := setUpHandler(t)
|
_, store := datastore.MustNewTestStore(t, true, false)
|
||||||
|
|
||||||
|
user := &portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole}
|
||||||
|
if err := store.User().Create(user); err != nil {
|
||||||
|
t.Fatal("could not create admin user:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||||
|
handler.DataStore = store
|
||||||
|
|
||||||
// Create all the tags and add them to the same edge group
|
// Create all the tags and add them to the same edge group
|
||||||
|
|
||||||
var tagIDs []portainer.TagID
|
var tagIDs []portainer.TagID
|
||||||
|
@ -81,128 +84,3 @@ func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) {
|
||||||
t.Fatal("the edge group is not consistent")
|
t.Fatal("the edge group is not consistent")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_tagDelete(t *testing.T) {
|
|
||||||
t.Run("should delete tag and update related endpoints and edge groups", func(t *testing.T) {
|
|
||||||
handler, store := setUpHandler(t)
|
|
||||||
|
|
||||||
tag := &portainer.Tag{
|
|
||||||
ID: 1,
|
|
||||||
Name: "tag-1",
|
|
||||||
Endpoints: make(map[portainer.EndpointID]bool),
|
|
||||||
EndpointGroups: make(map[portainer.EndpointGroupID]bool),
|
|
||||||
}
|
|
||||||
require.NoError(t, store.Tag().Create(tag))
|
|
||||||
|
|
||||||
endpointGroup := &portainer.EndpointGroup{
|
|
||||||
ID: 2,
|
|
||||||
Name: "endpoint-group-1",
|
|
||||||
TagIDs: []portainer.TagID{tag.ID},
|
|
||||||
}
|
|
||||||
require.NoError(t, store.EndpointGroup().Create(endpointGroup))
|
|
||||||
|
|
||||||
endpoint1 := &portainer.Endpoint{
|
|
||||||
ID: 1,
|
|
||||||
Name: "endpoint-1",
|
|
||||||
GroupID: endpointGroup.ID,
|
|
||||||
}
|
|
||||||
require.NoError(t, store.Endpoint().Create(endpoint1))
|
|
||||||
|
|
||||||
endpoint2 := &portainer.Endpoint{
|
|
||||||
ID: 2,
|
|
||||||
Name: "endpoint-2",
|
|
||||||
TagIDs: []portainer.TagID{tag.ID},
|
|
||||||
}
|
|
||||||
require.NoError(t, store.Endpoint().Create(endpoint2))
|
|
||||||
|
|
||||||
tag.Endpoints[endpoint2.ID] = true
|
|
||||||
tag.EndpointGroups[endpointGroup.ID] = true
|
|
||||||
require.NoError(t, store.Tag().Update(tag.ID, tag))
|
|
||||||
|
|
||||||
dynamicEdgeGroup := &portainer.EdgeGroup{
|
|
||||||
ID: 1,
|
|
||||||
Name: "edgegroup-1",
|
|
||||||
TagIDs: []portainer.TagID{tag.ID},
|
|
||||||
Dynamic: true,
|
|
||||||
}
|
|
||||||
require.NoError(t, store.EdgeGroup().Create(dynamicEdgeGroup))
|
|
||||||
|
|
||||||
staticEdgeGroup := &portainer.EdgeGroup{
|
|
||||||
ID: 2,
|
|
||||||
Name: "edgegroup-2",
|
|
||||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpoint2.ID}),
|
|
||||||
}
|
|
||||||
require.NoError(t, store.EdgeGroup().Create(staticEdgeGroup))
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodDelete, "/tags/"+strconv.Itoa(int(tag.ID)), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fail()
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusNoContent, rec.Code)
|
|
||||||
|
|
||||||
// Check that the tag is deleted
|
|
||||||
_, err = store.Tag().Read(tag.ID)
|
|
||||||
require.ErrorIs(t, err, portainerDsErrors.ErrObjectNotFound)
|
|
||||||
|
|
||||||
// Check that the endpoints are updated
|
|
||||||
endpoint1, err = store.Endpoint().Endpoint(endpoint1.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, endpoint1.TagIDs, 0, "endpoint-1 should not have any tags")
|
|
||||||
assert.Equal(t, endpoint1.GroupID, endpointGroup.ID, "endpoint-1 should still belong to the endpoint group")
|
|
||||||
|
|
||||||
endpoint2, err = store.Endpoint().Endpoint(endpoint2.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, endpoint2.TagIDs, 0, "endpoint-2 should not have any tags")
|
|
||||||
|
|
||||||
// Check that the dynamic edge group is updated
|
|
||||||
dynamicEdgeGroup, err = store.EdgeGroup().Read(dynamicEdgeGroup.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, dynamicEdgeGroup.TagIDs, 0, "dynamic edge group should not have any tags")
|
|
||||||
assert.Equal(t, 0, dynamicEdgeGroup.EndpointIDs.Len(), "dynamic edge group should not have any endpoints")
|
|
||||||
|
|
||||||
// Check that the static edge group is not updated
|
|
||||||
staticEdgeGroup, err = store.EdgeGroup().Read(staticEdgeGroup.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, staticEdgeGroup.TagIDs, 0, "static edge group should not have any tags")
|
|
||||||
assert.Equal(t, 1, staticEdgeGroup.EndpointIDs.Len(), "static edge group should have one endpoint")
|
|
||||||
assert.True(t, staticEdgeGroup.EndpointIDs.Contains(endpoint2.ID), "static edge group should have the endpoint-2")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test the tx.IsErrObjectNotFound logic when endpoint is not found during cleanup
|
|
||||||
t.Run("should continue gracefully when endpoint not found during cleanup", func(t *testing.T) {
|
|
||||||
_, store := setUpHandler(t)
|
|
||||||
// Create a tag with a reference to a non-existent endpoint
|
|
||||||
tag := &portainer.Tag{
|
|
||||||
ID: 1,
|
|
||||||
Name: "test-tag",
|
|
||||||
Endpoints: map[portainer.EndpointID]bool{999: true}, // Non-existent endpoint
|
|
||||||
EndpointGroups: make(map[portainer.EndpointGroupID]bool),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := store.Tag().Create(tag)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = deleteTag(store, 1)
|
|
||||||
require.NoError(t, err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func setUpHandler(t *testing.T) (*Handler, dataservices.DataStore) {
|
|
||||||
_, store := datastore.MustNewTestStore(t, true, false)
|
|
||||||
|
|
||||||
user := &portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole}
|
|
||||||
if err := store.User().Create(user); err != nil {
|
|
||||||
t.Fatal("could not create admin user:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
|
||||||
handler.DataStore = store
|
|
||||||
|
|
||||||
return handler, store
|
|
||||||
}
|
|
||||||
|
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
gittypes "github.com/portainer/portainer/api/git/types"
|
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
|
@ -72,15 +71,7 @@ func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *ht
|
||||||
|
|
||||||
defer handler.cleanUp(projectPath)
|
defer handler.cleanUp(projectPath)
|
||||||
|
|
||||||
if err := handler.GitService.CloneRepository(
|
if err := handler.GitService.CloneRepository(projectPath, template.Repository.URL, "", "", "", false); err != nil {
|
||||||
projectPath,
|
|
||||||
template.Repository.URL,
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
); err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to clone git repository", err)
|
return httperror.InternalServerError("Unable to clone git repository", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -40,13 +40,11 @@ func (handler *Handler) fetchTemplates() (*listResponse, *httperror.HandlerError
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
if err != nil {
|
||||||
return nil, httperror.InternalServerError("Unable to parse template file", err)
|
return nil, httperror.InternalServerError("Unable to parse template file", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range body.Templates {
|
|
||||||
body.Templates[i].ID = portainer.TemplateID(i + 1)
|
|
||||||
}
|
|
||||||
return body, nil
|
return body, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -80,7 +80,7 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h
|
||||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, endpointID, payload.RegistryID)
|
_, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, endpointID, payload.RegistryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.Forbidden("Permission deny to access registry", err)
|
return httperror.Forbidden("Permission deny to access registry", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,7 +69,7 @@ func (handler *Handler) webhookUpdate(w http.ResponseWriter, r *http.Request) *h
|
||||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, webhook.EndpointID, payload.RegistryID)
|
_, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, webhook.EndpointID, payload.RegistryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.Forbidden("Permission deny to access registry", err)
|
return httperror.Forbidden("Permission deny to access registry", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,12 +25,12 @@ type key int
|
||||||
const contextEndpoint key = 0
|
const contextEndpoint key = 0
|
||||||
|
|
||||||
func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam string) mux.MiddlewareFunc {
|
func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam string) mux.MiddlewareFunc {
|
||||||
if endpointIDParam == "" {
|
|
||||||
endpointIDParam = "id"
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) {
|
return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) {
|
||||||
|
if endpointIDParam == "" {
|
||||||
|
endpointIDParam = "id"
|
||||||
|
}
|
||||||
|
|
||||||
endpointID, err := requesthelpers.RetrieveNumericRouteVariableValue(request, endpointIDParam)
|
endpointID, err := requesthelpers.RetrieveNumericRouteVariableValue(request, endpointIDParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httperror.WriteError(rw, http.StatusBadRequest, "Invalid environment identifier route variable", err)
|
httperror.WriteError(rw, http.StatusBadRequest, "Invalid environment identifier route variable", err)
|
||||||
|
@ -51,6 +51,7 @@ func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam
|
||||||
ctx := context.WithValue(request.Context(), contextEndpoint, endpoint)
|
ctx := context.WithValue(request.Context(), contextEndpoint, endpoint)
|
||||||
|
|
||||||
next.ServeHTTP(rw, request.WithContext(ctx))
|
next.ServeHTTP(rw, request.WithContext(ctx))
|
||||||
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@ package middlewares
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gorilla/csrf"
|
"github.com/gorilla/csrf"
|
||||||
)
|
)
|
||||||
|
@ -17,45 +16,6 @@ type plainTextHTTPRequestHandler struct {
|
||||||
next http.Handler
|
next http.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseForwardedHeaderProto parses the Forwarded header and extracts the protocol.
|
|
||||||
// The Forwarded header format supports:
|
|
||||||
// - Single proxy: Forwarded: by=<identifier>;for=<identifier>;host=<host>;proto=<http|https>
|
|
||||||
// - Multiple proxies: Forwarded: for=192.0.2.43, for=198.51.100.17
|
|
||||||
// We take the first (leftmost) entry as it represents the original client
|
|
||||||
func parseForwardedHeaderProto(forwarded string) string {
|
|
||||||
if forwarded == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the first part (leftmost proxy, closest to original client)
|
|
||||||
firstPart, _, _ := strings.Cut(forwarded, ",")
|
|
||||||
firstPart = strings.TrimSpace(firstPart)
|
|
||||||
|
|
||||||
// Split by semicolon to get key-value pairs within this proxy entry
|
|
||||||
// Format: key=value;key=value;key=value
|
|
||||||
pairs := strings.Split(firstPart, ";")
|
|
||||||
for _, pair := range pairs {
|
|
||||||
// Split by equals sign to separate key and value
|
|
||||||
key, value, found := strings.Cut(pair, "=")
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.EqualFold(strings.TrimSpace(key), "proto") {
|
|
||||||
return strings.Trim(strings.TrimSpace(value), `"'`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// isHTTPSRequest checks if the original request was made over HTTPS
|
|
||||||
// by examining both X-Forwarded-Proto and Forwarded headers
|
|
||||||
func isHTTPSRequest(r *http.Request) bool {
|
|
||||||
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") ||
|
|
||||||
strings.EqualFold(parseForwardedHeaderProto(r.Header.Get("Forwarded")), "https")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
if slices.Contains(safeMethods, r.Method) {
|
if slices.Contains(safeMethods, r.Method) {
|
||||||
h.next.ServeHTTP(w, r)
|
h.next.ServeHTTP(w, r)
|
||||||
|
@ -64,7 +24,7 @@ func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.R
|
||||||
|
|
||||||
req := r
|
req := r
|
||||||
// If original request was HTTPS (via proxy), keep CSRF checks.
|
// If original request was HTTPS (via proxy), keep CSRF checks.
|
||||||
if !isHTTPSRequest(r) {
|
if xfproto := r.Header.Get("X-Forwarded-Proto"); xfproto != "https" {
|
||||||
req = csrf.PlaintextHTTPRequest(r)
|
req = csrf.PlaintextHTTPRequest(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,173 +0,0 @@
|
||||||
package middlewares
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
var tests = []struct {
|
|
||||||
name string
|
|
||||||
forwarded string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty header",
|
|
||||||
forwarded: "",
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single proxy with proto=https",
|
|
||||||
forwarded: "proto=https",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single proxy with proto=http",
|
|
||||||
forwarded: "proto=http",
|
|
||||||
expected: "http",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single proxy with multiple directives",
|
|
||||||
forwarded: "for=192.0.2.60;proto=https;by=203.0.113.43",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single proxy with proto in middle",
|
|
||||||
forwarded: "for=192.0.2.60;proto=https;host=example.com",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single proxy with proto at end",
|
|
||||||
forwarded: "for=192.0.2.60;host=example.com;proto=https",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple proxies - takes first",
|
|
||||||
forwarded: "proto=https, proto=http",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple proxies with complex format",
|
|
||||||
forwarded: "for=192.0.2.43;proto=https, for=198.51.100.17;proto=http",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple proxies with for directive only",
|
|
||||||
forwarded: "for=192.0.2.43, for=198.51.100.17",
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple proxies with proto only in second",
|
|
||||||
forwarded: "for=192.0.2.43, proto=https",
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple proxies with proto only in first",
|
|
||||||
forwarded: "proto=https, for=198.51.100.17",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "quoted protocol value",
|
|
||||||
forwarded: "proto=\"https\"",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single quoted protocol value",
|
|
||||||
forwarded: "proto='https'",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed case protocol",
|
|
||||||
forwarded: "proto=HTTPS",
|
|
||||||
expected: "HTTPS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no proto directive",
|
|
||||||
forwarded: "for=192.0.2.60;by=203.0.113.43",
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty proto value",
|
|
||||||
forwarded: "proto=",
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "whitespace around values",
|
|
||||||
forwarded: " proto = https ",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "whitespace around semicolons",
|
|
||||||
forwarded: "for=192.0.2.60 ; proto=https ; by=203.0.113.43",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "whitespace around commas",
|
|
||||||
forwarded: "proto=https , proto=http",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "IPv6 address in for directive",
|
|
||||||
forwarded: "for=\"[2001:db8:cafe::17]:4711\";proto=https",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "complex multiple proxies with IPv6",
|
|
||||||
forwarded: "for=192.0.2.43;proto=https, for=\"[2001:db8:cafe::17]\";proto=http",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "obfuscated identifiers",
|
|
||||||
forwarded: "for=_mdn;proto=https",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown identifier",
|
|
||||||
forwarded: "for=unknown;proto=https",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "malformed key-value pair",
|
|
||||||
forwarded: "proto",
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "malformed key-value pair with equals",
|
|
||||||
forwarded: "proto=",
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple equals signs",
|
|
||||||
forwarded: "proto=https=extra",
|
|
||||||
expected: "https=extra",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed case directive name",
|
|
||||||
forwarded: "PROTO=https",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed case directive name with spaces",
|
|
||||||
forwarded: " Proto = https ",
|
|
||||||
expected: "https",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseForwardedHeaderProto(t *testing.T) {
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := parseForwardedHeaderProto(tt.forwarded)
|
|
||||||
if result != tt.expected {
|
|
||||||
t.Errorf("parseForwardedHeader(%q) = %q, want %q", tt.forwarded, result, tt.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func FuzzParseForwardedHeaderProto(f *testing.F) {
|
|
||||||
for _, t := range tests {
|
|
||||||
f.Add(t.forwarded)
|
|
||||||
}
|
|
||||||
|
|
||||||
f.Fuzz(func(t *testing.T, forwarded string) {
|
|
||||||
parseForwardedHeaderProto(forwarded)
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -38,30 +38,14 @@ type K8sApplication struct {
|
||||||
Labels map[string]string `json:"Labels,omitempty"`
|
Labels map[string]string `json:"Labels,omitempty"`
|
||||||
Resource K8sApplicationResource `json:"Resource,omitempty"`
|
Resource K8sApplicationResource `json:"Resource,omitempty"`
|
||||||
HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"`
|
HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"`
|
||||||
CustomResourceMetadata CustomResourceMetadata `json:"CustomResourceMetadata,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
Labels map[string]string `json:"labels"`
|
Labels map[string]string `json:"labels"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomResourceMetadata struct {
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
APIVersion string `json:"apiVersion"`
|
|
||||||
Plural string `json:"plural"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Pod struct {
|
type Pod struct {
|
||||||
Name string `json:"Name"`
|
Status string `json:"Status"`
|
||||||
ContainerName string `json:"ContainerName"`
|
|
||||||
Image string `json:"Image"`
|
|
||||||
ImagePullPolicy string `json:"ImagePullPolicy"`
|
|
||||||
Status string `json:"Status"`
|
|
||||||
NodeName string `json:"NodeName"`
|
|
||||||
PodIP string `json:"PodIP"`
|
|
||||||
UID string `json:"Uid"`
|
|
||||||
Resource K8sApplicationResource `json:"Resource,omitempty"`
|
|
||||||
CreationDate time.Time `json:"CreationDate"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Configuration struct {
|
type Configuration struct {
|
||||||
|
@ -88,8 +72,8 @@ type TLSInfo struct {
|
||||||
|
|
||||||
// Existing types
|
// Existing types
|
||||||
type K8sApplicationResource struct {
|
type K8sApplicationResource struct {
|
||||||
CPURequest float64 `json:"CpuRequest,omitempty"`
|
CPURequest float64 `json:"CpuRequest"`
|
||||||
CPULimit float64 `json:"CpuLimit,omitempty"`
|
CPULimit float64 `json:"CpuLimit"`
|
||||||
MemoryRequest int64 `json:"MemoryRequest,omitempty"`
|
MemoryRequest int64 `json:"MemoryRequest"`
|
||||||
MemoryLimit int64 `json:"MemoryLimit,omitempty"`
|
MemoryLimit int64 `json:"MemoryLimit"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,7 +35,7 @@ type (
|
||||||
func getUniqueElements(items string) []string {
|
func getUniqueElements(items string) []string {
|
||||||
xs := strings.Split(items, ",")
|
xs := strings.Split(items, ",")
|
||||||
xs = slicesx.Map(xs, strings.TrimSpace)
|
xs = slicesx.Map(xs, strings.TrimSpace)
|
||||||
xs = slicesx.FilterInPlace(xs, func(x string) bool { return len(x) > 0 })
|
xs = slicesx.Filter(xs, func(x string) bool { return len(x) > 0 })
|
||||||
|
|
||||||
return slicesx.Unique(xs)
|
return slicesx.Unique(xs)
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,13 +55,12 @@ func createRegistryAuthenticationHeader(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = registryutils.PrepareRegistryCredentials(dataStore, matchingRegistry); err != nil {
|
if err = registryutils.EnsureRegTokenValid(dataStore, matchingRegistry); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticationHeader.Serveraddress = matchingRegistry.URL
|
authenticationHeader.Serveraddress = matchingRegistry.URL
|
||||||
authenticationHeader.Username = matchingRegistry.Username
|
authenticationHeader.Username, authenticationHeader.Password, err = registryutils.GetRegEffectiveCredential(matchingRegistry)
|
||||||
authenticationHeader.Password = matchingRegistry.Password
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,6 @@ import (
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
gittypes "github.com/portainer/portainer/api/git/types"
|
|
||||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
"github.com/portainer/portainer/api/internal/authorization"
|
"github.com/portainer/portainer/api/internal/authorization"
|
||||||
|
@ -419,14 +418,7 @@ func (transport *Transport) updateDefaultGitBranch(request *http.Request) error
|
||||||
}
|
}
|
||||||
|
|
||||||
repositoryURL := remote[:len(remote)-4]
|
repositoryURL := remote[:len(remote)-4]
|
||||||
latestCommitID, err := transport.gitService.LatestCommitID(
|
latestCommitID, err := transport.gitService.LatestCommitID(repositoryURL, "", "", "", false)
|
||||||
repositoryURL,
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
gittypes.GitCredentialAuthType_Basic,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,12 +24,11 @@ type (
|
||||||
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||||
gitService portainer.GitService
|
gitService portainer.GitService
|
||||||
snapshotService portainer.SnapshotService
|
snapshotService portainer.SnapshotService
|
||||||
jwtService portainer.JWTService
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewProxyFactory returns a pointer to a new instance of a ProxyFactory
|
// NewProxyFactory returns a pointer to a new instance of a ProxyFactory
|
||||||
func NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService, jwtService portainer.JWTService) *ProxyFactory {
|
func NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService) *ProxyFactory {
|
||||||
return &ProxyFactory{
|
return &ProxyFactory{
|
||||||
dataStore: dataStore,
|
dataStore: dataStore,
|
||||||
signatureService: signatureService,
|
signatureService: signatureService,
|
||||||
|
@ -39,7 +38,6 @@ func NewProxyFactory(dataStore dataservices.DataStore, signatureService portaine
|
||||||
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
||||||
gitService: gitService,
|
gitService: gitService,
|
||||||
snapshotService: snapshotService,
|
snapshotService: snapshotService,
|
||||||
jwtService: jwtService,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,108 +0,0 @@
|
||||||
package github
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/segmentio/encoding/json"
|
|
||||||
"oras.land/oras-go/v2/registry/remote/retry"
|
|
||||||
)
|
|
||||||
|
|
||||||
const GitHubAPIHost = "https://api.github.com"
|
|
||||||
|
|
||||||
// Package represents a GitHub container package
|
|
||||||
type Package struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Owner struct {
|
|
||||||
Login string `json:"login"`
|
|
||||||
} `json:"owner"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client represents a GitHub API client
|
|
||||||
type Client struct {
|
|
||||||
httpClient *http.Client
|
|
||||||
baseURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClient creates a new GitHub API client
|
|
||||||
func NewClient(token string) *Client {
|
|
||||||
return &Client{
|
|
||||||
httpClient: NewHTTPClient(token),
|
|
||||||
baseURL: GitHubAPIHost,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetContainerPackages fetches container packages for the configured namespace
|
|
||||||
// It's a small http client wrapper instead of using the github client because listing repositories is the only known operation that isn't directly supported by oras
|
|
||||||
func (c *Client) GetContainerPackages(ctx context.Context, useOrganisation bool, organisationName string) ([]string, error) {
|
|
||||||
// Determine the namespace (user or organisation) for the request
|
|
||||||
namespace := "user"
|
|
||||||
if useOrganisation {
|
|
||||||
namespace = "orgs/" + organisationName
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the full URL for listing container packages
|
|
||||||
url := fmt.Sprintf("%s/%s/packages?package_type=container", c.baseURL, namespace)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to execute request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var packages []Package
|
|
||||||
if err := json.Unmarshal(body, &packages); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract repository names in the form "owner/name"
|
|
||||||
repositories := make([]string, len(packages))
|
|
||||||
for i, pkg := range packages {
|
|
||||||
repositories[i] = fmt.Sprintf("%s/%s", strings.ToLower(pkg.Owner.Login), strings.ToLower(pkg.Name))
|
|
||||||
}
|
|
||||||
|
|
||||||
return repositories, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHTTPClient creates a new HTTP client configured for GitHub API requests
|
|
||||||
func NewHTTPClient(token string) *http.Client {
|
|
||||||
return &http.Client{
|
|
||||||
Transport: &tokenTransport{
|
|
||||||
token: token,
|
|
||||||
transport: retry.NewTransport(&http.Transport{}), // Use ORAS retry transport for consistent rate limiting and error handling
|
|
||||||
},
|
|
||||||
Timeout: 1 * time.Minute,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// tokenTransport automatically adds the Bearer token header to requests
|
|
||||||
type tokenTransport struct {
|
|
||||||
token string
|
|
||||||
transport http.RoundTripper
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
||||||
if t.token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+t.token)
|
|
||||||
req.Header.Set("Accept", "application/vnd.github+json")
|
|
||||||
}
|
|
||||||
return t.transport.RoundTrip(req)
|
|
||||||
}
|
|
|
@ -1,130 +0,0 @@
|
||||||
package gitlab
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/segmentio/encoding/json"
|
|
||||||
"oras.land/oras-go/v2/registry/remote/retry"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Repository represents a GitLab registry repository
|
|
||||||
type Repository struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
ProjectID int `json:"project_id"`
|
|
||||||
Location string `json:"location"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client represents a GitLab API client
|
|
||||||
type Client struct {
|
|
||||||
httpClient *http.Client
|
|
||||||
baseURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClient creates a new GitLab API client
|
|
||||||
// it currently is an http client because only GetRegistryRepositoryNames is needed (oras supports other commands).
|
|
||||||
// if we need to support other commands, consider using the gitlab client library.
|
|
||||||
func NewClient(baseURL, token string) *Client {
|
|
||||||
return &Client{
|
|
||||||
httpClient: NewHTTPClient(token),
|
|
||||||
baseURL: baseURL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRegistryRepositoryNames fetches registry repository names for a given project.
|
|
||||||
// It's a small http client wrapper instead of using the gitlab client library because listing repositories is the only known operation that isn't directly supported by oras
|
|
||||||
func (c *Client) GetRegistryRepositoryNames(ctx context.Context, projectID int) ([]string, error) {
|
|
||||||
url := fmt.Sprintf("%s/api/v4/projects/%d/registry/repositories", c.baseURL, projectID)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to execute request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("GitLab API returned status %d: %s", resp.StatusCode, resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var repositories []Repository
|
|
||||||
if err := json.Unmarshal(body, &repositories); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract repository names
|
|
||||||
names := make([]string, len(repositories))
|
|
||||||
for i, repo := range repositories {
|
|
||||||
// the full path is required for further repo operations
|
|
||||||
names[i] = repo.Path
|
|
||||||
}
|
|
||||||
|
|
||||||
return names, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type Transport struct {
|
|
||||||
httpTransport *http.Transport
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport
|
|
||||||
// interface for proxying requests to the Gitlab API.
|
|
||||||
func NewTransport() *Transport {
|
|
||||||
return &Transport{
|
|
||||||
httpTransport: &http.Transport{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RoundTrip is the implementation of the http.RoundTripper interface
|
|
||||||
func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
||||||
token := request.Header.Get("Private-Token")
|
|
||||||
if token == "" {
|
|
||||||
return nil, errors.New("no gitlab token provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
r, err := http.NewRequest(request.Method, request.URL.String(), request.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
r.Header.Set("Private-Token", token)
|
|
||||||
return transport.httpTransport.RoundTrip(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHTTPClient creates a new HTTP client configured for GitLab API requests
|
|
||||||
func NewHTTPClient(token string) *http.Client {
|
|
||||||
return &http.Client{
|
|
||||||
Transport: &tokenTransport{
|
|
||||||
token: token,
|
|
||||||
transport: retry.NewTransport(&http.Transport{}), // Use ORAS retry transport for consistent rate limiting and error handling
|
|
||||||
},
|
|
||||||
Timeout: 1 * time.Minute,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// tokenTransport automatically adds the Private-Token header to requests
|
|
||||||
type tokenTransport struct {
|
|
||||||
token string
|
|
||||||
transport http.RoundTripper
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
||||||
req.Header.Set("Private-Token", t.token)
|
|
||||||
return t.transport.RoundTrip(req)
|
|
||||||
}
|
|
34
api/http/proxy/factory/gitlab/transport.go
Normal file
34
api/http/proxy/factory/gitlab/transport.go
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
package gitlab
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Transport struct {
|
||||||
|
httpTransport *http.Transport
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport
|
||||||
|
// interface for proxying requests to the Gitlab API.
|
||||||
|
func NewTransport() *Transport {
|
||||||
|
return &Transport{
|
||||||
|
httpTransport: &http.Transport{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoundTrip is the implementation of the http.RoundTripper interface
|
||||||
|
func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||||
|
token := request.Header.Get("Private-Token")
|
||||||
|
if token == "" {
|
||||||
|
return nil, errors.New("no gitlab token provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := http.NewRequest(request.Method, request.URL.String(), request.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Header.Set("Private-Token", token)
|
||||||
|
return transport.httpTransport.RoundTrip(r)
|
||||||
|
}
|
|
@ -38,7 +38,7 @@ func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoin
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
transport, err := kubernetes.NewLocalTransport(tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore, factory.jwtService)
|
transport, err := kubernetes.NewLocalTransport(tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -74,7 +74,7 @@ func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endp
|
||||||
|
|
||||||
endpointURL.Scheme = "http"
|
endpointURL.Scheme = "http"
|
||||||
proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)
|
proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)
|
||||||
proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.signatureService, factory.reverseTunnelService, endpoint, tokenManager, factory.kubernetesClientFactory, factory.jwtService)
|
proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.signatureService, factory.reverseTunnelService, endpoint, tokenManager, factory.kubernetesClientFactory)
|
||||||
|
|
||||||
return proxy, nil
|
return proxy, nil
|
||||||
}
|
}
|
||||||
|
@ -105,7 +105,7 @@ func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.En
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL)
|
proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL)
|
||||||
proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore, factory.jwtService)
|
proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore)
|
||||||
|
|
||||||
return proxy, nil
|
return proxy, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ type agentTransport struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent
|
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent
|
||||||
func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore, jwtService portainer.JWTService) *agentTransport {
|
func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore) *agentTransport {
|
||||||
transport := &agentTransport{
|
transport := &agentTransport{
|
||||||
baseTransport: newBaseTransport(
|
baseTransport: newBaseTransport(
|
||||||
&http.Transport{
|
&http.Transport{
|
||||||
|
@ -26,7 +26,6 @@ func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsCo
|
||||||
endpoint,
|
endpoint,
|
||||||
k8sClientFactory,
|
k8sClientFactory,
|
||||||
dataStore,
|
dataStore,
|
||||||
jwtService,
|
|
||||||
),
|
),
|
||||||
signatureService: signatureService,
|
signatureService: signatureService,
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ type edgeTransport struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent
|
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent
|
||||||
func NewEdgeTransport(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, endpoint *portainer.Endpoint, tokenManager *tokenManager, k8sClientFactory *cli.ClientFactory, jwtService portainer.JWTService) *edgeTransport {
|
func NewEdgeTransport(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, endpoint *portainer.Endpoint, tokenManager *tokenManager, k8sClientFactory *cli.ClientFactory) *edgeTransport {
|
||||||
transport := &edgeTransport{
|
transport := &edgeTransport{
|
||||||
reverseTunnelService: reverseTunnelService,
|
reverseTunnelService: reverseTunnelService,
|
||||||
signatureService: signatureService,
|
signatureService: signatureService,
|
||||||
|
@ -26,7 +26,6 @@ func NewEdgeTransport(dataStore dataservices.DataStore, signatureService portain
|
||||||
endpoint,
|
endpoint,
|
||||||
k8sClientFactory,
|
k8sClientFactory,
|
||||||
dataStore,
|
dataStore,
|
||||||
jwtService,
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ type localTransport struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API
|
// NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API
|
||||||
func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore, jwtService portainer.JWTService) (*localTransport, error) {
|
func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore) (*localTransport, error) {
|
||||||
config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true)
|
config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -29,7 +29,6 @@ func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint,
|
||||||
endpoint,
|
endpoint,
|
||||||
k8sClientFactory,
|
k8sClientFactory,
|
||||||
dataStore,
|
dataStore,
|
||||||
jwtService,
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,18 +2,12 @@ package kubernetes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (transport *baseTransport) proxyPodsRequest(request *http.Request, namespace string) (*http.Response, error) {
|
func (transport *baseTransport) proxyPodsRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) {
|
||||||
if request.Method == http.MethodDelete {
|
if request.Method == http.MethodDelete {
|
||||||
transport.refreshRegistry(request, namespace)
|
transport.refreshRegistry(request, namespace)
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.Method == http.MethodPost && strings.Contains(request.URL.Path, "/exec") {
|
|
||||||
if err := transport.addTokenForExec(request); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return transport.executeKubernetesRequest(request)
|
return transport.executeKubernetesRequest(request)
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,17 +26,15 @@ type baseTransport struct {
|
||||||
endpoint *portainer.Endpoint
|
endpoint *portainer.Endpoint
|
||||||
k8sClientFactory *cli.ClientFactory
|
k8sClientFactory *cli.ClientFactory
|
||||||
dataStore dataservices.DataStore
|
dataStore dataservices.DataStore
|
||||||
jwtService portainer.JWTService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBaseTransport(httpTransport *http.Transport, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore, jwtService portainer.JWTService) *baseTransport {
|
func newBaseTransport(httpTransport *http.Transport, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore) *baseTransport {
|
||||||
return &baseTransport{
|
return &baseTransport{
|
||||||
httpTransport: httpTransport,
|
httpTransport: httpTransport,
|
||||||
tokenManager: tokenManager,
|
tokenManager: tokenManager,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
k8sClientFactory: k8sClientFactory,
|
k8sClientFactory: k8sClientFactory,
|
||||||
dataStore: dataStore,
|
dataStore: dataStore,
|
||||||
jwtService: jwtService,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,7 +58,6 @@ func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (*
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.EqualFold(requestPath, "/namespaces/portainer/configmaps/portainer-config") && (request.Method == "PUT" || request.Method == "POST"):
|
case strings.EqualFold(requestPath, "/namespaces/portainer/configmaps/portainer-config") && (request.Method == "PUT" || request.Method == "POST"):
|
||||||
transport.k8sClientFactory.ClearClientCache()
|
|
||||||
defer transport.tokenManager.UpdateUserServiceAccountsForEndpoint(portainer.EndpointID(endpointID))
|
defer transport.tokenManager.UpdateUserServiceAccountsForEndpoint(portainer.EndpointID(endpointID))
|
||||||
return transport.executeKubernetesRequest(request)
|
return transport.executeKubernetesRequest(request)
|
||||||
case strings.EqualFold(requestPath, "/namespaces"):
|
case strings.EqualFold(requestPath, "/namespaces"):
|
||||||
|
@ -84,7 +81,7 @@ func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fu
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(requestPath, "pods"):
|
case strings.HasPrefix(requestPath, "pods"):
|
||||||
return transport.proxyPodsRequest(request, namespace)
|
return transport.proxyPodsRequest(request, namespace, requestPath)
|
||||||
case strings.HasPrefix(requestPath, "deployments"):
|
case strings.HasPrefix(requestPath, "deployments"):
|
||||||
return transport.proxyDeploymentsRequest(request, namespace, requestPath)
|
return transport.proxyDeploymentsRequest(request, namespace, requestPath)
|
||||||
case requestPath == "" && request.Method == "DELETE":
|
case requestPath == "" && request.Method == "DELETE":
|
||||||
|
@ -94,23 +91,6 @@ func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// addTokenForExec injects a kubeconfig token into the request header
|
|
||||||
// this is only used with kubeconfig for kubectl exec requests
|
|
||||||
func (transport *baseTransport) addTokenForExec(request *http.Request) error {
|
|
||||||
tokenData, err := security.RetrieveTokenData(request)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := transport.jwtService.GenerateTokenForKubeconfig(tokenData)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
request.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (transport *baseTransport) executeKubernetesRequest(request *http.Request) (*http.Response, error) {
|
func (transport *baseTransport) executeKubernetesRequest(request *http.Request) (*http.Response, error) {
|
||||||
|
|
||||||
resp, err := transport.httpTransport.RoundTrip(request)
|
resp, err := transport.httpTransport.RoundTrip(request)
|
||||||
|
|
|
@ -1,359 +0,0 @@
|
||||||
package kubernetes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/datastore"
|
|
||||||
"github.com/portainer/portainer/api/http/security"
|
|
||||||
"github.com/portainer/portainer/api/jwt"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockJWTService implements portainer.JWTService for testing
|
|
||||||
type MockJWTService struct {
|
|
||||||
generateTokenFunc func(data *portainer.TokenData) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockJWTService) GenerateToken(data *portainer.TokenData) (string, time.Time, error) {
|
|
||||||
if m.generateTokenFunc != nil {
|
|
||||||
token, err := m.generateTokenFunc(data)
|
|
||||||
return token, time.Now().Add(24 * time.Hour), err
|
|
||||||
}
|
|
||||||
return "mock-token", time.Now().Add(24 * time.Hour), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockJWTService) GenerateTokenForKubeconfig(data *portainer.TokenData) (string, error) {
|
|
||||||
if m.generateTokenFunc != nil {
|
|
||||||
return m.generateTokenFunc(data)
|
|
||||||
}
|
|
||||||
return "mock-kubeconfig-token", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockJWTService) ParseAndVerifyToken(token string) (*portainer.TokenData, string, time.Time, error) {
|
|
||||||
return &portainer.TokenData{ID: 1, Username: "mock", Role: portainer.AdministratorRole}, "mock-id", time.Now().Add(24 * time.Hour), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockJWTService) SetUserSessionDuration(userSessionDuration time.Duration) {
|
|
||||||
// Mock implementation - not used in tests
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBaseTransport_AddTokenForExec(t *testing.T) {
|
|
||||||
// Setup test store and JWT service
|
|
||||||
_, store := datastore.MustNewTestStore(t, true, false)
|
|
||||||
|
|
||||||
// Create test users
|
|
||||||
adminUser := &portainer.User{
|
|
||||||
ID: 1,
|
|
||||||
Username: "admin",
|
|
||||||
Role: portainer.AdministratorRole,
|
|
||||||
}
|
|
||||||
err := store.User().Create(adminUser)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
standardUser := &portainer.User{
|
|
||||||
ID: 2,
|
|
||||||
Username: "standard",
|
|
||||||
Role: portainer.StandardUserRole,
|
|
||||||
}
|
|
||||||
err = store.User().Create(standardUser)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create JWT service
|
|
||||||
jwtService, err := jwt.NewService("24h", store)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create base transport
|
|
||||||
transport := &baseTransport{
|
|
||||||
jwtService: jwtService,
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
tokenData *portainer.TokenData
|
|
||||||
setupRequest func(*http.Request) *http.Request
|
|
||||||
expectError bool
|
|
||||||
errorMsg string
|
|
||||||
expectPanic bool
|
|
||||||
verifyResponse func(*testing.T, *http.Request, *portainer.TokenData)
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "admin user - successful token generation",
|
|
||||||
tokenData: &portainer.TokenData{
|
|
||||||
ID: adminUser.ID,
|
|
||||||
Username: adminUser.Username,
|
|
||||||
Role: adminUser.Role,
|
|
||||||
},
|
|
||||||
setupRequest: func(req *http.Request) *http.Request {
|
|
||||||
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
|
|
||||||
ID: adminUser.ID,
|
|
||||||
Username: adminUser.Username,
|
|
||||||
Role: adminUser.Role,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
expectError: false,
|
|
||||||
verifyResponse: func(t *testing.T, req *http.Request, tokenData *portainer.TokenData) {
|
|
||||||
authHeader := req.Header.Get("Authorization")
|
|
||||||
assert.NotEmpty(t, authHeader)
|
|
||||||
assert.True(t, strings.HasPrefix(authHeader, "Bearer "))
|
|
||||||
|
|
||||||
token := authHeader[7:] // Remove "Bearer " prefix
|
|
||||||
parsedTokenData, _, _, err := jwtService.ParseAndVerifyToken(token)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, tokenData.ID, parsedTokenData.ID)
|
|
||||||
assert.Equal(t, tokenData.Username, parsedTokenData.Username)
|
|
||||||
assert.Equal(t, tokenData.Role, parsedTokenData.Role)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "standard user - successful token generation",
|
|
||||||
tokenData: &portainer.TokenData{
|
|
||||||
ID: standardUser.ID,
|
|
||||||
Username: standardUser.Username,
|
|
||||||
Role: standardUser.Role,
|
|
||||||
},
|
|
||||||
setupRequest: func(req *http.Request) *http.Request {
|
|
||||||
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
|
|
||||||
ID: standardUser.ID,
|
|
||||||
Username: standardUser.Username,
|
|
||||||
Role: standardUser.Role,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
expectError: false,
|
|
||||||
verifyResponse: func(t *testing.T, req *http.Request, tokenData *portainer.TokenData) {
|
|
||||||
authHeader := req.Header.Get("Authorization")
|
|
||||||
assert.NotEmpty(t, authHeader)
|
|
||||||
assert.True(t, strings.HasPrefix(authHeader, "Bearer "))
|
|
||||||
|
|
||||||
token := authHeader[7:] // Remove "Bearer " prefix
|
|
||||||
parsedTokenData, _, _, err := jwtService.ParseAndVerifyToken(token)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, tokenData.ID, parsedTokenData.ID)
|
|
||||||
assert.Equal(t, tokenData.Username, parsedTokenData.Username)
|
|
||||||
assert.Equal(t, tokenData.Role, parsedTokenData.Role)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "request without token data in context",
|
|
||||||
tokenData: nil,
|
|
||||||
setupRequest: func(req *http.Request) *http.Request {
|
|
||||||
return req // Don't add token data to context
|
|
||||||
},
|
|
||||||
expectError: true,
|
|
||||||
errorMsg: "Unable to find JWT data in request context",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "request with nil token data",
|
|
||||||
tokenData: nil,
|
|
||||||
setupRequest: func(req *http.Request) *http.Request {
|
|
||||||
return req.WithContext(security.StoreTokenData(req, nil))
|
|
||||||
},
|
|
||||||
expectPanic: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "JWT service failure",
|
|
||||||
tokenData: &portainer.TokenData{
|
|
||||||
ID: 1,
|
|
||||||
Username: "test",
|
|
||||||
Role: portainer.AdministratorRole,
|
|
||||||
},
|
|
||||||
setupRequest: func(req *http.Request) *http.Request {
|
|
||||||
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
|
|
||||||
ID: 1,
|
|
||||||
Username: "test",
|
|
||||||
Role: portainer.AdministratorRole,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
expectPanic: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "verify authorization header format",
|
|
||||||
tokenData: &portainer.TokenData{
|
|
||||||
ID: adminUser.ID,
|
|
||||||
Username: adminUser.Username,
|
|
||||||
Role: adminUser.Role,
|
|
||||||
},
|
|
||||||
setupRequest: func(req *http.Request) *http.Request {
|
|
||||||
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
|
|
||||||
ID: adminUser.ID,
|
|
||||||
Username: adminUser.Username,
|
|
||||||
Role: adminUser.Role,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
expectError: false,
|
|
||||||
verifyResponse: func(t *testing.T, req *http.Request, tokenData *portainer.TokenData) {
|
|
||||||
authHeader := req.Header.Get("Authorization")
|
|
||||||
assert.NotEmpty(t, authHeader)
|
|
||||||
assert.True(t, strings.HasPrefix(authHeader, "Bearer "))
|
|
||||||
|
|
||||||
token := authHeader[7:] // Remove "Bearer " prefix
|
|
||||||
assert.NotEmpty(t, token)
|
|
||||||
assert.Greater(t, len(token), 0, "Token should not be empty")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "verify header is overwritten on subsequent calls",
|
|
||||||
tokenData: &portainer.TokenData{
|
|
||||||
ID: adminUser.ID,
|
|
||||||
Username: adminUser.Username,
|
|
||||||
Role: adminUser.Role,
|
|
||||||
},
|
|
||||||
setupRequest: func(req *http.Request) *http.Request {
|
|
||||||
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
|
|
||||||
ID: adminUser.ID,
|
|
||||||
Username: adminUser.Username,
|
|
||||||
Role: adminUser.Role,
|
|
||||||
}))
|
|
||||||
// Set an existing Authorization header
|
|
||||||
req.Header.Set("Authorization", "Bearer old-token")
|
|
||||||
return req
|
|
||||||
},
|
|
||||||
expectError: false,
|
|
||||||
verifyResponse: func(t *testing.T, req *http.Request, tokenData *portainer.TokenData) {
|
|
||||||
authHeader := req.Header.Get("Authorization")
|
|
||||||
assert.NotEqual(t, "Bearer old-token", authHeader)
|
|
||||||
assert.True(t, strings.HasPrefix(authHeader, "Bearer "))
|
|
||||||
|
|
||||||
token := authHeader[7:] // Remove "Bearer " prefix
|
|
||||||
parsedTokenData, _, _, err := jwtService.ParseAndVerifyToken(token)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, tokenData.ID, parsedTokenData.ID)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create request
|
|
||||||
request := httptest.NewRequest("GET", "/", nil)
|
|
||||||
request = tt.setupRequest(request)
|
|
||||||
|
|
||||||
// Determine which transport to use based on test case
|
|
||||||
var testTransport *baseTransport
|
|
||||||
if tt.name == "JWT service failure" {
|
|
||||||
testTransport = &baseTransport{
|
|
||||||
jwtService: nil,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
testTransport = transport
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call the function
|
|
||||||
if tt.expectPanic {
|
|
||||||
assert.Panics(t, func() {
|
|
||||||
_ = testTransport.addTokenForExec(request)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := testTransport.addTokenForExec(request)
|
|
||||||
|
|
||||||
// Check results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
if tt.errorMsg != "" {
|
|
||||||
assert.Contains(t, err.Error(), tt.errorMsg)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
if tt.verifyResponse != nil {
|
|
||||||
tt.verifyResponse(t, request, tt.tokenData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBaseTransport_AddTokenForExec_Integration(t *testing.T) {
|
|
||||||
// Create a test HTTP server to capture requests
|
|
||||||
var capturedRequest *http.Request
|
|
||||||
var capturedHeaders http.Header
|
|
||||||
|
|
||||||
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
capturedRequest = r
|
|
||||||
capturedHeaders = r.Header.Clone()
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("success"))
|
|
||||||
}))
|
|
||||||
defer testServer.Close()
|
|
||||||
|
|
||||||
// Create mock JWT service
|
|
||||||
mockJWTService := &MockJWTService{
|
|
||||||
generateTokenFunc: func(data *portainer.TokenData) (string, error) {
|
|
||||||
return "mock-token-" + data.Username, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create base transport
|
|
||||||
transport := &baseTransport{
|
|
||||||
httpTransport: &http.Transport{},
|
|
||||||
jwtService: mockJWTService,
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
tokenData *portainer.TokenData
|
|
||||||
requestPath string
|
|
||||||
expectedToken string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "admin user exec request",
|
|
||||||
tokenData: &portainer.TokenData{
|
|
||||||
ID: 1,
|
|
||||||
Username: "admin",
|
|
||||||
Role: portainer.AdministratorRole,
|
|
||||||
},
|
|
||||||
requestPath: "/api/endpoints/1/kubernetes/api/v1/namespaces/default/pods/test-pod/exec",
|
|
||||||
expectedToken: "mock-token-admin",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "standard user exec request",
|
|
||||||
tokenData: &portainer.TokenData{
|
|
||||||
ID: 2,
|
|
||||||
Username: "standard",
|
|
||||||
Role: portainer.StandardUserRole,
|
|
||||||
},
|
|
||||||
requestPath: "/api/endpoints/1/kubernetes/api/v1/namespaces/default/pods/test-pod/exec",
|
|
||||||
expectedToken: "mock-token-standard",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Reset captured data
|
|
||||||
capturedRequest = nil
|
|
||||||
capturedHeaders = nil
|
|
||||||
|
|
||||||
// Create request to the test server
|
|
||||||
request, err := http.NewRequest("POST", testServer.URL+tt.requestPath, strings.NewReader(""))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Add token data to request context
|
|
||||||
request = request.WithContext(security.StoreTokenData(request, tt.tokenData))
|
|
||||||
|
|
||||||
// Call proxyPodsRequest which triggers addTokenForExec for POST /exec requests
|
|
||||||
resp, err := transport.proxyPodsRequest(request, "default")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// Verify the response
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
// Verify the request was captured
|
|
||||||
assert.NotNil(t, capturedRequest)
|
|
||||||
assert.Equal(t, "POST", capturedRequest.Method)
|
|
||||||
assert.Equal(t, tt.requestPath, capturedRequest.URL.Path)
|
|
||||||
|
|
||||||
// Verify the authorization header was set correctly
|
|
||||||
capturedAuthHeader := capturedHeaders.Get("Authorization")
|
|
||||||
assert.NotEmpty(t, capturedAuthHeader)
|
|
||||||
assert.True(t, strings.HasPrefix(capturedAuthHeader, "Bearer "))
|
|
||||||
assert.Equal(t, "Bearer "+tt.expectedToken, capturedAuthHeader)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -9,20 +9,17 @@ import (
|
||||||
|
|
||||||
// Note that we discard any non-canonical headers by design
|
// Note that we discard any non-canonical headers by design
|
||||||
var allowedHeaders = map[string]struct{}{
|
var allowedHeaders = map[string]struct{}{
|
||||||
"Accept": {},
|
"Accept": {},
|
||||||
"Accept-Encoding": {},
|
"Accept-Encoding": {},
|
||||||
"Accept-Language": {},
|
"Accept-Language": {},
|
||||||
"Cache-Control": {},
|
"Cache-Control": {},
|
||||||
"Connection": {},
|
"Content-Length": {},
|
||||||
"Content-Length": {},
|
"Content-Type": {},
|
||||||
"Content-Type": {},
|
"Private-Token": {},
|
||||||
"Private-Token": {},
|
"User-Agent": {},
|
||||||
"Upgrade": {},
|
"X-Portaineragent-Target": {},
|
||||||
"User-Agent": {},
|
"X-Portainer-Volumename": {},
|
||||||
"X-Portaineragent-Target": {},
|
"X-Registry-Auth": {},
|
||||||
"X-Portainer-Volumename": {},
|
|
||||||
"X-Registry-Auth": {},
|
|
||||||
"X-Stream-Protocol-Version": {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
|
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
|
||||||
|
|
|
@ -32,8 +32,8 @@ func NewManager(kubernetesClientFactory *cli.ClientFactory) *Manager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *Manager) NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService, jwtService portainer.JWTService) {
|
func (manager *Manager) NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService) {
|
||||||
manager.proxyFactory = factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService, jwtService)
|
manager.proxyFactory = factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateAndRegisterEndpointProxy creates a new HTTP reverse proxy based on environment(endpoint) properties and adds it to the registered proxies.
|
// CreateAndRegisterEndpointProxy creates a new HTTP reverse proxy based on environment(endpoint) properties and adds it to the registered proxies.
|
||||||
|
|
|
@ -35,7 +35,6 @@ type (
|
||||||
JWTAuthLookup(*http.Request) (*portainer.TokenData, error)
|
JWTAuthLookup(*http.Request) (*portainer.TokenData, error)
|
||||||
TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error
|
TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error
|
||||||
RevokeJWT(string)
|
RevokeJWT(string)
|
||||||
DisableCSP()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequestBouncer represents an entity that manages API request accesses
|
// RequestBouncer represents an entity that manages API request accesses
|
||||||
|
@ -73,7 +72,7 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW
|
||||||
jwtService: jwtService,
|
jwtService: jwtService,
|
||||||
apiKeyService: apiKeyService,
|
apiKeyService: apiKeyService,
|
||||||
hsts: featureflags.IsEnabled("hsts"),
|
hsts: featureflags.IsEnabled("hsts"),
|
||||||
csp: true,
|
csp: featureflags.IsEnabled("csp"),
|
||||||
}
|
}
|
||||||
|
|
||||||
go b.cleanUpExpiredJWT()
|
go b.cleanUpExpiredJWT()
|
||||||
|
@ -81,11 +80,6 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// DisableCSP disables Content Security Policy
|
|
||||||
func (bouncer *RequestBouncer) DisableCSP() {
|
|
||||||
bouncer.csp = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// PublicAccess defines a security check for public API endpoints.
|
// PublicAccess defines a security check for public API endpoints.
|
||||||
// No authentication is required to access these endpoints.
|
// No authentication is required to access these endpoints.
|
||||||
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler {
|
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler {
|
||||||
|
@ -534,7 +528,7 @@ func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
if csp {
|
if csp {
|
||||||
w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud js.hsforms.net; frame-ancestors 'none';")
|
w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud")
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue