chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:39 +08:00
commit a0df89c693
5252 changed files with 523444 additions and 0 deletions
@@ -0,0 +1,120 @@
package endpointgroups
import (
"errors"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
type endpointGroupCreatePayload struct {
// Environment(Endpoint) group name
Name string `validate:"required" example:"my-environment-group"`
// Environment(Endpoint) group description
Description string `example:"description"`
// List of environment(endpoint) identifiers that will be part of this group
AssociatedEndpoints []portainer.EndpointID `example:"1,3"`
// List of tag identifiers to which this environment(endpoint) group is associated
TagIDs []portainer.TagID `example:"1,2"`
}
func (payload *endpointGroupCreatePayload) Validate(r *http.Request) error {
if len(payload.Name) == 0 {
return errors.New("invalid environment group name")
}
if payload.TagIDs == nil {
payload.TagIDs = []portainer.TagID{}
}
return nil
}
// @summary Create an Environment(Endpoint) Group
// @description Create a new environment(endpoint) group.
// @description **Access policy**: administrator
// @tags endpoint_groups
// @security ApiKeyAuth
// @security jwt
// @accept json
// @produce json
// @param body body endpointGroupCreatePayload true "Environment(Endpoint) Group details"
// @success 200 {object} portainer.EndpointGroup "Success"
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @router /endpoint_groups [post]
func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload endpointGroupCreatePayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
var endpointGroup *portainer.EndpointGroup
var err error
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
endpointGroup, err = handler.createEndpointGroup(tx, payload)
return err
})
return response.TxResponse(w, endpointGroup, err)
}
func (handler *Handler) createEndpointGroup(tx dataservices.DataStoreTx, payload endpointGroupCreatePayload) (*portainer.EndpointGroup, error) {
endpointGroup := &portainer.EndpointGroup{
Name: payload.Name,
Description: payload.Description,
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
TagIDs: payload.TagIDs,
}
err := tx.EndpointGroup().Create(endpointGroup)
if err != nil {
return nil, httperror.InternalServerError("Unable to persist the environment group inside the database", err)
}
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
}
for _, id := range payload.AssociatedEndpoints {
for _, endpoint := range endpoints {
if endpoint.ID == id {
endpoint.GroupID = endpointGroup.ID
err := tx.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return nil, httperror.InternalServerError("Unable to update environment", err)
}
err = handler.updateEndpointRelations(tx, &endpoint, endpointGroup)
if err != nil {
return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
}
break
}
}
}
for _, tagID := range endpointGroup.TagIDs {
tag, err := tx.Tag().Read(tagID)
if err != nil {
return nil, httperror.InternalServerError("Unable to find a tag inside the database", err)
}
tag.EndpointGroups[endpointGroup.ID] = true
err = tx.Tag().Update(tagID, tag)
if err != nil {
return nil, httperror.InternalServerError("Unable to persist tag changes inside the database", err)
}
}
return endpointGroup, nil
}
@@ -0,0 +1,90 @@
package endpointgroups
import (
"errors"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id EndpointGroupDelete
// @summary Remove an environment(endpoint) group
// @description Remove an environment(endpoint) group.
// @description **Access policy**: administrator
// @tags endpoint_groups
// @security ApiKeyAuth
// @security jwt
// @param id path int true "EndpointGroup identifier"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 404 "EndpointGroup not found"
// @failure 500 "Server error"
// @router /endpoint_groups/{id} [delete]
func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid environment group identifier route variable", err)
}
if endpointGroupID == 1 {
return httperror.Forbidden("Unable to remove the default 'Unassigned' group", errors.New("Cannot remove the default environment group"))
}
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return handler.deleteEndpointGroup(tx, portainer.EndpointGroupID(endpointGroupID))
})
return response.TxEmptyResponse(w, err)
}
func (handler *Handler) deleteEndpointGroup(tx dataservices.DataStoreTx, endpointGroupID portainer.EndpointGroupID) error {
endpointGroup, err := tx.EndpointGroup().Read(endpointGroupID)
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find an environment group with the specified identifier inside the database", err)
}
if err := tx.EndpointGroup().Delete(endpointGroupID); err != nil {
return httperror.InternalServerError("Unable to remove the environment group from the database", err)
}
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return httperror.InternalServerError("Unable to retrieve environment from the database", err)
}
for _, endpoint := range endpoints {
if endpoint.GroupID != endpointGroupID {
continue
}
endpoint.GroupID = portainer.EndpointGroupID(1)
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint); err != nil {
return httperror.InternalServerError("Unable to update environment", err)
}
if err := handler.updateEndpointRelations(tx, &endpoint, nil); err != nil {
return httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
}
}
for _, tagID := range endpointGroup.TagIDs {
tag, err := tx.Tag().Read(tagID)
if err != nil {
return httperror.InternalServerError("Unable to find a tag inside the database", err)
}
delete(tag.EndpointGroups, endpointGroup.ID)
if err := tx.Tag().Update(tagID, tag); err != nil {
return httperror.InternalServerError("Unable to persist tag changes inside the database", err)
}
}
return nil
}
@@ -0,0 +1,73 @@
package endpointgroups
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id EndpointGroupAddEndpoint
// @summary Add an environment(endpoint) to an environment(endpoint) group
// @description Add an environment(endpoint) to an environment(endpoint) group
// @description **Access policy**: administrator
// @tags endpoint_groups
// @security ApiKeyAuth
// @security jwt
// @param id path int true "EndpointGroup identifier"
// @param endpointId path int true "Environment(Endpoint) identifier"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 404 "EndpointGroup not found"
// @failure 500 "Server error"
// @router /endpoint_groups/{id}/endpoints/{endpointId} [put]
func (handler *Handler) endpointGroupAddEndpoint(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid environment group identifier route variable", err)
}
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "endpointId")
if err != nil {
return httperror.BadRequest("Invalid environment identifier route variable", err)
}
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return handler.addEndpoint(tx, portainer.EndpointGroupID(endpointGroupID), portainer.EndpointID(endpointID))
})
return response.TxEmptyResponse(w, err)
}
func (handler *Handler) addEndpoint(tx dataservices.DataStoreTx, endpointGroupID portainer.EndpointGroupID, endpointID portainer.EndpointID) error {
endpointGroup, err := tx.EndpointGroup().Read(endpointGroupID)
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find an environment group with the specified identifier inside the database", err)
}
endpoint, err := tx.Endpoint().Endpoint(endpointID)
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
}
endpoint.GroupID = endpointGroup.ID
err = tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
return httperror.InternalServerError("Unable to persist environment changes inside the database", err)
}
err = handler.updateEndpointRelations(tx, endpoint, endpointGroup)
if err != nil {
return httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
}
return nil
}
@@ -0,0 +1,73 @@
package endpointgroups
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dserrors "github.com/portainer/portainer/api/dataservices/errors"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id EndpointGroupDeleteEndpoint
// @summary Removes environment(endpoint) from an environment(endpoint) group
// @description **Access policy**: administrator
// @tags endpoint_groups
// @security ApiKeyAuth
// @security jwt
// @param id path int true "EndpointGroup identifier"
// @param endpointId path int true "Environment(Endpoint) identifier"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 404 "EndpointGroup not found"
// @failure 500 "Server error"
// @router /endpoint_groups/{id}/endpoints/{endpointId} [delete]
func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid environment group identifier route variable", err)
}
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "endpointId")
if err != nil {
return httperror.BadRequest("Invalid environment identifier route variable", err)
}
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return handler.removeEndpoint(tx, portainer.EndpointGroupID(endpointGroupID), portainer.EndpointID(endpointID))
})
return response.TxEmptyResponse(w, err)
}
func (handler *Handler) removeEndpoint(tx dataservices.DataStoreTx, endpointGroupID portainer.EndpointGroupID, endpointID portainer.EndpointID) error {
ok, err := tx.EndpointGroup().Exists(endpointGroupID)
if !ok {
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", dserrors.ErrObjectNotFound)
} else if err != nil {
return httperror.InternalServerError("Unable to find an environment group with the specified identifier inside the database", err)
}
endpoint, err := tx.Endpoint().Endpoint(endpointID)
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
}
endpoint.GroupID = portainer.EndpointGroupID(1)
err = tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
return httperror.InternalServerError("Unable to persist environment changes inside the database", err)
}
err = handler.updateEndpointRelations(tx, endpoint, nil)
if err != nil {
return httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
}
return nil
}
@@ -0,0 +1,72 @@
package endpointgroups
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @summary Inspect an Environment(Endpoint) group
// @description Retrieve details abont an environment(endpoint) group.
// @description **Access policy**: administrator
// @tags endpoint_groups
// @security ApiKeyAuth
// @security jwt
// @accept json
// @produce json
// @param id path int true "Environment(Endpoint) group identifier"
// @param size query boolean false "If true, include the number of environments and breakdown by type"
// @success 200 {object} EndpointGroupResponse "Success"
// @failure 400 "Invalid request"
// @failure 404 "EndpointGroup not found"
// @failure 500 "Server error"
// @router /endpoint_groups/{id} [get]
func (handler *Handler) endpointGroupInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid environment group identifier route variable", err)
}
includeSize, err := request.RetrieveBooleanQueryParameter(r, "size", true)
if err != nil {
return httperror.BadRequest("Invalid query parameter: size", err)
}
groupID := portainer.EndpointGroupID(endpointGroupID)
var endpointGroup *portainer.EndpointGroup
var endpoints []portainer.Endpoint
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
endpointGroup, err = tx.EndpointGroup().Read(groupID)
if err != nil {
return err
}
if includeSize {
endpoints, err = tx.Endpoint().Endpoints()
}
return err
}); err != nil {
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", err)
}
return httperror.InternalServerError("Unable to retrieve environment group details", err)
}
resp := EndpointGroupResponse{
EndpointGroup: *endpointGroup,
}
if includeSize {
countMap, typeInfoMap := computeGroupSizeInfo([]portainer.EndpointGroup{*endpointGroup}, endpoints)
resp.Total = countMap[groupID]
resp.TypeInfo = typeInfoMap[groupID]
}
return response.JSON(w, resp)
}
@@ -0,0 +1,152 @@
package endpointgroups
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/set"
endpointutils "github.com/portainer/portainer/pkg/endpoints"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
func computeGroupSizeInfo(endpointGroups []portainer.EndpointGroup, endpoints []portainer.Endpoint) (map[portainer.EndpointGroupID]int, map[portainer.EndpointGroupID]EndpointGroupTypeInfo) {
groupSet := set.Set[portainer.EndpointGroupID]{}
for i := range endpointGroups {
groupSet[endpointGroups[i].ID] = true
}
countMap := make(map[portainer.EndpointGroupID]int)
typeInfoMap := make(map[portainer.EndpointGroupID]EndpointGroupTypeInfo)
for _, endpoint := range endpoints {
if _, ok := groupSet[endpoint.GroupID]; !ok {
continue
}
countMap[endpoint.GroupID]++
typeInfo := typeInfoMap[endpoint.GroupID]
if endpointutils.IsKubernetesEndpoint(&endpoint) {
typeInfo.Kubernetes++
} else if endpoint.ContainerEngine == portainer.ContainerEnginePodman {
typeInfo.Podman++
} else {
typeInfo.Docker++
}
typeInfoMap[endpoint.GroupID] = typeInfo
}
for groupID, typeInfo := range typeInfoMap {
var bits int
if typeInfo.Docker > 0 {
bits |= 1
}
if typeInfo.Kubernetes > 0 {
bits |= 2
}
if typeInfo.Podman > 0 {
bits |= 4
}
typeInfo.Mixed = bits&(bits-1) != 0
typeInfoMap[groupID] = typeInfo
}
return countMap, typeInfoMap
}
type EndpointGroupTypeInfo struct {
Docker int `json:"Docker" validate:"required"`
Kubernetes int `json:"Kubernetes" validate:"required"`
Podman int `json:"Podman" validate:"required"`
Mixed bool `json:"Mixed" validate:"required"`
}
type EndpointGroupResponse struct {
portainer.EndpointGroup
Total int `json:"Total,omitzero"`
TypeInfo EndpointGroupTypeInfo `json:"TypeInfo,omitzero"`
}
// @id EndpointGroupList
// @summary List Environment(Endpoint) groups
// @description List all environment(endpoint) groups based on the current user authorizations. Will
// @description return all environment(endpoint) groups if using an administrator account otherwise it will
// @description only return authorized environment(endpoint) groups.
// @description **Access policy**: restricted
// @tags endpoint_groups
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param size query boolean false "If true, each environment(endpoint) group will include the number of environments(endpoints) associated to it and breakdown by type"
// @success 200 {array} EndpointGroupResponse "Environment(Endpoint) group"
// @failure 500 "Server error"
// @router /endpoint_groups [get]
func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
includeSize, err := request.RetrieveBooleanQueryParameter(r, "size", true)
if err != nil {
return httperror.BadRequest("Invalid query parameter: size", err)
}
var endpoints []portainer.Endpoint
var endpointGroups []portainer.EndpointGroup
var handlerErr *httperror.HandlerError
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
endpointGroups, txErr = handler.DataStore.EndpointGroup().ReadAll()
if txErr != nil {
handlerErr = httperror.InternalServerError("Unable to retrieve environment groups from the database", txErr)
return handlerErr
}
if includeSize {
endpoints, txErr = tx.Endpoint().Endpoints()
if txErr != nil {
handlerErr = httperror.InternalServerError("Unable to retrieve endpoints from the database", txErr)
return handlerErr
}
}
return nil
}); err != nil {
if handlerErr != nil {
return handlerErr
}
return httperror.InternalServerError("Unable to retrieve data from the database", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
endpointGroups = security.FilterEndpointGroups(endpointGroups, securityContext)
if len(endpointGroups) == 0 {
return response.JSON(w, []portainer.EndpointGroup{})
}
var endpointGroupCountMap map[portainer.EndpointGroupID]int
var endpointGroupTypeInfoMap map[portainer.EndpointGroupID]EndpointGroupTypeInfo
if includeSize {
endpointGroupCountMap, endpointGroupTypeInfoMap = computeGroupSizeInfo(endpointGroups, endpoints)
}
endpointGroupsResponse := make([]EndpointGroupResponse, len(endpointGroups))
for i := range endpointGroups {
groupID := endpointGroups[i].ID
endpointGroupsResponse[i] = EndpointGroupResponse{
EndpointGroup: endpointGroups[i],
}
if includeSize {
endpointGroupsResponse[i].Total = endpointGroupCountMap[groupID]
endpointGroupsResponse[i].TypeInfo = endpointGroupTypeInfoMap[groupID]
}
}
return response.JSON(w, endpointGroupsResponse)
}
@@ -0,0 +1,205 @@
package endpointgroups
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/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHandler_endpointGroupList(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, false)
handler := setUpHandler(t, store)
groups := setUpGroups(t, store)
t.Run("with groups, no size flag", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/endpoint_groups", nil)
rrc := &security.RestrictedRequestContext{
IsAdmin: true,
}
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
res := make([]EndpointGroupResponse, 0)
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
require.Len(t, res, len(groups)+1, "should contain an additional default group")
for _, group := range res {
assert.Zero(t, group.Total)
assert.Zero(t, group.TypeInfo.Docker)
assert.Zero(t, group.TypeInfo.Kubernetes)
assert.Zero(t, group.TypeInfo.Podman)
assert.False(t, group.TypeInfo.Mixed)
}
})
t.Run("with size flag, no endpoints", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/endpoint_groups?size=true", nil)
rrc := &security.RestrictedRequestContext{
IsAdmin: true,
}
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
res := make([]EndpointGroupResponse, 0)
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
for _, group := range res {
assert.Zero(t, group.Total)
}
})
t.Run("with size flag and single docker endpoint", func(t *testing.T) {
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: groups[0].ID,
Type: portainer.DockerEnvironment,
}
require.NoError(t, store.Endpoint().Create(endpoint))
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(endpoint.ID) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/endpoint_groups?size=true", nil)
rrc := &security.RestrictedRequestContext{
IsAdmin: true,
}
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
res := make([]EndpointGroupResponse, 0)
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
var group1 *EndpointGroupResponse
for i := range res {
if res[i].ID == groups[0].ID {
group1 = &res[i]
break
}
}
require.NotNil(t, group1)
assert.Equal(t, 1, group1.Total)
assert.Equal(t, 1, group1.TypeInfo.Docker)
assert.Equal(t, 0, group1.TypeInfo.Kubernetes)
assert.Equal(t, 0, group1.TypeInfo.Podman)
assert.False(t, group1.TypeInfo.Mixed)
})
t.Run("with mixed endpoint types", func(t *testing.T) {
dockerEndpoint := &portainer.Endpoint{
ID: 2,
GroupID: groups[1].ID,
Type: portainer.DockerEnvironment,
}
require.NoError(t, store.Endpoint().Create(dockerEndpoint))
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(dockerEndpoint.ID) })
k8sEndpoint := &portainer.Endpoint{
ID: 3,
GroupID: groups[1].ID,
Type: portainer.KubernetesLocalEnvironment,
}
require.NoError(t, store.Endpoint().Create(k8sEndpoint))
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(k8sEndpoint.ID) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/endpoint_groups?size=true", nil)
rrc := &security.RestrictedRequestContext{
IsAdmin: true,
}
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
res := make([]EndpointGroupResponse, 0)
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
var group2 *EndpointGroupResponse
for i := range res {
if res[i].ID == groups[1].ID {
group2 = &res[i]
break
}
}
require.NotNil(t, group2)
assert.Equal(t, 2, group2.Total)
assert.Equal(t, 1, group2.TypeInfo.Docker)
assert.Equal(t, 1, group2.TypeInfo.Kubernetes)
assert.Equal(t, 0, group2.TypeInfo.Podman)
assert.True(t, group2.TypeInfo.Mixed, "should be marked as mixed when multiple types exist")
})
t.Run("with podman endpoint", func(t *testing.T) {
dockerEndpoint := &portainer.Endpoint{
ID: 4,
GroupID: groups[0].ID,
Type: portainer.DockerEnvironment,
}
require.NoError(t, store.Endpoint().Create(dockerEndpoint))
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(dockerEndpoint.ID) })
podmanEndpoint := &portainer.Endpoint{
ID: 5,
GroupID: groups[0].ID,
Type: portainer.DockerEnvironment,
ContainerEngine: portainer.ContainerEnginePodman,
}
require.NoError(t, store.Endpoint().Create(podmanEndpoint))
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(podmanEndpoint.ID) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/endpoint_groups?size=true", nil)
rrc := &security.RestrictedRequestContext{
IsAdmin: true,
}
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
res := make([]EndpointGroupResponse, 0)
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
var group1 *EndpointGroupResponse
for i := range res {
if res[i].ID == groups[0].ID {
group1 = &res[i]
break
}
}
require.NotNil(t, group1)
assert.Equal(t, 2, group1.Total)
assert.Equal(t, 1, group1.TypeInfo.Docker)
assert.Equal(t, 0, group1.TypeInfo.Kubernetes)
assert.Equal(t, 1, group1.TypeInfo.Podman)
assert.True(t, group1.TypeInfo.Mixed)
})
}
func setUpGroups(t *testing.T, store *datastore.Store) []portainer.EndpointGroup {
group1 := &portainer.EndpointGroup{
ID: 1,
Name: "Group 1",
}
group2 := &portainer.EndpointGroup{
ID: 2,
Name: "Group 2",
}
require.NoError(t, store.EndpointGroup().Create(group1))
require.NoError(t, store.EndpointGroup().Create(group2))
return []portainer.EndpointGroup{*group1, *group2}
}
@@ -0,0 +1,224 @@
package endpointgroups
import (
"net/http"
"reflect"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/pendingactions/handlers"
"github.com/portainer/portainer/api/tag"
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"
)
type endpointGroupUpdatePayload struct {
// Environment(Endpoint) group name
Name string `example:"my-environment-group"`
// Environment(Endpoint) group description
Description *string `example:"description"`
// List of environment(endpoint) identifiers that will be part of this group
AssociatedEndpoints []portainer.EndpointID `example:"1,3"`
// List of tag identifiers associated to the environment(endpoint) group
TagIDs []portainer.TagID `example:"3,4"`
UserAccessPolicies portainer.UserAccessPolicies
TeamAccessPolicies portainer.TeamAccessPolicies
}
func (payload *endpointGroupUpdatePayload) Validate(r *http.Request) error {
return nil
}
// @id EndpointGroupUpdate
// @summary Update an environment(endpoint) group
// @description Update an environment(endpoint) group.
// @description **Access policy**: administrator
// @tags endpoint_groups
// @security ApiKeyAuth
// @security jwt
// @accept json
// @produce json
// @param id path int true "EndpointGroup identifier"
// @param body body endpointGroupUpdatePayload true "EndpointGroup details"
// @success 200 {object} portainer.EndpointGroup "Success"
// @failure 400 "Invalid request"
// @failure 404 "EndpointGroup not found"
// @failure 500 "Server error"
// @router /endpoint_groups/{id} [put]
func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid environment group identifier route variable", err)
}
var payload endpointGroupUpdatePayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
var endpointGroup *portainer.EndpointGroup
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
endpointGroup, err = handler.updateEndpointGroup(tx, portainer.EndpointGroupID(endpointGroupID), payload)
return err
})
return response.TxResponse(w, endpointGroup, err)
}
func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpointGroupID portainer.EndpointGroupID, payload endpointGroupUpdatePayload) (*portainer.EndpointGroup, error) {
endpointGroup, err := tx.EndpointGroup().Read(endpointGroupID)
if tx.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", err)
} else if err != nil {
return nil, httperror.InternalServerError("Unable to find an environment group with the specified identifier inside the database", err)
}
if payload.Name != "" {
endpointGroup.Name = payload.Name
}
if payload.Description != nil {
endpointGroup.Description = *payload.Description
}
tagsChanged := false
if payload.TagIDs != nil {
payloadTagSet := tag.Set(payload.TagIDs)
endpointGroupTagSet := tag.Set((endpointGroup.TagIDs))
union := tag.Union(payloadTagSet, endpointGroupTagSet)
intersection := tag.IntersectionCount(payloadTagSet, endpointGroupTagSet)
tagsChanged = len(union) > intersection
if tagsChanged {
removeTags := tag.Difference(endpointGroupTagSet, payloadTagSet)
for tagID := range removeTags {
tag, err := tx.Tag().Read(tagID)
if err != nil {
return nil, httperror.InternalServerError("Unable to find a tag inside the database", err)
}
delete(tag.EndpointGroups, endpointGroup.ID)
err = tx.Tag().Update(tagID, tag)
if err != nil {
return nil, httperror.InternalServerError("Unable to persist tag changes inside the database", err)
}
}
endpointGroup.TagIDs = payload.TagIDs
for _, tagID := range payload.TagIDs {
tag, err := tx.Tag().Read(tagID)
if err != nil {
return nil, httperror.InternalServerError("Unable to find a tag inside the database", err)
}
tag.EndpointGroups[endpointGroup.ID] = true
err = tx.Tag().Update(tagID, tag)
if err != nil {
return nil, httperror.InternalServerError("Unable to persist tag changes inside the database", err)
}
}
}
}
updateAuthorizations := false
if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpointGroup.UserAccessPolicies) {
endpointGroup.UserAccessPolicies = payload.UserAccessPolicies
updateAuthorizations = true
}
if payload.TeamAccessPolicies != nil && !reflect.DeepEqual(payload.TeamAccessPolicies, endpointGroup.TeamAccessPolicies) {
endpointGroup.TeamAccessPolicies = payload.TeamAccessPolicies
updateAuthorizations = true
}
if updateAuthorizations {
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
}
for _, endpoint := range endpoints {
if endpoint.GroupID == endpointGroup.ID && endpointutils.IsKubernetesEndpoint(&endpoint) {
if err := handler.AuthorizationService.CleanNAPWithOverridePolicies(tx, &endpoint, endpointGroup); err != nil {
// Update flag with endpoint and continue
if err := handler.PendingActionsService.Create(tx, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, &endpointGroup.ID)); err != nil {
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpoint.ID, endpointGroup.ID)
}
}
}
}
}
if err := tx.EndpointGroup().Update(endpointGroup.ID, endpointGroup); err != nil {
return nil, httperror.InternalServerError("Unable to persist environment group changes inside the database", err)
}
// Handle associated endpoints updates
endpointsChanged := false
if payload.AssociatedEndpoints != nil {
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
}
// Build a set of the new endpoint IDs for quick lookup
newEndpointSet := make(map[portainer.EndpointID]bool)
for _, id := range payload.AssociatedEndpoints {
newEndpointSet[id] = true
}
for i := range endpoints {
endpoint := &endpoints[i]
wasInGroup := endpoint.GroupID == endpointGroup.ID
shouldBeInGroup := newEndpointSet[endpoint.ID]
if wasInGroup && !shouldBeInGroup {
// Remove from group (move to Unassigned)
endpoint.GroupID = portainer.EndpointGroupID(1)
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
return nil, httperror.InternalServerError("Unable to update environment", err)
}
if err := handler.updateEndpointRelations(tx, endpoint, nil); err != nil {
return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
}
endpointsChanged = true
} else if !wasInGroup && shouldBeInGroup {
// Add to group
endpoint.GroupID = endpointGroup.ID
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
return nil, httperror.InternalServerError("Unable to update environment", err)
}
if err := handler.updateEndpointRelations(tx, endpoint, endpointGroup); err != nil {
return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
}
endpointsChanged = true
}
}
}
// Reconcile endpoints in the group if tags changed (but endpoints weren't already reconciled)
if tagsChanged && !endpointsChanged {
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
}
for _, endpoint := range endpoints {
if endpoint.GroupID == endpointGroup.ID {
if err := handler.updateEndpointRelations(tx, &endpoint, endpointGroup); err != nil {
return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
}
}
}
}
return endpointGroup, nil
}
@@ -0,0 +1,49 @@
package endpointgroups
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/edge"
)
func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup) error {
if endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment && endpoint.Type != portainer.EdgeAgentOnDockerEnvironment {
return nil
}
if endpointGroup == nil {
unassignedGroup, err := tx.EndpointGroup().Read(portainer.EndpointGroupID(1))
if err != nil {
return err
}
endpointGroup = unassignedGroup
}
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil {
return err
}
edgeGroups, err := tx.EdgeGroup().ReadAll()
if err != nil {
return err
}
edgeStacks, err := tx.EdgeStack().EdgeStacks()
if err != nil {
if tx.IsErrObjectNotFound(err) {
return nil
}
return err
}
endpointStacks := edge.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks)
stacksSet := map[portainer.EdgeStackID]bool{}
for _, edgeStackID := range endpointStacks {
stacksSet[edgeStackID] = true
}
endpointRelation.EdgeStacks = stacksSet
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
}
@@ -0,0 +1,43 @@
package endpointgroups
import (
"net/http"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/pendingactions"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/gorilla/mux"
)
// Handler is the HTTP handler used to handle environment(endpoint) group operations.
type Handler struct {
*mux.Router
AuthorizationService *authorization.Service
DataStore dataservices.DataStore
PendingActionsService *pendingactions.PendingActionsService
}
// NewHandler creates a handler to manage environment(endpoint) group operations.
func NewHandler(bouncer security.BouncerService) *Handler {
h := &Handler{
Router: mux.NewRouter(),
}
h.Handle("/endpoint_groups",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupCreate))).Methods(http.MethodPost)
h.Handle("/endpoint_groups",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointGroupList))).Methods(http.MethodGet)
h.Handle("/endpoint_groups/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupInspect))).Methods(http.MethodGet)
h.Handle("/endpoint_groups/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupUpdate))).Methods(http.MethodPut)
h.Handle("/endpoint_groups/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupDelete))).Methods(http.MethodDelete)
h.Handle("/endpoint_groups/{id}/endpoints/{endpointId}",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupAddEndpoint))).Methods(http.MethodPut)
h.Handle("/endpoint_groups/{id}/endpoints/{endpointId}",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupDeleteEndpoint))).Methods(http.MethodDelete)
return h
}
@@ -0,0 +1,15 @@
package endpointgroups
import (
"testing"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/testhelpers"
)
func setUpHandler(t *testing.T, store *datastore.Store) *Handler {
t.Helper()
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
return handler
}