mirror of
https://github.com/portainer/portainer.git
synced 2025-07-19 05:19:39 +02:00
Compare commits
67 commits
Author | SHA1 | Date | |
---|---|---|---|
|
eaa2be017d | ||
|
4e4c5ffdb6 | ||
|
383bcc4113 | ||
|
9f906b7417 | ||
|
db2e168540 | ||
|
2697d6c5d7 | ||
|
b6a6ce9aaf | ||
|
89f6a94bd8 | ||
|
96f2d69ae5 | ||
|
b7e906701a | ||
|
150d986179 | ||
|
ef10ea2a7d | ||
|
3bf84e8b0c | ||
|
ea4b334c7e | ||
|
4d11aa8655 | ||
|
302deb8299 | ||
|
0c80b1067d | ||
|
0a36d4fbfd | ||
|
c20a8b5a68 | ||
|
8ffe4e284a | ||
|
1332f718ae | ||
|
f4df51884c | ||
|
ce86129478 | ||
|
097b125e3a | ||
|
5c6b53922a | ||
|
e1b9f23f73 | ||
|
e1c480d3c3 | ||
|
363a62d885 | ||
|
c6ee9a5a52 | ||
|
cf5990ccba | ||
|
b6f3682a62 | ||
|
b43f864511 | ||
|
0556ffb4a1 | ||
|
303047656e | ||
|
8d29b5ae71 | ||
|
7d7ae24351 | ||
|
97838e614d | ||
|
c897baad20 | ||
|
d51e9205d9 | ||
|
e051c86bb5 | ||
|
c2b48cd003 | ||
|
a7009eb8d5 | ||
|
036b87b649 | ||
|
f07a3b1875 | ||
|
6e89ccc0ae | ||
|
cc67612432 | ||
|
17ebe221bb | ||
|
1963edda66 | ||
|
c9e3717ce3 | ||
|
9a85246631 | ||
|
75f165d1ff | ||
|
eaf0deb2f6 | ||
|
a9061e5258 | ||
|
caac45b834 | ||
|
24ff7a7911 | ||
|
b767dcb27e | ||
|
731afbee46 | ||
|
07dfd981a2 | ||
|
32ef208278 | ||
|
a80b185e10 | ||
|
b96328e098 | ||
|
45471ce86d | ||
|
1bc91d0c7c | ||
|
799325d9f8 | ||
|
b540709e03 | ||
|
44daab04ac | ||
|
ee65223ee7 |
322 changed files with 11859 additions and 3419 deletions
9
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
9
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
@ -94,11 +94,20 @@ 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.31.3'
|
||||||
|
- '2.31.2'
|
||||||
|
- '2.31.1'
|
||||||
|
- '2.31.0'
|
||||||
|
- '2.30.1'
|
||||||
|
- '2.30.0'
|
||||||
- '2.29.2'
|
- '2.29.2'
|
||||||
- '2.29.1'
|
- '2.29.1'
|
||||||
- '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,6 +61,8 @@ 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,6 +52,7 @@ 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"
|
||||||
|
@ -330,6 +331,18 @@ 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 {
|
||||||
|
@ -370,7 +383,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||||
|
|
||||||
gitService := git.NewService(shutdownCtx)
|
gitService := git.NewService(shutdownCtx)
|
||||||
|
|
||||||
openAMTService := openamt.NewService()
|
// Setting insecureSkipVerify to true to preserve the old behaviour.
|
||||||
|
openAMTService := openamt.NewService(true)
|
||||||
|
|
||||||
cryptoService := &crypto.Service{}
|
cryptoService := &crypto.Service{}
|
||||||
|
|
||||||
|
@ -545,6 +559,7 @@ 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,
|
||||||
|
@ -578,6 +593,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||||
PendingActionsService: pendingActionsService,
|
PendingActionsService: pendingActionsService,
|
||||||
PlatformService: platformService,
|
PlatformService: platformService,
|
||||||
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
|
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
|
||||||
|
TrustedOrigins: trustedOrigins,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -138,6 +138,8 @@ 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() ([]T, error)
|
ReadAll(predicates ...func(T) bool) ([]T, error)
|
||||||
Update(ID I, element *T) error
|
Update(ID I, element *T) error
|
||||||
Delete(ID I) error
|
Delete(ID I) error
|
||||||
}
|
}
|
||||||
|
@ -56,12 +56,13 @@ func (service BaseDataService[T, I]) Exists(ID I) (bool, error) {
|
||||||
return exists, err
|
return exists, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service BaseDataService[T, I]) ReadAll() ([]T, error) {
|
// ReadAll retrieves all the elements that satisfy all the provided predicates.
|
||||||
|
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()
|
collection, err = service.Tx(tx).ReadAll(predicates...)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
92
api/dataservices/base_test.go
Normal file
92
api/dataservices/base_test.go
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
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,13 +34,32 @@ func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) {
|
||||||
return service.Tx.KeyExists(service.Bucket, identifier)
|
return service.Tx.KeyExists(service.Bucket, identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) {
|
// ReadAll retrieves all the elements that satisfy all the provided predicates.
|
||||||
|
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),
|
||||||
AppendFn(&collection),
|
FilterFn(&collection, filterFn),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
89
api/dataservices/edgestackstatus/edgestackstatus.go
Normal file
89
api/dataservices/edgestackstatus/edgestackstatus.go
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
package edgestackstatus
|
||||||
|
|
||||||
|
import (
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ dataservices.EdgeStackStatusService = &Service{}
|
||||||
|
|
||||||
|
const BucketName = "edge_stack_status"
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
conn portainer.Connection
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) BucketName() string {
|
||||||
|
return BucketName
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(connection portainer.Connection) (*Service, error) {
|
||||||
|
if err := connection.SetServiceName(BucketName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{conn: connection}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||||
|
return ServiceTx{
|
||||||
|
service: s,
|
||||||
|
tx: tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
|
||||||
|
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
|
return s.Tx(tx).Create(edgeStackID, endpointID, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error) {
|
||||||
|
var element *portainer.EdgeStackStatusForEnv
|
||||||
|
|
||||||
|
return element, s.conn.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
var err error
|
||||||
|
element, err = s.Tx(tx).Read(edgeStackID, endpointID)
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error) {
|
||||||
|
var collection = make([]portainer.EdgeStackStatusForEnv, 0)
|
||||||
|
|
||||||
|
return collection, s.conn.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
var err error
|
||||||
|
collection, err = s.Tx(tx).ReadAll(edgeStackID)
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
|
||||||
|
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
|
return s.Tx(tx).Update(edgeStackID, endpointID, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error {
|
||||||
|
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
|
return s.Tx(tx).Delete(edgeStackID, endpointID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteAll(edgeStackID portainer.EdgeStackID) error {
|
||||||
|
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
|
return s.Tx(tx).DeleteAll(edgeStackID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error {
|
||||||
|
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
|
return s.Tx(tx).Clear(edgeStackID, relatedEnvironmentsIDs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) key(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) []byte {
|
||||||
|
return append(s.conn.ConvertToKey(int(edgeStackID)), s.conn.ConvertToKey(int(endpointID))...)
|
||||||
|
}
|
95
api/dataservices/edgestackstatus/tx.go
Normal file
95
api/dataservices/edgestackstatus/tx.go
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
package edgestackstatus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ dataservices.EdgeStackStatusService = &Service{}
|
||||||
|
|
||||||
|
type ServiceTx struct {
|
||||||
|
service *Service
|
||||||
|
tx portainer.Transaction
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service ServiceTx) Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
|
||||||
|
identifier := service.service.key(edgeStackID, endpointID)
|
||||||
|
return service.tx.CreateObjectWithStringId(BucketName, identifier, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceTx) Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error) {
|
||||||
|
var status portainer.EdgeStackStatusForEnv
|
||||||
|
identifier := s.service.key(edgeStackID, endpointID)
|
||||||
|
|
||||||
|
if err := s.tx.GetObject(BucketName, identifier, &status); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceTx) ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error) {
|
||||||
|
keyPrefix := s.service.conn.ConvertToKey(int(edgeStackID))
|
||||||
|
|
||||||
|
statuses := make([]portainer.EdgeStackStatusForEnv, 0)
|
||||||
|
|
||||||
|
if err := s.tx.GetAllWithKeyPrefix(BucketName, keyPrefix, &portainer.EdgeStackStatusForEnv{}, dataservices.AppendFn(&statuses)); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to retrieve EdgeStackStatus for EdgeStack %d: %w", edgeStackID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return statuses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceTx) Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
|
||||||
|
identifier := s.service.key(edgeStackID, endpointID)
|
||||||
|
return s.tx.UpdateObject(BucketName, identifier, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceTx) Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error {
|
||||||
|
identifier := s.service.key(edgeStackID, endpointID)
|
||||||
|
return s.tx.DeleteObject(BucketName, identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceTx) DeleteAll(edgeStackID portainer.EdgeStackID) error {
|
||||||
|
keyPrefix := s.service.conn.ConvertToKey(int(edgeStackID))
|
||||||
|
|
||||||
|
statuses := make([]portainer.EdgeStackStatusForEnv, 0)
|
||||||
|
|
||||||
|
if err := s.tx.GetAllWithKeyPrefix(BucketName, keyPrefix, &portainer.EdgeStackStatusForEnv{}, dataservices.AppendFn(&statuses)); err != nil {
|
||||||
|
return fmt.Errorf("unable to retrieve EdgeStackStatus for EdgeStack %d: %w", edgeStackID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, status := range statuses {
|
||||||
|
if err := s.tx.DeleteObject(BucketName, s.service.key(edgeStackID, status.EndpointID)); err != nil {
|
||||||
|
return fmt.Errorf("unable to delete EdgeStackStatus for EdgeStack %d and Endpoint %d: %w", edgeStackID, status.EndpointID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceTx) Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error {
|
||||||
|
for _, envID := range relatedEnvironmentsIDs {
|
||||||
|
existingStatus, err := s.Read(edgeStackID, envID)
|
||||||
|
if err != nil && !dataservices.IsErrObjectNotFound(err) {
|
||||||
|
return fmt.Errorf("unable to retrieve status for environment %d: %w", envID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var deploymentInfo portainer.StackDeploymentInfo
|
||||||
|
if existingStatus != nil {
|
||||||
|
deploymentInfo = existingStatus.DeploymentInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Update(edgeStackID, envID, &portainer.EdgeStackStatusForEnv{
|
||||||
|
EndpointID: envID,
|
||||||
|
Status: []portainer.EdgeStackDeploymentStatus{},
|
||||||
|
DeploymentInfo: deploymentInfo,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -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.ViewTx(func(tx portainer.Transaction) error {
|
return service.connection.UpdateTx(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.ViewTx(func(tx portainer.Transaction) error {
|
return service.connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||||
return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
|
return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ type (
|
||||||
EdgeGroup() EdgeGroupService
|
EdgeGroup() EdgeGroupService
|
||||||
EdgeJob() EdgeJobService
|
EdgeJob() EdgeJobService
|
||||||
EdgeStack() EdgeStackService
|
EdgeStack() EdgeStackService
|
||||||
|
EdgeStackStatus() EdgeStackStatusService
|
||||||
Endpoint() EndpointService
|
Endpoint() EndpointService
|
||||||
EndpointGroup() EndpointGroupService
|
EndpointGroup() EndpointGroupService
|
||||||
EndpointRelation() EndpointRelationService
|
EndpointRelation() EndpointRelationService
|
||||||
|
@ -39,8 +40,8 @@ type (
|
||||||
Open() (newStore bool, err error)
|
Open() (newStore bool, err error)
|
||||||
Init() error
|
Init() error
|
||||||
Close() error
|
Close() error
|
||||||
UpdateTx(func(DataStoreTx) error) error
|
UpdateTx(func(tx DataStoreTx) error) error
|
||||||
ViewTx(func(DataStoreTx) error) error
|
ViewTx(func(tx DataStoreTx) error) error
|
||||||
MigrateData() error
|
MigrateData() error
|
||||||
Rollback(force bool) error
|
Rollback(force bool) error
|
||||||
CheckCurrentEdition() error
|
CheckCurrentEdition() error
|
||||||
|
@ -89,6 +90,16 @@ type (
|
||||||
BucketName() string
|
BucketName() string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EdgeStackStatusService interface {
|
||||||
|
Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error
|
||||||
|
Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error)
|
||||||
|
ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error)
|
||||||
|
Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error
|
||||||
|
Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error
|
||||||
|
DeleteAll(edgeStackID portainer.EdgeStackID) error
|
||||||
|
Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error
|
||||||
|
}
|
||||||
|
|
||||||
// EndpointService represents a service for managing environment(endpoint) data
|
// EndpointService represents a service for managing environment(endpoint) data
|
||||||
EndpointService interface {
|
EndpointService interface {
|
||||||
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)
|
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)
|
||||||
|
|
|
@ -51,3 +51,20 @@ func (service *Service) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portai
|
||||||
|
|
||||||
return snapshot, err
|
return snapshot, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *Service) ReadRawMessage(ID portainer.EndpointID) (*portainer.SnapshotRawMessage, error) {
|
||||||
|
var snapshot *portainer.SnapshotRawMessage
|
||||||
|
|
||||||
|
err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
var err error
|
||||||
|
snapshot, err = service.Tx(tx).ReadRawMessage(ID)
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
return snapshot, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) CreateRawMessage(snapshot *portainer.SnapshotRawMessage) error {
|
||||||
|
return service.Connection.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
|
||||||
|
}
|
||||||
|
|
|
@ -35,3 +35,19 @@ func (service ServiceTx) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*porta
|
||||||
|
|
||||||
return &snapshot.Snapshot, nil
|
return &snapshot.Snapshot, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service ServiceTx) ReadRawMessage(ID portainer.EndpointID) (*portainer.SnapshotRawMessage, error) {
|
||||||
|
var snapshot = portainer.SnapshotRawMessage{}
|
||||||
|
|
||||||
|
identifier := service.Connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
|
if err := service.Tx.GetObject(service.Bucket, identifier, &snapshot); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service ServiceTx) CreateRawMessage(snapshot *portainer.SnapshotRawMessage) error {
|
||||||
|
return service.Tx.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
|
||||||
|
}
|
||||||
|
|
|
@ -40,13 +40,11 @@ func (store *Store) MigrateData() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// before we alter anything in the DB, create a backup
|
// before we alter anything in the DB, create a backup
|
||||||
_, err = store.Backup("")
|
if _, err := store.Backup(""); err != nil {
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "while backing up database")
|
return errors.Wrap(err, "while backing up database")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = store.FailSafeMigrate(migrator, version)
|
if err := store.FailSafeMigrate(migrator, version); err != nil {
|
||||||
if err != nil {
|
|
||||||
err = errors.Wrap(err, "failed to migrate database")
|
err = errors.Wrap(err, "failed to migrate database")
|
||||||
|
|
||||||
log.Warn().Err(err).Msg("migration failed, restoring database to previous version")
|
log.Warn().Err(err).Msg("migration failed, restoring database to previous version")
|
||||||
|
@ -85,6 +83,7 @@ func (store *Store) newMigratorParameters(version *models.Version, flags *portai
|
||||||
DockerhubService: store.DockerHubService,
|
DockerhubService: store.DockerHubService,
|
||||||
AuthorizationService: authorization.NewService(store),
|
AuthorizationService: authorization.NewService(store),
|
||||||
EdgeStackService: store.EdgeStackService,
|
EdgeStackService: store.EdgeStackService,
|
||||||
|
EdgeStackStatusService: store.EdgeStackStatusService,
|
||||||
EdgeJobService: store.EdgeJobService,
|
EdgeJobService: store.EdgeJobService,
|
||||||
TunnelServerService: store.TunnelServerService,
|
TunnelServerService: store.TunnelServerService,
|
||||||
PendingActionsService: store.PendingActionsService,
|
PendingActionsService: store.PendingActionsService,
|
||||||
|
@ -140,8 +139,7 @@ func (store *Store) connectionRollback(force bool) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := store.Restore()
|
if err := store.Restore(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
31
api/datastore/migrator/migrate_2_31_0.go
Normal file
31
api/datastore/migrator/migrate_2_31_0.go
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
package migrator
|
||||||
|
|
||||||
|
import portainer "github.com/portainer/portainer/api"
|
||||||
|
|
||||||
|
func (m *Migrator) migrateEdgeStacksStatuses_2_31_0() error {
|
||||||
|
edgeStacks, err := m.edgeStackService.EdgeStacks()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, edgeStack := range edgeStacks {
|
||||||
|
for envID, status := range edgeStack.Status {
|
||||||
|
if err := m.edgeStackStatusService.Create(edgeStack.ID, envID, &portainer.EdgeStackStatusForEnv{
|
||||||
|
EndpointID: envID,
|
||||||
|
Status: status.Status,
|
||||||
|
DeploymentInfo: status.DeploymentInfo,
|
||||||
|
ReadyRePullImage: status.ReadyRePullImage,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeStack.Status = nil
|
||||||
|
|
||||||
|
if err := m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
33
api/datastore/migrator/migrate_2_32_0.go
Normal file
33
api/datastore/migrator/migrate_2_32_0.go
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -3,12 +3,12 @@ package migrator
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/Masterminds/semver"
|
|
||||||
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/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/endpoint"
|
"github.com/portainer/portainer/api/dataservices/endpoint"
|
||||||
"github.com/portainer/portainer/api/dataservices/endpointgroup"
|
"github.com/portainer/portainer/api/dataservices/endpointgroup"
|
||||||
"github.com/portainer/portainer/api/dataservices/endpointrelation"
|
"github.com/portainer/portainer/api/dataservices/endpointrelation"
|
||||||
|
@ -27,6 +27,8 @@ import (
|
||||||
"github.com/portainer/portainer/api/dataservices/user"
|
"github.com/portainer/portainer/api/dataservices/user"
|
||||||
"github.com/portainer/portainer/api/dataservices/version"
|
"github.com/portainer/portainer/api/dataservices/version"
|
||||||
"github.com/portainer/portainer/api/internal/authorization"
|
"github.com/portainer/portainer/api/internal/authorization"
|
||||||
|
|
||||||
|
"github.com/Masterminds/semver"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -56,6 +58,7 @@ type (
|
||||||
authorizationService *authorization.Service
|
authorizationService *authorization.Service
|
||||||
dockerhubService *dockerhub.Service
|
dockerhubService *dockerhub.Service
|
||||||
edgeStackService *edgestack.Service
|
edgeStackService *edgestack.Service
|
||||||
|
edgeStackStatusService *edgestackstatus.Service
|
||||||
edgeJobService *edgejob.Service
|
edgeJobService *edgejob.Service
|
||||||
TunnelServerService *tunnelserver.Service
|
TunnelServerService *tunnelserver.Service
|
||||||
pendingActionsService *pendingactions.Service
|
pendingActionsService *pendingactions.Service
|
||||||
|
@ -84,6 +87,7 @@ type (
|
||||||
AuthorizationService *authorization.Service
|
AuthorizationService *authorization.Service
|
||||||
DockerhubService *dockerhub.Service
|
DockerhubService *dockerhub.Service
|
||||||
EdgeStackService *edgestack.Service
|
EdgeStackService *edgestack.Service
|
||||||
|
EdgeStackStatusService *edgestackstatus.Service
|
||||||
EdgeJobService *edgejob.Service
|
EdgeJobService *edgejob.Service
|
||||||
TunnelServerService *tunnelserver.Service
|
TunnelServerService *tunnelserver.Service
|
||||||
PendingActionsService *pendingactions.Service
|
PendingActionsService *pendingactions.Service
|
||||||
|
@ -114,6 +118,7 @@ func NewMigrator(parameters *MigratorParameters) *Migrator {
|
||||||
authorizationService: parameters.AuthorizationService,
|
authorizationService: parameters.AuthorizationService,
|
||||||
dockerhubService: parameters.DockerhubService,
|
dockerhubService: parameters.DockerhubService,
|
||||||
edgeStackService: parameters.EdgeStackService,
|
edgeStackService: parameters.EdgeStackService,
|
||||||
|
edgeStackStatusService: parameters.EdgeStackStatusService,
|
||||||
edgeJobService: parameters.EdgeJobService,
|
edgeJobService: parameters.EdgeJobService,
|
||||||
TunnelServerService: parameters.TunnelServerService,
|
TunnelServerService: parameters.TunnelServerService,
|
||||||
pendingActionsService: parameters.PendingActionsService,
|
pendingActionsService: parameters.PendingActionsService,
|
||||||
|
@ -242,6 +247,10 @@ func (m *Migrator) initMigrations() {
|
||||||
m.migratePendingActionsDataForDB130,
|
m.migratePendingActionsDataForDB130,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
m.addMigrations("2.31.0", m.migrateEdgeStacksStatuses_2_31_0)
|
||||||
|
|
||||||
|
m.addMigrations("2.32.0", m.addEndpointRelationForEdgeAgents_2_32_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.
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"github.com/portainer/portainer/api/dataservices/edgegroup"
|
"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/endpoint"
|
"github.com/portainer/portainer/api/dataservices/endpoint"
|
||||||
"github.com/portainer/portainer/api/dataservices/endpointgroup"
|
"github.com/portainer/portainer/api/dataservices/endpointgroup"
|
||||||
"github.com/portainer/portainer/api/dataservices/endpointrelation"
|
"github.com/portainer/portainer/api/dataservices/endpointrelation"
|
||||||
|
@ -39,6 +40,8 @@ import (
|
||||||
"github.com/segmentio/encoding/json"
|
"github.com/segmentio/encoding/json"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var _ dataservices.DataStore = &Store{}
|
||||||
|
|
||||||
// Store defines the implementation of portainer.DataStore using
|
// Store defines the implementation of portainer.DataStore using
|
||||||
// BoltDB as the storage system.
|
// BoltDB as the storage system.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
|
@ -51,6 +54,7 @@ type Store struct {
|
||||||
EdgeGroupService *edgegroup.Service
|
EdgeGroupService *edgegroup.Service
|
||||||
EdgeJobService *edgejob.Service
|
EdgeJobService *edgejob.Service
|
||||||
EdgeStackService *edgestack.Service
|
EdgeStackService *edgestack.Service
|
||||||
|
EdgeStackStatusService *edgestackstatus.Service
|
||||||
EndpointGroupService *endpointgroup.Service
|
EndpointGroupService *endpointgroup.Service
|
||||||
EndpointService *endpoint.Service
|
EndpointService *endpoint.Service
|
||||||
EndpointRelationService *endpointrelation.Service
|
EndpointRelationService *endpointrelation.Service
|
||||||
|
@ -109,6 +113,12 @@ func (store *Store) initServices() error {
|
||||||
store.EdgeStackService = edgeStackService
|
store.EdgeStackService = edgeStackService
|
||||||
endpointRelationService.RegisterUpdateStackFunction(edgeStackService.UpdateEdgeStackFunc, edgeStackService.UpdateEdgeStackFuncTx)
|
endpointRelationService.RegisterUpdateStackFunction(edgeStackService.UpdateEdgeStackFunc, edgeStackService.UpdateEdgeStackFuncTx)
|
||||||
|
|
||||||
|
edgeStackStatusService, err := edgestackstatus.NewService(store.connection)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store.EdgeStackStatusService = edgeStackStatusService
|
||||||
|
|
||||||
edgeGroupService, err := edgegroup.NewService(store.connection)
|
edgeGroupService, err := edgegroup.NewService(store.connection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -269,6 +279,10 @@ func (store *Store) EdgeStack() dataservices.EdgeStackService {
|
||||||
return store.EdgeStackService
|
return store.EdgeStackService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *Store) EdgeStackStatus() dataservices.EdgeStackStatusService {
|
||||||
|
return store.EdgeStackStatusService
|
||||||
|
}
|
||||||
|
|
||||||
// Environment(Endpoint) gives access to the Environment(Endpoint) data management layer
|
// Environment(Endpoint) gives access to the Environment(Endpoint) data management layer
|
||||||
func (store *Store) Endpoint() dataservices.EndpointService {
|
func (store *Store) Endpoint() dataservices.EndpointService {
|
||||||
return store.EndpointService
|
return store.EndpointService
|
||||||
|
|
|
@ -32,6 +32,10 @@ func (tx *StoreTx) EdgeStack() dataservices.EdgeStackService {
|
||||||
return tx.store.EdgeStackService.Tx(tx.tx)
|
return tx.store.EdgeStackService.Tx(tx.tx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (tx *StoreTx) EdgeStackStatus() dataservices.EdgeStackStatusService {
|
||||||
|
return tx.store.EdgeStackStatusService.Tx(tx.tx)
|
||||||
|
}
|
||||||
|
|
||||||
func (tx *StoreTx) Endpoint() dataservices.EndpointService {
|
func (tx *StoreTx) Endpoint() dataservices.EndpointService {
|
||||||
return tx.store.EndpointService.Tx(tx.tx)
|
return tx.store.EndpointService.Tx(tx.tx)
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"edge_stack": null,
|
"edge_stack": null,
|
||||||
|
"edge_stack_status": null,
|
||||||
"edgegroups": null,
|
"edgegroups": null,
|
||||||
"edgejobs": null,
|
"edgejobs": null,
|
||||||
"endpoint_groups": [
|
"endpoint_groups": [
|
||||||
|
@ -120,6 +121,10 @@
|
||||||
"Ecr": {
|
"Ecr": {
|
||||||
"Region": ""
|
"Region": ""
|
||||||
},
|
},
|
||||||
|
"Github": {
|
||||||
|
"OrganisationName": "",
|
||||||
|
"UseOrganisation": false
|
||||||
|
},
|
||||||
"Gitlab": {
|
"Gitlab": {
|
||||||
"InstanceURL": "",
|
"InstanceURL": "",
|
||||||
"ProjectId": 0,
|
"ProjectId": 0,
|
||||||
|
@ -610,7 +615,7 @@
|
||||||
"RequiredPasswordLength": 12
|
"RequiredPasswordLength": 12
|
||||||
},
|
},
|
||||||
"KubeconfigExpiry": "0",
|
"KubeconfigExpiry": "0",
|
||||||
"KubectlShellImage": "portainer/kubectl-shell:2.29.0",
|
"KubectlShellImage": "portainer/kubectl-shell:2.32.0",
|
||||||
"LDAPSettings": {
|
"LDAPSettings": {
|
||||||
"AnonymousMode": true,
|
"AnonymousMode": true,
|
||||||
"AutoCreateUsers": true,
|
"AutoCreateUsers": true,
|
||||||
|
@ -678,14 +683,11 @@
|
||||||
"Images": null,
|
"Images": null,
|
||||||
"Info": {
|
"Info": {
|
||||||
"Architecture": "",
|
"Architecture": "",
|
||||||
"BridgeNfIp6tables": false,
|
|
||||||
"BridgeNfIptables": false,
|
|
||||||
"CDISpecDirs": null,
|
"CDISpecDirs": null,
|
||||||
"CPUSet": false,
|
"CPUSet": false,
|
||||||
"CPUShares": false,
|
"CPUShares": false,
|
||||||
"CgroupDriver": "",
|
"CgroupDriver": "",
|
||||||
"ContainerdCommit": {
|
"ContainerdCommit": {
|
||||||
"Expected": "",
|
|
||||||
"ID": ""
|
"ID": ""
|
||||||
},
|
},
|
||||||
"Containers": 0,
|
"Containers": 0,
|
||||||
|
@ -709,7 +711,6 @@
|
||||||
"IndexServerAddress": "",
|
"IndexServerAddress": "",
|
||||||
"InitBinary": "",
|
"InitBinary": "",
|
||||||
"InitCommit": {
|
"InitCommit": {
|
||||||
"Expected": "",
|
|
||||||
"ID": ""
|
"ID": ""
|
||||||
},
|
},
|
||||||
"Isolation": "",
|
"Isolation": "",
|
||||||
|
@ -738,7 +739,6 @@
|
||||||
},
|
},
|
||||||
"RegistryConfig": null,
|
"RegistryConfig": null,
|
||||||
"RuncCommit": {
|
"RuncCommit": {
|
||||||
"Expected": "",
|
|
||||||
"ID": ""
|
"ID": ""
|
||||||
},
|
},
|
||||||
"Runtimes": null,
|
"Runtimes": null,
|
||||||
|
@ -780,6 +780,7 @@
|
||||||
"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,
|
||||||
|
@ -943,7 +944,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": {
|
"version": {
|
||||||
"VERSION": "{\"SchemaVersion\":\"2.29.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
"VERSION": "{\"SchemaVersion\":\"2.32.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||||
},
|
},
|
||||||
"webhooks": null
|
"webhooks": null
|
||||||
}
|
}
|
|
@ -32,9 +32,9 @@ type Service struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService initializes a new service.
|
// NewService initializes a new service.
|
||||||
func NewService() *Service {
|
func NewService(insecureSkipVerify bool) *Service {
|
||||||
tlsConfig := crypto.CreateTLSConfiguration()
|
tlsConfig := crypto.CreateTLSConfiguration()
|
||||||
tlsConfig.InsecureSkipVerify = true
|
tlsConfig.InsecureSkipVerify = insecureSkipVerify
|
||||||
|
|
||||||
return &Service{
|
return &Service{
|
||||||
httpsClient: &http.Client{
|
httpsClient: &http.Client{
|
||||||
|
|
|
@ -2,6 +2,7 @@ package csrf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
@ -9,7 +10,8 @@ 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"
|
||||||
|
|
||||||
gorillacsrf "github.com/gorilla/csrf"
|
gcsrf "github.com/gorilla/csrf"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/urfave/negroni"
|
"github.com/urfave/negroni"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -19,7 +21,7 @@ func SkipCSRFToken(w http.ResponseWriter) {
|
||||||
w.Header().Set(csrfSkipHeader, "1")
|
w.Header().Set(csrfSkipHeader, "1")
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithProtect(handler http.Handler) (http.Handler, error) {
|
func WithProtect(handler http.Handler, trustedOrigins []string) (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
|
||||||
|
@ -34,10 +36,12 @@ func WithProtect(handler http.Handler) (http.Handler, error) {
|
||||||
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
|
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler = gorillacsrf.Protect(
|
handler = gcsrf.Protect(
|
||||||
token,
|
token,
|
||||||
gorillacsrf.Path("/"),
|
gcsrf.Path("/"),
|
||||||
gorillacsrf.Secure(false),
|
gcsrf.Secure(false),
|
||||||
|
gcsrf.TrustedOrigins(trustedOrigins),
|
||||||
|
gcsrf.ErrorHandler(withErrorHandler(trustedOrigins)),
|
||||||
)(handler)
|
)(handler)
|
||||||
|
|
||||||
return withSkipCSRF(handler, isDockerDesktopExtension), nil
|
return withSkipCSRF(handler, isDockerDesktopExtension), nil
|
||||||
|
@ -55,7 +59,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", gorillacsrf.Token(r))
|
sw.Header().Set("X-CSRF-Token", gcsrf.Token(r))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -73,9 +77,33 @@ func withSkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Hand
|
||||||
}
|
}
|
||||||
|
|
||||||
if skip {
|
if skip {
|
||||||
r = gorillacsrf.UnsafeSkipCheck(r)
|
r = gcsrf.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,6 +2,7 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
@ -82,6 +83,11 @@ 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,6 +8,7 @@ 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"
|
||||||
|
@ -23,16 +24,18 @@ 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) *Handler {
|
func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimiter, passwordStrengthChecker security.PasswordStrengthChecker, kubernetesClientFactory *cli.ClientFactory) *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,6 +2,7 @@ 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"
|
||||||
|
@ -23,6 +24,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.Filter(customTemplates, func(customTemplate portainer.CustomTemplate) bool {
|
customTemplates = slicesx.FilterInPlace(customTemplates, func(customTemplate portainer.CustomTemplate) bool {
|
||||||
return customTemplate.EdgeTemplate == *edge
|
return customTemplate.EdgeTemplate == *edge
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/docker/api/types/swarm"
|
"github.com/docker/docker/api/types/swarm"
|
||||||
"github.com/docker/docker/api/types/volume"
|
"github.com/docker/docker/api/types/volume"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
@ -116,12 +117,12 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) *httperror.H
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
networks, err := cli.NetworkList(r.Context(), types.NetworkListOptions{})
|
networks, err := cli.NetworkList(r.Context(), network.ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to retrieve Docker networks", err)
|
return httperror.InternalServerError("Unable to retrieve Docker networks", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
networks, err = utils.FilterByResourceControl(tx, networks, portainer.NetworkResourceControl, context, func(c types.NetworkResource) string {
|
networks, err = utils.FilterByResourceControl(tx, networks, portainer.NetworkResourceControl, context, func(c network.Summary) string {
|
||||||
return c.Name
|
return c.Name
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -163,7 +163,7 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
|
||||||
|
|
||||||
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 && !handler.DataStore.IsErrObjectNotFound(err) {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -179,12 +179,6 @@ 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)
|
||||||
|
|
|
@ -101,8 +101,7 @@ func (payload *edgeStackFromFileUploadPayload) Validate(r *http.Request) error {
|
||||||
// @router /edge_stacks/create/file [post]
|
// @router /edge_stacks/create/file [post]
|
||||||
func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) {
|
func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) {
|
||||||
payload := &edgeStackFromFileUploadPayload{}
|
payload := &edgeStackFromFileUploadPayload{}
|
||||||
err := payload.Validate(r)
|
if err := payload.Validate(r); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -103,8 +103,7 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro
|
||||||
// @router /edge_stacks/create/repository [post]
|
// @router /edge_stacks/create/repository [post]
|
||||||
func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dataservices.DataStoreTx, dryrun bool, userID portainer.UserID) (*portainer.EdgeStack, error) {
|
func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dataservices.DataStoreTx, dryrun bool, userID portainer.UserID) (*portainer.EdgeStack, error) {
|
||||||
var payload edgeStackFromGitRepositoryPayload
|
var payload edgeStackFromGitRepositoryPayload
|
||||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -137,11 +136,9 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) storeManifestFromGitRepository(tx dataservices.DataStoreTx, stackFolder string, relatedEndpointIds []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType, currentUserID portainer.UserID, repositoryConfig gittypes.RepoConfig) (composePath, manifestPath, projectPath string, err error) {
|
func (handler *Handler) storeManifestFromGitRepository(tx dataservices.DataStoreTx, stackFolder string, relatedEndpointIds []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType, currentUserID portainer.UserID, repositoryConfig gittypes.RepoConfig) (composePath, manifestPath, projectPath string, err error) {
|
||||||
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType)
|
if hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err)
|
return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err)
|
||||||
}
|
} else if hasWrongType {
|
||||||
if hasWrongType {
|
|
||||||
return "", "", "", errors.New("edge stack with config do not match the environment type")
|
return "", "", "", errors.New("edge stack with config do not match the environment type")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -153,8 +150,7 @@ func (handler *Handler) storeManifestFromGitRepository(tx dataservices.DataStore
|
||||||
repositoryPassword = repositoryConfig.Authentication.Password
|
repositoryPassword = repositoryConfig.Authentication.Password
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryConfig.TLSSkipVerify)
|
if err := handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryConfig.TLSSkipVerify); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -76,8 +76,7 @@ func (payload *edgeStackFromStringPayload) Validate(r *http.Request) error {
|
||||||
// @router /edge_stacks/create/string [post]
|
// @router /edge_stacks/create/string [post]
|
||||||
func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) {
|
func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) {
|
||||||
var payload edgeStackFromStringPayload
|
var payload edgeStackFromStringPayload
|
||||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -96,11 +95,9 @@ func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx datas
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolder string, deploymentType portainer.EdgeStackDeploymentType, relatedEndpointIds []portainer.EndpointID, fileContent []byte) (composePath, manifestPath, projectPath string, err error) {
|
func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolder string, deploymentType portainer.EdgeStackDeploymentType, relatedEndpointIds []portainer.EndpointID, fileContent []byte) (composePath, manifestPath, projectPath string, err error) {
|
||||||
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType)
|
if hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err)
|
return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err)
|
||||||
}
|
} else if hasWrongType {
|
||||||
if hasWrongType {
|
|
||||||
return "", "", "", errors.New("edge stack with config do not match the environment type")
|
return "", "", "", errors.New("edge stack with config do not match the environment type")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,7 +121,6 @@ func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolde
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", manifestPath, projectPath, nil
|
return "", manifestPath, projectPath, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
errMessage := fmt.Sprintf("invalid deployment type: %d", deploymentType)
|
errMessage := fmt.Sprintf("invalid deployment type: %d", deploymentType)
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/segmentio/encoding/json"
|
"github.com/segmentio/encoding/json"
|
||||||
)
|
)
|
||||||
|
@ -28,9 +29,7 @@ func TestCreateAndInspect(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
err := handler.DataStore.EdgeGroup().Create(&edgeGroup)
|
err := handler.DataStore.EdgeGroup().Create(&edgeGroup)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointRelation := portainer.EndpointRelation{
|
endpointRelation := portainer.EndpointRelation{
|
||||||
EndpointID: endpoint.ID,
|
EndpointID: endpoint.ID,
|
||||||
|
@ -38,9 +37,7 @@ func TestCreateAndInspect(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.DataStore.EndpointRelation().Create(&endpointRelation)
|
err = handler.DataStore.EndpointRelation().Create(&endpointRelation)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := edgeStackFromStringPayload{
|
payload := edgeStackFromStringPayload{
|
||||||
Name: "test-stack",
|
Name: "test-stack",
|
||||||
|
@ -50,16 +47,14 @@ func TestCreateAndInspect(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonPayload, err := json.Marshal(payload)
|
jsonPayload, err := json.Marshal(payload)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("JSON marshal error:", err)
|
|
||||||
}
|
|
||||||
r := bytes.NewBuffer(jsonPayload)
|
r := bytes.NewBuffer(jsonPayload)
|
||||||
|
|
||||||
// Create EdgeStack
|
// Create EdgeStack
|
||||||
req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", r)
|
req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", r)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
req.Header.Add("x-api-key", rawAPIKey)
|
req.Header.Add("x-api-key", rawAPIKey)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
|
@ -70,15 +65,11 @@ func TestCreateAndInspect(t *testing.T) {
|
||||||
|
|
||||||
data := portainer.EdgeStack{}
|
data := portainer.EdgeStack{}
|
||||||
err = json.NewDecoder(rec.Body).Decode(&data)
|
err = json.NewDecoder(rec.Body).Decode(&data)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("error decoding response:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inspect
|
// Inspect
|
||||||
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", data.ID), nil)
|
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", data.ID), nil)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Add("x-api-key", rawAPIKey)
|
req.Header.Add("x-api-key", rawAPIKey)
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
|
@ -90,9 +81,7 @@ func TestCreateAndInspect(t *testing.T) {
|
||||||
|
|
||||||
data = portainer.EdgeStack{}
|
data = portainer.EdgeStack{}
|
||||||
err = json.NewDecoder(rec.Body).Decode(&data)
|
err = json.NewDecoder(rec.Body).Decode(&data)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("error decoding response:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.Name != data.Name {
|
if payload.Name != data.Name {
|
||||||
t.Fatalf("expected EdgeStack Name %s, found %s", payload.Name, data.Name)
|
t.Fatalf("expected EdgeStack Name %s, found %s", payload.Name, data.Name)
|
||||||
|
|
|
@ -30,10 +30,9 @@ func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request)
|
||||||
return httperror.BadRequest("Invalid edge stack identifier route variable", err)
|
return httperror.BadRequest("Invalid edge stack identifier route variable", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
return handler.deleteEdgeStack(tx, portainer.EdgeStackID(edgeStackID))
|
return handler.deleteEdgeStack(tx, portainer.EdgeStackID(edgeStackID))
|
||||||
})
|
}); err != nil {
|
||||||
if err != nil {
|
|
||||||
var httpErr *httperror.HandlerError
|
var httpErr *httperror.HandlerError
|
||||||
if errors.As(err, &httpErr) {
|
if errors.As(err, &httpErr) {
|
||||||
return httpErr
|
return httpErr
|
||||||
|
|
|
@ -8,9 +8,10 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
|
|
||||||
"github.com/segmentio/encoding/json"
|
"github.com/segmentio/encoding/json"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Delete
|
// Delete
|
||||||
|
@ -23,9 +24,7 @@ func TestDeleteAndInspect(t *testing.T) {
|
||||||
|
|
||||||
// Inspect
|
// Inspect
|
||||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
|
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Add("x-api-key", rawAPIKey)
|
req.Header.Add("x-api-key", rawAPIKey)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
@ -37,9 +36,7 @@ func TestDeleteAndInspect(t *testing.T) {
|
||||||
|
|
||||||
data := portainer.EdgeStack{}
|
data := portainer.EdgeStack{}
|
||||||
err = json.NewDecoder(rec.Body).Decode(&data)
|
err = json.NewDecoder(rec.Body).Decode(&data)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("error decoding response:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if data.ID != edgeStack.ID {
|
if data.ID != edgeStack.ID {
|
||||||
t.Fatalf("expected EdgeStackID %d, found %d", int(edgeStack.ID), data.ID)
|
t.Fatalf("expected EdgeStackID %d, found %d", int(edgeStack.ID), data.ID)
|
||||||
|
@ -47,9 +44,7 @@ func TestDeleteAndInspect(t *testing.T) {
|
||||||
|
|
||||||
// Delete
|
// Delete
|
||||||
req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
|
req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Add("x-api-key", rawAPIKey)
|
req.Header.Add("x-api-key", rawAPIKey)
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
|
@ -61,9 +56,7 @@ func TestDeleteAndInspect(t *testing.T) {
|
||||||
|
|
||||||
// Inspect
|
// Inspect
|
||||||
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
|
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Add("x-api-key", rawAPIKey)
|
req.Header.Add("x-api-key", rawAPIKey)
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
|
@ -117,15 +110,12 @@ func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
|
err := json.NewEncoder(&buf).Encode(payload)
|
||||||
t.Fatal("error encoding payload:", err)
|
require.NoError(t, err)
|
||||||
}
|
|
||||||
|
|
||||||
// Create
|
// Create
|
||||||
req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", &buf)
|
req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", &buf)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Add("x-api-key", rawAPIKey)
|
req.Header.Add("x-api-key", rawAPIKey)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
@ -138,9 +128,8 @@ func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) {
|
||||||
assert.DirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
|
assert.DirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
|
||||||
|
|
||||||
// Delete
|
// Delete
|
||||||
if req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil); err != nil {
|
req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil)
|
||||||
t.Fatal("request error:", err)
|
require.NoError(t, err)
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Add("x-api-key", rawAPIKey)
|
req.Header.Add("x-api-key", rawAPIKey)
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
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"
|
||||||
|
@ -33,5 +34,35 @@ func (handler *Handler) edgeStackInspect(w http.ResponseWriter, r *http.Request)
|
||||||
return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
|
return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := fillEdgeStackStatus(handler.DataStore, edgeStack); err != nil {
|
||||||
|
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
|
||||||
|
}
|
||||||
|
|
||||||
return response.JSON(w, edgeStack)
|
return response.JSON(w, edgeStack)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fillEdgeStackStatus(tx dataservices.DataStoreTx, edgeStack *portainer.EdgeStack) error {
|
||||||
|
status, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeStack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus, len(status))
|
||||||
|
|
||||||
|
emptyStatus := make([]portainer.EdgeStackDeploymentStatus, 0)
|
||||||
|
|
||||||
|
for _, s := range status {
|
||||||
|
if s.Status == nil {
|
||||||
|
s.Status = emptyStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeStack.Status[s.EndpointID] = portainer.EdgeStackStatus{
|
||||||
|
Status: s.Status,
|
||||||
|
EndpointID: s.EndpointID,
|
||||||
|
DeploymentInfo: s.DeploymentInfo,
|
||||||
|
ReadyRePullImage: s.ReadyRePullImage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -3,10 +3,39 @@ 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
|
||||||
|
@ -14,16 +43,122 @@ import (
|
||||||
// @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)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.JSON(w, edgeStacks)
|
res := make([]edgeStackListResponseItem, len(edgeStacks))
|
||||||
|
|
||||||
|
for i := range edgeStacks {
|
||||||
|
res[i].EdgeStack = edgeStacks[i]
|
||||||
|
|
||||||
|
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 response.JSON(w, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, ""
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,11 +9,10 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type updateStatusPayload struct {
|
type updateStatusPayload struct {
|
||||||
|
@ -78,12 +77,25 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
||||||
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
|
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFn := func(stack *portainer.EdgeStack) (*portainer.EdgeStack, error) {
|
var stack *portainer.EdgeStack
|
||||||
return handler.updateEdgeStackStatus(stack, stack.ID, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
stack, err := handler.stackCoordinator.UpdateStatus(r, portainer.EdgeStackID(stackID), updateFn)
|
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
if err != nil {
|
var err error
|
||||||
|
stack, err = tx.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID))
|
||||||
|
if err != nil {
|
||||||
|
if dataservices.IsErrObjectNotFound(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return httperror.InternalServerError("Unable to retrieve Edge stack from the database", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := handler.updateEdgeStackStatus(tx, stack, stack.ID, payload); err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to update Edge stack status", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
var httpErr *httperror.HandlerError
|
var httpErr *httperror.HandlerError
|
||||||
if errors.As(err, &httpErr) {
|
if errors.As(err, &httpErr) {
|
||||||
return httpErr
|
return httpErr
|
||||||
|
@ -96,43 +108,36 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := fillEdgeStackStatus(handler.DataStore, stack); err != nil {
|
||||||
|
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
|
||||||
|
}
|
||||||
|
|
||||||
return response.JSON(w, stack)
|
return response.JSON(w, stack)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) updateEdgeStackStatus(stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
|
func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) error {
|
||||||
if payload.Version > 0 && payload.Version < stack.Version {
|
if payload.Version > 0 && payload.Version < stack.Version {
|
||||||
return stack, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
status := *payload.Status
|
status := *payload.Status
|
||||||
|
|
||||||
log.Debug().
|
|
||||||
Int("stackID", int(stackID)).
|
|
||||||
Int("status", int(status)).
|
|
||||||
Msg("Updating stack status")
|
|
||||||
|
|
||||||
deploymentStatus := portainer.EdgeStackDeploymentStatus{
|
deploymentStatus := portainer.EdgeStackDeploymentStatus{
|
||||||
Type: status,
|
Type: status,
|
||||||
Error: payload.Error,
|
Error: payload.Error,
|
||||||
Time: payload.Time,
|
Time: payload.Time,
|
||||||
}
|
}
|
||||||
|
|
||||||
updateEnvStatus(payload.EndpointID, stack, deploymentStatus)
|
|
||||||
|
|
||||||
return stack, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeStack, deploymentStatus portainer.EdgeStackDeploymentStatus) {
|
|
||||||
if deploymentStatus.Type == portainer.EdgeStackStatusRemoved {
|
if deploymentStatus.Type == portainer.EdgeStackStatusRemoved {
|
||||||
delete(stack.Status, environmentId)
|
return tx.EdgeStackStatus().Delete(stackID, payload.EndpointID)
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
environmentStatus, ok := stack.Status[environmentId]
|
environmentStatus, err := tx.EdgeStackStatus().Read(stackID, payload.EndpointID)
|
||||||
if !ok {
|
if err != nil && !tx.IsErrObjectNotFound(err) {
|
||||||
environmentStatus = portainer.EdgeStackStatus{
|
return err
|
||||||
EndpointID: environmentId,
|
} else if tx.IsErrObjectNotFound(err) {
|
||||||
|
environmentStatus = &portainer.EdgeStackStatusForEnv{
|
||||||
|
EndpointID: payload.EndpointID,
|
||||||
Status: []portainer.EdgeStackDeploymentStatus{},
|
Status: []portainer.EdgeStackDeploymentStatus{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -143,5 +148,5 @@ func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeSt
|
||||||
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
|
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
stack.Status[environmentId] = environmentStatus
|
return tx.EdgeStackStatus().Update(stackID, payload.EndpointID, environmentStatus)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,155 +0,0 @@
|
||||||
package edgestacks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
type statusRequest struct {
|
|
||||||
respCh chan statusResponse
|
|
||||||
stackID portainer.EdgeStackID
|
|
||||||
updateFn statusUpdateFn
|
|
||||||
}
|
|
||||||
|
|
||||||
type statusResponse struct {
|
|
||||||
Stack *portainer.EdgeStack
|
|
||||||
Error error
|
|
||||||
}
|
|
||||||
|
|
||||||
type statusUpdateFn func(*portainer.EdgeStack) (*portainer.EdgeStack, error)
|
|
||||||
|
|
||||||
type EdgeStackStatusUpdateCoordinator struct {
|
|
||||||
updateCh chan statusRequest
|
|
||||||
dataStore dataservices.DataStore
|
|
||||||
}
|
|
||||||
|
|
||||||
var errAnotherStackUpdateInProgress = errors.New("another stack update is in progress")
|
|
||||||
|
|
||||||
func NewEdgeStackStatusUpdateCoordinator(dataStore dataservices.DataStore) *EdgeStackStatusUpdateCoordinator {
|
|
||||||
return &EdgeStackStatusUpdateCoordinator{
|
|
||||||
updateCh: make(chan statusRequest),
|
|
||||||
dataStore: dataStore,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *EdgeStackStatusUpdateCoordinator) Start() {
|
|
||||||
for {
|
|
||||||
c.loop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *EdgeStackStatusUpdateCoordinator) loop() {
|
|
||||||
u := <-c.updateCh
|
|
||||||
|
|
||||||
respChs := []chan statusResponse{u.respCh}
|
|
||||||
|
|
||||||
var stack *portainer.EdgeStack
|
|
||||||
|
|
||||||
err := c.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
|
||||||
// 1. Load the edge stack
|
|
||||||
var err error
|
|
||||||
|
|
||||||
stack, err = loadEdgeStack(tx, u.stackID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return early when the agent tries to update the status on a deleted stack
|
|
||||||
if stack == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Mutate the edge stack opportunistically until there are no more pending updates
|
|
||||||
for {
|
|
||||||
stack, err = u.updateFn(stack)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if m, ok := c.getNextUpdate(stack.ID); ok {
|
|
||||||
u = m
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
respChs = append(respChs, u.respCh)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Save the changes back to the database
|
|
||||||
if err := tx.EdgeStack().UpdateEdgeStack(stack.ID, stack); err != nil {
|
|
||||||
return handlerDBErr(fmt.Errorf("unable to update Edge stack: %w.", err), "Unable to persist the stack changes inside the database")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// 4. Send back the responses
|
|
||||||
for _, ch := range respChs {
|
|
||||||
ch <- statusResponse{Stack: stack, Error: err}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadEdgeStack(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID) (*portainer.EdgeStack, error) {
|
|
||||||
stack, err := tx.EdgeStack().EdgeStack(stackID)
|
|
||||||
if err != nil {
|
|
||||||
if dataservices.IsErrObjectNotFound(err) {
|
|
||||||
// Skip the error when the agent tries to update the status on a deleted stack
|
|
||||||
log.Debug().
|
|
||||||
Err(err).
|
|
||||||
Int("stackID", int(stackID)).
|
|
||||||
Msg("Unable to find a stack inside the database, skipping error")
|
|
||||||
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("unable to retrieve Edge stack from the database: %w.", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return stack, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *EdgeStackStatusUpdateCoordinator) getNextUpdate(stackID portainer.EdgeStackID) (statusRequest, bool) {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case u := <-c.updateCh:
|
|
||||||
// Discard the update and let the agent retry
|
|
||||||
if u.stackID != stackID {
|
|
||||||
u.respCh <- statusResponse{Error: errAnotherStackUpdateInProgress}
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
return u, true
|
|
||||||
|
|
||||||
default:
|
|
||||||
return statusRequest{}, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *EdgeStackStatusUpdateCoordinator) UpdateStatus(r *http.Request, stackID portainer.EdgeStackID, updateFn statusUpdateFn) (*portainer.EdgeStack, error) {
|
|
||||||
respCh := make(chan statusResponse)
|
|
||||||
defer close(respCh)
|
|
||||||
|
|
||||||
msg := statusRequest{
|
|
||||||
respCh: respCh,
|
|
||||||
stackID: stackID,
|
|
||||||
updateFn: updateFn,
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case c.updateCh <- msg:
|
|
||||||
r := <-respCh
|
|
||||||
|
|
||||||
return r.Stack, r.Error
|
|
||||||
|
|
||||||
case <-r.Context().Done():
|
|
||||||
return nil, r.Context().Err()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
|
||||||
"github.com/segmentio/encoding/json"
|
"github.com/segmentio/encoding/json"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update Status
|
// Update Status
|
||||||
|
@ -28,15 +29,11 @@ func TestUpdateStatusAndInspect(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonPayload, err := json.Marshal(payload)
|
jsonPayload, err := json.Marshal(payload)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := bytes.NewBuffer(jsonPayload)
|
r := bytes.NewBuffer(jsonPayload)
|
||||||
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r)
|
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
|
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
@ -48,9 +45,7 @@ func TestUpdateStatusAndInspect(t *testing.T) {
|
||||||
|
|
||||||
// Get updated edge stack
|
// Get updated edge stack
|
||||||
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
|
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Add("x-api-key", rawAPIKey)
|
req.Header.Add("x-api-key", rawAPIKey)
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
|
@ -62,14 +57,10 @@ func TestUpdateStatusAndInspect(t *testing.T) {
|
||||||
|
|
||||||
updatedStack := portainer.EdgeStack{}
|
updatedStack := portainer.EdgeStack{}
|
||||||
err = json.NewDecoder(rec.Body).Decode(&updatedStack)
|
err = json.NewDecoder(rec.Body).Decode(&updatedStack)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("error decoding response:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointStatus, ok := updatedStack.Status[payload.EndpointID]
|
endpointStatus, ok := updatedStack.Status[payload.EndpointID]
|
||||||
if !ok {
|
require.True(t, ok)
|
||||||
t.Fatal("Missing status")
|
|
||||||
}
|
|
||||||
|
|
||||||
lastStatus := endpointStatus.Status[len(endpointStatus.Status)-1]
|
lastStatus := endpointStatus.Status[len(endpointStatus.Status)-1]
|
||||||
|
|
||||||
|
@ -84,8 +75,8 @@ func TestUpdateStatusAndInspect(t *testing.T) {
|
||||||
if endpointStatus.EndpointID != payload.EndpointID {
|
if endpointStatus.EndpointID != payload.EndpointID {
|
||||||
t.Fatalf("expected EndpointID %d, found %d", payload.EndpointID, endpointStatus.EndpointID)
|
t.Fatalf("expected EndpointID %d, found %d", payload.EndpointID, endpointStatus.EndpointID)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateStatusWithInvalidPayload(t *testing.T) {
|
func TestUpdateStatusWithInvalidPayload(t *testing.T) {
|
||||||
handler, _ := setupHandler(t)
|
handler, _ := setupHandler(t)
|
||||||
|
|
||||||
|
@ -136,15 +127,11 @@ func TestUpdateStatusWithInvalidPayload(t *testing.T) {
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.Name, func(t *testing.T) {
|
t.Run(tc.Name, func(t *testing.T) {
|
||||||
jsonPayload, err := json.Marshal(tc.Payload)
|
jsonPayload, err := json.Marshal(tc.Payload)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := bytes.NewBuffer(jsonPayload)
|
r := bytes.NewBuffer(jsonPayload)
|
||||||
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r)
|
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
|
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"github.com/portainer/portainer/api/jwt"
|
"github.com/portainer/portainer/api/jwt"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
|
@ -51,27 +52,21 @@ func setupHandler(t *testing.T) (*Handler, string) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
coord := NewEdgeStackStatusUpdateCoordinator(store)
|
|
||||||
go coord.Start()
|
|
||||||
|
|
||||||
handler := NewHandler(
|
handler := NewHandler(
|
||||||
security.NewRequestBouncer(store, jwtService, apiKeyService),
|
security.NewRequestBouncer(store, jwtService, apiKeyService),
|
||||||
store,
|
store,
|
||||||
edgestacks.NewService(store),
|
edgestacks.NewService(store),
|
||||||
coord,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
handler.FileService = fs
|
handler.FileService = fs
|
||||||
|
|
||||||
settings, err := handler.DataStore.Settings().Settings()
|
settings, err := handler.DataStore.Settings().Settings()
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
settings.EnableEdgeComputeFeatures = true
|
settings.EnableEdgeComputeFeatures = true
|
||||||
|
|
||||||
if err := handler.DataStore.Settings().UpdateSettings(settings); err != nil {
|
err = handler.DataStore.Settings().UpdateSettings(settings)
|
||||||
t.Fatal(err)
|
require.NoError(t, err)
|
||||||
}
|
|
||||||
|
|
||||||
handler.GitService = testhelpers.NewGitService(errors.New("Clone error"), "git-service-id")
|
handler.GitService = testhelpers.NewGitService(errors.New("Clone error"), "git-service-id")
|
||||||
|
|
||||||
|
@ -90,9 +85,8 @@ func createEndpointWithId(t *testing.T, store dataservices.DataStore, endpointID
|
||||||
LastCheckInDate: time.Now().Unix(),
|
LastCheckInDate: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.Endpoint().Create(&endpoint); err != nil {
|
err := store.Endpoint().Create(&endpoint)
|
||||||
t.Fatal(err)
|
require.NoError(t, err)
|
||||||
}
|
|
||||||
|
|
||||||
return endpoint
|
return endpoint
|
||||||
}
|
}
|
||||||
|
@ -113,15 +107,13 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
|
||||||
PartialMatch: false,
|
PartialMatch: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.EdgeGroup().Create(&edgeGroup); err != nil {
|
err := store.EdgeGroup().Create(&edgeGroup)
|
||||||
t.Fatal(err)
|
require.NoError(t, err)
|
||||||
}
|
|
||||||
|
|
||||||
edgeStackID := portainer.EdgeStackID(14)
|
edgeStackID := portainer.EdgeStackID(14)
|
||||||
edgeStack := portainer.EdgeStack{
|
edgeStack := portainer.EdgeStack{
|
||||||
ID: edgeStackID,
|
ID: edgeStackID,
|
||||||
Name: "test-edge-stack-" + strconv.Itoa(int(edgeStackID)),
|
Name: "test-edge-stack-" + strconv.Itoa(int(edgeStackID)),
|
||||||
Status: map[portainer.EndpointID]portainer.EdgeStackStatus{},
|
|
||||||
CreationDate: time.Now().Unix(),
|
CreationDate: time.Now().Unix(),
|
||||||
EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID},
|
EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID},
|
||||||
ProjectPath: "/project/path",
|
ProjectPath: "/project/path",
|
||||||
|
@ -138,13 +130,11 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.EdgeStack().Create(edgeStack.ID, &edgeStack); err != nil {
|
err = store.EdgeStack().Create(edgeStack.ID, &edgeStack)
|
||||||
t.Fatal(err)
|
require.NoError(t, err)
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.EndpointRelation().Create(&endpointRelation); err != nil {
|
err = store.EndpointRelation().Create(&endpointRelation)
|
||||||
t.Fatal(err)
|
require.NoError(t, err)
|
||||||
}
|
|
||||||
|
|
||||||
return edgeStack
|
return edgeStack
|
||||||
}
|
}
|
||||||
|
@ -155,8 +145,8 @@ func createEdgeGroup(t *testing.T, store dataservices.DataStore) portainer.EdgeG
|
||||||
Name: "EdgeGroup 1",
|
Name: "EdgeGroup 1",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.EdgeGroup().Create(&edgeGroup); err != nil {
|
err := store.EdgeGroup().Create(&edgeGroup)
|
||||||
t.Fatal(err)
|
require.NoError(t, err)
|
||||||
}
|
|
||||||
return edgeGroup
|
return edgeGroup
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,6 +74,10 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
|
||||||
return httperror.InternalServerError("Unexpected error", err)
|
return httperror.InternalServerError("Unexpected error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := fillEdgeStackStatus(handler.DataStore, stack); err != nil {
|
||||||
|
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
|
||||||
|
}
|
||||||
|
|
||||||
return response.JSON(w, stack)
|
return response.JSON(w, stack)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,7 +124,7 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
|
||||||
stack.EdgeGroups = groupsIds
|
stack.EdgeGroups = groupsIds
|
||||||
|
|
||||||
if payload.UpdateVersion {
|
if payload.UpdateVersion {
|
||||||
if err := handler.updateStackVersion(stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds); err != nil {
|
if err := handler.updateStackVersion(tx, stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds); err != nil {
|
||||||
return nil, httperror.InternalServerError("Unable to update stack version", err)
|
return nil, httperror.InternalServerError("Unable to update stack version", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,9 +25,8 @@ func TestUpdateAndInspect(t *testing.T) {
|
||||||
endpointID := portainer.EndpointID(6)
|
endpointID := portainer.EndpointID(6)
|
||||||
newEndpoint := createEndpointWithId(t, handler.DataStore, endpointID)
|
newEndpoint := createEndpointWithId(t, handler.DataStore, endpointID)
|
||||||
|
|
||||||
if err := handler.DataStore.Endpoint().Create(&newEndpoint); err != nil {
|
err := handler.DataStore.Endpoint().Create(&newEndpoint)
|
||||||
t.Fatal(err)
|
require.NoError(t, err)
|
||||||
}
|
|
||||||
|
|
||||||
endpointRelation := portainer.EndpointRelation{
|
endpointRelation := portainer.EndpointRelation{
|
||||||
EndpointID: endpointID,
|
EndpointID: endpointID,
|
||||||
|
@ -36,9 +35,8 @@ func TestUpdateAndInspect(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := handler.DataStore.EndpointRelation().Create(&endpointRelation); err != nil {
|
err = handler.DataStore.EndpointRelation().Create(&endpointRelation)
|
||||||
t.Fatal(err)
|
require.NoError(t, err)
|
||||||
}
|
|
||||||
|
|
||||||
newEdgeGroup := portainer.EdgeGroup{
|
newEdgeGroup := portainer.EdgeGroup{
|
||||||
ID: 2,
|
ID: 2,
|
||||||
|
@ -49,9 +47,8 @@ func TestUpdateAndInspect(t *testing.T) {
|
||||||
PartialMatch: false,
|
PartialMatch: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := handler.DataStore.EdgeGroup().Create(&newEdgeGroup); err != nil {
|
err = handler.DataStore.EdgeGroup().Create(&newEdgeGroup)
|
||||||
t.Fatal(err)
|
require.NoError(t, err)
|
||||||
}
|
|
||||||
|
|
||||||
payload := updateEdgeStackPayload{
|
payload := updateEdgeStackPayload{
|
||||||
StackFileContent: "update-test",
|
StackFileContent: "update-test",
|
||||||
|
@ -61,15 +58,11 @@ func TestUpdateAndInspect(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonPayload, err := json.Marshal(payload)
|
jsonPayload, err := json.Marshal(payload)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := bytes.NewBuffer(jsonPayload)
|
r := bytes.NewBuffer(jsonPayload)
|
||||||
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r)
|
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Add("x-api-key", rawAPIKey)
|
req.Header.Add("x-api-key", rawAPIKey)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
@ -81,9 +74,7 @@ func TestUpdateAndInspect(t *testing.T) {
|
||||||
|
|
||||||
// Get updated edge stack
|
// Get updated edge stack
|
||||||
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
|
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Add("x-api-key", rawAPIKey)
|
req.Header.Add("x-api-key", rawAPIKey)
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
|
@ -94,9 +85,8 @@ func TestUpdateAndInspect(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedStack := portainer.EdgeStack{}
|
updatedStack := portainer.EdgeStack{}
|
||||||
if err := json.NewDecoder(rec.Body).Decode(&updatedStack); err != nil {
|
err = json.NewDecoder(rec.Body).Decode(&updatedStack)
|
||||||
t.Fatal("error decoding response:", err)
|
require.NoError(t, err)
|
||||||
}
|
|
||||||
|
|
||||||
if payload.UpdateVersion && updatedStack.Version != edgeStack.Version+1 {
|
if payload.UpdateVersion && updatedStack.Version != edgeStack.Version+1 {
|
||||||
t.Fatalf("expected EdgeStack version %d, found %d", edgeStack.Version+1, updatedStack.Version+1)
|
t.Fatalf("expected EdgeStack version %d, found %d", edgeStack.Version+1, updatedStack.Version+1)
|
||||||
|
@ -226,15 +216,11 @@ func TestUpdateWithInvalidPayload(t *testing.T) {
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.Name, func(t *testing.T) {
|
t.Run(tc.Name, func(t *testing.T) {
|
||||||
jsonPayload, err := json.Marshal(tc.Payload)
|
jsonPayload, err := json.Marshal(tc.Payload)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := bytes.NewBuffer(jsonPayload)
|
r := bytes.NewBuffer(jsonPayload)
|
||||||
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r)
|
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal("request error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Add("x-api-key", rawAPIKey)
|
req.Header.Add("x-api-key", rawAPIKey)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
|
@ -22,17 +22,15 @@ type Handler struct {
|
||||||
GitService portainer.GitService
|
GitService portainer.GitService
|
||||||
edgeStacksService *edgestackservice.Service
|
edgeStacksService *edgestackservice.Service
|
||||||
KubernetesDeployer portainer.KubernetesDeployer
|
KubernetesDeployer portainer.KubernetesDeployer
|
||||||
stackCoordinator *EdgeStackStatusUpdateCoordinator
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a handler to manage environment(endpoint) group operations.
|
// NewHandler creates a handler to manage environment(endpoint) group operations.
|
||||||
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service, stackCoordinator *EdgeStackStatusUpdateCoordinator) *Handler {
|
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler {
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
Router: mux.NewRouter(),
|
Router: mux.NewRouter(),
|
||||||
requestBouncer: bouncer,
|
requestBouncer: bouncer,
|
||||||
DataStore: dataStore,
|
DataStore: dataStore,
|
||||||
edgeStacksService: edgeStacksService,
|
edgeStacksService: edgeStacksService,
|
||||||
stackCoordinator: stackCoordinator,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h.Handle("/edge_stacks/create/{method}",
|
h.Handle("/edge_stacks/create/{method}",
|
||||||
|
|
|
@ -5,15 +5,18 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
edgestackutils "github.com/portainer/portainer/api/internal/edge/edgestacks"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (handler *Handler) updateStackVersion(stack *portainer.EdgeStack, deploymentType portainer.EdgeStackDeploymentType, config []byte, oldGitHash string, relatedEnvironmentsIDs []portainer.EndpointID) error {
|
func (handler *Handler) updateStackVersion(tx dataservices.DataStoreTx, stack *portainer.EdgeStack, deploymentType portainer.EdgeStackDeploymentType, config []byte, oldGitHash string, relatedEnvironmentsIDs []portainer.EndpointID) error {
|
||||||
stack.Version = stack.Version + 1
|
stack.Version++
|
||||||
stack.Status = edgestackutils.NewStatus(stack.Status, relatedEnvironmentsIDs)
|
|
||||||
|
if err := tx.EdgeStackStatus().Clear(stack.ID, relatedEnvironmentsIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return handler.storeStackFile(stack, deploymentType, config)
|
return handler.storeStackFile(stack, deploymentType, config)
|
||||||
}
|
}
|
||||||
|
|
|
@ -264,9 +264,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -287,11 +287,8 @@ func TestEdgeStackStatus(t *testing.T) {
|
||||||
|
|
||||||
edgeStackID := portainer.EdgeStackID(17)
|
edgeStackID := portainer.EdgeStackID(17)
|
||||||
edgeStack := portainer.EdgeStack{
|
edgeStack := portainer.EdgeStack{
|
||||||
ID: edgeStackID,
|
ID: edgeStackID,
|
||||||
Name: "test-edge-stack-17",
|
Name: "test-edge-stack-17",
|
||||||
Status: map[portainer.EndpointID]portainer.EdgeStackStatus{
|
|
||||||
endpointID: {},
|
|
||||||
},
|
|
||||||
CreationDate: time.Now().Unix(),
|
CreationDate: time.Now().Unix(),
|
||||||
EdgeGroups: []portainer.EdgeGroupID{1, 2},
|
EdgeGroups: []portainer.EdgeGroupID{1, 2},
|
||||||
ProjectPath: "/project/path",
|
ProjectPath: "/project/path",
|
||||||
|
|
|
@ -21,17 +21,10 @@ 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 && !tx.IsErrObjectNotFound(err) {
|
if err != nil {
|
||||||
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,6 +563,10 @@ 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
|
||||||
|
|
|
@ -214,14 +214,9 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
||||||
log.Warn().Err(err).Msg("Unable to retrieve edge stacks from the database")
|
log.Warn().Err(err).Msg("Unable to retrieve edge stacks from the database")
|
||||||
}
|
}
|
||||||
|
|
||||||
for idx := range edgeStacks {
|
for _, edgeStack := range edgeStacks {
|
||||||
edgeStack := &edgeStacks[idx]
|
if err := tx.EdgeStackStatus().Delete(edgeStack.ID, endpoint.ID); err != nil {
|
||||||
if _, ok := edgeStack.Status[endpoint.ID]; ok {
|
log.Warn().Err(err).Msg("Unable to delete edge stack status")
|
||||||
delete(edgeStack.Status, endpoint.ID)
|
|
||||||
|
|
||||||
if err := tx.EdgeStack().UpdateEdgeStack(edgeStack.ID, edgeStack); err != nil {
|
|
||||||
log.Warn().Err(err).Msg("Unable to update edge stack")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -95,12 +95,11 @@ 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 := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
|
filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(endpoints, query, endpointGroups, edgeGroups, settings, 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(r, registries, endpoint, user, securityContext.UserMemberships)
|
registries, handleError := handler.filterRegistriesByAccess(tx, 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(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
|
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) {
|
||||||
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(r, registries, endpoint, user, memberships)
|
return handler.filterKubernetesEndpointRegistries(tx, r, registries, endpoint, user, memberships)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) filterKubernetesEndpointRegistries(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
|
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) {
|
||||||
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(r *http.Request, regi
|
||||||
return registries, nil
|
return registries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return handler.filterKubernetesRegistriesByUserRole(r, registries, endpoint, user)
|
return handler.filterKubernetesRegistriesByUserRole(tx, 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(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) {
|
func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.DataStoreTx, 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(r *http.Request, re
|
||||||
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(endpoint, user)
|
userNamespaces, err := handler.userNamespaces(tx, 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(r *http.Request, re
|
||||||
return filterRegistriesByNamespaces(registries, endpoint.ID, userNamespaces), nil
|
return filterRegistriesByNamespaces(registries, endpoint.ID, userNamespaces), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) userNamespaces(endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) {
|
func (handler *Handler) userNamespaces(tx dataservices.DataStoreTx, 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(endpoint *portainer.Endpoint, user *porta
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
userMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
|
userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ 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/slicesx"
|
"github.com/portainer/portainer/api/slicesx"
|
||||||
|
@ -140,6 +141,7 @@ 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)
|
||||||
|
|
||||||
|
@ -181,11 +183,16 @@ 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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -247,19 +254,17 @@ func (handler *Handler) filterEndpointsByQuery(
|
||||||
return filteredEndpoints, totalAvailableEndpoints, nil
|
return filteredEndpoints, totalAvailableEndpoints, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func endpointStatusInStackMatchesFilter(edgeStackStatus map[portainer.EndpointID]portainer.EdgeStackStatus, envId portainer.EndpointID, statusFilter portainer.EdgeStackStatusType) bool {
|
func endpointStatusInStackMatchesFilter(stackStatus *portainer.EdgeStackStatusForEnv, envId portainer.EndpointID, statusFilter portainer.EdgeStackStatusType) bool {
|
||||||
status, ok := edgeStackStatus[envId]
|
|
||||||
|
|
||||||
// consider that if the env has no status in the stack it is in Pending state
|
// consider that if the env has no status in the stack it is in Pending state
|
||||||
if statusFilter == portainer.EdgeStackStatusPending {
|
if statusFilter == portainer.EdgeStackStatusPending {
|
||||||
return !ok || len(status.Status) == 0
|
return stackStatus == nil || len(stackStatus.Status) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ok {
|
if stackStatus == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return slices.ContainsFunc(status.Status, func(s portainer.EdgeStackDeploymentStatus) bool {
|
return slices.ContainsFunc(stackStatus.Status, func(s portainer.EdgeStackDeploymentStatus) bool {
|
||||||
return s.Type == statusFilter
|
return s.Type == statusFilter
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -291,7 +296,14 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port
|
||||||
if statusFilter != nil {
|
if statusFilter != nil {
|
||||||
n := 0
|
n := 0
|
||||||
for _, envId := range envIds {
|
for _, envId := range envIds {
|
||||||
if endpointStatusInStackMatchesFilter(stack.Status, envId, *statusFilter) {
|
edgeStackStatus, err := datastore.EdgeStackStatus().Read(edgeStackId, envId)
|
||||||
|
if dataservices.IsErrObjectNotFound(err) {
|
||||||
|
continue
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if endpointStatusInStackMatchesFilter(edgeStackStatus, envId, *statusFilter) {
|
||||||
envIds[n] = envId
|
envIds[n] = envId
|
||||||
n++
|
n++
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ 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/slicesx"
|
"github.com/portainer/portainer/api/slicesx"
|
||||||
|
|
||||||
|
@ -263,6 +264,7 @@ 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)
|
||||||
|
|
|
@ -17,17 +17,7 @@ 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 {
|
||||||
if !tx.IsErrObjectNotFound(err) {
|
return errors.WithMessage(err, "Unable to retrieve environment relation inside the database")
|
||||||
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)
|
||||||
|
|
|
@ -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, wasInstanceDisabled func() bool) *Handler {
|
func NewHandler(assetPublicPath string, csp bool, 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"),
|
||||||
featureflags.IsEnabled("csp"),
|
csp,
|
||||||
),
|
),
|
||||||
wasInstanceDisabled: wasInstanceDisabled,
|
wasInstanceDisabled: wasInstanceDisabled,
|
||||||
}
|
}
|
||||||
|
@ -36,6 +36,7 @@ func isHTML(acceptContent []string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,11 +44,13 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,7 +81,7 @@ type Handler struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// @title PortainerCE API
|
// @title PortainerCE API
|
||||||
// @version 2.29.0
|
// @version 2.32.0
|
||||||
// @description.markdown api-description.md
|
// @description.markdown api-description.md
|
||||||
// @termsOfService
|
// @termsOfService
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
|
|
||||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||||
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/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
@ -17,6 +18,8 @@ import (
|
||||||
// @description **Access policy**: authenticated
|
// @description **Access policy**: authenticated
|
||||||
// @tags helm
|
// @tags helm
|
||||||
// @param repo query string true "Helm repository URL"
|
// @param repo query string true "Helm repository URL"
|
||||||
|
// @param chart query string false "Helm chart name"
|
||||||
|
// @param useCache query string false "If true will use cache to search"
|
||||||
// @security ApiKeyAuth
|
// @security ApiKeyAuth
|
||||||
// @security jwt
|
// @security jwt
|
||||||
// @produce json
|
// @produce json
|
||||||
|
@ -32,13 +35,19 @@ func (handler *Handler) helmRepoSearch(w http.ResponseWriter, r *http.Request) *
|
||||||
return httperror.BadRequest("Bad request", errors.New("missing `repo` query parameter"))
|
return httperror.BadRequest("Bad request", errors.New("missing `repo` query parameter"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chart, _ := request.RetrieveQueryParameter(r, "chart", false)
|
||||||
|
// If true will useCache to search, will always add to cache after
|
||||||
|
useCache, _ := request.RetrieveBooleanQueryParameter(r, "useCache", false)
|
||||||
|
|
||||||
_, err := url.ParseRequestURI(repo)
|
_, err := url.ParseRequestURI(repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo)))
|
return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo)))
|
||||||
}
|
}
|
||||||
|
|
||||||
searchOpts := options.SearchRepoOptions{
|
searchOpts := options.SearchRepoOptions{
|
||||||
Repo: repo,
|
Repo: repo,
|
||||||
|
Chart: chart,
|
||||||
|
UseCache: useCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := handler.helmPackageManager.SearchRepo(searchOpts)
|
result, err := handler.helmPackageManager.SearchRepo(searchOpts)
|
||||||
|
|
|
@ -20,6 +20,7 @@ import (
|
||||||
// @tags helm
|
// @tags helm
|
||||||
// @param repo query string true "Helm repository URL"
|
// @param repo query string true "Helm repository URL"
|
||||||
// @param chart query string true "Chart name"
|
// @param chart query string true "Chart name"
|
||||||
|
// @param version query string true "Chart version"
|
||||||
// @param command path string true "chart/values/readme"
|
// @param command path string true "chart/values/readme"
|
||||||
// @security ApiKeyAuth
|
// @security ApiKeyAuth
|
||||||
// @security jwt
|
// @security jwt
|
||||||
|
@ -45,6 +46,11 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper
|
||||||
return httperror.BadRequest("Bad request", errors.New("missing `chart` query parameter"))
|
return httperror.BadRequest("Bad request", errors.New("missing `chart` query parameter"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
version, err := request.RetrieveQueryParameter(r, "version", true)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided version %q is not valid", version)))
|
||||||
|
}
|
||||||
|
|
||||||
cmd, err := request.RetrieveRouteVariableValue(r, "command")
|
cmd, err := request.RetrieveRouteVariableValue(r, "command")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cmd = "all"
|
cmd = "all"
|
||||||
|
@ -55,6 +61,7 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper
|
||||||
OutputFormat: options.ShowOutputFormat(cmd),
|
OutputFormat: options.ShowOutputFormat(cmd),
|
||||||
Chart: chart,
|
Chart: chart,
|
||||||
Repo: repo,
|
Repo: repo,
|
||||||
|
Version: version,
|
||||||
}
|
}
|
||||||
result, err := handler.helmPackageManager.Show(showOptions)
|
result, err := handler.helmPackageManager.Show(showOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -2,8 +2,10 @@ 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"
|
||||||
|
@ -25,13 +27,19 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
pcli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
|
tokenData, err := security.RetrieveTokenData(r)
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
pcli.IsKubeAdmin = cli.IsKubeAdmin
|
pcli.SetIsKubeAdmin(cli.GetIsKubeAdmin())
|
||||||
pcli.NonAdminNamespaces = cli.NonAdminNamespaces
|
pcli.SetClientNonAdminNamespaces(cli.GetClientNonAdminNamespaces())
|
||||||
|
|
||||||
return pcli, nil
|
return pcli, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,7 +32,7 @@ func (handler *Handler) getAllKubernetesClusterRoleBindings(w http.ResponseWrite
|
||||||
return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", httpErr)
|
return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", httpErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cli.IsKubeAdmin {
|
if !cli.GetIsKubeAdmin() {
|
||||||
log.Error().Str("context", "getAllKubernetesClusterRoleBindings").Msg("user is not authorized to fetch cluster role bindings from the Kubernetes cluster.")
|
log.Error().Str("context", "getAllKubernetesClusterRoleBindings").Msg("user is not authorized to fetch cluster role bindings from the Kubernetes cluster.")
|
||||||
return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", nil)
|
return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", nil)
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,7 +32,7 @@ func (handler *Handler) getAllKubernetesClusterRoles(w http.ResponseWriter, r *h
|
||||||
return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", httpErr)
|
return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", httpErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cli.IsKubeAdmin {
|
if !cli.GetIsKubeAdmin() {
|
||||||
log.Error().Str("context", "getAllKubernetesClusterRoles").Msg("user is not authorized to fetch cluster roles from the Kubernetes cluster.")
|
log.Error().Str("context", "getAllKubernetesClusterRoles").Msg("user is not authorized to fetch cluster roles from the Kubernetes cluster.")
|
||||||
return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", nil)
|
return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", nil)
|
||||||
}
|
}
|
||||||
|
|
102
api/http/handler/kubernetes/event.go
Normal file
102
api/http/handler/kubernetes/event.go
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
package kubernetes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @id getKubernetesEventsForNamespace
|
||||||
|
// @summary Gets kubernetes events for namespace
|
||||||
|
// @description Get events by optional query param resourceId for a given namespace.
|
||||||
|
// @description **Access policy**: Authenticated user.
|
||||||
|
// @tags kubernetes
|
||||||
|
// @security ApiKeyAuth || jwt
|
||||||
|
// @produce json
|
||||||
|
// @param id path int true "Environment identifier"
|
||||||
|
// @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"
|
||||||
|
// @success 200 {object} []kubernetes.K8sEvent "Success"
|
||||||
|
// @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 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 500 "Server error occurred while attempting to retrieve the events within the specified namespace."
|
||||||
|
// @router /kubernetes/{id}/namespaces/{namespace}/events [get]
|
||||||
|
func (handler *Handler) getKubernetesEventsForNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("context", "getKubernetesEvents").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable")
|
||||||
|
return httperror.BadRequest("Unable to retrieve namespace identifier route variable", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceId, err := request.RetrieveQueryParameter(r, "resourceId", true)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve resourceId query parameter")
|
||||||
|
return httperror.BadRequest("Unable to retrieve resourceId query parameter", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cli, httpErr := handler.getProxyKubeClient(r)
|
||||||
|
if httpErr != nil {
|
||||||
|
log.Error().Err(httpErr).Str("context", "getKubernetesEvents").Str("resourceId", resourceId).Msg("Unable to get a Kubernetes client for the user")
|
||||||
|
return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := cli.GetEvents(namespace, resourceId)
|
||||||
|
if err != nil {
|
||||||
|
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
|
||||||
|
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unauthorized access to the Kubernetes API")
|
||||||
|
return httperror.Forbidden("Unauthorized access to the Kubernetes API", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve events")
|
||||||
|
return httperror.InternalServerError("Unable to retrieve events", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @id getAllKubernetesEvents
|
||||||
|
// @summary Gets kubernetes events
|
||||||
|
// @description Get events by query param resourceId
|
||||||
|
// @description **Access policy**: Authenticated user.
|
||||||
|
// @tags kubernetes
|
||||||
|
// @security ApiKeyAuth || jwt
|
||||||
|
// @produce json
|
||||||
|
// @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"
|
||||||
|
// @success 200 {object} []kubernetes.K8sEvent "Success"
|
||||||
|
// @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 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 500 "Server error occurred while attempting to retrieve the events."
|
||||||
|
// @router /kubernetes/{id}/events [get]
|
||||||
|
func (handler *Handler) getAllKubernetesEvents(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
resourceId, err := request.RetrieveQueryParameter(r, "resourceId", true)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve resourceId query parameter")
|
||||||
|
return httperror.BadRequest("Unable to retrieve resourceId query parameter", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cli, httpErr := handler.getProxyKubeClient(r)
|
||||||
|
if httpErr != nil {
|
||||||
|
log.Error().Err(httpErr).Str("context", "getKubernetesEvents").Str("resourceId", resourceId).Msg("Unable to get a Kubernetes client for the user")
|
||||||
|
return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := cli.GetEvents("", resourceId)
|
||||||
|
if err != nil {
|
||||||
|
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
|
||||||
|
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unauthorized access to the Kubernetes API")
|
||||||
|
return httperror.Forbidden("Unauthorized access to the Kubernetes API", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve events")
|
||||||
|
return httperror.InternalServerError("Unable to retrieve events", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, events)
|
||||||
|
}
|
60
api/http/handler/kubernetes/event_test.go
Normal file
60
api/http/handler/kubernetes/event_test.go
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
package kubernetes
|
||||||
|
|
||||||
|
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/authorization"
|
||||||
|
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
|
"github.com/portainer/portainer/api/jwt"
|
||||||
|
"github.com/portainer/portainer/api/kubernetes"
|
||||||
|
kubeClient "github.com/portainer/portainer/api/kubernetes/cli"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Currently this test just tests the HTTP Handler is setup correctly, in the future we should move the ClientFactory to a mock in order
|
||||||
|
// test the logic in event.go
|
||||||
|
func TestGetKubernetesEvents(t *testing.T) {
|
||||||
|
is := assert.New(t)
|
||||||
|
|
||||||
|
_, store := datastore.MustNewTestStore(t, true, true)
|
||||||
|
|
||||||
|
err := store.Endpoint().Create(&portainer.Endpoint{
|
||||||
|
ID: 1,
|
||||||
|
Type: portainer.AgentOnKubernetesEnvironment,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is.NoError(err, "error creating environment")
|
||||||
|
|
||||||
|
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
|
||||||
|
is.NoError(err, "error creating a user")
|
||||||
|
|
||||||
|
jwtService, err := jwt.NewService("1h", store)
|
||||||
|
is.NoError(err, "Error initiating jwt service")
|
||||||
|
|
||||||
|
tk, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: 1, Username: "admin", Role: portainer.AdministratorRole})
|
||||||
|
|
||||||
|
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
|
||||||
|
|
||||||
|
cli := testhelpers.NewKubernetesClient()
|
||||||
|
factory, _ := kubeClient.NewClientFactory(nil, nil, store, "", "", "")
|
||||||
|
|
||||||
|
authorizationService := authorization.NewService(store)
|
||||||
|
handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService,
|
||||||
|
factory, cli)
|
||||||
|
is.NotNil(handler, "Handler should not fail")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/kubernetes/1/events?resourceId=8", nil)
|
||||||
|
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
testhelpers.AddTestSecurityCookie(req, tk)
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
is.Equal(http.StatusOK, rr.Code, "Status should be 200")
|
||||||
|
}
|
|
@ -58,6 +58,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
||||||
endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet)
|
endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet)
|
||||||
endpointRouter.Handle("/cron_jobs", httperror.LoggerHandler(h.getAllKubernetesCronJobs)).Methods(http.MethodGet)
|
endpointRouter.Handle("/cron_jobs", httperror.LoggerHandler(h.getAllKubernetesCronJobs)).Methods(http.MethodGet)
|
||||||
endpointRouter.Handle("/cron_jobs/delete", httperror.LoggerHandler(h.deleteKubernetesCronJobs)).Methods(http.MethodPost)
|
endpointRouter.Handle("/cron_jobs/delete", httperror.LoggerHandler(h.deleteKubernetesCronJobs)).Methods(http.MethodPost)
|
||||||
|
endpointRouter.Handle("/events", httperror.LoggerHandler(h.getAllKubernetesEvents)).Methods(http.MethodGet)
|
||||||
endpointRouter.Handle("/jobs", httperror.LoggerHandler(h.getAllKubernetesJobs)).Methods(http.MethodGet)
|
endpointRouter.Handle("/jobs", httperror.LoggerHandler(h.getAllKubernetesJobs)).Methods(http.MethodGet)
|
||||||
endpointRouter.Handle("/jobs/delete", httperror.LoggerHandler(h.deleteKubernetesJobs)).Methods(http.MethodPost)
|
endpointRouter.Handle("/jobs/delete", httperror.LoggerHandler(h.deleteKubernetesJobs)).Methods(http.MethodPost)
|
||||||
endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet)
|
endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet)
|
||||||
|
@ -110,6 +111,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
||||||
// to keep it simple, we've decided to leave it like this.
|
// to keep it simple, we've decided to leave it like this.
|
||||||
namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
|
namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
|
||||||
namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet)
|
namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet)
|
||||||
|
namespaceRouter.Handle("/events", httperror.LoggerHandler(h.getKubernetesEventsForNamespace)).Methods(http.MethodGet)
|
||||||
namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
|
namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
|
||||||
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet)
|
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet)
|
||||||
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut)
|
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut)
|
||||||
|
@ -133,7 +135,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
||||||
// getProxyKubeClient gets a kubeclient for the user. It's generally what you want as it retrieves the kubeclient
|
// getProxyKubeClient gets a kubeclient for the user. It's generally what you want as it retrieves the kubeclient
|
||||||
// from the Authorization token of the currently logged in user. The kubeclient that is not from the proxy is actually using
|
// from the Authorization token of the currently logged in user. The kubeclient that is not from the proxy is actually using
|
||||||
// admin permissions. If you're unsure which one to use, use this.
|
// admin permissions. If you're unsure which one to use, use this.
|
||||||
func (h *Handler) getProxyKubeClient(r *http.Request) (*cli.KubeClient, *httperror.HandlerError) {
|
func (h *Handler) getProxyKubeClient(r *http.Request) (portainer.KubeClient, *httperror.HandlerError) {
|
||||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, httperror.BadRequest(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, the environment identifier route variable is invalid for /api/kubernetes/%d. Error: ", endpointID), err)
|
return nil, httperror.BadRequest(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, the environment identifier route variable is invalid for /api/kubernetes/%d. Error: ", endpointID), err)
|
||||||
|
@ -144,7 +146,7 @@ func (h *Handler) getProxyKubeClient(r *http.Request) (*cli.KubeClient, *httperr
|
||||||
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), tokenData.Token)
|
cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), strconv.Itoa(int(tokenData.ID)))
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
@ -177,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), tokenData.Token)
|
_, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), strconv.Itoa(int(tokenData.ID)))
|
||||||
if ok {
|
if ok {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
|
@ -253,7 +255,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
serverURL.Scheme = "https"
|
serverURL.Scheme = "https"
|
||||||
serverURL.Host = "localhost" + handler.KubernetesClientFactory.AddrHTTPS
|
serverURL.Host = "localhost" + handler.KubernetesClientFactory.GetAddrHTTPS()
|
||||||
config.Clusters[0].Cluster.Server = serverURL.String()
|
config.Clusters[0].Cluster.Server = serverURL.String()
|
||||||
|
|
||||||
yaml, err := cli.GenerateYAML(config)
|
yaml, err := cli.GenerateYAML(config)
|
||||||
|
@ -267,7 +269,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), tokenData.Token, kubeCli)
|
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), strconv.Itoa(int(tokenData.ID)), kubeCli)
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ 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."
|
||||||
|
@ -36,6 +37,12 @@ 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")
|
||||||
|
@ -48,6 +55,14 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,9 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/http/client"
|
"github.com/portainer/portainer/api/http/client"
|
||||||
"github.com/portainer/portainer/pkg/libcrypto"
|
"github.com/portainer/portainer/pkg/libcrypto"
|
||||||
|
libclient "github.com/portainer/portainer/pkg/libhttp/client"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"github.com/segmentio/encoding/json"
|
"github.com/segmentio/encoding/json"
|
||||||
)
|
)
|
||||||
|
@ -37,6 +39,12 @@ type motdData struct {
|
||||||
// @success 200 {object} motdResponse
|
// @success 200 {object} motdResponse
|
||||||
// @router /motd [get]
|
// @router /motd [get]
|
||||||
func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) {
|
func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := libclient.ExternalRequestDisabled(portainer.MessageOfTheDayURL); err != nil {
|
||||||
|
log.Debug().Err(err).Msg("External request disabled: MOTD")
|
||||||
|
response.JSON(w, &motdResponse{Message: ""})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
motd, err := client.Get(portainer.MessageOfTheDayURL, 0)
|
motd, err := client.Get(portainer.MessageOfTheDayURL, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.JSON(w, &motdResponse{Message: ""})
|
response.JSON(w, &motdResponse{Message: ""})
|
||||||
|
|
|
@ -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/endpointutils"
|
"github.com/portainer/portainer/api/internal/registryutils/access"
|
||||||
"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,6 +17,7 @@ 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) {
|
||||||
|
@ -56,17 +57,20 @@ 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)
|
||||||
|
|
||||||
authenticatedRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryInspect)).Methods(http.MethodGet)
|
// Use registry-specific access bouncer for inspect and repositories endpoints
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,9 +92,7 @@ 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)
|
||||||
|
@ -98,11 +100,6 @@ 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
|
||||||
|
@ -128,47 +125,68 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain
|
||||||
return false, false, err
|
return false, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
|
// Use the enhanced registry access utility function that includes namespace validation
|
||||||
|
_, err = access.GetAccessibleRegistry(
|
||||||
|
handler.DataStore,
|
||||||
|
handler.K8sClientFactory,
|
||||||
|
securityContext.UserID,
|
||||||
|
endpointId,
|
||||||
|
registry.ID,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, nil
|
return false, false, nil // No access
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces)
|
return true, false, nil
|
||||||
if endpointutils.IsKubernetesEndpoint(endpoint) {
|
}
|
||||||
kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
|
|
||||||
if err != nil {
|
// RegistryAccess defines a security check for registry-specific API endpoints.
|
||||||
return false, false, errors.Wrap(err, "unable to retrieve kubernetes client to validate registry access")
|
// Authentication is required to access these endpoints.
|
||||||
}
|
// The user must have direct or indirect access to the specific registry being requested.
|
||||||
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
|
// This bouncer validates registry access using the userHasRegistryAccess logic.
|
||||||
if err != nil {
|
func (handler *Handler) RegistryAccess(next http.Handler) http.Handler {
|
||||||
return false, false, errors.Wrap(err, "unable to retrieve environment's namespaces policies to validate registry access")
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
// First ensure the user is authenticated
|
||||||
|
tokenData, err := security.RetrieveTokenData(r)
|
||||||
authorizedNamespaces := registry.RegistryAccesses[endpointId].Namespaces
|
if err != nil {
|
||||||
|
httperror.WriteError(w, http.StatusUnauthorized, "Authentication required", httperrors.ErrUnauthorized)
|
||||||
for _, namespace := range authorizedNamespaces {
|
return
|
||||||
// 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
|
|
||||||
if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace {
|
// Extract registry ID from the route
|
||||||
return true, false, nil
|
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
}
|
if err != nil {
|
||||||
|
httperror.WriteError(w, http.StatusBadRequest, "Invalid registry identifier route variable", err)
|
||||||
namespacePolicy := accessPolicies[namespace]
|
return
|
||||||
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))
|
||||||
return false, false, nil
|
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||||
}
|
httperror.WriteError(w, http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err)
|
||||||
|
return
|
||||||
// validate access for docker environments
|
} else if err != nil {
|
||||||
// leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access)
|
httperror.WriteError(w, http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err)
|
||||||
// and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams)
|
return
|
||||||
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)
|
||||||
// when user has no access via their role, direct grant or indirect grant
|
if err != nil {
|
||||||
// then they don't have access to the registry
|
httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve info from request context", err)
|
||||||
return false, false, nil
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
89
api/http/handler/registries/registry_access_test.go
Normal file
89
api/http/handler/registries/registry_access_test.go
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
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,10 +4,12 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
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
|
||||||
|
@ -31,6 +33,11 @@ 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)
|
||||||
|
@ -38,14 +45,12 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAccess, isAdmin, err := handler.userHasRegistryAccess(r, registry)
|
// Check if user is admin to determine if we should hide sensitive fields
|
||||||
|
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, !isAdmin)
|
hideFields(registry, !securityContext.IsAdmin)
|
||||||
return response.JSON(w, registry)
|
return response.JSON(w, registry)
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"github.com/portainer/portainer/api/http/client"
|
"github.com/portainer/portainer/api/http/client"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
"github.com/portainer/portainer/pkg/build"
|
"github.com/portainer/portainer/pkg/build"
|
||||||
|
libclient "github.com/portainer/portainer/pkg/libhttp/client"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
|
|
||||||
|
@ -69,10 +70,14 @@ func (handler *Handler) version(w http.ResponseWriter, r *http.Request) *httperr
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetLatestVersion() string {
|
func GetLatestVersion() string {
|
||||||
|
if err := libclient.ExternalRequestDisabled(portainer.VersionCheckURL); err != nil {
|
||||||
|
log.Debug().Err(err).Msg("External request disabled: Version check")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
motd, err := client.Get(portainer.VersionCheckURL, 5)
|
motd, err := client.Get(portainer.VersionCheckURL, 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debug().Err(err).Msg("couldn't fetch latest Portainer release version")
|
log.Debug().Err(err).Msg("couldn't fetch latest Portainer release version")
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ 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"
|
||||||
|
@ -58,6 +59,9 @@ 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)
|
||||||
}
|
}
|
||||||
|
@ -103,15 +107,10 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, endpoint := range endpoints {
|
edgeJobs, err := tx.EdgeJob().ReadAll()
|
||||||
if (tag.Endpoints[endpoint.ID] || tag.EndpointGroups[endpoint.GroupID]) && (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) {
|
if err != nil {
|
||||||
err = updateEndpointRelations(tx, endpoint, edgeGroups, edgeStacks)
|
return httperror.InternalServerError("Unable to retrieve edge job configurations from the database", err)
|
||||||
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
|
||||||
|
@ -123,6 +122,16 @@ 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)
|
||||||
|
@ -131,19 +140,12 @@ 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) error {
|
func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack, edgeJobs []portainer.EdgeJob) error {
|
||||||
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
||||||
if err != nil && !tx.IsErrObjectNotFound(err) {
|
if err != nil {
|
||||||
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
|
||||||
|
@ -157,5 +159,25 @@ func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.End
|
||||||
|
|
||||||
endpointRelation.EdgeStacks = stacksSet
|
endpointRelation.EdgeStacks = stacksSet
|
||||||
|
|
||||||
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
|
if err := tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation); err != nil {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package tags
|
package tags
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
@ -8,23 +9,18 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
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/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) {
|
func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) {
|
||||||
const tagsCount = 100
|
const tagsCount = 100
|
||||||
|
|
||||||
_, store := datastore.MustNewTestStore(t, true, false)
|
handler, store := setUpHandler(t)
|
||||||
|
|
||||||
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
|
||||||
|
@ -84,3 +80,132 @@ 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",
|
||||||
|
Endpoints: []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.Len(t, dynamicEdgeGroup.Endpoints, 0, "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.Len(t, staticEdgeGroup.Endpoints, 1, "static edge group should have one endpoint")
|
||||||
|
assert.Equal(t, endpoint2.ID, staticEdgeGroup.Endpoints[0], "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)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("could not create tag:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = deleteTag(store, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("could not delete tag:", 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
|
||||||
|
}
|
||||||
|
|
|
@ -4,7 +4,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
libclient "github.com/portainer/portainer/pkg/libhttp/client"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/segmentio/encoding/json"
|
"github.com/segmentio/encoding/json"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -24,18 +26,27 @@ func (handler *Handler) fetchTemplates() (*listResponse, *httperror.HandlerError
|
||||||
templatesURL = portainer.DefaultTemplatesURL
|
templatesURL = portainer.DefaultTemplatesURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var body *listResponse
|
||||||
|
if err := libclient.ExternalRequestDisabled(templatesURL); err != nil {
|
||||||
|
if templatesURL == portainer.DefaultTemplatesURL {
|
||||||
|
log.Debug().Err(err).Msg("External request disabled: Default templates")
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := http.Get(templatesURL)
|
resp, err := http.Get(templatesURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, httperror.InternalServerError("Unable to retrieve templates via the network", err)
|
return nil, httperror.InternalServerError("Unable to retrieve templates via the network", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var body *listResponse
|
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, tokenData.ID, endpointID, payload.RegistryID)
|
_, err = access.GetAccessibleRegistry(handler.DataStore, nil, 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, tokenData.ID, webhook.EndpointID, payload.RegistryID)
|
_, err = access.GetAccessibleRegistry(handler.DataStore, nil, 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,7 +51,6 @@ 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,6 +3,7 @@ package middlewares
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gorilla/csrf"
|
"github.com/gorilla/csrf"
|
||||||
)
|
)
|
||||||
|
@ -16,6 +17,45 @@ 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)
|
||||||
|
@ -24,7 +64,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 xfproto := r.Header.Get("X-Forwarded-Proto"); xfproto != "https" {
|
if !isHTTPSRequest(r) {
|
||||||
req = csrf.PlaintextHTTPRequest(r)
|
req = csrf.PlaintextHTTPRequest(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
173
api/http/middlewares/plaintext_http_request_test.go
Normal file
173
api/http/middlewares/plaintext_http_request_test.go
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
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,14 +38,30 @@ 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 {
|
||||||
Status string `json:"Status"`
|
Name string `json:"Name"`
|
||||||
|
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 {
|
||||||
|
@ -72,8 +88,8 @@ type TLSInfo struct {
|
||||||
|
|
||||||
// Existing types
|
// Existing types
|
||||||
type K8sApplicationResource struct {
|
type K8sApplicationResource struct {
|
||||||
CPURequest float64 `json:"CpuRequest"`
|
CPURequest float64 `json:"CpuRequest,omitempty"`
|
||||||
CPULimit float64 `json:"CpuLimit"`
|
CPULimit float64 `json:"CpuLimit,omitempty"`
|
||||||
MemoryRequest int64 `json:"MemoryRequest"`
|
MemoryRequest int64 `json:"MemoryRequest,omitempty"`
|
||||||
MemoryLimit int64 `json:"MemoryLimit"`
|
MemoryLimit int64 `json:"MemoryLimit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
25
api/http/models/kubernetes/event.go
Normal file
25
api/http/models/kubernetes/event.go
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
package kubernetes
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type K8sEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
EventTime time.Time `json:"eventTime"`
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
Count int32 `json:"count"`
|
||||||
|
FirstTimestamp *time.Time `json:"firstTimestamp,omitempty"`
|
||||||
|
LastTimestamp *time.Time `json:"lastTimestamp,omitempty"`
|
||||||
|
UID string `json:"uid"`
|
||||||
|
InvolvedObjectKind K8sEventInvolvedObject `json:"involvedObject"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type K8sEventInvolvedObject struct {
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
}
|
|
@ -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.Filter(xs, func(x string) bool { return len(x) > 0 })
|
xs = slicesx.FilterInPlace(xs, func(x string) bool { return len(x) > 0 })
|
||||||
|
|
||||||
return slicesx.Unique(xs)
|
return slicesx.Unique(xs)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import (
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types/network"
|
||||||
|
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
func getInheritedResourceControlFromNetworkLabels(dockerClient *client.Client, endpointID portainer.EndpointID, networkID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
|
func getInheritedResourceControlFromNetworkLabels(dockerClient *client.Client, endpointID portainer.EndpointID, networkID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
|
||||||
network, err := dockerClient.NetworkInspect(context.Background(), networkID, types.NetworkInspectOptions{})
|
network, err := dockerClient.NetworkInspect(context.Background(), networkID, network.InspectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,12 +55,13 @@ func createRegistryAuthenticationHeader(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = registryutils.EnsureRegTokenValid(dataStore, matchingRegistry); err != nil {
|
if err = registryutils.PrepareRegistryCredentials(dataStore, matchingRegistry); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticationHeader.Serveraddress = matchingRegistry.URL
|
authenticationHeader.Serveraddress = matchingRegistry.URL
|
||||||
authenticationHeader.Username, authenticationHeader.Password, err = registryutils.GetRegEffectiveCredential(matchingRegistry)
|
authenticationHeader.Username = matchingRegistry.Username
|
||||||
|
authenticationHeader.Password = matchingRegistry.Password
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
108
api/http/proxy/factory/github/client.go
Normal file
108
api/http/proxy/factory/github/client.go
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
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)
|
||||||
|
}
|
130
api/http/proxy/factory/gitlab/client.go
Normal file
130
api/http/proxy/factory/gitlab/client.go
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
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)
|
||||||
|
}
|
|
@ -1,34 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
|
@ -58,6 +58,7 @@ 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"):
|
||||||
|
|
|
@ -7,6 +7,21 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Note that we discard any non-canonical headers by design
|
||||||
|
var allowedHeaders = map[string]struct{}{
|
||||||
|
"Accept": {},
|
||||||
|
"Accept-Encoding": {},
|
||||||
|
"Accept-Language": {},
|
||||||
|
"Cache-Control": {},
|
||||||
|
"Content-Length": {},
|
||||||
|
"Content-Type": {},
|
||||||
|
"Private-Token": {},
|
||||||
|
"User-Agent": {},
|
||||||
|
"X-Portaineragent-Target": {},
|
||||||
|
"X-Portainer-Volumename": {},
|
||||||
|
"X-Registry-Auth": {},
|
||||||
|
}
|
||||||
|
|
||||||
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
|
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
|
||||||
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
|
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
|
||||||
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
|
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
|
||||||
|
@ -15,7 +30,6 @@ func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseP
|
||||||
}
|
}
|
||||||
|
|
||||||
func createDirector(target *url.URL) func(*http.Request) {
|
func createDirector(target *url.URL) func(*http.Request) {
|
||||||
sensitiveHeaders := []string{"Cookie", "X-Csrf-Token"}
|
|
||||||
targetQuery := target.RawQuery
|
targetQuery := target.RawQuery
|
||||||
return func(req *http.Request) {
|
return func(req *http.Request) {
|
||||||
req.URL.Scheme = target.Scheme
|
req.URL.Scheme = target.Scheme
|
||||||
|
@ -32,8 +46,11 @@ func createDirector(target *url.URL) func(*http.Request) {
|
||||||
req.Header.Set("User-Agent", "")
|
req.Header.Set("User-Agent", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, header := range sensitiveHeaders {
|
for k := range req.Header {
|
||||||
delete(req.Header, header)
|
if _, ok := allowedHeaders[k]; !ok {
|
||||||
|
// We use delete here instead of req.Header.Del because we want to delete non canonical headers.
|
||||||
|
delete(req.Header, k)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_createDirector(t *testing.T) {
|
func Test_createDirector(t *testing.T) {
|
||||||
|
@ -23,12 +24,14 @@ func Test_createDirector(t *testing.T) {
|
||||||
"GET",
|
"GET",
|
||||||
"https://agent-portainer.io/test?c=7",
|
"https://agent-portainer.io/test?c=7",
|
||||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
|
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
|
||||||
|
true,
|
||||||
),
|
),
|
||||||
expectedReq: createRequest(
|
expectedReq: createRequest(
|
||||||
t,
|
t,
|
||||||
"GET",
|
"GET",
|
||||||
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
||||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
|
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
|
||||||
|
true,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -39,12 +42,14 @@ func Test_createDirector(t *testing.T) {
|
||||||
"GET",
|
"GET",
|
||||||
"https://agent-portainer.io/test?c=7",
|
"https://agent-portainer.io/test?c=7",
|
||||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json"},
|
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json"},
|
||||||
|
true,
|
||||||
),
|
),
|
||||||
expectedReq: createRequest(
|
expectedReq: createRequest(
|
||||||
t,
|
t,
|
||||||
"GET",
|
"GET",
|
||||||
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
||||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": ""},
|
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": ""},
|
||||||
|
true,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -55,18 +60,83 @@ func Test_createDirector(t *testing.T) {
|
||||||
"GET",
|
"GET",
|
||||||
"https://agent-portainer.io/test?c=7",
|
"https://agent-portainer.io/test?c=7",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"Accept-Encoding": "gzip",
|
"Authorization": "secret",
|
||||||
"Accept": "application/json",
|
"Proxy-Authorization": "secret",
|
||||||
"User-Agent": "something",
|
"Cookie": "secret",
|
||||||
"Cookie": "junk",
|
"X-Csrf-Token": "secret",
|
||||||
"X-Csrf-Token": "junk",
|
"X-Api-Key": "secret",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Accept-Encoding": "gzip",
|
||||||
|
"Accept-Language": "en-GB",
|
||||||
|
"Cache-Control": "None",
|
||||||
|
"Content-Length": "100",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Private-Token": "test-private-token",
|
||||||
|
"User-Agent": "test-user-agent",
|
||||||
|
"X-Portaineragent-Target": "test-agent-1",
|
||||||
|
"X-Portainer-Volumename": "test-volume-1",
|
||||||
|
"X-Registry-Auth": "test-registry-auth",
|
||||||
},
|
},
|
||||||
|
true,
|
||||||
),
|
),
|
||||||
expectedReq: createRequest(
|
expectedReq: createRequest(
|
||||||
t,
|
t,
|
||||||
"GET",
|
"GET",
|
||||||
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
||||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
|
map[string]string{
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Accept-Encoding": "gzip",
|
||||||
|
"Accept-Language": "en-GB",
|
||||||
|
"Cache-Control": "None",
|
||||||
|
"Content-Length": "100",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Private-Token": "test-private-token",
|
||||||
|
"User-Agent": "test-user-agent",
|
||||||
|
"X-Portaineragent-Target": "test-agent-1",
|
||||||
|
"X-Portainer-Volumename": "test-volume-1",
|
||||||
|
"X-Registry-Auth": "test-registry-auth",
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Non canonical Headers",
|
||||||
|
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
|
||||||
|
req: createRequest(
|
||||||
|
t,
|
||||||
|
"GET",
|
||||||
|
"https://agent-portainer.io/test?c=7",
|
||||||
|
map[string]string{
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Accept-Encoding": "gzip",
|
||||||
|
"Accept-Language": "en-GB",
|
||||||
|
"Cache-Control": "None",
|
||||||
|
"Content-Length": "100",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Private-Token": "test-private-token",
|
||||||
|
"User-Agent": "test-user-agent",
|
||||||
|
portainer.PortainerAgentTargetHeader: "test-agent-1",
|
||||||
|
"X-Portainer-VolumeName": "test-volume-1",
|
||||||
|
"X-Registry-Auth": "test-registry-auth",
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
expectedReq: createRequest(
|
||||||
|
t,
|
||||||
|
"GET",
|
||||||
|
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
||||||
|
map[string]string{
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Accept-Encoding": "gzip",
|
||||||
|
"Accept-Language": "en-GB",
|
||||||
|
"Cache-Control": "None",
|
||||||
|
"Content-Length": "100",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Private-Token": "test-private-token",
|
||||||
|
"User-Agent": "test-user-agent",
|
||||||
|
"X-Registry-Auth": "test-registry-auth",
|
||||||
|
},
|
||||||
|
true,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -92,13 +162,17 @@ func createURL(t *testing.T, urlString string) *url.URL {
|
||||||
return parsedURL
|
return parsedURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func createRequest(t *testing.T, method, url string, headers map[string]string) *http.Request {
|
func createRequest(t *testing.T, method, url string, headers map[string]string, canonicalHeaders bool) *http.Request {
|
||||||
req, err := http.NewRequest(method, url, nil)
|
req, err := http.NewRequest(method, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create http request: %s", err)
|
t.Fatalf("Failed to create http request: %s", err)
|
||||||
} else {
|
} else {
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Add(k, v)
|
if canonicalHeaders {
|
||||||
|
req.Header.Add(k, v)
|
||||||
|
} else {
|
||||||
|
req.Header[k] = []string{v}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -35,6 +35,7 @@ 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
|
||||||
|
@ -72,7 +73,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: featureflags.IsEnabled("csp"),
|
csp: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
go b.cleanUpExpiredJWT()
|
go b.cleanUpExpiredJWT()
|
||||||
|
@ -80,6 +81,11 @@ 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 {
|
||||||
|
@ -528,7 +534,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")
|
w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud; frame-ancestors 'none';")
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
|
|
@ -530,3 +530,34 @@ func TestJWTRevocation(t *testing.T) {
|
||||||
|
|
||||||
require.Equal(t, 1, revokeLen())
|
require.Equal(t, 1, revokeLen())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCSPHeaderDefault(t *testing.T) {
|
||||||
|
b := NewRequestBouncer(nil, nil, nil)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(
|
||||||
|
b.PublicAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})),
|
||||||
|
)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
require.Contains(t, resp.Header, "Content-Security-Policy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSPHeaderDisabled(t *testing.T) {
|
||||||
|
b := NewRequestBouncer(nil, nil, nil)
|
||||||
|
b.DisableCSP()
|
||||||
|
|
||||||
|
srv := httptest.NewServer(
|
||||||
|
b.PublicAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})),
|
||||||
|
)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
require.NotContains(t, resp.Header, "Content-Security-Policy")
|
||||||
|
}
|
||||||
|
|
|
@ -77,6 +77,7 @@ type Server struct {
|
||||||
AuthorizationService *authorization.Service
|
AuthorizationService *authorization.Service
|
||||||
BindAddress string
|
BindAddress string
|
||||||
BindAddressHTTPS string
|
BindAddressHTTPS string
|
||||||
|
CSP bool
|
||||||
HTTPEnabled bool
|
HTTPEnabled bool
|
||||||
AssetsPath string
|
AssetsPath string
|
||||||
Status *portainer.Status
|
Status *portainer.Status
|
||||||
|
@ -113,6 +114,7 @@ type Server struct {
|
||||||
PendingActionsService *pendingactions.PendingActionsService
|
PendingActionsService *pendingactions.PendingActionsService
|
||||||
PlatformService platform.Service
|
PlatformService platform.Service
|
||||||
PullLimitCheckDisabled bool
|
PullLimitCheckDisabled bool
|
||||||
|
TrustedOrigins []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start starts the HTTP server
|
// Start starts the HTTP server
|
||||||
|
@ -120,13 +122,16 @@ func (server *Server) Start() error {
|
||||||
kubernetesTokenCacheManager := server.KubernetesTokenCacheManager
|
kubernetesTokenCacheManager := server.KubernetesTokenCacheManager
|
||||||
|
|
||||||
requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService, server.APIKeyService)
|
requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService, server.APIKeyService)
|
||||||
|
if !server.CSP {
|
||||||
|
requestBouncer.DisableCSP()
|
||||||
|
}
|
||||||
|
|
||||||
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
||||||
offlineGate := offlinegate.NewOfflineGate()
|
offlineGate := offlinegate.NewOfflineGate()
|
||||||
|
|
||||||
passwordStrengthChecker := security.NewPasswordStrengthChecker(server.DataStore.Settings())
|
passwordStrengthChecker := security.NewPasswordStrengthChecker(server.DataStore.Settings())
|
||||||
|
|
||||||
var authHandler = auth.NewHandler(requestBouncer, rateLimiter, passwordStrengthChecker)
|
var authHandler = auth.NewHandler(requestBouncer, rateLimiter, passwordStrengthChecker, server.KubernetesClientFactory)
|
||||||
authHandler.DataStore = server.DataStore
|
authHandler.DataStore = server.DataStore
|
||||||
authHandler.CryptoService = server.CryptoService
|
authHandler.CryptoService = server.CryptoService
|
||||||
authHandler.JWTService = server.JWTService
|
authHandler.JWTService = server.JWTService
|
||||||
|
@ -161,10 +166,7 @@ func (server *Server) Start() error {
|
||||||
edgeJobsHandler.FileService = server.FileService
|
edgeJobsHandler.FileService = server.FileService
|
||||||
edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService
|
edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||||
|
|
||||||
edgeStackCoordinator := edgestacks.NewEdgeStackStatusUpdateCoordinator(server.DataStore)
|
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService)
|
||||||
go edgeStackCoordinator.Start()
|
|
||||||
|
|
||||||
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService, edgeStackCoordinator)
|
|
||||||
edgeStacksHandler.FileService = server.FileService
|
edgeStacksHandler.FileService = server.FileService
|
||||||
edgeStacksHandler.GitService = server.GitService
|
edgeStacksHandler.GitService = server.GitService
|
||||||
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer
|
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer
|
||||||
|
@ -202,7 +204,7 @@ func (server *Server) Start() error {
|
||||||
|
|
||||||
var dockerHandler = dockerhandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.DockerClientFactory, containerService)
|
var dockerHandler = dockerhandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.DockerClientFactory, containerService)
|
||||||
|
|
||||||
var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), adminMonitor.WasInstanceDisabled)
|
var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), server.CSP, adminMonitor.WasInstanceDisabled)
|
||||||
|
|
||||||
var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeClusterAccessService)
|
var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeClusterAccessService)
|
||||||
|
|
||||||
|
@ -339,7 +341,7 @@ func (server *Server) Start() error {
|
||||||
|
|
||||||
handler = middlewares.WithPanicLogger(middlewares.WithSlowRequestsLogger(handler))
|
handler = middlewares.WithPanicLogger(middlewares.WithSlowRequestsLogger(handler))
|
||||||
|
|
||||||
handler, err := csrf.WithProtect(handler)
|
handler, err := csrf.WithProtect(handler, server.TrustedOrigins)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "failed to create CSRF middleware")
|
return errors.Wrap(err, "failed to create CSRF middleware")
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,7 +49,6 @@ func (service *Service) BuildEdgeStack(
|
||||||
DeploymentType: deploymentType,
|
DeploymentType: deploymentType,
|
||||||
CreationDate: time.Now().Unix(),
|
CreationDate: time.Now().Unix(),
|
||||||
EdgeGroups: edgeGroups,
|
EdgeGroups: edgeGroups,
|
||||||
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus, 0),
|
|
||||||
Version: 1,
|
Version: 1,
|
||||||
UseManifestNamespaces: useManifestNamespaces,
|
UseManifestNamespaces: useManifestNamespaces,
|
||||||
}, nil
|
}, nil
|
||||||
|
@ -104,6 +103,14 @@ func (service *Service) PersistEdgeStack(
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, endpointID := range relatedEndpointIds {
|
||||||
|
status := &portainer.EdgeStackStatusForEnv{EndpointID: endpointID}
|
||||||
|
|
||||||
|
if err := tx.EdgeStackStatus().Create(stack.ID, endpointID, status); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEndpointIds, stack.ID); err != nil {
|
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEndpointIds, stack.ID); err != nil {
|
||||||
return nil, fmt.Errorf("unable to add endpoint relations: %w", err)
|
return nil, fmt.Errorf("unable to add endpoint relations: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -122,9 +129,6 @@ func (service *Service) updateEndpointRelations(tx dataservices.DataStoreTx, edg
|
||||||
for _, endpointID := range relatedEndpointIds {
|
for _, endpointID := range relatedEndpointIds {
|
||||||
relation, err := endpointRelationService.EndpointRelation(endpointID)
|
relation, err := endpointRelationService.EndpointRelation(endpointID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if tx.IsErrObjectNotFound(err) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return fmt.Errorf("unable to find endpoint relation in database: %w", err)
|
return fmt.Errorf("unable to find endpoint relation in database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -158,5 +162,9 @@ func (service *Service) DeleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
|
||||||
return errors.WithMessage(err, "Unable to remove the edge stack from the database")
|
return errors.WithMessage(err, "Unable to remove the edge stack from the database")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := tx.EdgeStackStatus().DeleteAll(edgeStackID); err != nil {
|
||||||
|
return errors.WithMessage(err, "unable to remove edge stack statuses from the database")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
package edgestacks
|
|
||||||
|
|
||||||
import (
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewStatus returns a new status object for an Edge stack
|
|
||||||
func NewStatus(oldStatus map[portainer.EndpointID]portainer.EdgeStackStatus, relatedEnvironmentIDs []portainer.EndpointID) map[portainer.EndpointID]portainer.EdgeStackStatus {
|
|
||||||
status := map[portainer.EndpointID]portainer.EdgeStackStatus{}
|
|
||||||
|
|
||||||
for _, environmentID := range relatedEnvironmentIDs {
|
|
||||||
newEnvStatus := portainer.EdgeStackStatus{
|
|
||||||
Status: []portainer.EdgeStackDeploymentStatus{},
|
|
||||||
EndpointID: environmentID,
|
|
||||||
}
|
|
||||||
|
|
||||||
oldEnvStatus, ok := oldStatus[environmentID]
|
|
||||||
if ok {
|
|
||||||
newEnvStatus.DeploymentInfo = oldEnvStatus.DeploymentInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
status[environmentID] = newEnvStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
return status
|
|
||||||
}
|
|
|
@ -249,3 +249,19 @@ func getEndpointCheckinInterval(endpoint *portainer.Endpoint, settings *portaine
|
||||||
|
|
||||||
return defaultInterval
|
return defaultInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func InitializeEdgeEndpointRelation(endpoint *portainer.Endpoint, tx dataservices.DataStoreTx) error {
|
||||||
|
if !IsEdgeEndpoint(endpoint) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
relation := &portainer.EndpointRelation{
|
||||||
|
EndpointID: endpoint.ID,
|
||||||
|
EdgeStacks: make(map[portainer.EdgeStackID]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.EndpointRelation().Create(relation); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -2,40 +2,82 @@ package access
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
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/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
|
"github.com/portainer/portainer/api/kubernetes"
|
||||||
|
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func hasPermission(
|
func hasPermission(
|
||||||
dataStore dataservices.DataStore,
|
dataStore dataservices.DataStore,
|
||||||
|
k8sClientFactory *cli.ClientFactory,
|
||||||
userID portainer.UserID,
|
userID portainer.UserID,
|
||||||
endpointID portainer.EndpointID,
|
endpointID portainer.EndpointID,
|
||||||
registry *portainer.Registry,
|
registry *portainer.Registry,
|
||||||
) (hasPermission bool, err error) {
|
) (hasPermission bool, err error) {
|
||||||
user, err := dataStore.User().Read(userID)
|
user, err := dataStore.User().Read(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Role == portainer.AdministratorRole {
|
if user.Role == portainer.AdministratorRole {
|
||||||
return true, err
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint, err := dataStore.Endpoint().Endpoint(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
teamMemberships, err := dataStore.TeamMembership().TeamMembershipsByUserID(userID)
|
teamMemberships, err := dataStore.TeamMembership().TeamMembershipsByUserID(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces)
|
||||||
|
if endpointutils.IsKubernetesEndpoint(endpoint) && k8sClientFactory != nil {
|
||||||
|
kcl, err := k8sClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("unable to retrieve kubernetes client to validate registry access: %w", err)
|
||||||
|
}
|
||||||
|
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("unable to retrieve environment's namespaces policies to validate registry access: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authorizedNamespaces := registry.RegistryAccesses[endpointID].Namespaces
|
||||||
|
|
||||||
|
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
|
||||||
|
if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
namespacePolicy := accessPolicies[namespace]
|
||||||
|
if security.AuthorizedAccess(user.ID, teamMemberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate access for docker environments
|
||||||
|
// leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access)
|
||||||
|
// and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams)
|
||||||
hasPermission = security.AuthorizedRegistryAccess(registry, user, teamMemberships, endpointID)
|
hasPermission = security.AuthorizedRegistryAccess(registry, user, teamMemberships, endpointID)
|
||||||
|
|
||||||
return
|
return hasPermission, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAccessibleRegistry get the registry if the user has permission
|
// GetAccessibleRegistry get the registry if the user has permission
|
||||||
func GetAccessibleRegistry(
|
func GetAccessibleRegistry(
|
||||||
dataStore dataservices.DataStore,
|
dataStore dataservices.DataStore,
|
||||||
|
k8sClientFactory *cli.ClientFactory,
|
||||||
userID portainer.UserID,
|
userID portainer.UserID,
|
||||||
endpointID portainer.EndpointID,
|
endpointID portainer.EndpointID,
|
||||||
registryID portainer.RegistryID,
|
registryID portainer.RegistryID,
|
||||||
|
@ -46,7 +88,7 @@ func GetAccessibleRegistry(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hasPermission, err := hasPermission(dataStore, userID, endpointID, registry)
|
hasPermission, err := hasPermission(dataStore, k8sClientFactory, userID, endpointID, registry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,3 +62,26 @@ func GetRegEffectiveCredential(registry *portainer.Registry) (username, password
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrepareRegistryCredentials consolidates the common pattern of ensuring valid ECR token
|
||||||
|
// and setting effective credentials on the registry when authentication is enabled.
|
||||||
|
// This function modifies the registry in-place by setting Username and Password to the effective values.
|
||||||
|
func PrepareRegistryCredentials(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
|
||||||
|
if !registry.Authentication {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EnsureRegTokenValid(tx, registry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
username, password, err := GetRegEffectiveCredential(registry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.Username = username
|
||||||
|
registry.Password = password
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"github.com/portainer/portainer/api/database"
|
"github.com/portainer/portainer/api/database"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/dataservices/errors"
|
"github.com/portainer/portainer/api/dataservices/errors"
|
||||||
|
"github.com/portainer/portainer/api/slicesx"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ dataservices.DataStore = &testDatastore{}
|
var _ dataservices.DataStore = &testDatastore{}
|
||||||
|
@ -16,6 +17,7 @@ type testDatastore struct {
|
||||||
edgeGroup dataservices.EdgeGroupService
|
edgeGroup dataservices.EdgeGroupService
|
||||||
edgeJob dataservices.EdgeJobService
|
edgeJob dataservices.EdgeJobService
|
||||||
edgeStack dataservices.EdgeStackService
|
edgeStack dataservices.EdgeStackService
|
||||||
|
edgeStackStatus dataservices.EdgeStackStatusService
|
||||||
endpoint dataservices.EndpointService
|
endpoint dataservices.EndpointService
|
||||||
endpointGroup dataservices.EndpointGroupService
|
endpointGroup dataservices.EndpointGroupService
|
||||||
endpointRelation dataservices.EndpointRelationService
|
endpointRelation dataservices.EndpointRelationService
|
||||||
|
@ -53,8 +55,11 @@ func (d *testDatastore) CustomTemplate() dataservices.CustomTemplateService { re
|
||||||
func (d *testDatastore) EdgeGroup() dataservices.EdgeGroupService { return d.edgeGroup }
|
func (d *testDatastore) EdgeGroup() dataservices.EdgeGroupService { return d.edgeGroup }
|
||||||
func (d *testDatastore) EdgeJob() dataservices.EdgeJobService { return d.edgeJob }
|
func (d *testDatastore) EdgeJob() dataservices.EdgeJobService { return d.edgeJob }
|
||||||
func (d *testDatastore) EdgeStack() dataservices.EdgeStackService { return d.edgeStack }
|
func (d *testDatastore) EdgeStack() dataservices.EdgeStackService { return d.edgeStack }
|
||||||
func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint }
|
func (d *testDatastore) EdgeStackStatus() dataservices.EdgeStackStatusService {
|
||||||
func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup }
|
return d.edgeStackStatus
|
||||||
|
}
|
||||||
|
func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint }
|
||||||
|
func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup }
|
||||||
|
|
||||||
func (d *testDatastore) EndpointRelation() dataservices.EndpointRelationService {
|
func (d *testDatastore) EndpointRelation() dataservices.EndpointRelationService {
|
||||||
return d.endpointRelation
|
return d.endpointRelation
|
||||||
|
@ -148,8 +153,17 @@ type stubUserService struct {
|
||||||
users []portainer.User
|
users []portainer.User
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubUserService) BucketName() string { return "users" }
|
func (s *stubUserService) BucketName() string { return "users" }
|
||||||
func (s *stubUserService) ReadAll() ([]portainer.User, error) { return s.users, nil }
|
func (s *stubUserService) ReadAll(predicates ...func(portainer.User) bool) ([]portainer.User, error) {
|
||||||
|
filtered := s.users
|
||||||
|
|
||||||
|
for _, p := range predicates {
|
||||||
|
filtered = slicesx.Filter(filtered, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
|
func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
|
||||||
return s.users, nil
|
return s.users, nil
|
||||||
}
|
}
|
||||||
|
@ -167,8 +181,16 @@ type stubEdgeJobService struct {
|
||||||
jobs []portainer.EdgeJob
|
jobs []portainer.EdgeJob
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubEdgeJobService) BucketName() string { return "edgejobs" }
|
func (s *stubEdgeJobService) BucketName() string { return "edgejobs" }
|
||||||
func (s *stubEdgeJobService) ReadAll() ([]portainer.EdgeJob, error) { return s.jobs, nil }
|
func (s *stubEdgeJobService) ReadAll(predicates ...func(portainer.EdgeJob) bool) ([]portainer.EdgeJob, error) {
|
||||||
|
filtered := s.jobs
|
||||||
|
|
||||||
|
for _, p := range predicates {
|
||||||
|
filtered = slicesx.Filter(filtered, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
// WithEdgeJobs option will instruct testDatastore to return provided jobs
|
// WithEdgeJobs option will instruct testDatastore to return provided jobs
|
||||||
func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
|
func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
|
||||||
|
@ -358,8 +380,14 @@ func (s *stubStacksService) Read(ID portainer.StackID) (*portainer.Stack, error)
|
||||||
return nil, errors.ErrObjectNotFound
|
return nil, errors.ErrObjectNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubStacksService) ReadAll() ([]portainer.Stack, error) {
|
func (s *stubStacksService) ReadAll(predicates ...func(portainer.Stack) bool) ([]portainer.Stack, error) {
|
||||||
return s.stacks, nil
|
filtered := s.stacks
|
||||||
|
|
||||||
|
for _, p := range predicates {
|
||||||
|
filtered = slicesx.Filter(filtered, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubStacksService) StacksByEndpointID(endpointID portainer.EndpointID) ([]portainer.Stack, error) {
|
func (s *stubStacksService) StacksByEndpointID(endpointID portainer.EndpointID) ([]portainer.Stack, error) {
|
||||||
|
|
19
api/internal/testhelpers/kube_client.go
Normal file
19
api/internal/testhelpers/kube_client.go
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
package testhelpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testKubeClient struct {
|
||||||
|
portainer.KubeClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKubernetesClient() portainer.KubeClient {
|
||||||
|
return &testKubeClient{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event
|
||||||
|
func (kcl *testKubeClient) GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
|
@ -60,6 +60,8 @@ func (testRequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData,
|
||||||
|
|
||||||
func (testRequestBouncer) RevokeJWT(jti string) {}
|
func (testRequestBouncer) RevokeJWT(jti string) {}
|
||||||
|
|
||||||
|
func (testRequestBouncer) DisableCSP() {}
|
||||||
|
|
||||||
// AddTestSecurityCookie adds a security cookie to the request
|
// AddTestSecurityCookie adds a security cookie to the request
|
||||||
func AddTestSecurityCookie(r *http.Request, jwt string) {
|
func AddTestSecurityCookie(r *http.Request, jwt string) {
|
||||||
r.AddCookie(&http.Cookie{
|
r.AddCookie(&http.Cookie{
|
||||||
|
|
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