1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-25 00:09:40 +02:00

Compare commits

..

75 commits

Author SHA1 Message Date
James Carppe
1afae99345 Updates for release 2.32.0 (#936) 2025-07-24 14:30:37 +12:00
Steven Kang
bdb2e2f417 fix(transport): portainer generated kubeconfig causes kubectl exec fail [R8S-430] (#929) 2025-07-24 13:11:13 +12:00
andres-portainer
bba3751268 fix(roar): return empty slices instead of nil for easier API compatibility BE-12053 (#932) 2025-07-23 14:06:20 -03:00
Ali
60bc04bc33 feat(helm): show manifest previews/changes when installing and upgrading a helm chart [r8s-405] (#898) 2025-07-23 10:52:58 +12:00
andres-portainer
a4cff13531 fix(bouncer): add missing domain to CSP header BE-12067 (#916) 2025-07-21 21:32:50 -03:00
andres-portainer
937456596a fix(edgegroups): convert the related endpoint IDs to roaring bitmaps to increase performance BE-12053 (#903) 2025-07-21 21:31:13 -03:00
Devon Steenberg
caf382b64c feat(git): support bearer token auth for git [BE-11770] (#879) 2025-07-22 08:36:08 +12:00
Ali
55cc250d2e fix(pods): represent pod container statuses correctly [r8s-416] (#910) 2025-07-21 15:05:08 +12:00
Ali
eaa2be017d fix(helm): ensure the form is not 'dirty', when the values are unchanged [r8s-421] (#901) 2025-07-17 12:07:11 +12:00
James Player
4e4c5ffdb6 fix(app/kubernetes): Fix listing of secrets and configmaps with same name [r8s-288] (#897) 2025-07-16 16:37:59 +12:00
James Player
383bcc4113 fix(docker/images): Fix image detail actions icon colours [be-12044] (#892) 2025-07-15 13:57:43 +12:00
James Player
9f906b7417 refactor(app/tests): Make createMockUsers more deterministic [r8s-406] (#887) 2025-07-14 17:16:33 +12:00
Cara Ryan
db2e168540 chore: bump version to 2.32.0 (#884) 2025-07-14 10:23:05 +12:00
Ali
2697d6c5d7 feat(oci): oci helm support [r8s-361] (#787) 2025-07-13 10:37:43 +12:00
andres-portainer
b6a6ce9aaf fix(endpointedge): fix a deadlock in createAsyncEdgeAgentEndpoint() BE-12039 (#883) 2025-07-11 18:54:05 -03:00
Ali
89f6a94bd8 chore(select): show data-cy react select [r8s-402] (#881) 2025-07-11 20:06:41 +12:00
Steven Kang
96f2d69ae5 feat(observability): alerting experimental feature (#801)
Co-authored-by: JamesPlayer <james.player@portainer.io>
2025-07-11 16:55:23 +12:00
Cara Ryan
b7e906701a fix(kubernetes): Namespace access permission changes role bindings not created [R8S-366] (#826) 2025-07-11 14:55:48 +12:00
Steven Kang
150d986179 fix: CVE-2025-53547 (#880) 2025-07-11 13:57:21 +12:00
James Player
ef10ea2a7d fix(ui): Fixed TagsDatatable name column link (#847) 2025-07-11 11:01:37 +12:00
Viktor Pettersson
3bf84e8b0c fix(tags): reconcile edge relations prior to deletion [BE-11969] (#867) 2025-07-10 10:52:12 +12:00
andres-portainer
ea4b334c7e feat(csp): enable CSP by default BE-11961 (#872) 2025-07-09 16:15:43 -03:00
Oscar Zhou
4d11aa8655 fix(tag): ignore "environment not found" when deleting tag [BE-11944] (#869) 2025-07-09 09:55:59 -03:00
andres-portainer
302deb8299 chore(dataservices): enhance ReadAll() so it takes predicates for filtering results BE-12016 (#866) 2025-07-07 14:29:56 -03:00
Viktor Pettersson
0c80b1067d fix(styles): update datetime picker styles for improved dark mode support [BE-11672] (#863) 2025-07-07 20:54:44 +12:00
Steven Kang
0a36d4fbfd fix: kubectl sdk - capture fatal error and return instead of exiting 1 [r7s-371] (#841) 2025-07-07 11:29:29 +12:00
Oscar Zhou
c20a8b5a68 fix(template): app template v3 error [BE-11998] (#854) 2025-07-04 11:49:33 -03:00
Devon Steenberg
8ffe4e284a fix(tls): set insecureSkipVerify to false in FIPS mode [BE-11932] (#849) 2025-07-04 10:48:54 +12:00
Steven Kang
1332f718ae feat: add warning events count next to the status badge (#828) 2025-07-04 10:07:57 +12:00
James Player
f4df51884c fix(tests): Fix ServicesDatatable tests - r8s-395 (#860) 2025-07-03 16:01:08 +12:00
James Carppe
ce86129478 Updates for release 2.31.3 (#859) 2025-07-03 15:17:50 +12:00
andres-portainer
097b125e3a fix(boltdb): change some options to increase performance BE-12002 (#848) 2025-07-02 18:17:19 -03:00
andres-portainer
5c6b53922a feat(go): upgrade to Go v1.24.4 BE-11774 (#855) 2025-07-02 18:14:29 -03:00
James Carppe
e1b9f23f73 Updates for release 2.27.9 (#853) 2025-07-02 17:45:59 +12:00
LP B
e1c480d3c3 feat(app/edge-stacks): summarize the edge stack statuses in the backend (#818) 2025-07-01 15:04:10 +02:00
Steven Kang
363a62d885 fix: bump the docker binary version to v28.3.0 [r8s-390] (#837) 2025-07-01 20:10:39 +12:00
James Player
c6ee9a5a52 feat(ui): Rebranding - r8s-374 (#840) 2025-07-01 12:58:31 +12:00
andres-portainer
cf5990ccba fix(edgestackstatus): improve error handling BE-11963 (#844) 2025-06-30 20:54:16 -03:00
Oscar Zhou
b6f3682a62 refactor(edge): init endpoint relation when endpoint is created [BE-11928] (#814) 2025-06-30 15:15:56 -03:00
LP B
b43f864511 fix(api/endpoints): filter out waiting room environments for non admins (#810) 2025-06-30 15:35:51 +02:00
Oscar Zhou
0556ffb4a1 feat(csrf): add trusted origins cli flags [BE-11972] (#836) 2025-06-27 17:41:10 -03:00
Ali
303047656e fix(k8s-services): avoid rerendering services table [r8s-387] (#832) 2025-06-27 22:48:40 +12:00
Steven Kang
8d29b5ae71 fix: kubeconfig download button inconsistency between http and https (#829) 2025-06-27 09:38:04 +12:00
James Carppe
7d7ae24351 Updates for release 2.31.2 (#834) 2025-06-26 15:41:23 +12:00
James Carppe
97838e614d Updates for release 2.27.8 (#827) 2025-06-25 17:11:58 +12:00
Steven Kang
c897baad20 fix: fetching values from both install and upgrade views - develop [R8S-368] (#820) 2025-06-24 15:46:10 +12:00
andres-portainer
d51e9205d9 fix(endpointrelation): use a read-write transaction for mutations BE-11964 (#819) 2025-06-20 20:03:35 -03:00
James Carppe
e051c86bb5 Updates for release 2.31.1 (#816) 2025-06-19 14:07:18 +12:00
Steven Kang
c2b48cd003 feat(k8s): CloudNativePG in applications list and details - [R8S-357] (#777) 2025-06-19 09:03:52 +12:00
James Carppe
a7009eb8d5 Update bug report template for 2.27.7 (#805) 2025-06-17 12:52:12 +12:00
andres-portainer
036b87b649 fix(middlewares): fix data race in WithEndpoint() BE-11949 (#803) 2025-06-16 12:56:51 -03:00
Steven Kang
f07a3b1875 security: cve-2025-22874 & cve-2025-22871 bump go to 1.23.10 (#798) 2025-06-12 17:30:53 +12:00
Yajith Dayarathna
6e89ccc0ae fix(api-documentation): swagger document genration error (#795) 2025-06-12 13:39:34 +12:00
James Carppe
cc67612432 Update bug report template for 2.31.0 (#793) 2025-06-12 13:26:25 +12:00
Malcolm Lockyer
17ebe221bb chore: bump version to 2.31.0 (#789) 2025-06-10 16:47:17 +12:00
Ali
1963edda66 feat(helm): add registry dropdown [r8s-340] (#779) 2025-06-09 20:08:50 +12:00
Cara Ryan
c9e3717ce3 fix(kubernetes): Display more than 10 workloads under Helm expandable in the Applications view [R8S-339] (#781) 2025-06-09 15:12:24 +12:00
Oscar Zhou
9a85246631 fix(edgestack): display deploying status by default after creating edgestack [BE-11924] (#783) 2025-06-07 09:06:57 +12:00
andres-portainer
75f165d1ff feat(edgestackstatus): optimize the Edge Stack structures BE-11740 (#756) 2025-06-05 19:46:10 -03:00
Viktor Pettersson
eaf0deb2f6 feat(update-schedules): new update schedules view [BE-11754, BE-11887] (#686) 2025-06-05 17:03:43 +12:00
Ali
a9061e5258 feat(helm): enhance helm chart install [r8s-341] (#766) 2025-06-05 13:13:45 +12:00
James Player
caac45b834 feat(UI): Add repository url to Helm chart installation list items (#769) 2025-06-05 10:14:39 +12:00
LP B
24ff7a7911 chore(deps): upgrade docker/cli to v28.2.1 | docker/docker to v28.2.1 | docker/compose to v2.36.2 (#758) 2025-05-30 09:12:27 +02:00
Devon Steenberg
b767dcb27e fix(proxy): whitelist headers for proxy to forward [BE-11819] (#665) 2025-05-30 11:49:23 +12:00
Cara Ryan
731afbee46 feat(helm): filter on chart versions at API level [R8S-324] (#754) 2025-05-27 15:20:28 +12:00
Cara Ryan
07dfd981a2 fix(kubernetes): events api to call the backend [R8S-243] (#563) 2025-05-27 13:55:31 +12:00
Cara Ryan
32ef208278 Revert "feat(helm): filter on chart versions at API level [R8S-324]" (#753) 2025-05-26 16:58:53 +12:00
Cara Ryan
a80b185e10 feat(helm): filter on chart versions at API level [R8S-324] (#747) 2025-05-26 14:10:38 +12:00
Malcolm Lockyer
b96328e098 fix(async-perf): In async poll snapshot handling, reduce redundant json marshal [be-11861] (#726) 2025-05-23 12:42:45 +12:00
Devon Steenberg
45471ce86d fix(docker): check len of device capabilities [BE-11898] (#750) 2025-05-22 14:27:14 +12:00
Viktor Pettersson
1bc91d0c7c fix(edge-update): set edge stack status to EdgeStackStatusError to avoid redeployment of portainer-updater [BE-11855] (#714) 2025-05-20 08:28:40 +02:00
James Carppe
799325d9f8 Update bug report template for 2.30.1 (#749) 2025-05-20 14:40:43 +12:00
James Carppe
b540709e03 Update bug report template for 2.30.0 (#737) 2025-05-15 12:09:28 +12:00
Oscar Zhou
44daab04ac fix(libclient): option to disable external http request [BE-11696] (#719) 2025-05-15 09:54:35 +12:00
Ali
ee65223ee7 chore: bump version to 2.30.0 (#735) 2025-05-14 17:35:05 +12:00
403 changed files with 15693 additions and 3819 deletions

View file

@ -94,11 +94,21 @@ body:
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed. description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false multiple: false
options: options:
- '2.32.0'
- '2.31.3'
- '2.31.2'
- '2.31.1'
- '2.31.0'
- '2.30.1'
- '2.30.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'

View file

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

View file

@ -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{}
@ -437,7 +451,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
snapshotService.Start() snapshotService.Start()
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService) proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService, jwtService)
helmPackageManager, err := initHelmPackageManager() helmPackageManager, err := initHelmPackageManager()
if err != nil { if err != nil {
@ -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,
} }
} }

View file

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

View file

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

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

View file

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

View file

@ -17,11 +17,29 @@ func (service ServiceTx) UpdateEdgeGroupFunc(ID portainer.EdgeGroupID, updateFun
} }
func (service ServiceTx) Create(group *portainer.EdgeGroup) error { func (service ServiceTx) Create(group *portainer.EdgeGroup) error {
return service.Tx.CreateObject( es := group.Endpoints
group.Endpoints = nil // Clear deprecated field
err := service.Tx.CreateObject(
BucketName, BucketName,
func(id uint64) (int, any) { func(id uint64) (int, any) {
group.ID = portainer.EdgeGroupID(id) group.ID = portainer.EdgeGroupID(id)
return int(group.ID), group return int(group.ID), group
}, },
) )
group.Endpoints = es // Restore endpoints after create
return err
}
func (service ServiceTx) Update(ID portainer.EdgeGroupID, group *portainer.EdgeGroup) error {
es := group.Endpoints
group.Endpoints = nil // Clear deprecated field
err := service.BaseDataServiceTx.Update(ID, group)
group.Endpoints = es // Restore endpoints after update
return err
} }

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,7 +83,9 @@ 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,
EdgeGroupService: store.EdgeGroupService,
TunnelServerService: store.TunnelServerService, TunnelServerService: store.TunnelServerService,
PendingActionsService: store.PendingActionsService, PendingActionsService: store.PendingActionsService,
} }
@ -140,8 +140,7 @@ func (store *Store) connectionRollback(force bool) error {
} }
} }
err := store.Restore() if err := store.Restore(); err != nil {
if err != nil {
return err return err
} }

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

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

View file

@ -0,0 +1,23 @@
package migrator
import (
"github.com/portainer/portainer/api/roar"
)
func (m *Migrator) migrateEdgeGroupEndpointsToRoars_2_33_0() error {
egs, err := m.edgeGroupService.ReadAll()
if err != nil {
return err
}
for _, eg := range egs {
eg.EndpointIDs = roar.FromSlice(eg.Endpoints)
eg.Endpoints = nil
if err := m.edgeGroupService.Update(eg.ID, &eg); err != nil {
return err
}
}
return nil
}

View file

@ -3,12 +3,13 @@ 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/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"
@ -27,6 +28,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,7 +59,9 @@ 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
edgeGroupService *edgegroup.Service
TunnelServerService *tunnelserver.Service TunnelServerService *tunnelserver.Service
pendingActionsService *pendingactions.Service pendingActionsService *pendingactions.Service
} }
@ -84,7 +89,9 @@ 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
EdgeGroupService *edgegroup.Service
TunnelServerService *tunnelserver.Service TunnelServerService *tunnelserver.Service
PendingActionsService *pendingactions.Service PendingActionsService *pendingactions.Service
} }
@ -114,12 +121,15 @@ 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,
edgeGroupService: parameters.EdgeGroupService,
TunnelServerService: parameters.TunnelServerService, TunnelServerService: parameters.TunnelServerService,
pendingActionsService: parameters.PendingActionsService, pendingActionsService: parameters.PendingActionsService,
} }
migrator.initMigrations() migrator.initMigrations()
return migrator return migrator
} }
@ -242,6 +252,12 @@ 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)
m.addMigrations("2.33.0", m.migrateEdgeGroupEndpointsToRoars_2_33_0)
// Add new migrations above... // Add new migrations above...
// One function per migration, each versions migration funcs in the same file. // One function per migration, each versions migration funcs in the same file.
} }

View 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

View file

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

View file

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

View file

@ -58,7 +58,15 @@ func TestService_ClonePublicRepository_Azure(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
dst := t.TempDir() dst := t.TempDir()
repositoryUrl := fmt.Sprintf(tt.args.repositoryURLFormat, tt.args.password) repositoryUrl := fmt.Sprintf(tt.args.repositoryURLFormat, tt.args.password)
err := service.CloneRepository(dst, repositoryUrl, tt.args.referenceName, "", "", false) err := service.CloneRepository(
dst,
repositoryUrl,
tt.args.referenceName,
"",
"",
gittypes.GitCredentialAuthType_Basic,
false,
)
assert.NoError(t, err) assert.NoError(t, err)
assert.FileExists(t, filepath.Join(dst, "README.md")) assert.FileExists(t, filepath.Join(dst, "README.md"))
}) })
@ -73,7 +81,15 @@ func TestService_ClonePrivateRepository_Azure(t *testing.T) {
dst := t.TempDir() dst := t.TempDir()
err := service.CloneRepository(dst, privateAzureRepoURL, "refs/heads/main", "", pat, false) err := service.CloneRepository(
dst,
privateAzureRepoURL,
"refs/heads/main",
"",
pat,
gittypes.GitCredentialAuthType_Basic,
false,
)
assert.NoError(t, err) assert.NoError(t, err)
assert.FileExists(t, filepath.Join(dst, "README.md")) assert.FileExists(t, filepath.Join(dst, "README.md"))
} }
@ -84,7 +100,14 @@ func TestService_LatestCommitID_Azure(t *testing.T) {
pat := getRequiredValue(t, "AZURE_DEVOPS_PAT") pat := getRequiredValue(t, "AZURE_DEVOPS_PAT")
service := NewService(context.TODO()) service := NewService(context.TODO())
id, err := service.LatestCommitID(privateAzureRepoURL, "refs/heads/main", "", pat, false) id, err := service.LatestCommitID(
privateAzureRepoURL,
"refs/heads/main",
"",
pat,
gittypes.GitCredentialAuthType_Basic,
false,
)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty") assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty")
} }
@ -96,7 +119,14 @@ func TestService_ListRefs_Azure(t *testing.T) {
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
service := NewService(context.TODO()) service := NewService(context.TODO())
refs, err := service.ListRefs(privateAzureRepoURL, username, accessToken, false, false) refs, err := service.ListRefs(
privateAzureRepoURL,
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
)
assert.NoError(t, err) assert.NoError(t, err)
assert.GreaterOrEqual(t, len(refs), 1) assert.GreaterOrEqual(t, len(refs), 1)
} }
@ -108,8 +138,8 @@ func TestService_ListRefs_Azure_Concurrently(t *testing.T) {
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond) service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
go service.ListRefs(privateAzureRepoURL, username, accessToken, false, false) go service.ListRefs(privateAzureRepoURL, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
service.ListRefs(privateAzureRepoURL, username, accessToken, false, false) service.ListRefs(privateAzureRepoURL, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
} }
@ -247,7 +277,17 @@ func TestService_ListFiles_Azure(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
paths, err := service.ListFiles(tt.args.repositoryUrl, tt.args.referenceName, tt.args.username, tt.args.password, false, false, tt.extensions, false) paths, err := service.ListFiles(
tt.args.repositoryUrl,
tt.args.referenceName,
tt.args.username,
tt.args.password,
gittypes.GitCredentialAuthType_Basic,
false,
false,
tt.extensions,
false,
)
if tt.expect.shouldFail { if tt.expect.shouldFail {
assert.Error(t, err) assert.Error(t, err)
if tt.expect.err != nil { if tt.expect.err != nil {
@ -270,8 +310,28 @@ func TestService_ListFiles_Azure_Concurrently(t *testing.T) {
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond) service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
go service.ListFiles(privateAzureRepoURL, "refs/heads/main", username, accessToken, false, false, []string{}, false) go service.ListFiles(
service.ListFiles(privateAzureRepoURL, "refs/heads/main", username, accessToken, false, false, []string{}, false) privateAzureRepoURL,
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
false,
)
service.ListFiles(
privateAzureRepoURL,
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
false,
)
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
} }

View file

@ -19,6 +19,7 @@ type CloneOptions struct {
ReferenceName string ReferenceName string
Username string Username string
Password string Password string
AuthType gittypes.GitCredentialAuthType
// TLSSkipVerify skips SSL verification when cloning the Git repository // TLSSkipVerify skips SSL verification when cloning the Git repository
TLSSkipVerify bool `example:"false"` TLSSkipVerify bool `example:"false"`
} }
@ -42,7 +43,15 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File
cleanUp = true cleanUp = true
if err := gitService.CloneRepository(options.ProjectPath, options.URL, options.ReferenceName, options.Username, options.Password, options.TLSSkipVerify); err != nil { if err := gitService.CloneRepository(
options.ProjectPath,
options.URL,
options.ReferenceName,
options.Username,
options.Password,
options.AuthType,
options.TLSSkipVerify,
); err != nil {
cleanUp = false cleanUp = false
if err := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath, false); err != nil { if err := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath, false); err != nil {
log.Warn().Err(err).Msg("failed restoring backup folder") log.Warn().Err(err).Msg("failed restoring backup folder")

View file

@ -7,12 +7,14 @@ import (
"strings" "strings"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/rs/zerolog/log"
"github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode" "github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http" githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/storage/memory" "github.com/go-git/go-git/v5/storage/memory"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -33,7 +35,7 @@ func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) e
URL: opt.repositoryUrl, URL: opt.repositoryUrl,
Depth: opt.depth, Depth: opt.depth,
InsecureSkipTLS: opt.tlsSkipVerify, InsecureSkipTLS: opt.tlsSkipVerify,
Auth: getAuth(opt.username, opt.password), Auth: getAuth(opt.authType, opt.username, opt.password),
Tags: git.NoTags, Tags: git.NoTags,
} }
@ -51,7 +53,10 @@ func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) e
} }
if !c.preserveGitDirectory { if !c.preserveGitDirectory {
os.RemoveAll(filepath.Join(dst, ".git")) err := os.RemoveAll(filepath.Join(dst, ".git"))
if err != nil {
log.Error().Err(err).Msg("failed to remove .git directory")
}
} }
return nil return nil
@ -64,7 +69,7 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string
}) })
listOptions := &git.ListOptions{ listOptions := &git.ListOptions{
Auth: getAuth(opt.username, opt.password), Auth: getAuth(opt.authType, opt.username, opt.password),
InsecureSkipTLS: opt.tlsSkipVerify, InsecureSkipTLS: opt.tlsSkipVerify,
} }
@ -94,7 +99,23 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string
return "", errors.Errorf("could not find ref %q in the repository", opt.referenceName) return "", errors.Errorf("could not find ref %q in the repository", opt.referenceName)
} }
func getAuth(username, password string) *githttp.BasicAuth { func getAuth(authType gittypes.GitCredentialAuthType, username, password string) transport.AuthMethod {
if password == "" {
return nil
}
switch authType {
case gittypes.GitCredentialAuthType_Basic:
return getBasicAuth(username, password)
case gittypes.GitCredentialAuthType_Token:
return getTokenAuth(password)
default:
log.Warn().Msg("unknown git credentials authorization type, defaulting to None")
return nil
}
}
func getBasicAuth(username, password string) *githttp.BasicAuth {
if password != "" { if password != "" {
if username == "" { if username == "" {
username = "token" username = "token"
@ -108,6 +129,15 @@ func getAuth(username, password string) *githttp.BasicAuth {
return nil return nil
} }
func getTokenAuth(token string) *githttp.TokenAuth {
if token != "" {
return &githttp.TokenAuth{
Token: token,
}
}
return nil
}
func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, error) { func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, error) {
rem := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{ rem := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{
Name: "origin", Name: "origin",
@ -115,7 +145,7 @@ func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, err
}) })
listOptions := &git.ListOptions{ listOptions := &git.ListOptions{
Auth: getAuth(opt.username, opt.password), Auth: getAuth(opt.authType, opt.username, opt.password),
InsecureSkipTLS: opt.tlsSkipVerify, InsecureSkipTLS: opt.tlsSkipVerify,
} }
@ -143,7 +173,7 @@ func (c *gitClient) listFiles(ctx context.Context, opt fetchOption) ([]string, e
Depth: 1, Depth: 1,
SingleBranch: true, SingleBranch: true,
ReferenceName: plumbing.ReferenceName(opt.referenceName), ReferenceName: plumbing.ReferenceName(opt.referenceName),
Auth: getAuth(opt.username, opt.password), Auth: getAuth(opt.authType, opt.username, opt.password),
InsecureSkipTLS: opt.tlsSkipVerify, InsecureSkipTLS: opt.tlsSkipVerify,
Tags: git.NoTags, Tags: git.NoTags,
} }

View file

@ -2,6 +2,8 @@ package git
import ( import (
"context" "context"
"net/http"
"net/http/httptest"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
@ -24,7 +26,15 @@ func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
dst := t.TempDir() dst := t.TempDir()
repositoryUrl := privateGitRepoURL repositoryUrl := privateGitRepoURL
err := service.CloneRepository(dst, repositoryUrl, "refs/heads/main", username, accessToken, false) err := service.CloneRepository(
dst,
repositoryUrl,
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
)
assert.NoError(t, err) assert.NoError(t, err)
assert.FileExists(t, filepath.Join(dst, "README.md")) assert.FileExists(t, filepath.Join(dst, "README.md"))
} }
@ -37,7 +47,14 @@ func TestService_LatestCommitID_GitHub(t *testing.T) {
service := newService(context.TODO(), 0, 0) service := newService(context.TODO(), 0, 0)
repositoryUrl := privateGitRepoURL repositoryUrl := privateGitRepoURL
id, err := service.LatestCommitID(repositoryUrl, "refs/heads/main", username, accessToken, false) id, err := service.LatestCommitID(
repositoryUrl,
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty") assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty")
} }
@ -50,7 +67,7 @@ func TestService_ListRefs_GitHub(t *testing.T) {
service := newService(context.TODO(), 0, 0) service := newService(context.TODO(), 0, 0)
repositoryUrl := privateGitRepoURL repositoryUrl := privateGitRepoURL
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false) refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
assert.NoError(t, err) assert.NoError(t, err)
assert.GreaterOrEqual(t, len(refs), 1) assert.GreaterOrEqual(t, len(refs), 1)
} }
@ -63,8 +80,8 @@ func TestService_ListRefs_Github_Concurrently(t *testing.T) {
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond) service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
repositoryUrl := privateGitRepoURL repositoryUrl := privateGitRepoURL
go service.ListRefs(repositoryUrl, username, accessToken, false, false) go service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
service.ListRefs(repositoryUrl, username, accessToken, false, false) service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
} }
@ -202,7 +219,17 @@ func TestService_ListFiles_GitHub(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
paths, err := service.ListFiles(tt.args.repositoryUrl, tt.args.referenceName, tt.args.username, tt.args.password, false, false, tt.extensions, false) paths, err := service.ListFiles(
tt.args.repositoryUrl,
tt.args.referenceName,
tt.args.username,
tt.args.password,
gittypes.GitCredentialAuthType_Basic,
false,
false,
tt.extensions,
false,
)
if tt.expect.shouldFail { if tt.expect.shouldFail {
assert.Error(t, err) assert.Error(t, err)
if tt.expect.err != nil { if tt.expect.err != nil {
@ -226,8 +253,28 @@ func TestService_ListFiles_Github_Concurrently(t *testing.T) {
username := getRequiredValue(t, "GITHUB_USERNAME") username := getRequiredValue(t, "GITHUB_USERNAME")
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond) service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
go service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) go service.ListFiles(
service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) repositoryUrl,
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
false,
)
service.ListFiles(
repositoryUrl,
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
false,
)
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
} }
@ -240,8 +287,18 @@ func TestService_purgeCache_Github(t *testing.T) {
username := getRequiredValue(t, "GITHUB_USERNAME") username := getRequiredValue(t, "GITHUB_USERNAME")
service := NewService(context.TODO()) service := NewService(context.TODO())
service.ListRefs(repositoryUrl, username, accessToken, false, false) service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) service.ListFiles(
repositoryUrl,
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
false,
)
assert.Equal(t, 1, service.repoRefCache.Len()) assert.Equal(t, 1, service.repoRefCache.Len())
assert.Equal(t, 1, service.repoFileCache.Len()) assert.Equal(t, 1, service.repoFileCache.Len())
@ -261,8 +318,18 @@ func TestService_purgeCacheByTTL_Github(t *testing.T) {
// 40*timeout is designed for giving enough time for ListRefs and ListFiles to cache the result // 40*timeout is designed for giving enough time for ListRefs and ListFiles to cache the result
service := newService(context.TODO(), 2, 40*timeout) service := newService(context.TODO(), 2, 40*timeout)
service.ListRefs(repositoryUrl, username, accessToken, false, false) service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) service.ListFiles(
repositoryUrl,
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
false,
)
assert.Equal(t, 1, service.repoRefCache.Len()) assert.Equal(t, 1, service.repoRefCache.Len())
assert.Equal(t, 1, service.repoFileCache.Len()) assert.Equal(t, 1, service.repoFileCache.Len())
@ -293,12 +360,12 @@ func TestService_HardRefresh_ListRefs_GitHub(t *testing.T) {
service := newService(context.TODO(), 2, 0) service := newService(context.TODO(), 2, 0)
repositoryUrl := privateGitRepoURL repositoryUrl := privateGitRepoURL
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false) refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
assert.NoError(t, err) assert.NoError(t, err)
assert.GreaterOrEqual(t, len(refs), 1) assert.GreaterOrEqual(t, len(refs), 1)
assert.Equal(t, 1, service.repoRefCache.Len()) assert.Equal(t, 1, service.repoRefCache.Len())
_, err = service.ListRefs(repositoryUrl, username, "fake-token", false, false) _, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, false, false)
assert.Error(t, err) assert.Error(t, err)
assert.Equal(t, 1, service.repoRefCache.Len()) assert.Equal(t, 1, service.repoRefCache.Len())
} }
@ -311,26 +378,46 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
service := newService(context.TODO(), 2, 0) service := newService(context.TODO(), 2, 0)
repositoryUrl := privateGitRepoURL repositoryUrl := privateGitRepoURL
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false) refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
assert.NoError(t, err) assert.NoError(t, err)
assert.GreaterOrEqual(t, len(refs), 1) assert.GreaterOrEqual(t, len(refs), 1)
assert.Equal(t, 1, service.repoRefCache.Len()) assert.Equal(t, 1, service.repoRefCache.Len())
files, err := service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) files, err := service.ListFiles(
repositoryUrl,
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
false,
)
assert.NoError(t, err) assert.NoError(t, err)
assert.GreaterOrEqual(t, len(files), 1) assert.GreaterOrEqual(t, len(files), 1)
assert.Equal(t, 1, service.repoFileCache.Len()) assert.Equal(t, 1, service.repoFileCache.Len())
files, err = service.ListFiles(repositoryUrl, "refs/heads/test", username, accessToken, false, false, []string{}, false) files, err = service.ListFiles(
repositoryUrl,
"refs/heads/test",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
false,
)
assert.NoError(t, err) assert.NoError(t, err)
assert.GreaterOrEqual(t, len(files), 1) assert.GreaterOrEqual(t, len(files), 1)
assert.Equal(t, 2, service.repoFileCache.Len()) assert.Equal(t, 2, service.repoFileCache.Len())
_, err = service.ListRefs(repositoryUrl, username, "fake-token", false, false) _, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, false, false)
assert.Error(t, err) assert.Error(t, err)
assert.Equal(t, 1, service.repoRefCache.Len()) assert.Equal(t, 1, service.repoRefCache.Len())
_, err = service.ListRefs(repositoryUrl, username, "fake-token", true, false) _, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, true, false)
assert.Error(t, err) assert.Error(t, err)
assert.Equal(t, 1, service.repoRefCache.Len()) assert.Equal(t, 1, service.repoRefCache.Len())
// The relevant file caches should be removed too // The relevant file caches should be removed too
@ -344,12 +431,72 @@ func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) {
accessToken := getRequiredValue(t, "GITHUB_PAT") accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME") username := getRequiredValue(t, "GITHUB_USERNAME")
repositoryUrl := privateGitRepoURL repositoryUrl := privateGitRepoURL
files, err := service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) files, err := service.ListFiles(
repositoryUrl,
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
false,
)
assert.NoError(t, err) assert.NoError(t, err)
assert.GreaterOrEqual(t, len(files), 1) assert.GreaterOrEqual(t, len(files), 1)
assert.Equal(t, 1, service.repoFileCache.Len()) assert.Equal(t, 1, service.repoFileCache.Len())
_, err = service.ListFiles(repositoryUrl, "refs/heads/main", username, "fake-token", false, true, []string{}, false) _, err = service.ListFiles(
repositoryUrl,
"refs/heads/main",
username,
"fake-token",
gittypes.GitCredentialAuthType_Basic,
false,
true,
[]string{},
false,
)
assert.Error(t, err) assert.Error(t, err)
assert.Equal(t, 0, service.repoFileCache.Len()) assert.Equal(t, 0, service.repoFileCache.Len())
} }
func TestService_CloneRepository_TokenAuth(t *testing.T) {
ensureIntegrationTest(t)
service := newService(context.TODO(), 2, 0)
var requests []*http.Request
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r)
}))
accessToken := "test_access_token"
username := "test_username"
repositoryUrl := testServer.URL
// Since we aren't hitting a real git server we ignore the error
_ = service.CloneRepository(
"test_dir",
repositoryUrl,
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Token,
false,
)
testServer.Close()
if len(requests) != 1 {
t.Fatalf("expected 1 request sent but got %d", len(requests))
}
gotAuthHeader := requests[0].Header.Get("Authorization")
if gotAuthHeader == "" {
t.Fatal("no Authorization header in git request")
}
expectedAuthHeader := "Bearer test_access_token"
if gotAuthHeader != expectedAuthHeader {
t.Fatalf("expected Authorization header %q but got %q", expectedAuthHeader, gotAuthHeader)
}
}

View file

@ -38,7 +38,7 @@ func Test_ClonePublicRepository_Shallow(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Logf("Cloning into %s", dir) t.Logf("Cloning into %s", dir)
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", false) err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 1, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth") assert.Equal(t, 1, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth")
} }
@ -50,7 +50,7 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Logf("Cloning into %s", dir) t.Logf("Cloning into %s", dir)
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", false) err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
assert.NoError(t, err) assert.NoError(t, err)
assert.NoDirExists(t, filepath.Join(dir, ".git")) assert.NoDirExists(t, filepath.Join(dir, ".git"))
} }
@ -84,7 +84,7 @@ func Test_latestCommitID(t *testing.T) {
repositoryURL := setup(t) repositoryURL := setup(t)
referenceName := "refs/heads/main" referenceName := "refs/heads/main"
id, err := service.LatestCommitID(repositoryURL, referenceName, "", "", false) id, err := service.LatestCommitID(repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id) assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id)
@ -95,7 +95,7 @@ func Test_ListRefs(t *testing.T) {
repositoryURL := setup(t) repositoryURL := setup(t)
fs, err := service.ListRefs(repositoryURL, "", "", false, false) fs, err := service.ListRefs(repositoryURL, "", "", gittypes.GitCredentialAuthType_Basic, false, false)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, []string{"refs/heads/main"}, fs) assert.Equal(t, []string{"refs/heads/main"}, fs)
@ -107,7 +107,17 @@ func Test_ListFiles(t *testing.T) {
repositoryURL := setup(t) repositoryURL := setup(t)
referenceName := "refs/heads/main" referenceName := "refs/heads/main"
fs, err := service.ListFiles(repositoryURL, referenceName, "", "", false, false, []string{".yml"}, false) fs, err := service.ListFiles(
repositoryURL,
referenceName,
"",
"",
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{".yml"},
false,
)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, []string{"docker-compose.yml"}, fs) assert.Equal(t, []string{"docker-compose.yml"}, fs)
@ -255,7 +265,7 @@ func Test_listFilesPrivateRepository(t *testing.T) {
name: "list tree with real repository and head ref but no credential", name: "list tree with real repository and head ref but no credential",
args: fetchOption{ args: fetchOption{
baseOption: baseOption{ baseOption: baseOption{
repositoryUrl: privateGitRepoURL + "fake", repositoryUrl: privateGitRepoURL,
username: "", username: "",
password: "", password: "",
}, },

View file

@ -8,6 +8,7 @@ import (
"time" "time"
lru "github.com/hashicorp/golang-lru" lru "github.com/hashicorp/golang-lru"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"golang.org/x/sync/singleflight" "golang.org/x/sync/singleflight"
) )
@ -22,6 +23,7 @@ type baseOption struct {
repositoryUrl string repositoryUrl string
username string username string
password string password string
authType gittypes.GitCredentialAuthType
tlsSkipVerify bool tlsSkipVerify bool
} }
@ -123,13 +125,22 @@ func (service *Service) timerHasStopped() bool {
// CloneRepository clones a git repository using the specified URL in the specified // CloneRepository clones a git repository using the specified URL in the specified
// destination folder. // destination folder.
func (service *Service) CloneRepository(destination, repositoryURL, referenceName, username, password string, tlsSkipVerify bool) error { func (service *Service) CloneRepository(
destination,
repositoryURL,
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) error {
options := cloneOption{ options := cloneOption{
fetchOption: fetchOption{ fetchOption: fetchOption{
baseOption: baseOption{ baseOption: baseOption{
repositoryUrl: repositoryURL, repositoryUrl: repositoryURL,
username: username, username: username,
password: password, password: password,
authType: authType,
tlsSkipVerify: tlsSkipVerify, tlsSkipVerify: tlsSkipVerify,
}, },
referenceName: referenceName, referenceName: referenceName,
@ -155,12 +166,20 @@ func (service *Service) cloneRepository(destination string, options cloneOption)
} }
// LatestCommitID returns SHA1 of the latest commit of the specified reference // LatestCommitID returns SHA1 of the latest commit of the specified reference
func (service *Service) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) { func (service *Service) LatestCommitID(
repositoryURL,
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) (string, error) {
options := fetchOption{ options := fetchOption{
baseOption: baseOption{ baseOption: baseOption{
repositoryUrl: repositoryURL, repositoryUrl: repositoryURL,
username: username, username: username,
password: password, password: password,
authType: authType,
tlsSkipVerify: tlsSkipVerify, tlsSkipVerify: tlsSkipVerify,
}, },
referenceName: referenceName, referenceName: referenceName,
@ -170,7 +189,14 @@ func (service *Service) LatestCommitID(repositoryURL, referenceName, username, p
} }
// ListRefs will list target repository's references without cloning the repository // ListRefs will list target repository's references without cloning the repository
func (service *Service) ListRefs(repositoryURL, username, password string, hardRefresh bool, tlsSkipVerify bool) ([]string, error) { func (service *Service) ListRefs(
repositoryURL,
username,
password string,
authType gittypes.GitCredentialAuthType,
hardRefresh bool,
tlsSkipVerify bool,
) ([]string, error) {
refCacheKey := generateCacheKey(repositoryURL, username, password, strconv.FormatBool(tlsSkipVerify)) refCacheKey := generateCacheKey(repositoryURL, username, password, strconv.FormatBool(tlsSkipVerify))
if service.cacheEnabled && hardRefresh { if service.cacheEnabled && hardRefresh {
// Should remove the cache explicitly, so that the following normal list can show the correct result // Should remove the cache explicitly, so that the following normal list can show the correct result
@ -196,6 +222,7 @@ func (service *Service) ListRefs(repositoryURL, username, password string, hardR
repositoryUrl: repositoryURL, repositoryUrl: repositoryURL,
username: username, username: username,
password: password, password: password,
authType: authType,
tlsSkipVerify: tlsSkipVerify, tlsSkipVerify: tlsSkipVerify,
} }
@ -215,18 +242,62 @@ var singleflightGroup = &singleflight.Group{}
// ListFiles will list all the files of the target repository with specific extensions. // ListFiles will list all the files of the target repository with specific extensions.
// If extension is not provided, it will list all the files under the target repository // If extension is not provided, it will list all the files under the target repository
func (service *Service) ListFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, includedExts []string, tlsSkipVerify bool) ([]string, error) { func (service *Service) ListFiles(
repoKey := generateCacheKey(repositoryURL, referenceName, username, password, strconv.FormatBool(tlsSkipVerify), strconv.FormatBool(dirOnly)) repositoryURL,
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
dirOnly,
hardRefresh bool,
includedExts []string,
tlsSkipVerify bool,
) ([]string, error) {
repoKey := generateCacheKey(
repositoryURL,
referenceName,
username,
password,
strconv.FormatBool(tlsSkipVerify),
strconv.Itoa(int(authType)),
strconv.FormatBool(dirOnly),
)
fs, err, _ := singleflightGroup.Do(repoKey, func() (any, error) { fs, err, _ := singleflightGroup.Do(repoKey, func() (any, error) {
return service.listFiles(repositoryURL, referenceName, username, password, dirOnly, hardRefresh, tlsSkipVerify) return service.listFiles(
repositoryURL,
referenceName,
username,
password,
authType,
dirOnly,
hardRefresh,
tlsSkipVerify,
)
}) })
return filterFiles(fs.([]string), includedExts), err return filterFiles(fs.([]string), includedExts), err
} }
func (service *Service) listFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, tlsSkipVerify bool) ([]string, error) { func (service *Service) listFiles(
repoKey := generateCacheKey(repositoryURL, referenceName, username, password, strconv.FormatBool(tlsSkipVerify), strconv.FormatBool(dirOnly)) repositoryURL,
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
dirOnly,
hardRefresh bool,
tlsSkipVerify bool,
) ([]string, error) {
repoKey := generateCacheKey(
repositoryURL,
referenceName,
username,
password,
strconv.FormatBool(tlsSkipVerify),
strconv.Itoa(int(authType)),
strconv.FormatBool(dirOnly),
)
if service.cacheEnabled && hardRefresh { if service.cacheEnabled && hardRefresh {
// Should remove the cache explicitly, so that the following normal list can show the correct result // Should remove the cache explicitly, so that the following normal list can show the correct result
@ -247,6 +318,7 @@ func (service *Service) listFiles(repositoryURL, referenceName, username, passwo
repositoryUrl: repositoryURL, repositoryUrl: repositoryURL,
username: username, username: username,
password: password, password: password,
authType: authType,
tlsSkipVerify: tlsSkipVerify, tlsSkipVerify: tlsSkipVerify,
}, },
referenceName: referenceName, referenceName: referenceName,

View file

@ -1,12 +1,21 @@
package gittypes package gittypes
import "errors" import (
"errors"
)
var ( var (
ErrIncorrectRepositoryURL = errors.New("git repository could not be found, please ensure that the URL is correct") ErrIncorrectRepositoryURL = errors.New("git repository could not be found, please ensure that the URL is correct")
ErrAuthenticationFailure = errors.New("authentication failed, please ensure that the git credentials are correct") ErrAuthenticationFailure = errors.New("authentication failed, please ensure that the git credentials are correct")
) )
type GitCredentialAuthType int
const (
GitCredentialAuthType_Basic GitCredentialAuthType = iota
GitCredentialAuthType_Token
)
// RepoConfig represents a configuration for a repo // RepoConfig represents a configuration for a repo
type RepoConfig struct { type RepoConfig struct {
// The repo url // The repo url
@ -24,10 +33,11 @@ type RepoConfig struct {
} }
type GitAuthentication struct { type GitAuthentication struct {
Username string Username string
Password string Password string
AuthorizationType GitCredentialAuthType
// Git credentials identifier when the value is not 0 // Git credentials identifier when the value is not 0
// When the value is 0, Username and Password are set without using saved credential // When the value is 0, Username, Password, and Authtype are set without using saved credential
// This is introduced since 2.15.0 // This is introduced since 2.15.0
GitCredentialID int `example:"0"` GitCredentialID int `example:"0"`
} }

View file

@ -29,7 +29,14 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
return false, "", errors.WithMessagef(err, "failed to get credentials for %v", objId) return false, "", errors.WithMessagef(err, "failed to get credentials for %v", objId)
} }
newHash, err := gitService.LatestCommitID(gitConfig.URL, gitConfig.ReferenceName, username, password, gitConfig.TLSSkipVerify) newHash, err := gitService.LatestCommitID(
gitConfig.URL,
gitConfig.ReferenceName,
username,
password,
gittypes.GitCredentialAuthType_Basic,
gitConfig.TLSSkipVerify,
)
if err != nil { if err != nil {
return false, "", errors.WithMessagef(err, "failed to fetch latest commit id of %v", objId) return false, "", errors.WithMessagef(err, "failed to fetch latest commit id of %v", objId)
} }
@ -62,6 +69,7 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
cloneParams.auth = &gitAuth{ cloneParams.auth = &gitAuth{
username: username, username: username,
password: password, password: password,
authType: gitConfig.Authentication.AuthorizationType,
} }
} }
@ -89,14 +97,31 @@ type cloneRepositoryParameters struct {
} }
type gitAuth struct { type gitAuth struct {
authType gittypes.GitCredentialAuthType
username string username string
password string password string
} }
func cloneGitRepository(gitService portainer.GitService, cloneParams *cloneRepositoryParameters) error { func cloneGitRepository(gitService portainer.GitService, cloneParams *cloneRepositoryParameters) error {
if cloneParams.auth != nil { if cloneParams.auth != nil {
return gitService.CloneRepository(cloneParams.toDir, cloneParams.url, cloneParams.ref, cloneParams.auth.username, cloneParams.auth.password, cloneParams.tlsSkipVerify) return gitService.CloneRepository(
cloneParams.toDir,
cloneParams.url,
cloneParams.ref,
cloneParams.auth.username,
cloneParams.auth.password,
cloneParams.auth.authType,
cloneParams.tlsSkipVerify,
)
} }
return gitService.CloneRepository(cloneParams.toDir, cloneParams.url, cloneParams.ref, "", "", cloneParams.tlsSkipVerify) return gitService.CloneRepository(
cloneParams.toDir,
cloneParams.url,
cloneParams.ref,
"",
"",
gittypes.GitCredentialAuthType_Basic,
cloneParams.tlsSkipVerify,
)
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -33,13 +33,28 @@ type TestGitService struct {
targetFilePath string targetFilePath string
} }
func (g *TestGitService) CloneRepository(destination string, repositoryURL, referenceName string, username, password string, tlsSkipVerify bool) error { func (g *TestGitService) CloneRepository(
destination string,
repositoryURL,
referenceName string,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) error {
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
return createTestFile(g.targetFilePath) return createTestFile(g.targetFilePath)
} }
func (g *TestGitService) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) { func (g *TestGitService) LatestCommitID(
repositoryURL,
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) (string, error) {
return "", nil return "", nil
} }
@ -56,11 +71,26 @@ type InvalidTestGitService struct {
targetFilePath string targetFilePath string
} }
func (g *InvalidTestGitService) CloneRepository(dest, repoUrl, refName, username, password string, tlsSkipVerify bool) error { func (g *InvalidTestGitService) CloneRepository(
dest,
repoUrl,
refName,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) error {
return errors.New("simulate network error") return errors.New("simulate network error")
} }
func (g *InvalidTestGitService) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) { func (g *InvalidTestGitService) LatestCommitID(
repositoryURL,
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) (string, error) {
return "", nil return "", nil
} }

View file

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

View file

@ -37,14 +37,16 @@ type customTemplateUpdatePayload struct {
RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"` RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"`
// Reference name of a Git repository hosting the Stack file // Reference name of a Git repository hosting the Stack file
RepositoryReferenceName string `example:"refs/heads/master"` RepositoryReferenceName string `example:"refs/heads/master"`
// Use basic authentication to clone the Git repository // Use authentication to clone the Git repository
RepositoryAuthentication bool `example:"true"` RepositoryAuthentication bool `example:"true"`
// Username used in basic authentication. Required when RepositoryAuthentication is true // Username used in basic authentication. Required when RepositoryAuthentication is true
// and RepositoryGitCredentialID is 0 // and RepositoryGitCredentialID is 0. Ignored if RepositoryAuthType is token
RepositoryUsername string `example:"myGitUsername"` RepositoryUsername string `example:"myGitUsername"`
// Password used in basic authentication. Required when RepositoryAuthentication is true // Password used in basic authentication or token used in token authentication.
// and RepositoryGitCredentialID is 0 // Required when RepositoryAuthentication is true and RepositoryGitCredentialID is 0
RepositoryPassword string `example:"myGitPassword"` RepositoryPassword string `example:"myGitPassword"`
// RepositoryAuthorizationType is the authorization type to use
RepositoryAuthorizationType gittypes.GitCredentialAuthType `example:"0"`
// GitCredentialID used to identify the bound git credential. Required when RepositoryAuthentication // GitCredentialID used to identify the bound git credential. Required when RepositoryAuthentication
// is true and RepositoryUsername/RepositoryPassword are not provided // is true and RepositoryUsername/RepositoryPassword are not provided
RepositoryGitCredentialID int `example:"0"` RepositoryGitCredentialID int `example:"0"`
@ -182,12 +184,15 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
repositoryUsername := "" repositoryUsername := ""
repositoryPassword := "" repositoryPassword := ""
repositoryAuthType := gittypes.GitCredentialAuthType_Basic
if payload.RepositoryAuthentication { if payload.RepositoryAuthentication {
repositoryUsername = payload.RepositoryUsername repositoryUsername = payload.RepositoryUsername
repositoryPassword = payload.RepositoryPassword repositoryPassword = payload.RepositoryPassword
repositoryAuthType = payload.RepositoryAuthorizationType
gitConfig.Authentication = &gittypes.GitAuthentication{ gitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername, Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword, Password: payload.RepositoryPassword,
AuthorizationType: payload.RepositoryAuthorizationType,
} }
} }
@ -197,6 +202,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
ReferenceName: gitConfig.ReferenceName, ReferenceName: gitConfig.ReferenceName,
Username: repositoryUsername, Username: repositoryUsername,
Password: repositoryPassword, Password: repositoryPassword,
AuthType: repositoryAuthType,
TLSSkipVerify: gitConfig.TLSSkipVerify, TLSSkipVerify: gitConfig.TLSSkipVerify,
}) })
if err != nil { if err != nil {
@ -205,7 +211,14 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
defer cleanBackup() defer cleanBackup()
commitHash, err := handler.GitService.LatestCommitID(gitConfig.URL, gitConfig.ReferenceName, repositoryUsername, repositoryPassword, gitConfig.TLSSkipVerify) commitHash, err := handler.GitService.LatestCommitID(
gitConfig.URL,
gitConfig.ReferenceName,
repositoryUsername,
repositoryPassword,
repositoryAuthType,
gitConfig.TLSSkipVerify,
)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable get latest commit id", fmt.Errorf("failed to fetch latest commit id of the template %v: %w", customTemplate.ID, err)) return httperror.InternalServerError("Unable get latest commit id", fmt.Errorf("failed to fetch latest commit id of the template %v: %w", customTemplate.ID, err))
} }

View file

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

View file

@ -4,6 +4,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/endpointutils" "github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/roar"
) )
type endpointSetType map[portainer.EndpointID]bool type endpointSetType map[portainer.EndpointID]bool
@ -49,22 +50,29 @@ func GetEndpointsByTags(tx dataservices.DataStoreTx, tagIDs []portainer.TagID, p
return results, nil return results, nil
} }
func getTrustedEndpoints(tx dataservices.DataStoreTx, endpointIDs []portainer.EndpointID) ([]portainer.EndpointID, error) { func getTrustedEndpoints(tx dataservices.DataStoreTx, endpointIDs roar.Roar[portainer.EndpointID]) ([]portainer.EndpointID, error) {
var innerErr error
results := []portainer.EndpointID{} results := []portainer.EndpointID{}
for _, endpointID := range endpointIDs {
endpointIDs.Iterate(func(endpointID portainer.EndpointID) bool {
endpoint, err := tx.Endpoint().Endpoint(endpointID) endpoint, err := tx.Endpoint().Endpoint(endpointID)
if err != nil { if err != nil {
return nil, err innerErr = err
return false
} }
if !endpoint.UserTrusted { if !endpoint.UserTrusted {
continue return true
} }
results = append(results, endpoint.ID) results = append(results, endpoint.ID)
}
return results, nil return true
})
return results, innerErr
} }
func mapEndpointGroupToEndpoints(endpoints []portainer.Endpoint) map[portainer.EndpointGroupID]endpointSetType { func mapEndpointGroupToEndpoints(endpoints []portainer.Endpoint) map[portainer.EndpointGroupID]endpointSetType {

View file

@ -7,6 +7,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/endpointutils" "github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/roar"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
) )
@ -52,6 +53,7 @@ func calculateEndpointsOrTags(tx dataservices.DataStoreTx, edgeGroup *portainer.
} }
edgeGroup.Endpoints = endpointIDs edgeGroup.Endpoints = endpointIDs
edgeGroup.EndpointIDs = roar.FromSlice(endpointIDs)
return nil return nil
} }
@ -94,6 +96,7 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request)
Dynamic: payload.Dynamic, Dynamic: payload.Dynamic,
TagIDs: []portainer.TagID{}, TagIDs: []portainer.TagID{},
Endpoints: []portainer.EndpointID{}, Endpoints: []portainer.EndpointID{},
EndpointIDs: roar.Roar[portainer.EndpointID]{},
PartialMatch: payload.PartialMatch, PartialMatch: payload.PartialMatch,
} }
@ -108,5 +111,5 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request)
return nil return nil
}) })
return txResponse(w, edgeGroup, err) return txResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err)
} }

View file

@ -0,0 +1,62 @@
package edgegroups
import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestEdgeGroupCreateHandler(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
err := store.EndpointGroup().Create(&portainer.EndpointGroup{
ID: 1,
Name: "Test Group",
})
require.NoError(t, err)
for i := range 3 {
err = store.Endpoint().Create(&portainer.Endpoint{
ID: portainer.EndpointID(i + 1),
Name: "Test Endpoint " + strconv.Itoa(i+1),
Type: portainer.EdgeAgentOnDockerEnvironment,
GroupID: 1,
})
require.NoError(t, err)
err = store.EndpointRelation().Create(&portainer.EndpointRelation{
EndpointID: portainer.EndpointID(i + 1),
EdgeStacks: map[portainer.EdgeStackID]bool{},
})
require.NoError(t, err)
}
rr := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPost,
"/edge_groups",
strings.NewReader(`{"Name": "New Edge Group", "Endpoints": [1, 2, 3]}`),
)
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var responseGroup portainer.EdgeGroup
err = json.NewDecoder(rr.Body).Decode(&responseGroup)
require.NoError(t, err)
require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints)
}

View file

@ -5,6 +5,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/roar"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
) )
@ -33,7 +34,9 @@ func (handler *Handler) edgeGroupInspect(w http.ResponseWriter, r *http.Request)
return err return err
}) })
return txResponse(w, edgeGroup, err) edgeGroup.Endpoints = edgeGroup.EndpointIDs.ToSlice()
return txResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err)
} }
func getEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) (*portainer.EdgeGroup, error) { func getEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) (*portainer.EdgeGroup, error) {
@ -50,7 +53,7 @@ func getEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) (*porta
return nil, httperror.InternalServerError("Unable to retrieve environments and environment groups for Edge group", err) return nil, httperror.InternalServerError("Unable to retrieve environments and environment groups for Edge group", err)
} }
edgeGroup.Endpoints = endpoints edgeGroup.EndpointIDs = roar.FromSlice(endpoints)
} }
return edgeGroup, err return edgeGroup, err

View file

@ -0,0 +1,176 @@
package edgegroups
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/roar"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEdgeGroupInspectHandler(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
err := store.EndpointGroup().Create(&portainer.EndpointGroup{
ID: 1,
Name: "Test Group",
})
require.NoError(t, err)
for i := range 3 {
err = store.Endpoint().Create(&portainer.Endpoint{
ID: portainer.EndpointID(i + 1),
Name: "Test Endpoint " + strconv.Itoa(i+1),
Type: portainer.EdgeAgentOnDockerEnvironment,
GroupID: 1,
})
require.NoError(t, err)
err = store.EndpointRelation().Create(&portainer.EndpointRelation{
EndpointID: portainer.EndpointID(i + 1),
EdgeStacks: map[portainer.EdgeStackID]bool{},
})
require.NoError(t, err)
}
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 1,
Name: "Test Edge Group",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1, 2, 3}),
})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodGet,
"/edge_groups/1",
nil,
)
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var responseGroup portainer.EdgeGroup
err = json.NewDecoder(rr.Body).Decode(&responseGroup)
require.NoError(t, err)
assert.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints)
}
func TestEmptyEdgeGroupInspectHandler(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
err := store.EndpointGroup().Create(&portainer.EndpointGroup{
ID: 1,
Name: "Test Group",
})
require.NoError(t, err)
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 1,
Name: "Test Edge Group",
EndpointIDs: roar.Roar[portainer.EndpointID]{},
})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodGet,
"/edge_groups/1",
nil,
)
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var responseGroup portainer.EdgeGroup
err = json.NewDecoder(rr.Body).Decode(&responseGroup)
require.NoError(t, err)
// Make sure the frontend does not get a null value but a [] instead
require.NotNil(t, responseGroup.Endpoints)
require.Len(t, responseGroup.Endpoints, 0)
}
func TestDynamicEdgeGroupInspectHandler(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
err := store.EndpointGroup().Create(&portainer.EndpointGroup{
ID: 1,
Name: "Test Group",
})
require.NoError(t, err)
err = store.Tag().Create(&portainer.Tag{
ID: 1,
Name: "Test Tag",
Endpoints: map[portainer.EndpointID]bool{
1: true,
2: true,
3: true,
},
})
require.NoError(t, err)
for i := range 3 {
err = store.Endpoint().Create(&portainer.Endpoint{
ID: portainer.EndpointID(i + 1),
Name: "Test Endpoint " + strconv.Itoa(i+1),
Type: portainer.EdgeAgentOnDockerEnvironment,
GroupID: 1,
TagIDs: []portainer.TagID{1},
UserTrusted: true,
})
require.NoError(t, err)
err = store.EndpointRelation().Create(&portainer.EndpointRelation{
EndpointID: portainer.EndpointID(i + 1),
EdgeStacks: map[portainer.EdgeStackID]bool{},
})
require.NoError(t, err)
}
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 1,
Name: "Test Edge Group",
Dynamic: true,
TagIDs: []portainer.TagID{1},
})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodGet,
"/edge_groups/1",
nil,
)
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var responseGroup portainer.EdgeGroup
err = json.NewDecoder(rr.Body).Decode(&responseGroup)
require.NoError(t, err)
require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints)
}

View file

@ -7,11 +7,17 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/roar"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
) )
type decoratedEdgeGroup struct { type shadowedEdgeGroup struct {
portainer.EdgeGroup portainer.EdgeGroup
EndpointIds int `json:"EndpointIds,omitempty"` // Shadow to avoid exposing in the API
}
type decoratedEdgeGroup struct {
shadowedEdgeGroup
HasEdgeStack bool `json:"HasEdgeStack"` HasEdgeStack bool `json:"HasEdgeStack"`
HasEdgeJob bool `json:"HasEdgeJob"` HasEdgeJob bool `json:"HasEdgeJob"`
EndpointTypes []portainer.EndpointType EndpointTypes []portainer.EndpointType
@ -76,8 +82,8 @@ func getEdgeGroupList(tx dataservices.DataStoreTx) ([]decoratedEdgeGroup, error)
} }
edgeGroup := decoratedEdgeGroup{ edgeGroup := decoratedEdgeGroup{
EdgeGroup: orgEdgeGroup, shadowedEdgeGroup: shadowedEdgeGroup{EdgeGroup: orgEdgeGroup},
EndpointTypes: []portainer.EndpointType{}, EndpointTypes: []portainer.EndpointType{},
} }
if edgeGroup.Dynamic { if edgeGroup.Dynamic {
endpointIDs, err := GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch) endpointIDs, err := GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch)
@ -88,15 +94,16 @@ func getEdgeGroupList(tx dataservices.DataStoreTx) ([]decoratedEdgeGroup, error)
edgeGroup.Endpoints = endpointIDs edgeGroup.Endpoints = endpointIDs
edgeGroup.TrustedEndpoints = endpointIDs edgeGroup.TrustedEndpoints = endpointIDs
} else { } else {
trustedEndpoints, err := getTrustedEndpoints(tx, edgeGroup.Endpoints) trustedEndpoints, err := getTrustedEndpoints(tx, edgeGroup.EndpointIDs)
if err != nil { if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve environments for Edge group", err) return nil, httperror.InternalServerError("Unable to retrieve environments for Edge group", err)
} }
edgeGroup.Endpoints = edgeGroup.EndpointIDs.ToSlice()
edgeGroup.TrustedEndpoints = trustedEndpoints edgeGroup.TrustedEndpoints = trustedEndpoints
} }
endpointTypes, err := getEndpointTypes(tx, edgeGroup.Endpoints) endpointTypes, err := getEndpointTypes(tx, edgeGroup.EndpointIDs)
if err != nil { if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve environment types for Edge group", err) return nil, httperror.InternalServerError("Unable to retrieve environment types for Edge group", err)
} }
@ -111,15 +118,26 @@ func getEdgeGroupList(tx dataservices.DataStoreTx) ([]decoratedEdgeGroup, error)
return decoratedEdgeGroups, nil return decoratedEdgeGroups, nil
} }
func getEndpointTypes(tx dataservices.DataStoreTx, endpointIds []portainer.EndpointID) ([]portainer.EndpointType, error) { func getEndpointTypes(tx dataservices.DataStoreTx, endpointIds roar.Roar[portainer.EndpointID]) ([]portainer.EndpointType, error) {
var innerErr error
typeSet := map[portainer.EndpointType]bool{} typeSet := map[portainer.EndpointType]bool{}
for _, endpointID := range endpointIds {
endpointIds.Iterate(func(endpointID portainer.EndpointID) bool {
endpoint, err := tx.Endpoint().Endpoint(endpointID) endpoint, err := tx.Endpoint().Endpoint(endpointID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed fetching environment: %w", err) innerErr = fmt.Errorf("failed fetching environment: %w", err)
return false
} }
typeSet[endpoint.Type] = true typeSet[endpoint.Type] = true
return true
})
if innerErr != nil {
return nil, innerErr
} }
endpointTypes := make([]portainer.EndpointType, 0, len(typeSet)) endpointTypes := make([]portainer.EndpointType, 0, len(typeSet))

View file

@ -1,11 +1,19 @@
package edgegroups package edgegroups
import ( import (
"net/http"
"net/http/httptest"
"strconv"
"testing" "testing"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/roar"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func Test_getEndpointTypes(t *testing.T) { func Test_getEndpointTypes(t *testing.T) {
@ -38,7 +46,7 @@ func Test_getEndpointTypes(t *testing.T) {
} }
for _, test := range tests { for _, test := range tests {
ans, err := getEndpointTypes(datastore, test.endpointIds) ans, err := getEndpointTypes(datastore, roar.FromSlice(test.endpointIds))
assert.NoError(t, err, "getEndpointTypes shouldn't fail") assert.NoError(t, err, "getEndpointTypes shouldn't fail")
assert.ElementsMatch(t, test.expected, ans, "getEndpointTypes expected to return %b for %v, but returned %b", test.expected, test.endpointIds, ans) assert.ElementsMatch(t, test.expected, ans, "getEndpointTypes expected to return %b for %v, but returned %b", test.expected, test.endpointIds, ans)
@ -48,6 +56,61 @@ func Test_getEndpointTypes(t *testing.T) {
func Test_getEndpointTypes_failWhenEndpointDontExist(t *testing.T) { func Test_getEndpointTypes_failWhenEndpointDontExist(t *testing.T) {
datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{})) datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{}))
_, err := getEndpointTypes(datastore, []portainer.EndpointID{1}) _, err := getEndpointTypes(datastore, roar.FromSlice([]portainer.EndpointID{1}))
assert.Error(t, err, "getEndpointTypes should fail") assert.Error(t, err, "getEndpointTypes should fail")
} }
func TestEdgeGroupListHandler(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
err := store.EndpointGroup().Create(&portainer.EndpointGroup{
ID: 1,
Name: "Test Group",
})
require.NoError(t, err)
for i := range 3 {
err = store.Endpoint().Create(&portainer.Endpoint{
ID: portainer.EndpointID(i + 1),
Name: "Test Endpoint " + strconv.Itoa(i+1),
Type: portainer.EdgeAgentOnDockerEnvironment,
GroupID: 1,
})
require.NoError(t, err)
err = store.EndpointRelation().Create(&portainer.EndpointRelation{
EndpointID: portainer.EndpointID(i + 1),
EdgeStacks: map[portainer.EdgeStackID]bool{},
})
require.NoError(t, err)
}
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 1,
Name: "Test Edge Group",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1, 2, 3}),
})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodGet,
"/edge_groups",
nil,
)
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var responseGroups []decoratedEdgeGroup
err = json.NewDecoder(rr.Body).Decode(&responseGroups)
require.NoError(t, err)
require.Len(t, responseGroups, 1)
require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroups[0].Endpoints)
require.Len(t, responseGroups[0].TrustedEndpoints, 0)
}

View file

@ -158,12 +158,12 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
return nil return nil
}) })
return txResponse(w, edgeGroup, err) return txResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err)
} }
func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error { func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil && !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)

View file

@ -0,0 +1,70 @@
package edgegroups
import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/roar"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestEdgeGroupUpdateHandler(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
err := store.EndpointGroup().Create(&portainer.EndpointGroup{
ID: 1,
Name: "Test Group",
})
require.NoError(t, err)
for i := range 3 {
err = store.Endpoint().Create(&portainer.Endpoint{
ID: portainer.EndpointID(i + 1),
Name: "Test Endpoint " + strconv.Itoa(i+1),
Type: portainer.EdgeAgentOnDockerEnvironment,
GroupID: 1,
})
require.NoError(t, err)
err = store.EndpointRelation().Create(&portainer.EndpointRelation{
EndpointID: portainer.EndpointID(i + 1),
EdgeStacks: map[portainer.EdgeStackID]bool{},
})
require.NoError(t, err)
}
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 1,
Name: "Test Edge Group",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}),
})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPut,
"/edge_groups/1",
strings.NewReader(`{"Endpoints": [1, 2, 3]}`),
)
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var responseGroup portainer.EdgeGroup
err = json.NewDecoder(rr.Body).Decode(&responseGroup)
require.NoError(t, err)
require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints)
}

View file

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

View file

@ -33,6 +33,8 @@ type edgeStackFromGitRepositoryPayload struct {
RepositoryUsername string `example:"myGitUsername"` RepositoryUsername string `example:"myGitUsername"`
// Password used in basic authentication. Required when RepositoryAuthentication is true. // Password used in basic authentication. Required when RepositoryAuthentication is true.
RepositoryPassword string `example:"myGitPassword"` RepositoryPassword string `example:"myGitPassword"`
// RepositoryAuthorizationType is the authorization type to use
RepositoryAuthorizationType gittypes.GitCredentialAuthType `example:"0"`
// Path to the Stack file inside the Git repository // Path to the Stack file inside the Git repository
FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
// List of identifiers of EdgeGroups // List of identifiers of EdgeGroups
@ -103,8 +105,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
} }
@ -126,8 +127,9 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat
if payload.RepositoryAuthentication { if payload.RepositoryAuthentication {
repoConfig.Authentication = &gittypes.GitAuthentication{ repoConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername, Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword, Password: payload.RepositoryPassword,
AuthorizationType: payload.RepositoryAuthorizationType,
} }
} }
@ -137,24 +139,31 @@ 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")
} }
projectPath = handler.FileService.GetEdgeStackProjectPath(stackFolder) projectPath = handler.FileService.GetEdgeStackProjectPath(stackFolder)
repositoryUsername := "" repositoryUsername := ""
repositoryPassword := "" repositoryPassword := ""
repositoryAuthType := gittypes.GitCredentialAuthType_Basic
if repositoryConfig.Authentication != nil && repositoryConfig.Authentication.Password != "" { if repositoryConfig.Authentication != nil && repositoryConfig.Authentication.Password != "" {
repositoryUsername = repositoryConfig.Authentication.Username repositoryUsername = repositoryConfig.Authentication.Username
repositoryPassword = repositoryConfig.Authentication.Password repositoryPassword = repositoryConfig.Authentication.Password
repositoryAuthType = repositoryConfig.Authentication.AuthorizationType
} }
err = handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryConfig.TLSSkipVerify) if err := handler.GitService.CloneRepository(
if err != nil { projectPath,
repositoryConfig.URL,
repositoryConfig.ReferenceName,
repositoryUsername,
repositoryPassword,
repositoryAuthType,
repositoryConfig.TLSSkipVerify,
); err != nil {
return "", "", "", err return "", "", "", err
} }

View file

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

View file

@ -8,8 +8,10 @@ import (
"testing" "testing"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/roar"
"github.com/segmentio/encoding/json" "github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
) )
// Create // Create
@ -23,14 +25,12 @@ func TestCreateAndInspect(t *testing.T) {
Name: "EdgeGroup 1", Name: "EdgeGroup 1",
Dynamic: false, Dynamic: false,
TagIDs: nil, TagIDs: nil,
Endpoints: []portainer.EndpointID{endpoint.ID}, EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpoint.ID}),
PartialMatch: false, PartialMatch: false,
} }
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 +38,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 +48,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 +66,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 +82,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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,8 +15,10 @@ import (
"github.com/portainer/portainer/api/internal/edge/edgestacks" "github.com/portainer/portainer/api/internal/edge/edgestacks"
"github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt" "github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/roar"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/stretchr/testify/require"
) )
// Helpers // Helpers
@ -51,27 +53,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 +86,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
} }
@ -109,19 +104,17 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
Name: "EdgeGroup 1", Name: "EdgeGroup 1",
Dynamic: false, Dynamic: false,
TagIDs: nil, TagIDs: nil,
Endpoints: []portainer.EndpointID{endpointID}, EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpointID}),
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 +131,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 +146,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
} }

View file

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

View file

@ -9,9 +9,10 @@ import (
"testing" "testing"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/require" "github.com/portainer/portainer/api/roar"
"github.com/segmentio/encoding/json" "github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
) )
// Update // Update
@ -25,9 +26,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,22 +36,20 @@ 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,
Name: "EdgeGroup 2", Name: "EdgeGroup 2",
Dynamic: false, Dynamic: false,
TagIDs: nil, TagIDs: nil,
Endpoints: []portainer.EndpointID{newEndpoint.ID}, EndpointIDs: roar.FromSlice([]portainer.EndpointID{newEndpoint.ID}),
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 +59,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 +75,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 +86,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)
@ -122,7 +113,7 @@ func TestUpdateWithInvalidEdgeGroups(t *testing.T) {
Name: "EdgeGroup 2", Name: "EdgeGroup 2",
Dynamic: false, Dynamic: false,
TagIDs: nil, TagIDs: nil,
Endpoints: []portainer.EndpointID{8889}, EndpointIDs: roar.FromSlice([]portainer.EndpointID{8889}),
PartialMatch: false, PartialMatch: false,
} }
@ -226,15 +217,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()

View file

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

View file

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

View file

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

View file

@ -16,6 +16,7 @@ import (
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/jwt" "github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/roar"
"github.com/segmentio/encoding/json" "github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -287,11 +288,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",
@ -369,8 +367,8 @@ func TestEdgeJobsResponse(t *testing.T) {
unrelatedEndpoint := localCreateEndpoint(80, nil) unrelatedEndpoint := localCreateEndpoint(80, nil)
staticEdgeGroup := portainer.EdgeGroup{ staticEdgeGroup := portainer.EdgeGroup{
ID: 1, ID: 1,
Endpoints: []portainer.EndpointID{endpointFromStaticEdgeGroup.ID}, EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpointFromStaticEdgeGroup.ID}),
} }
err := handler.DataStore.EdgeGroup().Create(&staticEdgeGroup) err := handler.DataStore.EdgeGroup().Create(&staticEdgeGroup)
require.NoError(t, err) require.NoError(t, err)

View file

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

View file

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

View file

@ -3,7 +3,6 @@ package endpoints
import ( import (
"errors" "errors"
"net/http" "net/http"
"slices"
"strconv" "strconv"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
@ -200,9 +199,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
} }
for _, edgeGroup := range edgeGroups { for _, edgeGroup := range edgeGroups {
edgeGroup.Endpoints = slices.DeleteFunc(edgeGroup.Endpoints, func(e portainer.EndpointID) bool { edgeGroup.EndpointIDs.Remove(endpoint.ID)
return e == endpoint.ID
})
if err := tx.EdgeGroup().Update(edgeGroup.ID, &edgeGroup); err != nil { if err := tx.EdgeGroup().Update(edgeGroup.ID, &edgeGroup); err != nil {
log.Warn().Err(err).Msg("Unable to update edge group") log.Warn().Err(err).Msg("Unable to update edge group")
@ -214,14 +211,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")
}
} }
} }

View file

@ -11,6 +11,7 @@ import (
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/roar"
) )
func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) { func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) {
@ -21,7 +22,7 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) {
handler := NewHandler(testhelpers.NewTestRequestBouncer()) handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store handler.DataStore = store
handler.ProxyManager = proxy.NewManager(nil) handler.ProxyManager = proxy.NewManager(nil)
handler.ProxyManager.NewProxyFactory(nil, nil, nil, nil, nil, nil, nil, nil) handler.ProxyManager.NewProxyFactory(nil, nil, nil, nil, nil, nil, nil, nil, nil)
// Create all the environments and add them to the same edge group // Create all the environments and add them to the same edge group
@ -42,9 +43,9 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) {
} }
if err := store.EdgeGroup().Create(&portainer.EdgeGroup{ if err := store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 1, ID: 1,
Name: "edgegroup-1", Name: "edgegroup-1",
Endpoints: endpointIDs, EndpointIDs: roar.FromSlice(endpointIDs),
}); err != nil { }); err != nil {
t.Fatal("could not create edge group:", err) t.Fatal("could not create edge group:", err)
} }
@ -78,7 +79,7 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) {
t.Fatal("could not retrieve the edge group:", err) t.Fatal("could not retrieve the edge group:", err)
} }
if len(edgeGroup.Endpoints) > 0 { if edgeGroup.EndpointIDs.Len() > 0 {
t.Fatal("the edge group is not consistent") t.Fatal("the edge group is not consistent")
} }
} }

View file

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

View file

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

View file

@ -11,9 +11,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"
"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/roar"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -140,11 +141,14 @@ func (handler *Handler) filterEndpointsByQuery(
groups []portainer.EndpointGroup, groups []portainer.EndpointGroup,
edgeGroups []portainer.EdgeGroup, edgeGroups []portainer.EdgeGroup,
settings *portainer.Settings, settings *portainer.Settings,
context *security.RestrictedRequestContext,
) ([]portainer.Endpoint, int, error) { ) ([]portainer.Endpoint, int, error) {
totalAvailableEndpoints := len(filteredEndpoints) totalAvailableEndpoints := len(filteredEndpoints)
if len(query.endpointIds) > 0 { if len(query.endpointIds) > 0 {
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, query.endpointIds) endpointIDs := roar.FromSlice(query.endpointIds)
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, endpointIDs)
} }
if len(query.excludeIds) > 0 { if len(query.excludeIds) > 0 {
@ -181,11 +185,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 +256,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
}) })
} }
@ -270,7 +277,7 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port
return nil, errors.WithMessage(err, "Unable to retrieve edge stack from the database") return nil, errors.WithMessage(err, "Unable to retrieve edge stack from the database")
} }
envIds := make([]portainer.EndpointID, 0) envIds := roar.Roar[portainer.EndpointID]{}
for _, edgeGroupdId := range stack.EdgeGroups { for _, edgeGroupdId := range stack.EdgeGroups {
edgeGroup, err := datastore.EdgeGroup().Read(edgeGroupdId) edgeGroup, err := datastore.EdgeGroup().Read(edgeGroupdId)
if err != nil { if err != nil {
@ -282,25 +289,37 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port
if err != nil { if err != nil {
return nil, errors.WithMessage(err, "Unable to retrieve environments and environment groups for Edge group") return nil, errors.WithMessage(err, "Unable to retrieve environments and environment groups for Edge group")
} }
edgeGroup.Endpoints = endpointIDs edgeGroup.EndpointIDs = roar.FromSlice(endpointIDs)
} }
envIds = append(envIds, edgeGroup.Endpoints...) envIds.Union(edgeGroup.EndpointIDs)
} }
if statusFilter != nil { if statusFilter != nil {
n := 0 var innerErr error
for _, envId := range envIds {
if endpointStatusInStackMatchesFilter(stack.Status, envId, *statusFilter) { envIds.Iterate(func(envId portainer.EndpointID) bool {
envIds[n] = envId edgeStackStatus, err := datastore.EdgeStackStatus().Read(edgeStackId, envId)
n++ if dataservices.IsErrObjectNotFound(err) {
return true
} else if err != nil {
innerErr = errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId)
return false
} }
if !endpointStatusInStackMatchesFilter(edgeStackStatus, portainer.EndpointID(envId), *statusFilter) {
envIds.Remove(envId)
}
return true
})
if innerErr != nil {
return nil, innerErr
} }
envIds = envIds[:n]
} }
uniqueIds := slicesx.Unique(envIds) filteredEndpoints := filteredEndpointsByIds(endpoints, envIds)
filteredEndpoints := filteredEndpointsByIds(endpoints, uniqueIds)
return filteredEndpoints, nil return filteredEndpoints, nil
} }
@ -332,16 +351,14 @@ func filterEndpointsByEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGroups []
} }
edgeGroups = edgeGroups[:n] edgeGroups = edgeGroups[:n]
endpointIDSet := make(map[portainer.EndpointID]struct{}) endpointIDSet := roar.Roar[portainer.EndpointID]{}
for _, edgeGroup := range edgeGroups { for _, edgeGroup := range edgeGroups {
for _, endpointID := range edgeGroup.Endpoints { endpointIDSet.Union(edgeGroup.EndpointIDs)
endpointIDSet[endpointID] = struct{}{}
}
} }
n = 0 n = 0
for _, endpoint := range endpoints { for _, endpoint := range endpoints {
if _, exists := endpointIDSet[endpoint.ID]; exists { if endpointIDSet.Contains(endpoint.ID) {
endpoints[n] = endpoint endpoints[n] = endpoint
n++ n++
} }
@ -357,12 +374,11 @@ func filterEndpointsByExcludeEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGr
} }
n := 0 n := 0
excludeEndpointIDSet := make(map[portainer.EndpointID]struct{}) excludeEndpointIDSet := roar.Roar[portainer.EndpointID]{}
for _, edgeGroup := range edgeGroups { for _, edgeGroup := range edgeGroups {
if _, ok := excludeEdgeGroupIDSet[edgeGroup.ID]; ok { if _, ok := excludeEdgeGroupIDSet[edgeGroup.ID]; ok {
for _, endpointID := range edgeGroup.Endpoints { excludeEndpointIDSet.Union(edgeGroup.EndpointIDs)
excludeEndpointIDSet[endpointID] = struct{}{}
}
} else { } else {
edgeGroups[n] = edgeGroup edgeGroups[n] = edgeGroup
n++ n++
@ -372,7 +388,7 @@ func filterEndpointsByExcludeEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGr
n = 0 n = 0
for _, endpoint := range endpoints { for _, endpoint := range endpoints {
if _, ok := excludeEndpointIDSet[endpoint.ID]; !ok { if !excludeEndpointIDSet.Contains(endpoint.ID) {
endpoints[n] = endpoint endpoints[n] = endpoint
n++ n++
} }
@ -597,15 +613,10 @@ func endpointFullMatchTags(endpoint portainer.Endpoint, endpointGroup portainer.
return len(missingTags) == 0 return len(missingTags) == 0
} }
func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids []portainer.EndpointID) []portainer.Endpoint { func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids roar.Roar[portainer.EndpointID]) []portainer.Endpoint {
idsSet := make(map[portainer.EndpointID]bool, len(ids))
for _, id := range ids {
idsSet[id] = true
}
n := 0 n := 0
for _, endpoint := range endpoints { for _, endpoint := range endpoints {
if idsSet[endpoint.ID] { if ids.Contains(endpoint.ID) {
endpoints[n] = endpoint endpoints[n] = endpoint
n++ n++
} }

View file

@ -6,10 +6,13 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/roar"
"github.com/portainer/portainer/api/slicesx" "github.com/portainer/portainer/api/slicesx"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
type filterTest struct { type filterTest struct {
@ -174,7 +177,7 @@ func BenchmarkFilterEndpointsBySearchCriteria_PartialMatch(b *testing.B) {
edgeGroups = append(edgeGroups, portainer.EdgeGroup{ edgeGroups = append(edgeGroups, portainer.EdgeGroup{
ID: portainer.EdgeGroupID(i + 1), ID: portainer.EdgeGroupID(i + 1),
Name: "edge-group-" + strconv.Itoa(i+1), Name: "edge-group-" + strconv.Itoa(i+1),
Endpoints: append([]portainer.EndpointID{}, endpointIDs...), EndpointIDs: roar.FromSlice(endpointIDs),
Dynamic: true, Dynamic: true,
TagIDs: []portainer.TagID{1, 2, 3}, TagIDs: []portainer.TagID{1, 2, 3},
PartialMatch: true, PartialMatch: true,
@ -221,11 +224,11 @@ func BenchmarkFilterEndpointsBySearchCriteria_FullMatch(b *testing.B) {
edgeGroups := []portainer.EdgeGroup{} edgeGroups := []portainer.EdgeGroup{}
for i := range 1000 { for i := range 1000 {
edgeGroups = append(edgeGroups, portainer.EdgeGroup{ edgeGroups = append(edgeGroups, portainer.EdgeGroup{
ID: portainer.EdgeGroupID(i + 1), ID: portainer.EdgeGroupID(i + 1),
Name: "edge-group-" + strconv.Itoa(i+1), Name: "edge-group-" + strconv.Itoa(i+1),
Endpoints: append([]portainer.EndpointID{}, endpointIDs...), EndpointIDs: roar.FromSlice(endpointIDs),
Dynamic: true, Dynamic: true,
TagIDs: []portainer.TagID{1}, TagIDs: []portainer.TagID{1},
}) })
} }
@ -263,6 +266,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)
@ -298,3 +302,127 @@ func setupFilterTest(t *testing.T, endpoints []portainer.Endpoint) *Handler {
return handler return handler
} }
func TestFilterEndpointsByEdgeStack(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
endpoints := []portainer.Endpoint{
{ID: 1, Name: "Endpoint 1"},
{ID: 2, Name: "Endpoint 2"},
{ID: 3, Name: "Endpoint 3"},
{ID: 4, Name: "Endpoint 4"},
}
edgeStackId := portainer.EdgeStackID(1)
err := store.EdgeStack().Create(edgeStackId, &portainer.EdgeStack{
ID: edgeStackId,
Name: "Test Edge Stack",
EdgeGroups: []portainer.EdgeGroupID{1, 2},
})
require.NoError(t, err)
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 1,
Name: "Edge Group 1",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}),
})
require.NoError(t, err)
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 2,
Name: "Edge Group 2",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{2, 3}),
})
require.NoError(t, err)
es, err := filterEndpointsByEdgeStack(endpoints, edgeStackId, nil, store)
require.NoError(t, err)
require.Len(t, es, 3)
require.Contains(t, es, endpoints[0]) // Endpoint 1
require.Contains(t, es, endpoints[1]) // Endpoint 2
require.Contains(t, es, endpoints[2]) // Endpoint 3
require.NotContains(t, es, endpoints[3]) // Endpoint 4
}
func TestFilterEndpointsByEdgeGroup(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
endpoints := []portainer.Endpoint{
{ID: 1, Name: "Endpoint 1"},
{ID: 2, Name: "Endpoint 2"},
{ID: 3, Name: "Endpoint 3"},
{ID: 4, Name: "Endpoint 4"},
}
err := store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 1,
Name: "Edge Group 1",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}),
})
require.NoError(t, err)
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 2,
Name: "Edge Group 2",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{2, 3}),
})
require.NoError(t, err)
edgeGroups, err := store.EdgeGroup().ReadAll()
require.NoError(t, err)
es, egs := filterEndpointsByEdgeGroupIDs(endpoints, edgeGroups, []portainer.EdgeGroupID{1, 2})
require.NoError(t, err)
require.Len(t, es, 3)
require.Contains(t, es, endpoints[0]) // Endpoint 1
require.Contains(t, es, endpoints[1]) // Endpoint 2
require.Contains(t, es, endpoints[2]) // Endpoint 3
require.NotContains(t, es, endpoints[3]) // Endpoint 4
require.Len(t, egs, 2)
require.Equal(t, egs[0].ID, portainer.EdgeGroupID(1))
require.Equal(t, egs[1].ID, portainer.EdgeGroupID(2))
}
func TestFilterEndpointsByExcludeEdgeGroupIDs(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
endpoints := []portainer.Endpoint{
{ID: 1, Name: "Endpoint 1"},
{ID: 2, Name: "Endpoint 2"},
{ID: 3, Name: "Endpoint 3"},
{ID: 4, Name: "Endpoint 4"},
}
err := store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 1,
Name: "Edge Group 1",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}),
})
require.NoError(t, err)
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 2,
Name: "Edge Group 2",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{2, 3}),
})
require.NoError(t, err)
edgeGroups, err := store.EdgeGroup().ReadAll()
require.NoError(t, err)
es, egs := filterEndpointsByExcludeEdgeGroupIDs(endpoints, edgeGroups, []portainer.EdgeGroupID{1})
require.NoError(t, err)
require.Len(t, es, 3)
require.Equal(t, es, []portainer.Endpoint{
{ID: 2, Name: "Endpoint 2"},
{ID: 3, Name: "Endpoint 3"},
{ID: 4, Name: "Endpoint 4"},
})
require.Len(t, egs, 1)
require.Equal(t, egs[0].ID, portainer.EdgeGroupID(2))
}

View file

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

View file

@ -1,12 +1,11 @@
package endpoints package endpoints
import ( import (
"slices"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/set" "github.com/portainer/portainer/api/set"
"github.com/pkg/errors"
) )
func updateEnvironmentEdgeGroups(tx dataservices.DataStoreTx, newEdgeGroups []portainer.EdgeGroupID, environmentID portainer.EndpointID) (bool, error) { func updateEnvironmentEdgeGroups(tx dataservices.DataStoreTx, newEdgeGroups []portainer.EdgeGroupID, environmentID portainer.EndpointID) (bool, error) {
@ -19,10 +18,8 @@ func updateEnvironmentEdgeGroups(tx dataservices.DataStoreTx, newEdgeGroups []po
environmentEdgeGroupsSet := set.Set[portainer.EdgeGroupID]{} environmentEdgeGroupsSet := set.Set[portainer.EdgeGroupID]{}
for _, edgeGroup := range edgeGroups { for _, edgeGroup := range edgeGroups {
for _, eID := range edgeGroup.Endpoints { if edgeGroup.EndpointIDs.Contains(environmentID) {
if eID == environmentID { environmentEdgeGroupsSet[edgeGroup.ID] = true
environmentEdgeGroupsSet[edgeGroup.ID] = true
}
} }
} }
@ -52,20 +49,16 @@ func updateEnvironmentEdgeGroups(tx dataservices.DataStoreTx, newEdgeGroups []po
} }
removeEdgeGroups := environmentEdgeGroupsSet.Difference(newEdgeGroupsSet) removeEdgeGroups := environmentEdgeGroupsSet.Difference(newEdgeGroupsSet)
err = updateSet(removeEdgeGroups, func(edgeGroup *portainer.EdgeGroup) { if err := updateSet(removeEdgeGroups, func(edgeGroup *portainer.EdgeGroup) {
edgeGroup.Endpoints = slices.DeleteFunc(edgeGroup.Endpoints, func(eID portainer.EndpointID) bool { edgeGroup.EndpointIDs.Remove(environmentID)
return eID == environmentID }); err != nil {
})
})
if err != nil {
return false, err return false, err
} }
addToEdgeGroups := newEdgeGroupsSet.Difference(environmentEdgeGroupsSet) addToEdgeGroups := newEdgeGroupsSet.Difference(environmentEdgeGroupsSet)
err = updateSet(addToEdgeGroups, func(edgeGroup *portainer.EdgeGroup) { if err := updateSet(addToEdgeGroups, func(edgeGroup *portainer.EdgeGroup) {
edgeGroup.Endpoints = append(edgeGroup.Endpoints, environmentID) edgeGroup.EndpointIDs.Add(environmentID)
}) }); err != nil {
if err != nil {
return false, err return false, err
} }

View file

@ -6,6 +6,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/datastore" "github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -14,10 +15,9 @@ func Test_updateEdgeGroups(t *testing.T) {
groups := make([]portainer.EdgeGroup, len(names)) groups := make([]portainer.EdgeGroup, len(names))
for index, name := range names { for index, name := range names {
group := &portainer.EdgeGroup{ group := &portainer.EdgeGroup{
Name: name, Name: name,
Dynamic: false, Dynamic: false,
TagIDs: make([]portainer.TagID, 0), TagIDs: make([]portainer.TagID, 0),
Endpoints: make([]portainer.EndpointID, 0),
} }
if err := store.EdgeGroup().Create(group); err != nil { if err := store.EdgeGroup().Create(group); err != nil {
@ -35,13 +35,8 @@ func Test_updateEdgeGroups(t *testing.T) {
group, err := store.EdgeGroup().Read(groupID) group, err := store.EdgeGroup().Read(groupID)
is.NoError(err) is.NoError(err)
for _, endpoint := range group.Endpoints { is.True(group.EndpointIDs.Contains(endpointID),
if endpoint == endpointID { "expected endpoint to be in group")
return
}
}
is.Fail("expected endpoint to be in group")
} }
} }
@ -81,7 +76,7 @@ func Test_updateEdgeGroups(t *testing.T) {
endpointGroups := groupsByName(groups, testCase.endpointGroupNames) endpointGroups := groupsByName(groups, testCase.endpointGroupNames)
for _, group := range endpointGroups { for _, group := range endpointGroups {
group.Endpoints = append(group.Endpoints, testCase.endpoint.ID) group.EndpointIDs.Add(testCase.endpoint.ID)
err = store.EdgeGroup().Update(group.ID, &group) err = store.EdgeGroup().Update(group.ID, &group)
is.NoError(err) is.NoError(err)

View file

@ -10,7 +10,6 @@ import (
) )
func Test_updateTags(t *testing.T) { func Test_updateTags(t *testing.T) {
createTags := func(store *datastore.Store, tagNames []string) ([]portainer.Tag, error) { createTags := func(store *datastore.Store, tagNames []string) ([]portainer.Tag, error) {
tags := make([]portainer.Tag, len(tagNames)) tags := make([]portainer.Tag, len(tagNames))
for index, tagName := range tagNames { for index, tagName := range tagNames {

View file

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

View file

@ -17,10 +17,11 @@ type fileResponse struct {
} }
type repositoryFilePreviewPayload struct { type repositoryFilePreviewPayload struct {
Repository string `json:"repository" example:"https://github.com/openfaas/faas" validate:"required"` Repository string `json:"repository" example:"https://github.com/openfaas/faas" validate:"required"`
Reference string `json:"reference" example:"refs/heads/master"` Reference string `json:"reference" example:"refs/heads/master"`
Username string `json:"username" example:"myGitUsername"` Username string `json:"username" example:"myGitUsername"`
Password string `json:"password" example:"myGitPassword"` Password string `json:"password" example:"myGitPassword"`
AuthorizationType gittypes.GitCredentialAuthType `json:"authorizationType"`
// Path to file whose content will be read // Path to file whose content will be read
TargetFile string `json:"targetFile" example:"docker-compose.yml"` TargetFile string `json:"targetFile" example:"docker-compose.yml"`
// TLSSkipVerify skips SSL verification when cloning the Git repository // TLSSkipVerify skips SSL verification when cloning the Git repository
@ -68,7 +69,15 @@ func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *ht
return httperror.InternalServerError("Unable to create temporary folder", err) return httperror.InternalServerError("Unable to create temporary folder", err)
} }
err = handler.gitService.CloneRepository(projectPath, payload.Repository, payload.Reference, payload.Username, payload.Password, payload.TLSSkipVerify) err = handler.gitService.CloneRepository(
projectPath,
payload.Repository,
payload.Reference,
payload.Username,
payload.Password,
payload.AuthorizationType,
payload.TLSSkipVerify,
)
if err != nil { if err != nil {
if errors.Is(err, gittypes.ErrAuthenticationFailure) { if errors.Is(err, gittypes.ErrAuthenticationFailure) {
return httperror.BadRequest("Invalid git credential", err) return httperror.BadRequest("Invalid git credential", err)

View file

@ -81,7 +81,7 @@ type Handler struct {
} }
// @title PortainerCE API // @title PortainerCE API
// @version 2.30.0 // @version 2.32.0
// @description.markdown api-description.md // @description.markdown api-description.md
// @termsOfService // @termsOfService

View file

@ -46,18 +46,24 @@ var errChartNameInvalid = errors.New("invalid chart name. " +
// @produce json // @produce json
// @param id path int true "Environment(Endpoint) identifier" // @param id path int true "Environment(Endpoint) identifier"
// @param payload body installChartPayload true "Chart details" // @param payload body installChartPayload true "Chart details"
// @param dryRun query bool false "Dry run"
// @success 201 {object} release.Release "Created" // @success 201 {object} release.Release "Created"
// @failure 401 "Unauthorized" // @failure 401 "Unauthorized"
// @failure 404 "Environment(Endpoint) or ServiceAccount not found" // @failure 404 "Environment(Endpoint) or ServiceAccount not found"
// @failure 500 "Server error" // @failure 500 "Server error"
// @router /endpoints/{id}/kubernetes/helm [post] // @router /endpoints/{id}/kubernetes/helm [post]
func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
dryRun, err := request.RetrieveBooleanQueryParameter(r, "dryRun", true)
if err != nil {
return httperror.BadRequest("Invalid dryRun query parameter", err)
}
var payload installChartPayload var payload installChartPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid Helm install payload", err) return httperror.BadRequest("Invalid Helm install payload", err)
} }
release, err := handler.installChart(r, payload) release, err := handler.installChart(r, payload, dryRun)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to install a chart", err) return httperror.InternalServerError("Unable to install a chart", err)
} }
@ -94,7 +100,7 @@ func (p *installChartPayload) Validate(_ *http.Request) error {
return nil return nil
} }
func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*release.Release, error) { func (handler *Handler) installChart(r *http.Request, p installChartPayload, dryRun bool) (*release.Release, error) {
clusterAccess, httperr := handler.getHelmClusterAccess(r) clusterAccess, httperr := handler.getHelmClusterAccess(r)
if httperr != nil { if httperr != nil {
return nil, httperr.Err return nil, httperr.Err
@ -107,6 +113,7 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
Namespace: p.Namespace, Namespace: p.Namespace,
Repo: p.Repo, Repo: p.Repo,
Atomic: p.Atomic, Atomic: p.Atomic,
DryRun: dryRun,
KubernetesClusterAccess: clusterAccess, KubernetesClusterAccess: clusterAccess,
} }
@ -134,13 +141,14 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
return nil, err return nil, err
} }
manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest) if !installOpts.DryRun {
if err != nil { manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest)
return nil, err if err != nil {
} return nil, err
}
if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); err != nil { if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); err != nil {
return nil, err return nil, err
}
} }
return release, nil return release, nil

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -19,14 +19,15 @@ import (
) )
type stackGitUpdatePayload struct { type stackGitUpdatePayload struct {
AutoUpdate *portainer.AutoUpdateSettings AutoUpdate *portainer.AutoUpdateSettings
Env []portainer.Pair Env []portainer.Pair
Prune bool Prune bool
RepositoryReferenceName string RepositoryReferenceName string
RepositoryAuthentication bool RepositoryAuthentication bool
RepositoryUsername string RepositoryUsername string
RepositoryPassword string RepositoryPassword string
TLSSkipVerify bool RepositoryAuthorizationType gittypes.GitCredentialAuthType
TLSSkipVerify bool
} }
func (payload *stackGitUpdatePayload) Validate(r *http.Request) error { func (payload *stackGitUpdatePayload) Validate(r *http.Request) error {
@ -151,11 +152,19 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
} }
stack.GitConfig.Authentication = &gittypes.GitAuthentication{ stack.GitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername, Username: payload.RepositoryUsername,
Password: password, Password: password,
AuthorizationType: payload.RepositoryAuthorizationType,
} }
if _, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, stack.GitConfig.Authentication.Username, stack.GitConfig.Authentication.Password, stack.GitConfig.TLSSkipVerify); err != nil { if _, err := handler.GitService.LatestCommitID(
stack.GitConfig.URL,
stack.GitConfig.ReferenceName,
stack.GitConfig.Authentication.Username,
stack.GitConfig.Authentication.Password,
stack.GitConfig.Authentication.AuthorizationType,
stack.GitConfig.TLSSkipVerify,
); err != nil {
return httperror.InternalServerError("Unable to fetch git repository", err) return httperror.InternalServerError("Unable to fetch git repository", err)
} }
} else { } else {

View file

@ -6,6 +6,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/git" "github.com/portainer/portainer/api/git"
gittypes "github.com/portainer/portainer/api/git/types"
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
k "github.com/portainer/portainer/api/kubernetes" k "github.com/portainer/portainer/api/kubernetes"
@ -19,12 +20,13 @@ import (
) )
type stackGitRedployPayload struct { type stackGitRedployPayload struct {
RepositoryReferenceName string RepositoryReferenceName string
RepositoryAuthentication bool RepositoryAuthentication bool
RepositoryUsername string RepositoryUsername string
RepositoryPassword string RepositoryPassword string
Env []portainer.Pair RepositoryAuthorizationType gittypes.GitCredentialAuthType
Prune bool Env []portainer.Pair
Prune bool
// Force a pulling to current image with the original tag though the image is already the latest // Force a pulling to current image with the original tag though the image is already the latest
PullImage bool `example:"false"` PullImage bool `example:"false"`
@ -135,13 +137,16 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
repositoryUsername := "" repositoryUsername := ""
repositoryPassword := "" repositoryPassword := ""
repositoryAuthType := gittypes.GitCredentialAuthType_Basic
if payload.RepositoryAuthentication { if payload.RepositoryAuthentication {
repositoryPassword = payload.RepositoryPassword repositoryPassword = payload.RepositoryPassword
repositoryAuthType = payload.RepositoryAuthorizationType
// When the existing stack is using the custom username/password and the password is not updated, // When the existing stack is using the custom username/password and the password is not updated,
// the stack should keep using the saved username/password // the stack should keep using the saved username/password
if repositoryPassword == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil { if repositoryPassword == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil {
repositoryPassword = stack.GitConfig.Authentication.Password repositoryPassword = stack.GitConfig.Authentication.Password
repositoryAuthType = stack.GitConfig.Authentication.AuthorizationType
} }
repositoryUsername = payload.RepositoryUsername repositoryUsername = payload.RepositoryUsername
} }
@ -152,6 +157,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
ReferenceName: stack.GitConfig.ReferenceName, ReferenceName: stack.GitConfig.ReferenceName,
Username: repositoryUsername, Username: repositoryUsername,
Password: repositoryPassword, Password: repositoryPassword,
AuthType: repositoryAuthType,
TLSSkipVerify: stack.GitConfig.TLSSkipVerify, TLSSkipVerify: stack.GitConfig.TLSSkipVerify,
} }
@ -166,7 +172,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
return err return err
} }
newHash, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, repositoryUsername, repositoryPassword, stack.GitConfig.TLSSkipVerify) newHash, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryAuthType, stack.GitConfig.TLSSkipVerify)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable get latest commit id", errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID)) return httperror.InternalServerError("Unable get latest commit id", errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID))
} }

View file

@ -27,12 +27,13 @@ type kubernetesFileStackUpdatePayload struct {
} }
type kubernetesGitStackUpdatePayload struct { type kubernetesGitStackUpdatePayload struct {
RepositoryReferenceName string RepositoryReferenceName string
RepositoryAuthentication bool RepositoryAuthentication bool
RepositoryUsername string RepositoryUsername string
RepositoryPassword string RepositoryPassword string
AutoUpdate *portainer.AutoUpdateSettings RepositoryAuthorizationType gittypes.GitCredentialAuthType
TLSSkipVerify bool AutoUpdate *portainer.AutoUpdateSettings
TLSSkipVerify bool
} }
func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error { func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error {
@ -76,11 +77,19 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
} }
stack.GitConfig.Authentication = &gittypes.GitAuthentication{ stack.GitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername, Username: payload.RepositoryUsername,
Password: password, Password: password,
AuthorizationType: payload.RepositoryAuthorizationType,
} }
if _, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, stack.GitConfig.Authentication.Username, stack.GitConfig.Authentication.Password, stack.GitConfig.TLSSkipVerify); err != nil { if _, err := handler.GitService.LatestCommitID(
stack.GitConfig.URL,
stack.GitConfig.ReferenceName,
stack.GitConfig.Authentication.Username,
stack.GitConfig.Authentication.Password,
stack.GitConfig.Authentication.AuthorizationType,
stack.GitConfig.TLSSkipVerify,
); err != nil {
return httperror.InternalServerError("Unable to fetch git repository", err) return httperror.InternalServerError("Unable to fetch git repository", err)
} }
} }

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more