Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2833aa26e | |||
| 420488116b | |||
| bb3de163a6 | |||
| 3cef57760b | |||
| 7d49f61a05 | |||
| 0b51ad7f01 | |||
| 92527f1212 | |||
| 1c903b35a6 | |||
| 8a354ceceb | |||
| 7519e7cb89 | |||
| a33a72923d | |||
| 8ddd2ade8b |
@@ -613,7 +613,7 @@
|
|||||||
"RequiredPasswordLength": 12
|
"RequiredPasswordLength": 12
|
||||||
},
|
},
|
||||||
"KubeconfigExpiry": "0",
|
"KubeconfigExpiry": "0",
|
||||||
"KubectlShellImage": "portainer/kubectl-shell:2.38.0",
|
"KubectlShellImage": "portainer/kubectl-shell:2.38.1",
|
||||||
"LDAPSettings": {
|
"LDAPSettings": {
|
||||||
"AnonymousMode": true,
|
"AnonymousMode": true,
|
||||||
"AutoCreateUsers": true,
|
"AutoCreateUsers": true,
|
||||||
@@ -942,7 +942,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": {
|
"version": {
|
||||||
"VERSION": "{\"SchemaVersion\":\"2.38.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
"VERSION": "{\"SchemaVersion\":\"2.38.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||||
},
|
},
|
||||||
"webhooks": null
|
"webhooks": null
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,9 @@ type endpointGroupUpdatePayload struct {
|
|||||||
// Environment(Endpoint) group name
|
// Environment(Endpoint) group name
|
||||||
Name string `example:"my-environment-group"`
|
Name string `example:"my-environment-group"`
|
||||||
// Environment(Endpoint) group description
|
// Environment(Endpoint) group description
|
||||||
Description string `example:"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
|
// List of tag identifiers associated to the environment(endpoint) group
|
||||||
TagIDs []portainer.TagID `example:"3,4"`
|
TagIDs []portainer.TagID `example:"3,4"`
|
||||||
UserAccessPolicies portainer.UserAccessPolicies
|
UserAccessPolicies portainer.UserAccessPolicies
|
||||||
@@ -80,8 +82,8 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
|
|||||||
endpointGroup.Name = payload.Name
|
endpointGroup.Name = payload.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Description != "" {
|
if payload.Description != nil {
|
||||||
endpointGroup.Description = payload.Description
|
endpointGroup.Description = *payload.Description
|
||||||
}
|
}
|
||||||
|
|
||||||
tagsChanged := false
|
tagsChanged := false
|
||||||
@@ -161,7 +163,51 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
|
|||||||
return nil, httperror.InternalServerError("Unable to persist environment group changes inside the database", err)
|
return nil, httperror.InternalServerError("Unable to persist environment group changes inside the database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tagsChanged {
|
// 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()
|
endpoints, err := tx.Endpoint().Endpoints()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
|
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
|
||||||
|
|||||||
@@ -161,12 +161,6 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
|||||||
|
|
||||||
handler.ProxyManager.DeleteEndpointProxy(endpoint.ID)
|
handler.ProxyManager.DeleteEndpointProxy(endpoint.ID)
|
||||||
|
|
||||||
if len(endpoint.UserAccessPolicies) > 0 || len(endpoint.TeamAccessPolicies) > 0 {
|
|
||||||
if err := handler.AuthorizationService.UpdateUsersAuthorizationsTx(tx); err != nil {
|
|
||||||
log.Warn().Err(err).Msg("Unable to update user authorizations")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.EndpointRelation().DeleteEndpointRelation(endpoint.ID); err != nil {
|
if err := tx.EndpointRelation().DeleteEndpointRelation(endpoint.ID); err != nil {
|
||||||
log.Warn().Err(err).Msg("Unable to remove environment relation from the database")
|
log.Warn().Err(err).Msg("Unable to remove environment relation from the database")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ type Handler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @title PortainerCE API
|
// @title PortainerCE API
|
||||||
// @version 2.38.0
|
// @version 2.38.1
|
||||||
// @description.markdown api-description.md
|
// @description.markdown api-description.md
|
||||||
// @termsOfService
|
// @termsOfService
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package kubernetes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/http/middlewares"
|
"github.com/portainer/portainer/api/http/middlewares"
|
||||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
@@ -31,33 +33,23 @@ import (
|
|||||||
// @failure 500 "Server error occurred while attempting to retrieve ingress controllers"
|
// @failure 500 "Server error occurred while attempting to retrieve ingress controllers"
|
||||||
// @router /kubernetes/{id}/ingresscontrollers [get]
|
// @router /kubernetes/{id}/ingresscontrollers [get]
|
||||||
func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Invalid environment identifier route variable")
|
|
||||||
return httperror.BadRequest("Invalid environment identifier route variable", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
|
||||||
if err != nil {
|
|
||||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
|
||||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
|
|
||||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
|
|
||||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
allowedOnly, err := request.RetrieveBooleanQueryParameter(r, "allowedOnly", true)
|
allowedOnly, err := request.RetrieveBooleanQueryParameter(r, "allowedOnly", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to retrieve allowedOnly query parameter")
|
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Invalid allowedOnly boolean query parameter")
|
||||||
return httperror.BadRequest("Unable to retrieve allowedOnly query parameter", err)
|
return httperror.BadRequest("Invalid allowedOnly boolean query parameter", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get endpoint from context (may have policies applied in-memory)
|
||||||
|
endpoint, err := middlewares.FetchEndpoint(r)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to fetch endpoint")
|
||||||
|
return httperror.InternalServerError(err.Error(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
|
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to get privileged kube client")
|
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to create Kubernetes client")
|
||||||
return httperror.InternalServerError("Unable to get privileged kube client", err)
|
return httperror.InternalServerError("Unable to create Kubernetes client", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
controllers, err := cli.GetIngressControllers()
|
controllers, err := cli.GetIngressControllers()
|
||||||
@@ -72,6 +64,7 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add none controller if "AllowNone" is set for endpoint.
|
// Add none controller if "AllowNone" is set for endpoint.
|
||||||
|
// Use the policy-applied endpoint for this check since it affects what's shown to the user.
|
||||||
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
|
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
|
||||||
controllers = append(controllers, models.K8sIngressController{
|
controllers = append(controllers, models.K8sIngressController{
|
||||||
Name: "none",
|
Name: "none",
|
||||||
@@ -79,37 +72,46 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
|
|||||||
Type: "custom",
|
Type: "custom",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
|
|
||||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
// Fetch raw endpoint and update IngressClasses within a transaction.
|
||||||
for i := range controllers {
|
// This prevents policy-applied values from being persisted to the database.
|
||||||
controllers[i].Availability = true
|
var updatedClasses []portainer.KubernetesIngressClassConfig
|
||||||
if controllers[i].ClassName != "none" {
|
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
controllers[i].New = true
|
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
|
||||||
Name: controllers[i].ClassName,
|
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
|
||||||
Type: controllers[i].Type,
|
updatedClasses = []portainer.KubernetesIngressClassConfig{}
|
||||||
}
|
for i := range controllers {
|
||||||
|
controllers[i].Availability = true
|
||||||
// Check if the controller is already known.
|
if controllers[i].ClassName != "none" {
|
||||||
for _, existingClass := range existingClasses {
|
controllers[i].New = true
|
||||||
if controllers[i].ClassName != existingClass.Name {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
controllers[i].New = false
|
|
||||||
controllers[i].Availability = !existingClass.GloballyBlocked
|
|
||||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
|
||||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
|
||||||
}
|
|
||||||
updatedClasses = append(updatedClasses, updatedClass)
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||||
err = handler.DataStore.Endpoint().UpdateEndpoint(
|
Name: controllers[i].ClassName,
|
||||||
portainer.EndpointID(endpointID),
|
Type: controllers[i].Type,
|
||||||
endpoint,
|
}
|
||||||
)
|
|
||||||
|
// Check if the controller is already known.
|
||||||
|
for _, existingClass := range existingClasses {
|
||||||
|
if controllers[i].ClassName != existingClass.Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
controllers[i].New = false
|
||||||
|
controllers[i].Availability = !existingClass.GloballyBlocked
|
||||||
|
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||||
|
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||||
|
}
|
||||||
|
updatedClasses = append(updatedClasses, updatedClass)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||||
|
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database")
|
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database")
|
||||||
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
|
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
|
||||||
@@ -126,6 +128,7 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
|
|||||||
}
|
}
|
||||||
controllers = allowedControllers
|
controllers = allowedControllers
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.JSON(w, controllers)
|
return response.JSON(w, controllers)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,21 +149,16 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
|
|||||||
// @failure 500 "Server error occurred while attempting to retrieve ingress controllers by a namespace"
|
// @failure 500 "Server error occurred while attempting to retrieve ingress controllers by a namespace"
|
||||||
// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [get]
|
// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [get]
|
||||||
func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve environment identifier from request")
|
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace identifier from request")
|
||||||
return httperror.BadRequest("Unable to retrieve environment identifier from request", err)
|
return httperror.BadRequest("Unable to retrieve namespace identifier from request", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
endpoint, err := middlewares.FetchEndpoint(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
|
||||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to find an environment with the specified identifier inside the database")
|
return httperror.InternalServerError(err.Error(), err)
|
||||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to find an environment with the specified identifier inside the database")
|
|
||||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
|
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||||
@@ -169,12 +167,6 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
|||||||
return httperror.InternalServerError("Unable to create Kubernetes client", err)
|
return httperror.InternalServerError("Unable to create Kubernetes client", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request")
|
|
||||||
return httperror.BadRequest("Unable to retrieve namespace from request", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentControllers, err := cli.GetIngressControllers()
|
currentControllers, err := cli.GetIngressControllers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
|
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
|
||||||
@@ -185,7 +177,9 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
|||||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to retrieve ingress controllers from the Kubernetes")
|
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to retrieve ingress controllers from the Kubernetes")
|
||||||
return httperror.InternalServerError("Unable to retrieve ingress controllers from the Kubernetes", err)
|
return httperror.InternalServerError("Unable to retrieve ingress controllers from the Kubernetes", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add none controller if "AllowNone" is set for endpoint.
|
// Add none controller if "AllowNone" is set for endpoint.
|
||||||
|
// Use the policy-applied endpoint for this check since it affects what's shown to the user.
|
||||||
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
|
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
|
||||||
currentControllers = append(currentControllers, models.K8sIngressController{
|
currentControllers = append(currentControllers, models.K8sIngressController{
|
||||||
Name: "none",
|
Name: "none",
|
||||||
@@ -194,55 +188,66 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
kubernetesConfig := endpoint.Kubernetes.Configuration
|
// Use policy-applied endpoint for ingressAvailabilityPerNamespace since it affects the response.
|
||||||
existingClasses := kubernetesConfig.IngressClasses
|
ingressAvailabilityPerNamespace := endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace
|
||||||
ingressAvailabilityPerNamespace := kubernetesConfig.IngressAvailabilityPerNamespace
|
|
||||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
|
||||||
controllers := models.K8sIngressControllers{}
|
controllers := models.K8sIngressControllers{}
|
||||||
|
|
||||||
for i := range currentControllers {
|
// Fetch raw endpoint and update IngressClasses within a transaction.
|
||||||
globallyblocked := false
|
// This prevents policy-applied values from being persisted to the database.
|
||||||
currentControllers[i].Availability = true
|
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
if currentControllers[i].ClassName != "none" {
|
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
|
||||||
currentControllers[i].New = true
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
|
||||||
Name: currentControllers[i].ClassName,
|
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
|
||||||
Type: currentControllers[i].Type,
|
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the controller is blocked globally or in the current
|
for i := range currentControllers {
|
||||||
// namespace.
|
globallyblocked := false
|
||||||
for _, existingClass := range existingClasses {
|
currentControllers[i].Availability = true
|
||||||
if currentControllers[i].ClassName != existingClass.Name {
|
if currentControllers[i].ClassName != "none" {
|
||||||
continue
|
currentControllers[i].New = true
|
||||||
}
|
}
|
||||||
currentControllers[i].New = false
|
|
||||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
|
||||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
|
||||||
|
|
||||||
globallyblocked = existingClass.GloballyBlocked
|
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||||
|
Name: currentControllers[i].ClassName,
|
||||||
|
Type: currentControllers[i].Type,
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the current namespace is blocked if ingressAvailabilityPerNamespace is set to true
|
// Check if the controller is blocked globally or in the current
|
||||||
if ingressAvailabilityPerNamespace {
|
// namespace.
|
||||||
for _, ns := range existingClass.BlockedNamespaces {
|
for _, existingClass := range existingClasses {
|
||||||
if namespace == ns {
|
if currentControllers[i].ClassName != existingClass.Name {
|
||||||
currentControllers[i].Availability = false
|
continue
|
||||||
|
}
|
||||||
|
currentControllers[i].New = false
|
||||||
|
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||||
|
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||||
|
|
||||||
|
globallyblocked = existingClass.GloballyBlocked
|
||||||
|
|
||||||
|
// Check if the current namespace is blocked if ingressAvailabilityPerNamespace is set to true
|
||||||
|
if ingressAvailabilityPerNamespace {
|
||||||
|
for _, ns := range existingClass.BlockedNamespaces {
|
||||||
|
if namespace == ns {
|
||||||
|
currentControllers[i].Availability = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !globallyblocked {
|
||||||
|
controllers = append(controllers, currentControllers[i])
|
||||||
|
}
|
||||||
|
updatedClasses = append(updatedClasses, updatedClass)
|
||||||
}
|
}
|
||||||
if !globallyblocked {
|
|
||||||
controllers = append(controllers, currentControllers[i])
|
|
||||||
}
|
|
||||||
updatedClasses = append(updatedClasses, updatedClass)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the database to match the list of found controllers.
|
// Update the database to match the list of found controllers.
|
||||||
// This includes pruning out controllers which no longer exist.
|
// This includes pruning out controllers which no longer exist.
|
||||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||||
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)
|
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to store found IngressClasses inside the database")
|
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to store found IngressClasses inside the database")
|
||||||
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
|
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
|
||||||
@@ -268,21 +273,10 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
|||||||
// @failure 500 "Server error occurred while attempting to update ingress controllers."
|
// @failure 500 "Server error occurred while attempting to update ingress controllers."
|
||||||
// @router /kubernetes/{id}/ingresscontrollers [put]
|
// @router /kubernetes/{id}/ingresscontrollers [put]
|
||||||
func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
endpoint, err := middlewares.FetchEndpoint(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve environment identifier from request")
|
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve environment")
|
||||||
return httperror.BadRequest("Unable to retrieve environment identifier from request", err)
|
return httperror.BadRequest("Unable to retrieve environment", err)
|
||||||
}
|
|
||||||
|
|
||||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
|
||||||
if err != nil {
|
|
||||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
|
||||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
|
|
||||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
|
|
||||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := models.K8sIngressControllers{}
|
payload := models.K8sIngressControllers{}
|
||||||
@@ -298,7 +292,6 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
|||||||
return httperror.InternalServerError("Unable to get privileged kube client", err)
|
return httperror.InternalServerError("Unable to get privileged kube client", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
|
|
||||||
controllers, err := cli.GetIngressControllers()
|
controllers, err := cli.GetIngressControllers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
|
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
|
||||||
@@ -316,6 +309,7 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add none controller if "AllowNone" is set for endpoint.
|
// Add none controller if "AllowNone" is set for endpoint.
|
||||||
|
// Use policy-applied endpoint for this check since it affects the response.
|
||||||
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
|
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
|
||||||
controllers = append(controllers, models.K8sIngressController{
|
controllers = append(controllers, models.K8sIngressController{
|
||||||
Name: "none",
|
Name: "none",
|
||||||
@@ -324,48 +318,55 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
// Fetch raw endpoint and update IngressClasses within a transaction.
|
||||||
for i := range controllers {
|
// This prevents policy-applied values from being persisted to the database.
|
||||||
controllers[i].Availability = true
|
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
controllers[i].New = true
|
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
|
||||||
|
if err != nil {
|
||||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
return err
|
||||||
Name: controllers[i].ClassName,
|
|
||||||
Type: controllers[i].Type,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the controller is already known.
|
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
|
||||||
for _, existingClass := range existingClasses {
|
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
|
||||||
if controllers[i].ClassName != existingClass.Name {
|
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||||
continue
|
|
||||||
}
|
|
||||||
controllers[i].New = false
|
|
||||||
controllers[i].Availability = !existingClass.GloballyBlocked
|
|
||||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
|
||||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
|
||||||
}
|
|
||||||
updatedClasses = append(updatedClasses, updatedClass)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range payload {
|
|
||||||
for i := range controllers {
|
for i := range controllers {
|
||||||
// Now set new payload data
|
controllers[i].Availability = true
|
||||||
if updatedClasses[i].Name == p.ClassName {
|
controllers[i].New = true
|
||||||
updatedClasses[i].GloballyBlocked = !p.Availability
|
|
||||||
|
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||||
|
Name: controllers[i].ClassName,
|
||||||
|
Type: controllers[i].Type,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the controller is already known.
|
||||||
|
for _, existingClass := range existingClasses {
|
||||||
|
if controllers[i].ClassName != existingClass.Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
controllers[i].New = false
|
||||||
|
controllers[i].Availability = !existingClass.GloballyBlocked
|
||||||
|
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||||
|
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||||
|
}
|
||||||
|
updatedClasses = append(updatedClasses, updatedClass)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range payload {
|
||||||
|
for i := range controllers {
|
||||||
|
// Now set new payload data
|
||||||
|
if updatedClasses[i].Name == p.ClassName {
|
||||||
|
updatedClasses[i].GloballyBlocked = !p.Availability
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||||
err = handler.DataStore.Endpoint().UpdateEndpoint(
|
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
|
||||||
portainer.EndpointID(endpointID),
|
})
|
||||||
endpoint,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database")
|
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database")
|
||||||
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
|
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.Empty(w)
|
return response.Empty(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,12 +389,6 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
|||||||
// @failure 500 "Server error occurred while attempting to update ingress controllers by namespace."
|
// @failure 500 "Server error occurred while attempting to update ingress controllers by namespace."
|
||||||
// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [put]
|
// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [put]
|
||||||
func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
endpoint, err := middlewares.FetchEndpoint(r)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
|
|
||||||
return httperror.NotFound("Unable to fetch endpoint", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request")
|
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request")
|
||||||
@@ -407,75 +402,88 @@ func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.Res
|
|||||||
return httperror.BadRequest("Unable to decode and validate the request payload", err)
|
return httperror.BadRequest("Unable to decode and validate the request payload", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
|
endpoint, err := middlewares.FetchEndpoint(r)
|
||||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
if err != nil {
|
||||||
PayloadLoop:
|
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
|
||||||
for _, p := range payload {
|
return httperror.InternalServerError("Unable to fetch endpoint", err)
|
||||||
for _, existingClass := range existingClasses {
|
}
|
||||||
if p.ClassName != existingClass.Name {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
|
||||||
Name: existingClass.Name,
|
|
||||||
Type: existingClass.Type,
|
|
||||||
GloballyBlocked: existingClass.GloballyBlocked,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle "allow"
|
// Fetch raw endpoint and update IngressClasses within a transaction.
|
||||||
if p.Availability {
|
// This prevents policy-applied values from being persisted to the database.
|
||||||
// remove the namespace from the list of blocked namespaces
|
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
// in the existingClass.
|
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
|
||||||
for _, blockedNS := range existingClass.BlockedNamespaces {
|
if err != nil {
|
||||||
if blockedNS != namespace {
|
return err
|
||||||
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
|
}
|
||||||
|
|
||||||
|
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
|
||||||
|
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
|
||||||
|
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||||
|
|
||||||
|
for _, p := range payload {
|
||||||
|
for _, existingClass := range existingClasses {
|
||||||
|
if p.ClassName != existingClass.Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||||
|
Name: existingClass.Name,
|
||||||
|
Type: existingClass.Type,
|
||||||
|
GloballyBlocked: existingClass.GloballyBlocked,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "allow"
|
||||||
|
if p.Availability {
|
||||||
|
// remove the namespace from the list of blocked namespaces
|
||||||
|
// in the existingClass.
|
||||||
|
for _, blockedNS := range existingClass.BlockedNamespaces {
|
||||||
|
if blockedNS != namespace {
|
||||||
|
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
updatedClasses = append(updatedClasses, updatedClass)
|
|
||||||
continue PayloadLoop
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle "disallow"
|
|
||||||
// If it's meant to be blocked we need to add the current
|
|
||||||
// namespace. First, check if it's already in the
|
|
||||||
// BlockedNamespaces and if not we append it.
|
|
||||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
|
||||||
for _, ns := range updatedClass.BlockedNamespaces {
|
|
||||||
if namespace == ns {
|
|
||||||
updatedClasses = append(updatedClasses, updatedClass)
|
updatedClasses = append(updatedClasses, updatedClass)
|
||||||
continue PayloadLoop
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "disallow"
|
||||||
|
// If it's meant to be blocked we need to add the current
|
||||||
|
// namespace. First, check if it's already in the
|
||||||
|
// BlockedNamespaces and if not we append it.
|
||||||
|
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||||
|
if !slices.Contains(updatedClass.BlockedNamespaces, namespace) {
|
||||||
|
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, namespace)
|
||||||
|
}
|
||||||
|
updatedClasses = append(updatedClasses, updatedClass)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point it's possible we had an existing class which was globally
|
||||||
|
// blocked and thus not included in the payload. As a result it is not yet
|
||||||
|
// part of updatedClasses, but we MUST include it or we would remove the
|
||||||
|
// global block.
|
||||||
|
for _, existingClass := range existingClasses {
|
||||||
|
found := false
|
||||||
|
|
||||||
|
for _, updatedClass := range updatedClasses {
|
||||||
|
if existingClass.Name == updatedClass.Name {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, namespace)
|
|
||||||
updatedClasses = append(updatedClasses, updatedClass)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// At this point it's possible we had an existing class which was globally
|
if !found {
|
||||||
// blocked and thus not included in the payload. As a result it is not yet
|
updatedClasses = append(updatedClasses, existingClass)
|
||||||
// part of updatedClasses, but we MUST include it or we would remove the
|
|
||||||
// global block.
|
|
||||||
for _, existingClass := range existingClasses {
|
|
||||||
found := false
|
|
||||||
|
|
||||||
for _, updatedClass := range updatedClasses {
|
|
||||||
if existingClass.Name == updatedClass.Name {
|
|
||||||
found = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !found {
|
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||||
updatedClasses = append(updatedClasses, existingClass)
|
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
|
||||||
}
|
})
|
||||||
}
|
|
||||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
|
||||||
|
|
||||||
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to store BlockedIngressClasses inside the database")
|
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to store BlockedIngressClasses inside the database")
|
||||||
return httperror.InternalServerError("Unable to store BlockedIngressClasses inside the database", err)
|
return httperror.InternalServerError("Unable to store BlockedIngressClasses inside the database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.Empty(w)
|
return response.Empty(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ func (service *Service) RemoveTeamAccessPolicies(tx dataservices.DataStoreTx, te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return service.UpdateUsersAuthorizationsTx(tx)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveUserAccessPolicies will remove all existing access policies associated to the specified user
|
// RemoveUserAccessPolicies will remove all existing access policies associated to the specified user
|
||||||
@@ -569,198 +569,14 @@ func (service *Service) RemoveUserAccessPolicies(tx dataservices.DataStoreTx, us
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUserAuthorizations will update the authorizations for the provided userid
|
// UpdateUsersAuthorizations is a no-op kept for backward compatibility with database migrations.
|
||||||
func (service *Service) UpdateUserAuthorizations(tx dataservices.DataStoreTx, userID portainer.UserID) error {
|
//
|
||||||
err := service.updateUserAuthorizations(tx, userID)
|
// Deprecated: This function previously populated the User.EndpointAuthorizations field which is
|
||||||
if err != nil {
|
// no longer used. Authorization is now computed dynamically via ResolveUserEndpointAccess.
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateUsersAuthorizations will trigger an update of the authorizations for all the users.
|
|
||||||
func (service *Service) UpdateUsersAuthorizations() error {
|
func (service *Service) UpdateUsersAuthorizations() error {
|
||||||
return service.UpdateUsersAuthorizationsTx(service.dataStore)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (service *Service) UpdateUsersAuthorizationsTx(tx dataservices.DataStoreTx) error {
|
|
||||||
users, err := tx.User().ReadAll()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, user := range users {
|
|
||||||
err := service.updateUserAuthorizations(tx, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) updateUserAuthorizations(tx dataservices.DataStoreTx, userID portainer.UserID) error {
|
|
||||||
user, err := tx.User().Read(userID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointAuthorizations, err := service.getAuthorizations(tx, user)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
user.EndpointAuthorizations = endpointAuthorizations
|
|
||||||
|
|
||||||
return tx.User().Update(userID, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (service *Service) getAuthorizations(tx dataservices.DataStoreTx, user *portainer.User) (portainer.EndpointAuthorizations, error) {
|
|
||||||
endpointAuthorizations := portainer.EndpointAuthorizations{}
|
|
||||||
if user.Role == portainer.AdministratorRole {
|
|
||||||
return endpointAuthorizations, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return endpointAuthorizations, err
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoints, err := tx.Endpoint().Endpoints()
|
|
||||||
if err != nil {
|
|
||||||
return endpointAuthorizations, err
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointGroups, err := tx.EndpointGroup().ReadAll()
|
|
||||||
if err != nil {
|
|
||||||
return endpointAuthorizations, err
|
|
||||||
}
|
|
||||||
|
|
||||||
roles, err := tx.Role().ReadAll()
|
|
||||||
if err != nil {
|
|
||||||
return endpointAuthorizations, err
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointAuthorizations = getUserEndpointAuthorizations(user, endpoints, endpointGroups, roles, userMemberships)
|
|
||||||
|
|
||||||
return endpointAuthorizations, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getUserEndpointAuthorizations(user *portainer.User, endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, roles []portainer.Role, userMemberships []portainer.TeamMembership) portainer.EndpointAuthorizations {
|
|
||||||
endpointAuthorizations := make(portainer.EndpointAuthorizations)
|
|
||||||
|
|
||||||
groupUserAccessPolicies := map[portainer.EndpointGroupID]portainer.UserAccessPolicies{}
|
|
||||||
groupTeamAccessPolicies := map[portainer.EndpointGroupID]portainer.TeamAccessPolicies{}
|
|
||||||
for _, endpointGroup := range endpointGroups {
|
|
||||||
groupUserAccessPolicies[endpointGroup.ID] = endpointGroup.UserAccessPolicies
|
|
||||||
groupTeamAccessPolicies[endpointGroup.ID] = endpointGroup.TeamAccessPolicies
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, endpoint := range endpoints {
|
|
||||||
authorizations := getAuthorizationsFromUserEndpointPolicy(user, &endpoint, roles)
|
|
||||||
if len(authorizations) > 0 {
|
|
||||||
endpointAuthorizations[endpoint.ID] = authorizations
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
authorizations = getAuthorizationsFromUserEndpointGroupPolicy(user, &endpoint, roles, groupUserAccessPolicies)
|
|
||||||
if len(authorizations) > 0 {
|
|
||||||
endpointAuthorizations[endpoint.ID] = authorizations
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
authorizations = getAuthorizationsFromTeamEndpointPolicies(userMemberships, &endpoint, roles)
|
|
||||||
if len(authorizations) > 0 {
|
|
||||||
endpointAuthorizations[endpoint.ID] = authorizations
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
authorizations = getAuthorizationsFromTeamEndpointGroupPolicies(userMemberships, &endpoint, roles, groupTeamAccessPolicies)
|
|
||||||
if len(authorizations) > 0 {
|
|
||||||
endpointAuthorizations[endpoint.ID] = authorizations
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return endpointAuthorizations
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAuthorizationsFromUserEndpointPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations {
|
|
||||||
policyRoles := make([]portainer.RoleID, 0)
|
|
||||||
|
|
||||||
policy, ok := endpoint.UserAccessPolicies[user.ID]
|
|
||||||
if ok {
|
|
||||||
policyRoles = append(policyRoles, policy.RoleID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return getAuthorizationsFromRoles(policyRoles, roles)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAuthorizationsFromUserEndpointGroupPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.UserAccessPolicies) portainer.Authorizations {
|
|
||||||
policyRoles := make([]portainer.RoleID, 0)
|
|
||||||
|
|
||||||
policy, ok := groupAccessPolicies[endpoint.GroupID][user.ID]
|
|
||||||
if ok {
|
|
||||||
policyRoles = append(policyRoles, policy.RoleID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return getAuthorizationsFromRoles(policyRoles, roles)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAuthorizationsFromTeamEndpointPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations {
|
|
||||||
policyRoles := make([]portainer.RoleID, 0)
|
|
||||||
|
|
||||||
for _, membership := range memberships {
|
|
||||||
policy, ok := endpoint.TeamAccessPolicies[membership.TeamID]
|
|
||||||
if ok {
|
|
||||||
policyRoles = append(policyRoles, policy.RoleID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return getAuthorizationsFromRoles(policyRoles, roles)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAuthorizationsFromTeamEndpointGroupPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.TeamAccessPolicies) portainer.Authorizations {
|
|
||||||
policyRoles := make([]portainer.RoleID, 0)
|
|
||||||
|
|
||||||
for _, membership := range memberships {
|
|
||||||
policy, ok := groupAccessPolicies[endpoint.GroupID][membership.TeamID]
|
|
||||||
if ok {
|
|
||||||
policyRoles = append(policyRoles, policy.RoleID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return getAuthorizationsFromRoles(policyRoles, roles)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAuthorizationsFromRoles(roleIdentifiers []portainer.RoleID, roles []portainer.Role) portainer.Authorizations {
|
|
||||||
var associatedRoles []portainer.Role
|
|
||||||
|
|
||||||
for _, id := range roleIdentifiers {
|
|
||||||
for _, role := range roles {
|
|
||||||
if role.ID == id {
|
|
||||||
associatedRoles = append(associatedRoles, role)
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var authorizations portainer.Authorizations
|
|
||||||
highestPriority := 0
|
|
||||||
for _, role := range associatedRoles {
|
|
||||||
if role.Priority > highestPriority {
|
|
||||||
highestPriority = role.Priority
|
|
||||||
authorizations = role.Authorizations
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return authorizations
|
|
||||||
}
|
|
||||||
|
|
||||||
func (service *Service) UserIsAdminOrAuthorized(tx dataservices.DataStoreTx, userID portainer.UserID, endpointID portainer.EndpointID, authorizations []portainer.Authorization) (bool, error) {
|
func (service *Service) UserIsAdminOrAuthorized(tx dataservices.DataStoreTx, userID portainer.UserID, endpointID portainer.EndpointID, authorizations []portainer.Authorization) (bool, error) {
|
||||||
user, err := tx.User().Read(userID)
|
user, err := tx.User().Read(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+4
-2
@@ -549,6 +549,8 @@ type (
|
|||||||
Status HelmInstallStatus `json:"status"`
|
Status HelmInstallStatus `json:"status"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
|
// Unix timestamp
|
||||||
|
LastAttemptTime int64 `json:"lastAttemptTime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageBundle struct {
|
ImageBundle struct {
|
||||||
@@ -584,7 +586,7 @@ type (
|
|||||||
|
|
||||||
// RestoreSettings contains instructions for restoring environment-level settings
|
// RestoreSettings contains instructions for restoring environment-level settings
|
||||||
RestoreSettings struct {
|
RestoreSettings struct {
|
||||||
Manifest string `json:"manifest"` // Base64-encoded Kubernetes YAML manifest
|
Manifest string `json:"manifest,omitempty"` // Base64-encoded Kubernetes YAML manifest
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestoreSettingsBundle maps restore type to restoration instructions
|
// RestoreSettingsBundle maps restore type to restoration instructions
|
||||||
@@ -1855,7 +1857,7 @@ type (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// APIVersion is the version number of the Portainer API
|
// APIVersion is the version number of the Portainer API
|
||||||
APIVersion = "2.38.0"
|
APIVersion = "2.38.1"
|
||||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||||
APIVersionSupport = "STS"
|
APIVersionSupport = "STS"
|
||||||
// Edition is what this edition of Portainer is called
|
// Edition is what this edition of Portainer is called
|
||||||
|
|||||||
@@ -256,8 +256,12 @@ angular
|
|||||||
url: '/:id',
|
url: '/:id',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
templateUrl: './views/groups/edit/group.html',
|
component: 'environmentGroupEditView',
|
||||||
controller: 'GroupController',
|
},
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
id: {
|
||||||
|
type: 'int',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -267,8 +271,7 @@ angular
|
|||||||
url: '/new',
|
url: '/new',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
templateUrl: './views/groups/create/creategroup.html',
|
component: 'environmentGroupCreateView',
|
||||||
controller: 'CreateGroupController',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import angular from 'angular';
|
|
||||||
import GroupFormController from './groupFormController';
|
|
||||||
|
|
||||||
angular.module('portainer.app').component('groupForm', {
|
|
||||||
templateUrl: './groupForm.html',
|
|
||||||
controller: GroupFormController,
|
|
||||||
bindings: {
|
|
||||||
loaded: '<',
|
|
||||||
model: '=',
|
|
||||||
associatedEndpoints: '=',
|
|
||||||
formAction: '<',
|
|
||||||
formActionLabel: '@',
|
|
||||||
actionInProgress: '<',
|
|
||||||
|
|
||||||
onChangeEnvironments: '<',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -6,8 +6,6 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
|
|||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { AnnotationsBeTeaser } from '@/react/kubernetes/annotations/AnnotationsBeTeaser';
|
import { AnnotationsBeTeaser } from '@/react/kubernetes/annotations/AnnotationsBeTeaser';
|
||||||
import { withFormValidation } from '@/react-tools/withFormValidation';
|
import { withFormValidation } from '@/react-tools/withFormValidation';
|
||||||
import { GroupAssociationTable } from '@/react/portainer/environments/environment-groups/components/GroupAssociationTable';
|
|
||||||
import { AssociatedEnvironmentsSelector } from '@/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector';
|
|
||||||
import { withControlledInput } from '@/react-tools/withControlledInput';
|
import { withControlledInput } from '@/react-tools/withControlledInput';
|
||||||
import { NamespacePortainerSelect } from '@/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector';
|
import { NamespacePortainerSelect } from '@/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector';
|
||||||
|
|
||||||
@@ -271,20 +269,7 @@ export const ngModule = angular
|
|||||||
'inlineLoader',
|
'inlineLoader',
|
||||||
r2a(InlineLoader, ['children', 'className', 'size'])
|
r2a(InlineLoader, ['children', 'className', 'size'])
|
||||||
)
|
)
|
||||||
.component(
|
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, []));
|
||||||
'groupAssociationTable',
|
|
||||||
r2a(withReactQuery(GroupAssociationTable), [
|
|
||||||
'onClickRow',
|
|
||||||
'query',
|
|
||||||
'title',
|
|
||||||
'data-cy',
|
|
||||||
])
|
|
||||||
)
|
|
||||||
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, []))
|
|
||||||
.component(
|
|
||||||
'associatedEndpointsSelector',
|
|
||||||
r2a(withReactQuery(AssociatedEnvironmentsSelector), ['onChange', 'value'])
|
|
||||||
);
|
|
||||||
|
|
||||||
export const componentsModule = ngModule.name;
|
export const componentsModule = ngModule.name;
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,20 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
|||||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { ListView } from '@/react/portainer/environments/environment-groups/ListView';
|
import { ListView } from '@/react/portainer/environments/environment-groups/ListView';
|
||||||
|
import { EditGroupView } from '@/react/portainer/environments/environment-groups/ItemView/EditGroupView';
|
||||||
|
import { CreateGroupView } from '@/react/portainer/environments/environment-groups/CreateView/CreateGroupView';
|
||||||
|
|
||||||
export const environmentGroupModule = angular
|
export const environmentGroupModule = angular
|
||||||
.module('portainer.app.react.views.environment-groups', [])
|
.module('portainer.app.react.views.environment-groups', [])
|
||||||
.component(
|
.component(
|
||||||
'environmentGroupsListView',
|
'environmentGroupsListView',
|
||||||
r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), [])
|
r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), [])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
'environmentGroupEditView',
|
||||||
|
r2a(withUIRouter(withReactQuery(withCurrentUser(EditGroupView))), [])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
'environmentGroupCreateView',
|
||||||
|
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateGroupView))), [])
|
||||||
).name;
|
).name;
|
||||||
|
|||||||
@@ -292,7 +292,9 @@ function EndpointController(
|
|||||||
$scope.endpointType = 'remote';
|
$scope.endpointType = 'remote';
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint.URL = $filter('stripprotocol')(endpoint.URL);
|
if ($scope.state.azureEndpoint || $scope.state.edgeEndpoint) {
|
||||||
|
endpoint.URL = $filter('stripprotocol')(endpoint.URL);
|
||||||
|
}
|
||||||
|
|
||||||
if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment || endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
|
if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment || endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
|
||||||
$scope.state.edgeAssociated = !!endpoint.EdgeID;
|
$scope.state.edgeAssociated = !!endpoint.EdgeID;
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { EndpointGroupDefaultModel } from '../../../models/group';
|
|
||||||
|
|
||||||
angular.module('portainer.app').controller('CreateGroupController', function CreateGroupController($async, $scope, $state, GroupService, Notifications) {
|
|
||||||
$scope.state = {
|
|
||||||
actionInProgress: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.onChangeEnvironments = onChangeEnvironments;
|
|
||||||
|
|
||||||
$scope.create = function () {
|
|
||||||
var model = $scope.model;
|
|
||||||
|
|
||||||
$scope.state.actionInProgress = true;
|
|
||||||
GroupService.createGroup(model, $scope.associatedEndpoints)
|
|
||||||
.then(function success() {
|
|
||||||
Notifications.success('Success', 'Group successfully created');
|
|
||||||
$state.go('portainer.groups', {}, { reload: true });
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to create group');
|
|
||||||
})
|
|
||||||
.finally(function final() {
|
|
||||||
$scope.state.actionInProgress = false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function initView() {
|
|
||||||
$scope.associatedEndpoints = [];
|
|
||||||
$scope.model = new EndpointGroupDefaultModel();
|
|
||||||
$scope.loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onChangeEnvironments(value) {
|
|
||||||
return $scope.$evalAsync(() => {
|
|
||||||
$scope.associatedEndpoints = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
initView();
|
|
||||||
});
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<page-header title="'Create environment group'" breadcrumbs="[{label:'Environment groups', link:'portainer.groups'}, 'Add group']" reload="true"> </page-header>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-body>
|
|
||||||
<group-form
|
|
||||||
loaded="loaded"
|
|
||||||
model="model"
|
|
||||||
associated-endpoints="associatedEndpoints"
|
|
||||||
form-action="create"
|
|
||||||
form-action-label="Create the group"
|
|
||||||
action-in-progress="state.actionInProgress"
|
|
||||||
on-change-environments="(onChangeEnvironments)"
|
|
||||||
></group-form>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<page-header title="'Environment group details'" breadcrumbs="[{label:'Groups', link:'portainer.groups'}, group.Name]" reload="true"> </page-header>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-body>
|
|
||||||
<group-form
|
|
||||||
loaded="loaded"
|
|
||||||
model="group"
|
|
||||||
associated-endpoints="associatedEndpoints"
|
|
||||||
form-action="update"
|
|
||||||
form-action-label="Update the group"
|
|
||||||
action-in-progress="state.actionInProgress"
|
|
||||||
on-change-environments="(onChangeEnvironments)"
|
|
||||||
></group-form>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { getEnvironments } from '@/react/portainer/environments/environment.service';
|
|
||||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
|
||||||
|
|
||||||
angular.module('portainer.app').controller('GroupController', function GroupController($async, $q, $scope, $state, $transition$, GroupService, Notifications) {
|
|
||||||
$scope.state = {
|
|
||||||
actionInProgress: false,
|
|
||||||
};
|
|
||||||
$scope.onChangeEnvironments = onChangeEnvironments;
|
|
||||||
$scope.associatedEndpoints = [];
|
|
||||||
|
|
||||||
$scope.update = function () {
|
|
||||||
var model = $scope.group;
|
|
||||||
|
|
||||||
$scope.state.actionInProgress = true;
|
|
||||||
GroupService.updateGroup(model)
|
|
||||||
.then(function success() {
|
|
||||||
Notifications.success('Success', 'Group successfully updated');
|
|
||||||
$state.go('portainer.groups', {}, { reload: true });
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to update group');
|
|
||||||
})
|
|
||||||
.finally(function final() {
|
|
||||||
$scope.state.actionInProgress = false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function onChangeEnvironments(value, meta) {
|
|
||||||
return $async(async () => {
|
|
||||||
let success = false;
|
|
||||||
if (meta.type === 'add') {
|
|
||||||
success = await onAssociate(meta.value);
|
|
||||||
} else if (meta.type === 'remove') {
|
|
||||||
success = await onDisassociate(meta.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
$scope.associatedEndpoints = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onAssociate(endpointId) {
|
|
||||||
try {
|
|
||||||
await GroupService.addEndpoint($scope.group.Id, endpointId);
|
|
||||||
|
|
||||||
notifySuccess('Success', `Environment successfully added to group`);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
notifyError('Failure', err, `Unable to add environment to group`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDisassociate(endpointId) {
|
|
||||||
try {
|
|
||||||
await GroupService.removeEndpoint($scope.group.Id, endpointId);
|
|
||||||
|
|
||||||
notifySuccess('Success', `Environment successfully removed to group`);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
notifyError('Failure', err, `Unable to remove environment to group`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initView() {
|
|
||||||
var groupId = $transition$.params().id;
|
|
||||||
|
|
||||||
$q.all({
|
|
||||||
group: GroupService.group(groupId),
|
|
||||||
endpoints: getEnvironments({ query: { groupIds: [groupId] } }),
|
|
||||||
})
|
|
||||||
.then(function success(data) {
|
|
||||||
$scope.group = data.group;
|
|
||||||
$scope.associatedEndpoints = data.endpoints.value.map((endpoint) => endpoint.Id);
|
|
||||||
$scope.loaded = true;
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to load group details');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
initView();
|
|
||||||
});
|
|
||||||
@@ -37,10 +37,8 @@ function loadingButtonIcon(isLoading: boolean, defaultIcon: ReactNode) {
|
|||||||
return defaultIcon;
|
return defaultIcon;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Icon
|
<span className="flex items-center" role="status" aria-label="loading">
|
||||||
icon={Loader2}
|
<Icon icon={Loader2} className="ml-1 animate-spin-slow" />
|
||||||
className="ml-1 animate-spin-slow"
|
</span>
|
||||||
aria-label="loading"
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ beforeEach(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// set timezone explicitly to avoid daylight savings drift
|
||||||
|
vi.stubEnv('TZ', 'UTC');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
it('should return null when data is loading', () => {
|
it('should return null when data is loading', () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/endpoints/:environmentId/docker/configs', async () => {
|
http.get('/api/endpoints/:environmentId/docker/configs', async () => {
|
||||||
|
|||||||
+7
-5
@@ -11,11 +11,13 @@ function transformNamespaces(
|
|||||||
namespaces: PortainerNamespace[],
|
namespaces: PortainerNamespace[],
|
||||||
showSystem?: boolean
|
showSystem?: boolean
|
||||||
) {
|
) {
|
||||||
const transformedNamespaces = namespaces.map(({ Name, IsSystem }) => ({
|
const transformedNamespaces = namespaces
|
||||||
label: IsSystem ? `${Name} - system` : Name,
|
.map(({ Name, IsSystem }) => ({
|
||||||
value: Name,
|
label: IsSystem ? `${Name} - system` : Name,
|
||||||
isSystem: IsSystem,
|
value: Name,
|
||||||
}));
|
isSystem: IsSystem,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
if (showSystem === undefined) {
|
if (showSystem === undefined) {
|
||||||
return transformedNamespaces;
|
return transformedNamespaces;
|
||||||
}
|
}
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { filterNamespaces } from '@/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector';
|
||||||
|
|
||||||
|
const mockUseNamespacesQuery = vi.fn(() => ({
|
||||||
|
data: { 1: { Name: 'gamma' }, 2: { Name: 'alpha' }, 3: { Name: 'beta' } },
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseEnvironmentId = vi.fn(() => 1);
|
||||||
|
|
||||||
|
vi.mock('@/react/kubernetes/namespaces/queries/useNamespacesQuery', () => ({
|
||||||
|
useNamespacesQuery: () => mockUseNamespacesQuery(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
||||||
|
useEnvironmentId: () => mockUseEnvironmentId(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('CustomResourceDetailsView', () => {
|
||||||
|
it('renders the correct summary widget details for a custom resource', async () => {
|
||||||
|
const mockNamespaces = [
|
||||||
|
{ Name: 'gamma', IsSystem: false },
|
||||||
|
{ Name: 'alpha', IsSystem: false },
|
||||||
|
{ Name: 'beta', IsSystem: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const namespaceSelect = filterNamespaces(mockNamespaces);
|
||||||
|
|
||||||
|
expect(namespaceSelect).toStrictEqual([
|
||||||
|
{ label: 'alpha', value: 'alpha' },
|
||||||
|
{ label: 'beta', value: 'beta' },
|
||||||
|
{ label: 'gamma', value: 'gamma' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+13
-6
@@ -6,6 +6,18 @@ import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNam
|
|||||||
import { FormControl } from '@@/form-components/FormControl';
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||||
|
|
||||||
|
export function filterNamespaces(
|
||||||
|
namespaces: { IsSystem: boolean; Name: string }[] | undefined
|
||||||
|
) {
|
||||||
|
return Object.values(namespaces ?? {})
|
||||||
|
.filter((ns) => !ns.IsSystem)
|
||||||
|
.map((ns) => ({
|
||||||
|
label: ns.Name,
|
||||||
|
value: ns.Name,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
values: string;
|
values: string;
|
||||||
@@ -22,12 +34,7 @@ export function NamespaceSelector({
|
|||||||
const environmentId = useEnvironmentId();
|
const environmentId = useEnvironmentId();
|
||||||
const { data: namespaces, ...namespacesQuery } =
|
const { data: namespaces, ...namespacesQuery } =
|
||||||
useNamespacesQuery(environmentId);
|
useNamespacesQuery(environmentId);
|
||||||
const namespaceNames = Object.entries(namespaces ?? {})
|
const namespaceNames = filterNamespaces(namespaces);
|
||||||
.filter(([, ns]) => !ns.IsSystem)
|
|
||||||
.map(([, ns]) => ({
|
|
||||||
label: ns.Name,
|
|
||||||
value: ns.Name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormControl
|
<FormControl
|
||||||
|
|||||||
+209
-1
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { DefaultBodyType, http, HttpResponse } from 'msw';
|
import { DefaultBodyType, http, HttpResponse } from 'msw';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi, test } from 'vitest';
|
||||||
|
|
||||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
import { server } from '@/setup-tests/server';
|
import { server } from '@/setup-tests/server';
|
||||||
@@ -201,6 +201,214 @@ describe('GeneralEnvironmentForm', () => {
|
|||||||
expect(screen.getAllByRole('alert').length).toBeGreaterThan(0);
|
expect(screen.getAllByRole('alert').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('URL handling', () => {
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
description: 'unix:// socket URL',
|
||||||
|
inputUrl: 'unix:///var/run/docker.sock',
|
||||||
|
expectedDisplay: 'unix:///var/run/docker.sock',
|
||||||
|
environmentType: EnvironmentType.Docker,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'tcp:// Docker URL',
|
||||||
|
inputUrl: 'tcp://10.0.0.1:2375',
|
||||||
|
expectedDisplay: '10.0.0.1:2375',
|
||||||
|
environmentType: EnvironmentType.Docker,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'https:// URL',
|
||||||
|
inputUrl: 'https://docker.example.com:2376',
|
||||||
|
expectedDisplay: 'docker.example.com:2376',
|
||||||
|
environmentType: EnvironmentType.Docker,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'http:// URL',
|
||||||
|
inputUrl: 'http://docker.example.com:2375',
|
||||||
|
expectedDisplay: 'docker.example.com:2375',
|
||||||
|
environmentType: EnvironmentType.Docker,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'URL without protocol',
|
||||||
|
inputUrl: '192.168.1.100:2375',
|
||||||
|
expectedDisplay: '192.168.1.100:2375',
|
||||||
|
environmentType: EnvironmentType.Docker,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Kubernetes local URL',
|
||||||
|
inputUrl: 'https://k8s.example.com:6443',
|
||||||
|
expectedDisplay: 'k8s.example.com:6443',
|
||||||
|
environmentType: EnvironmentType.KubernetesLocal,
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should handle $description correctly on load',
|
||||||
|
async ({ inputUrl, expectedDisplay, environmentType }) => {
|
||||||
|
const env = createMockEnvironment({
|
||||||
|
URL: inputUrl,
|
||||||
|
Type: environmentType,
|
||||||
|
});
|
||||||
|
renderComponent(env);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const urlInput = screen.getByLabelText('Environment URL');
|
||||||
|
expect(urlInput).toHaveValue(expectedDisplay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TLS visibility', () => {
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
description: 'Docker tcp:// API',
|
||||||
|
url: 'tcp://10.0.0.1:2375',
|
||||||
|
environmentType: EnvironmentType.Docker,
|
||||||
|
shouldShowTLS: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Docker tcp:// API with TLS port',
|
||||||
|
url: 'tcp://docker.example.com:2376',
|
||||||
|
environmentType: EnvironmentType.Docker,
|
||||||
|
shouldShowTLS: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Docker unix:// socket',
|
||||||
|
url: 'unix:///var/run/docker.sock',
|
||||||
|
environmentType: EnvironmentType.Docker,
|
||||||
|
shouldShowTLS: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Agent on Docker',
|
||||||
|
url: 'tcp://agent:9001',
|
||||||
|
environmentType: EnvironmentType.AgentOnDocker,
|
||||||
|
shouldShowTLS: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Agent on Kubernetes',
|
||||||
|
url: 'agent-k8s:9001',
|
||||||
|
environmentType: EnvironmentType.AgentOnKubernetes,
|
||||||
|
shouldShowTLS: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Kubernetes Local',
|
||||||
|
url: 'https://k8s.local:6443',
|
||||||
|
environmentType: EnvironmentType.KubernetesLocal,
|
||||||
|
shouldShowTLS: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Edge Agent on Docker',
|
||||||
|
url: 'edge-agent:8000',
|
||||||
|
environmentType: EnvironmentType.EdgeAgentOnDocker,
|
||||||
|
shouldShowTLS: false,
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should $description - TLS visible: $shouldShowTLS',
|
||||||
|
async ({ url, environmentType, shouldShowTLS }) => {
|
||||||
|
const env = createMockEnvironment({
|
||||||
|
Type: environmentType,
|
||||||
|
URL: url,
|
||||||
|
});
|
||||||
|
renderComponent(env);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox', { name: /Name/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldShowTLS) {
|
||||||
|
expect(screen.getByText(/TLS/i)).toBeVisible();
|
||||||
|
} else {
|
||||||
|
expect(screen.queryByText(/TLS/i)).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form submission', () => {
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
description: 'Docker tcp:// URL',
|
||||||
|
environmentType: EnvironmentType.Docker,
|
||||||
|
inputUrl: '10.0.0.1:2375',
|
||||||
|
expectedPayloadUrl: 'tcp://10.0.0.1:2375',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Docker plain URL becomes tcp://',
|
||||||
|
environmentType: EnvironmentType.Docker,
|
||||||
|
inputUrl: 'docker.example.com:2376',
|
||||||
|
expectedPayloadUrl: 'tcp://docker.example.com:2376',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Kubernetes Local adds https://',
|
||||||
|
environmentType: EnvironmentType.KubernetesLocal,
|
||||||
|
inputUrl: 'k8s.local:6443',
|
||||||
|
expectedPayloadUrl: 'https://k8s.local:6443',
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should submit $description correctly',
|
||||||
|
async ({ environmentType, inputUrl, expectedPayloadUrl }) => {
|
||||||
|
let requestPayload: DefaultBodyType;
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.put('/api/endpoints/:id', async ({ request }) => {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 100);
|
||||||
|
});
|
||||||
|
requestPayload = await request.json();
|
||||||
|
return HttpResponse.json({});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const env = createMockEnvironment({
|
||||||
|
Id: 1,
|
||||||
|
Name: 'test-env',
|
||||||
|
Type: environmentType,
|
||||||
|
});
|
||||||
|
const onSuccess = vi.fn();
|
||||||
|
renderComponent(env, { onSuccess });
|
||||||
|
|
||||||
|
const nameInput = screen.getByRole('textbox', { name: /Name/ });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(nameInput).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fill form fields
|
||||||
|
await userEvent.clear(nameInput);
|
||||||
|
await userEvent.type(nameInput, 'my-environment');
|
||||||
|
|
||||||
|
const urlInput = screen.getByLabelText('Environment URL');
|
||||||
|
await userEvent.clear(urlInput);
|
||||||
|
await userEvent.type(urlInput, inputUrl);
|
||||||
|
|
||||||
|
// Wait for debounce to complete (NameField uses useDebounce)
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', {
|
||||||
|
name: /update environment/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for Formik to process all changes and enable submit button
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(submitButton);
|
||||||
|
expect(await screen.findByText(/updating environment/i)).toBeVisible();
|
||||||
|
|
||||||
|
// Verify payload
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requestPayload).toMatchObject({
|
||||||
|
Name: 'my-environment',
|
||||||
|
URL: expectedPayloadUrl,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSuccess).toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderComponent(
|
function renderComponent(
|
||||||
|
|||||||
+20
-15
@@ -1,4 +1,4 @@
|
|||||||
import { Formik, Form } from 'formik';
|
import { Formik, Form, FormikErrors } from 'formik';
|
||||||
|
|
||||||
import { useUpdateEnvironmentMutation } from '@/react/portainer/environments/queries/useUpdateEnvironmentMutation';
|
import { useUpdateEnvironmentMutation } from '@/react/portainer/environments/queries/useUpdateEnvironmentMutation';
|
||||||
import { NameField } from '@/react/portainer/environments/common/NameField/NameField';
|
import { NameField } from '@/react/portainer/environments/common/NameField/NameField';
|
||||||
@@ -8,8 +8,7 @@ import { TLSFieldset } from '@/react/components/TLSFieldset';
|
|||||||
import { MetadataFieldset } from '@/react/portainer/environments/common/MetadataFieldset';
|
import { MetadataFieldset } from '@/react/portainer/environments/common/MetadataFieldset';
|
||||||
import {
|
import {
|
||||||
isAgentEnvironment,
|
isAgentEnvironment,
|
||||||
isDockerAPIEnvironment,
|
isLocalDockerEnvironment,
|
||||||
isLocalEnvironment,
|
|
||||||
} from '@/react/portainer/environments/utils';
|
} from '@/react/portainer/environments/utils';
|
||||||
import {
|
import {
|
||||||
Environment,
|
Environment,
|
||||||
@@ -19,6 +18,7 @@ import {
|
|||||||
import { FormSection } from '@@/form-components/FormSection';
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
import { Widget } from '@@/Widget/Widget';
|
import { Widget } from '@@/Widget/Widget';
|
||||||
import { WidgetBody } from '@@/Widget';
|
import { WidgetBody } from '@@/Widget';
|
||||||
|
import { TLSConfig } from '@@/TLSFieldset/types';
|
||||||
|
|
||||||
import { EnvironmentFormActions } from '../EnvironmentFormActions/EnvironmentFormActions';
|
import { EnvironmentFormActions } from '../EnvironmentFormActions/EnvironmentFormActions';
|
||||||
|
|
||||||
@@ -33,9 +33,8 @@ interface Props {
|
|||||||
export function GeneralEnvironmentForm({ environment, onSuccess }: Props) {
|
export function GeneralEnvironmentForm({ environment, onSuccess }: Props) {
|
||||||
const updateMutation = useUpdateEnvironmentMutation();
|
const updateMutation = useUpdateEnvironmentMutation();
|
||||||
|
|
||||||
const isDockerAPI = isDockerAPIEnvironment(environment);
|
|
||||||
const isAgent = isAgentEnvironment(environment.Type);
|
const isAgent = isAgentEnvironment(environment.Type);
|
||||||
const isLocal = isLocalEnvironment(environment);
|
const isLocalDocker = isLocalDockerEnvironment(environment.URL);
|
||||||
const hasError = environment.Status === EnvironmentStatus.Error;
|
const hasError = environment.Status === EnvironmentStatus.Error;
|
||||||
const validationSchema = useGeneralValidation({
|
const validationSchema = useGeneralValidation({
|
||||||
status: environment.Status,
|
status: environment.Status,
|
||||||
@@ -49,7 +48,10 @@ export function GeneralEnvironmentForm({ environment, onSuccess }: Props) {
|
|||||||
initialValues={buildInitialValues(environment)}
|
initialValues={buildInitialValues(environment)}
|
||||||
validationSchema={validationSchema}
|
validationSchema={validationSchema}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
const payload = buildUpdatePayload(values, environment.Type);
|
const payload = buildUpdatePayload({
|
||||||
|
values,
|
||||||
|
environmentType: environment.Type,
|
||||||
|
});
|
||||||
updateMutation.mutate(
|
updateMutation.mutate(
|
||||||
{ id: environment.Id, payload },
|
{ id: environment.Id, payload },
|
||||||
{ onSuccess }
|
{ onSuccess }
|
||||||
@@ -57,28 +59,31 @@ export function GeneralEnvironmentForm({ environment, onSuccess }: Props) {
|
|||||||
}}
|
}}
|
||||||
validateOnMount
|
validateOnMount
|
||||||
>
|
>
|
||||||
{(formik) => (
|
{({ values, setFieldValue, errors, isValid, dirty }) => (
|
||||||
<Form className="form-horizontal">
|
<Form className="form-horizontal">
|
||||||
<FormSection title="Configuration">
|
<FormSection title="Configuration">
|
||||||
<NameField />
|
<NameField />
|
||||||
|
|
||||||
{!hasError && (
|
{!hasError && (
|
||||||
<>
|
<>
|
||||||
<EnvironmentUrlField isAgent={isAgent} disabled={isLocal} />
|
<EnvironmentUrlField
|
||||||
|
isAgent={isAgent}
|
||||||
|
disabled={isLocalDocker}
|
||||||
|
/>
|
||||||
<PublicUrlField />
|
<PublicUrlField />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hasError && isDockerAPI && (
|
{!hasError && values.tls && (
|
||||||
<TLSFieldset
|
<TLSFieldset
|
||||||
values={formik.values.tls}
|
values={values.tls}
|
||||||
onChange={(partialValues) => {
|
onChange={(partialValues) => {
|
||||||
formik.setFieldValue('tls', {
|
setFieldValue('tls', {
|
||||||
...formik.values.tls,
|
...values.tls,
|
||||||
...partialValues,
|
...partialValues,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
errors={formik.errors.tls}
|
errors={errors.tls as FormikErrors<TLSConfig> | undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FormSection>
|
</FormSection>
|
||||||
@@ -87,8 +92,8 @@ export function GeneralEnvironmentForm({ environment, onSuccess }: Props) {
|
|||||||
|
|
||||||
<EnvironmentFormActions
|
<EnvironmentFormActions
|
||||||
isLoading={updateMutation.isLoading}
|
isLoading={updateMutation.isLoading}
|
||||||
isValid={formik.isValid}
|
isValid={isValid}
|
||||||
isDirty={formik.dirty}
|
isDirty={dirty}
|
||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,52 +1,210 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, test, expect } from 'vitest';
|
||||||
|
|
||||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||||
|
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
||||||
|
|
||||||
import { formatURL } from './helpers';
|
import { formatURL, buildInitialValues, buildUpdatePayload } from './helpers';
|
||||||
|
|
||||||
describe('helpers', () => {
|
describe('formatURL', () => {
|
||||||
describe('formatURL', () => {
|
test.each([
|
||||||
it('should add tcp:// prefix for Docker environments', () => {
|
{
|
||||||
const result = formatURL('10.0.0.1:2375', EnvironmentType.Docker);
|
description: 'Docker environment with plain URL',
|
||||||
expect(result).toBe('tcp://10.0.0.1:2375');
|
url: '10.0.0.1:2375',
|
||||||
});
|
environmentType: EnvironmentType.Docker,
|
||||||
|
expected: 'tcp://10.0.0.1:2375',
|
||||||
it('should add tcp:// prefix for Docker standalone', () => {
|
},
|
||||||
const result = formatURL('10.0.0.1:2375', EnvironmentType.Docker);
|
{
|
||||||
expect(result).toBe('tcp://10.0.0.1:2375');
|
description: 'Docker environment with tcp:// protocol',
|
||||||
});
|
url: 'tcp://10.0.0.1:2375',
|
||||||
|
environmentType: EnvironmentType.Docker,
|
||||||
it('should add tcp:// prefix for Agent environment', () => {
|
expected: 'tcp://10.0.0.1:2375',
|
||||||
const result = formatURL('agent-host', EnvironmentType.AgentOnDocker);
|
},
|
||||||
expect(result).toBe('tcp://agent-host');
|
{
|
||||||
});
|
description: 'Docker environment with https:// protocol',
|
||||||
|
url: 'https://docker.example.com:2376',
|
||||||
it('should add https:// prefix for Kubernetes Local', () => {
|
environmentType: EnvironmentType.Docker,
|
||||||
const result = formatURL(
|
expected: 'tcp://docker.example.com:2376',
|
||||||
'k8s.example.com',
|
},
|
||||||
EnvironmentType.KubernetesLocal
|
{
|
||||||
);
|
description: 'Docker environment with http:// protocol',
|
||||||
expect(result).toBe('https://k8s.example.com');
|
url: 'http://docker.example.com:2375',
|
||||||
});
|
environmentType: EnvironmentType.Docker,
|
||||||
|
expected: 'tcp://docker.example.com:2375',
|
||||||
it('should not add prefix for Agent on Kubernetes', () => {
|
},
|
||||||
const result = formatURL('k8s-agent', EnvironmentType.AgentOnKubernetes);
|
{
|
||||||
expect(result).toBe('k8s-agent');
|
description: 'Docker environment with unix:// socket',
|
||||||
});
|
url: 'unix:///var/run/docker.sock',
|
||||||
|
environmentType: EnvironmentType.Docker,
|
||||||
it('should strip existing protocol before formatting', () => {
|
expected: 'unix:///var/run/docker.sock',
|
||||||
const result = formatURL('tcp://10.0.0.1:2375', EnvironmentType.Docker);
|
},
|
||||||
expect(result).toBe('tcp://10.0.0.1:2375');
|
{
|
||||||
});
|
description: 'Agent on Docker',
|
||||||
|
url: 'agent-host:9001',
|
||||||
it('should handle empty URL', () => {
|
environmentType: EnvironmentType.AgentOnDocker,
|
||||||
const result = formatURL('', EnvironmentType.Docker);
|
expected: 'tcp://agent-host:9001',
|
||||||
expect(result).toBe('');
|
},
|
||||||
});
|
{
|
||||||
|
description: 'Kubernetes Local with https://',
|
||||||
it('should strip https:// and add tcp:// for Docker', () => {
|
url: 'https://k8s.example.com:6443',
|
||||||
const result = formatURL('https://10.0.0.1:2375', EnvironmentType.Docker);
|
environmentType: EnvironmentType.KubernetesLocal,
|
||||||
expect(result).toBe('tcp://10.0.0.1:2375');
|
expected: 'https://k8s.example.com:6443',
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
description: 'Kubernetes Local without protocol',
|
||||||
|
url: 'k8s.example.com:6443',
|
||||||
|
environmentType: EnvironmentType.KubernetesLocal,
|
||||||
|
expected: 'https://k8s.example.com:6443',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Agent on Kubernetes no protocol added',
|
||||||
|
url: 'k8s-agent:9001',
|
||||||
|
environmentType: EnvironmentType.AgentOnKubernetes,
|
||||||
|
expected: 'k8s-agent:9001',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Edge Agent on Docker',
|
||||||
|
url: 'edge-agent:8000',
|
||||||
|
environmentType: EnvironmentType.EdgeAgentOnDocker,
|
||||||
|
expected: 'tcp://edge-agent:8000',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Edge Agent on Kubernetes',
|
||||||
|
url: 'edge-k8s:9001',
|
||||||
|
environmentType: EnvironmentType.EdgeAgentOnKubernetes,
|
||||||
|
expected: 'tcp://edge-k8s:9001',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Empty URL',
|
||||||
|
url: '',
|
||||||
|
environmentType: EnvironmentType.Docker,
|
||||||
|
expected: '',
|
||||||
|
},
|
||||||
|
])('$description', ({ url, environmentType, expected }) => {
|
||||||
|
const result = formatURL({ url, environmentType });
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildInitialValues', () => {
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
description: 'Docker with tcp:// URL',
|
||||||
|
environment: {
|
||||||
|
URL: 'tcp://10.0.0.1:2375',
|
||||||
|
Type: EnvironmentType.Docker,
|
||||||
|
TLSConfig: { TLS: true, TLSSkipVerify: false },
|
||||||
|
},
|
||||||
|
expectedUrl: '10.0.0.1:2375',
|
||||||
|
expectedTls: {
|
||||||
|
tls: true,
|
||||||
|
skipVerify: false,
|
||||||
|
caCertFile: undefined,
|
||||||
|
certFile: undefined,
|
||||||
|
keyFile: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Docker with unix:// socket',
|
||||||
|
environment: {
|
||||||
|
URL: 'unix:///var/run/docker.sock',
|
||||||
|
Type: EnvironmentType.Docker,
|
||||||
|
},
|
||||||
|
expectedUrl: 'unix:///var/run/docker.sock',
|
||||||
|
expectedTls: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Kubernetes without TLS config',
|
||||||
|
environment: {
|
||||||
|
URL: 'https://k8s.example.com:6443',
|
||||||
|
Type: EnvironmentType.AgentOnKubernetes,
|
||||||
|
},
|
||||||
|
expectedUrl: 'k8s.example.com:6443',
|
||||||
|
expectedTls: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Agent on Docker without TLS',
|
||||||
|
environment: {
|
||||||
|
URL: 'tcp://agent:9001',
|
||||||
|
Type: EnvironmentType.AgentOnDocker,
|
||||||
|
},
|
||||||
|
expectedUrl: 'agent:9001',
|
||||||
|
expectedTls: undefined,
|
||||||
|
},
|
||||||
|
])('$description', ({ environment, expectedUrl, expectedTls }) => {
|
||||||
|
const mockEnv = createMockEnvironment(environment);
|
||||||
|
const result = buildInitialValues(mockEnv);
|
||||||
|
|
||||||
|
expect(result.environmentUrl).toBe(expectedUrl);
|
||||||
|
expect(result.tls).toEqual(expectedTls);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildUpdatePayload', () => {
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
description: 'Docker environment with plain URL',
|
||||||
|
values: {
|
||||||
|
name: 'my-docker',
|
||||||
|
environmentUrl: '10.0.0.1:2375',
|
||||||
|
publicUrl: '1.2.3.4',
|
||||||
|
meta: { groupId: 1, tagIds: [1, 2] },
|
||||||
|
tls: {
|
||||||
|
tls: true,
|
||||||
|
skipVerify: false,
|
||||||
|
caCertFile: undefined,
|
||||||
|
certFile: undefined,
|
||||||
|
keyFile: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
environmentType: EnvironmentType.Docker,
|
||||||
|
expectedUrl: 'tcp://10.0.0.1:2375',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Kubernetes Local environment',
|
||||||
|
values: {
|
||||||
|
name: 'my-k8s',
|
||||||
|
environmentUrl: 'k8s.local:6443',
|
||||||
|
publicUrl: '',
|
||||||
|
meta: { groupId: 1, tagIds: [] },
|
||||||
|
tls: undefined,
|
||||||
|
},
|
||||||
|
environmentType: EnvironmentType.KubernetesLocal,
|
||||||
|
expectedUrl: 'https://k8s.local:6443',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Agent on Kubernetes',
|
||||||
|
values: {
|
||||||
|
name: 'k8s-agent',
|
||||||
|
environmentUrl: 'agent:9001',
|
||||||
|
publicUrl: '',
|
||||||
|
meta: { groupId: 2, tagIds: [] },
|
||||||
|
tls: undefined,
|
||||||
|
},
|
||||||
|
environmentType: EnvironmentType.AgentOnKubernetes,
|
||||||
|
expectedUrl: 'agent:9001',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Local Docker (should return the same URL)',
|
||||||
|
values: {
|
||||||
|
name: 'local-docker',
|
||||||
|
environmentUrl: 'unix:///var/run/docker.sock',
|
||||||
|
publicUrl: '',
|
||||||
|
meta: { groupId: 1, tagIds: [] },
|
||||||
|
tls: undefined,
|
||||||
|
},
|
||||||
|
environmentType: EnvironmentType.Docker,
|
||||||
|
expectedUrl: 'unix:///var/run/docker.sock',
|
||||||
|
},
|
||||||
|
])('$description', ({ values, environmentType, expectedUrl }) => {
|
||||||
|
const payload = buildUpdatePayload({
|
||||||
|
values,
|
||||||
|
environmentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.URL).toBe(expectedUrl);
|
||||||
|
expect(payload.Name).toBe(values.name);
|
||||||
|
expect(payload.PublicURL).toBe(values.publicUrl);
|
||||||
|
expect(payload.GroupID).toBe(values.meta.groupId);
|
||||||
|
expect(payload.TagIds).toEqual(values.meta.tagIds);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,14 +5,20 @@ import {
|
|||||||
import { UpdateEnvironmentPayload } from '@/react/portainer/environments/queries/useUpdateEnvironmentMutation';
|
import { UpdateEnvironmentPayload } from '@/react/portainer/environments/queries/useUpdateEnvironmentMutation';
|
||||||
import { stripProtocol } from '@/react/common/string-utils';
|
import { stripProtocol } from '@/react/common/string-utils';
|
||||||
|
|
||||||
|
import { isDockerAPIEnvironment, isLocalDockerEnvironment } from '../../utils';
|
||||||
|
|
||||||
import { GeneralEnvironmentFormValues } from './types';
|
import { GeneralEnvironmentFormValues } from './types';
|
||||||
|
|
||||||
export function buildInitialValues(
|
export function buildInitialValues(
|
||||||
environment: Environment
|
environment: Environment
|
||||||
): GeneralEnvironmentFormValues {
|
): GeneralEnvironmentFormValues {
|
||||||
|
const isDockerAPI = isDockerAPIEnvironment(environment);
|
||||||
|
const isLocalDocker = isLocalDockerEnvironment(environment.URL);
|
||||||
return {
|
return {
|
||||||
name: environment.Name,
|
name: environment.Name,
|
||||||
environmentUrl: stripProtocol(environment.URL),
|
environmentUrl: isLocalDocker
|
||||||
|
? environment.URL
|
||||||
|
: stripProtocol(environment.URL),
|
||||||
publicUrl: environment.PublicURL || '',
|
publicUrl: environment.PublicURL || '',
|
||||||
|
|
||||||
meta: {
|
meta: {
|
||||||
@@ -20,54 +26,65 @@ export function buildInitialValues(
|
|||||||
tagIds: environment.TagIds || [],
|
tagIds: environment.TagIds || [],
|
||||||
},
|
},
|
||||||
|
|
||||||
tls: {
|
tls: isDockerAPI
|
||||||
tls: environment.TLSConfig?.TLS || false,
|
? {
|
||||||
skipVerify: environment.TLSConfig?.TLSSkipVerify || false,
|
tls: environment.TLSConfig?.TLS || false,
|
||||||
caCertFile: undefined,
|
skipVerify: environment.TLSConfig?.TLSSkipVerify || false,
|
||||||
certFile: undefined,
|
caCertFile: undefined,
|
||||||
keyFile: undefined,
|
certFile: undefined,
|
||||||
},
|
keyFile: undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildUpdatePayload(
|
export function buildUpdatePayload({
|
||||||
values: GeneralEnvironmentFormValues,
|
environmentType,
|
||||||
environmentType: EnvironmentType
|
values,
|
||||||
): Partial<UpdateEnvironmentPayload> {
|
}: {
|
||||||
|
values: GeneralEnvironmentFormValues;
|
||||||
|
environmentType: EnvironmentType;
|
||||||
|
}): Partial<UpdateEnvironmentPayload> {
|
||||||
return {
|
return {
|
||||||
Name: values.name,
|
Name: values.name,
|
||||||
PublicURL: values.publicUrl,
|
PublicURL: values.publicUrl,
|
||||||
GroupID: values.meta.groupId,
|
GroupID: values.meta.groupId,
|
||||||
TagIds: values.meta.tagIds,
|
TagIds: values.meta.tagIds,
|
||||||
|
|
||||||
URL: formatURL(values.environmentUrl, environmentType),
|
URL: formatURL({
|
||||||
|
url: values.environmentUrl,
|
||||||
|
environmentType,
|
||||||
|
}),
|
||||||
|
|
||||||
TLS: values.tls.tls,
|
TLS: values.tls?.tls,
|
||||||
TLSSkipVerify: values.tls.skipVerify,
|
TLSSkipVerify: values.tls?.skipVerify,
|
||||||
TLSSkipClientVerify: values.tls.skipVerify,
|
TLSSkipClientVerify: values.tls?.skipVerify,
|
||||||
TLSCACert: values.tls.caCertFile,
|
TLSCACert: values.tls?.caCertFile,
|
||||||
TLSCert: values.tls.certFile,
|
TLSCert: values.tls?.certFile,
|
||||||
TLSKey: values.tls.keyFile,
|
TLSKey: values.tls?.keyFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL Formatting Logic (from Angular controller lines 195-242)
|
export function formatURL({
|
||||||
export function formatURL(url: string, type: EnvironmentType): string {
|
environmentType,
|
||||||
if (!url) return '';
|
url,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
environmentType: EnvironmentType;
|
||||||
|
}) {
|
||||||
|
if (!url || isLocalDockerEnvironment(url)) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
// Strip any existing protocol
|
|
||||||
const stripped = stripProtocol(url);
|
const stripped = stripProtocol(url);
|
||||||
|
|
||||||
// Kubernetes Local - prefix https://
|
if (environmentType === EnvironmentType.KubernetesLocal) {
|
||||||
if (type === EnvironmentType.KubernetesLocal) {
|
|
||||||
return `https://${stripped}`;
|
return `https://${stripped}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent on Kubernetes - use as-is
|
if (environmentType === EnvironmentType.AgentOnKubernetes) {
|
||||||
if (type === EnvironmentType.AgentOnKubernetes) {
|
|
||||||
return stripped;
|
return stripped;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default (Docker) - prefix tcp://
|
|
||||||
return `tcp://${stripped}`;
|
return `tcp://${stripped}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface GeneralEnvironmentFormValues {
|
|||||||
environmentUrl: string;
|
environmentUrl: string;
|
||||||
publicUrl: string;
|
publicUrl: string;
|
||||||
|
|
||||||
tls: TLSConfig;
|
tls?: TLSConfig;
|
||||||
|
|
||||||
meta: EnvironmentMetadata;
|
meta: EnvironmentMetadata;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function useGeneralValidation({
|
|||||||
? string().required('Environment address is required')
|
? string().required('Environment address is required')
|
||||||
: string().default(''),
|
: string().default(''),
|
||||||
publicUrl: string().default(''),
|
publicUrl: string().default(''),
|
||||||
tls: tlsConfigValidation({ optionalCert: true }),
|
tls: tlsConfigValidation({ optionalCert: true }).optional(),
|
||||||
meta: metadataValidation(),
|
meta: metadataValidation(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { Link } from '@/react/components/Link';
|
|||||||
import { Icon } from '@/react/components/Icon';
|
import { Icon } from '@/react/components/Icon';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
environmentId: EnvironmentId;
|
environmentId?: EnvironmentId;
|
||||||
environmentType: EnvironmentType;
|
environmentType?: EnvironmentType;
|
||||||
edgeId?: string;
|
edgeId?: string;
|
||||||
status: EnvironmentStatus;
|
status: EnvironmentStatus;
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,10 @@ export function KubeConfigInfo({
|
|||||||
edgeId,
|
edgeId,
|
||||||
status,
|
status,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
if (!environmentType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const isVisible =
|
const isVisible =
|
||||||
isKubernetesEnvironment(environmentType) &&
|
isKubernetesEnvironment(environmentType) &&
|
||||||
(!isEdgeEnvironment(environmentType) || !!edgeId) &&
|
(!isEdgeEnvironment(environmentType) || !!edgeId) &&
|
||||||
|
|||||||
+266
@@ -0,0 +1,266 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { http, HttpResponse, DefaultBodyType } from 'msw';
|
||||||
|
|
||||||
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
|
import { server } from '@/setup-tests/server';
|
||||||
|
|
||||||
|
import { CreateGroupView } from './CreateGroupView';
|
||||||
|
|
||||||
|
vi.mock('@/react/hooks/useCanExit', () => ({
|
||||||
|
useCanExit: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/portainer/services/notifications', () => ({
|
||||||
|
notifyError: vi.fn(),
|
||||||
|
notifySuccess: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderCreateGroupView({
|
||||||
|
onMutationError,
|
||||||
|
}: { onMutationError?(): void } = {}) {
|
||||||
|
// Set up default mocks
|
||||||
|
server.use(
|
||||||
|
http.get('/api/tags', () =>
|
||||||
|
HttpResponse.json([
|
||||||
|
{ ID: 1, Name: 'production' },
|
||||||
|
{ ID: 2, Name: 'staging' },
|
||||||
|
])
|
||||||
|
),
|
||||||
|
http.get('/api/endpoints', () =>
|
||||||
|
HttpResponse.json([], {
|
||||||
|
headers: {
|
||||||
|
'x-total-count': '0',
|
||||||
|
'x-total-available': '0',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(CreateGroupView)),
|
||||||
|
{ onMutationError }
|
||||||
|
);
|
||||||
|
|
||||||
|
return render(<Wrapped />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CreateGroupView', () => {
|
||||||
|
describe('Page rendering', () => {
|
||||||
|
it('should render the page header with correct title', async () => {
|
||||||
|
renderCreateGroupView();
|
||||||
|
|
||||||
|
// Use heading role to get the h1 title specifically (not the breadcrumb)
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', { name: /Create group/i })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render breadcrumbs with link to Groups', async () => {
|
||||||
|
renderCreateGroupView();
|
||||||
|
|
||||||
|
// Use the data-cy attribute to find the breadcrumb link
|
||||||
|
expect(await screen.findByTestId('breadcrumb-Groups')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the form', async () => {
|
||||||
|
renderCreateGroupView();
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText(/Name/i)).toBeVisible();
|
||||||
|
expect(screen.getByLabelText(/Description/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the Create button', async () => {
|
||||||
|
renderCreateGroupView();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('button', { name: /Create/i })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form submission and payload validation', () => {
|
||||||
|
it('should submit the correct API payload with all fields', async () => {
|
||||||
|
let requestBody: DefaultBodyType;
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post('/api/endpoint_groups', async ({ request }) => {
|
||||||
|
requestBody = await request.json();
|
||||||
|
return HttpResponse.json({
|
||||||
|
Id: 1,
|
||||||
|
Name: 'test-group',
|
||||||
|
Description: 'Test description',
|
||||||
|
TagIds: [],
|
||||||
|
Policies: [],
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderCreateGroupView();
|
||||||
|
|
||||||
|
// Fill in the name
|
||||||
|
const nameInput = await screen.findByLabelText(/Name/i);
|
||||||
|
await user.type(nameInput, 'test-group');
|
||||||
|
|
||||||
|
// Fill in the description
|
||||||
|
const descriptionInput = screen.getByLabelText(/Description/i);
|
||||||
|
await user.type(descriptionInput, 'Test description');
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
const submitButton = screen.getByRole('button', { name: /Create/i });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
// Verify the request body matches expected API payload
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requestBody).toEqual({
|
||||||
|
Name: 'test-group',
|
||||||
|
Description: 'Test description',
|
||||||
|
TagIDs: [],
|
||||||
|
AssociatedEndpoints: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should submit the correct API payload with minimal fields (name only)', async () => {
|
||||||
|
let requestBody: DefaultBodyType;
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post('/api/endpoint_groups', async ({ request }) => {
|
||||||
|
requestBody = await request.json();
|
||||||
|
return HttpResponse.json({
|
||||||
|
Id: 1,
|
||||||
|
Name: 'minimal-group',
|
||||||
|
Description: '',
|
||||||
|
TagIds: [],
|
||||||
|
Policies: [],
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderCreateGroupView();
|
||||||
|
|
||||||
|
// Fill in only the name (required)
|
||||||
|
const nameInput = await screen.findByLabelText(/Name/i);
|
||||||
|
await user.type(nameInput, 'minimal-group');
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
const submitButton = screen.getByRole('button', { name: /Create/i });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
// Verify the request body
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requestBody).toEqual({
|
||||||
|
Name: 'minimal-group',
|
||||||
|
Description: '',
|
||||||
|
TagIDs: [],
|
||||||
|
AssociatedEndpoints: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show Creating... loading state during submission', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('/api/endpoint_groups', async () => {
|
||||||
|
// Delay response to test loading state
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 100);
|
||||||
|
});
|
||||||
|
return HttpResponse.json({
|
||||||
|
Id: 1,
|
||||||
|
Name: 'test-group',
|
||||||
|
Description: '',
|
||||||
|
TagIds: [],
|
||||||
|
Policies: [],
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderCreateGroupView();
|
||||||
|
|
||||||
|
const nameInput = await screen.findByLabelText(/Name/i);
|
||||||
|
await user.type(nameInput, 'test-group');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: /Create/i });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
// Should show loading state
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('button', { name: /Creating.../i })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error handling', () => {
|
||||||
|
it('should handle API error gracefully', async () => {
|
||||||
|
const consoleErrorSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const mutationError = vi.fn();
|
||||||
|
const errorMessage = 'Failed to create group';
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post('/api/endpoint_groups', () =>
|
||||||
|
HttpResponse.json({ message: errorMessage }, { status: 500 })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderCreateGroupView({ onMutationError: mutationError });
|
||||||
|
|
||||||
|
const nameInput = await screen.findByLabelText(/Name/i);
|
||||||
|
await user.type(nameInput, 'test-group');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: /Create/i });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mutationError).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Validation', () => {
|
||||||
|
it('should show validation error for empty name', async () => {
|
||||||
|
renderCreateGroupView();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByText(/Name is required/i)[0]).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable submit button when name is empty', async () => {
|
||||||
|
renderCreateGroupView();
|
||||||
|
|
||||||
|
const submitButton = await screen.findByRole('button', {
|
||||||
|
name: /Create/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(submitButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
import { FormikHelpers } from 'formik';
|
||||||
|
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
|
import { Widget } from '@@/Widget';
|
||||||
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
|
||||||
|
import { useCreateGroupMutation } from '../queries/useCreateGroupMutation';
|
||||||
|
import { GroupForm, GroupFormValues } from '../components/GroupForm';
|
||||||
|
|
||||||
|
export function CreateGroupView() {
|
||||||
|
const router = useRouter();
|
||||||
|
const createMutation = useCreateGroupMutation();
|
||||||
|
|
||||||
|
const initialValues: GroupFormValues = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
tagIds: [],
|
||||||
|
associatedEnvironments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Create group"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: 'Groups', link: 'portainer.groups' },
|
||||||
|
{ label: 'Create group' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<Widget>
|
||||||
|
<Widget.Body>
|
||||||
|
<GroupForm
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitLabel="Create"
|
||||||
|
submitLoadingLabel="Creating..."
|
||||||
|
/>
|
||||||
|
</Widget.Body>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSubmit(
|
||||||
|
values: GroupFormValues,
|
||||||
|
{ resetForm }: FormikHelpers<GroupFormValues>
|
||||||
|
) {
|
||||||
|
await createMutation.mutateAsync(
|
||||||
|
{
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
tagIds: values.tagIds,
|
||||||
|
associatedEnvironments: values.associatedEnvironments,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
resetForm();
|
||||||
|
notifySuccess('Success', 'Group successfully created');
|
||||||
|
router.stateService.go('portainer.groups');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { http, HttpResponse, DefaultBodyType } from 'msw';
|
||||||
|
|
||||||
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
|
import { server } from '@/setup-tests/server';
|
||||||
|
import {
|
||||||
|
Environment,
|
||||||
|
EnvironmentType,
|
||||||
|
EnvironmentStatus,
|
||||||
|
} from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { EnvironmentGroup } from '../types';
|
||||||
|
|
||||||
|
import { EditGroupView } from './EditGroupView';
|
||||||
|
|
||||||
|
vi.mock('@/react/hooks/useCanExit', () => ({
|
||||||
|
useCanExit: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/react/hooks/useIdParam', () => ({
|
||||||
|
useIdParam: () => 2, // Default to group ID 2 for most tests
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/portainer/services/notifications', () => ({
|
||||||
|
notifyError: vi.fn(),
|
||||||
|
notifySuccess: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGroup: EnvironmentGroup = {
|
||||||
|
Id: 2,
|
||||||
|
Name: 'Test Group',
|
||||||
|
Description: 'Test description',
|
||||||
|
TagIds: [1],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEnvironment: Partial<Environment> = {
|
||||||
|
Id: 1,
|
||||||
|
Name: 'Test Environment',
|
||||||
|
Type: EnvironmentType.Docker,
|
||||||
|
Status: EnvironmentStatus.Up,
|
||||||
|
GroupId: 2,
|
||||||
|
TagIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildMockEnvironment(
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
groupId: number = 1
|
||||||
|
): Partial<Environment> {
|
||||||
|
return {
|
||||||
|
Id: id,
|
||||||
|
Name: name,
|
||||||
|
Type: EnvironmentType.Docker,
|
||||||
|
Status: EnvironmentStatus.Up,
|
||||||
|
GroupId: groupId,
|
||||||
|
TagIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditGroupView({
|
||||||
|
onMutationError,
|
||||||
|
groupData = mockGroup,
|
||||||
|
associatedEnvironments = [mockEnvironment],
|
||||||
|
}: {
|
||||||
|
onMutationError?(): void;
|
||||||
|
groupData?: EnvironmentGroup | null;
|
||||||
|
associatedEnvironments?: Array<Partial<Environment>>;
|
||||||
|
} = {}) {
|
||||||
|
// Set up default mocks
|
||||||
|
server.use(
|
||||||
|
http.get('/api/tags', () =>
|
||||||
|
HttpResponse.json([
|
||||||
|
{ ID: 1, Name: 'production' },
|
||||||
|
{ ID: 2, Name: 'staging' },
|
||||||
|
])
|
||||||
|
),
|
||||||
|
http.get<object, never, EnvironmentGroup | { message: string }>(
|
||||||
|
'/api/endpoint_groups/2',
|
||||||
|
() => {
|
||||||
|
if (groupData === null) {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ message: 'Group not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return HttpResponse.json(groupData);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
// Mock for environments query (associated environments)
|
||||||
|
http.get('/api/endpoints', ({ request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
// Get all groupIds values (handles both groupIds=2 and groupIds[]=2 formats)
|
||||||
|
const groupIdsParam = url.searchParams.getAll('groupIds');
|
||||||
|
const groupIds =
|
||||||
|
groupIdsParam.length > 0
|
||||||
|
? groupIdsParam
|
||||||
|
: url.searchParams.getAll('groupIds[]');
|
||||||
|
const endpointIdsParam = url.searchParams.getAll('endpointIds');
|
||||||
|
const endpointIds =
|
||||||
|
endpointIdsParam.length > 0
|
||||||
|
? endpointIdsParam
|
||||||
|
: url.searchParams.getAll('endpointIds[]');
|
||||||
|
|
||||||
|
// Helper to create response with required headers
|
||||||
|
function createResponse(envs: Array<Partial<Environment>>) {
|
||||||
|
return HttpResponse.json(envs, {
|
||||||
|
headers: {
|
||||||
|
'x-total-count': String(envs.length),
|
||||||
|
'x-total-available': String(envs.length),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If querying by endpointIds (AssociatedEnvironmentsSelector initial query)
|
||||||
|
if (endpointIds.length > 0) {
|
||||||
|
const ids = endpointIds.map(Number);
|
||||||
|
const envs = associatedEnvironments.filter((e) =>
|
||||||
|
ids.includes(e.Id as number)
|
||||||
|
);
|
||||||
|
return createResponse(envs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If querying for group's associated environments
|
||||||
|
if (groupIds.includes('2')) {
|
||||||
|
return createResponse(associatedEnvironments);
|
||||||
|
}
|
||||||
|
// For available environments (unassigned group = 1)
|
||||||
|
if (groupIds.includes('1')) {
|
||||||
|
return createResponse([
|
||||||
|
buildMockEnvironment(10, 'Available Env 1'),
|
||||||
|
buildMockEnvironment(11, 'Available Env 2'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return createResponse([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EditGroupView)),
|
||||||
|
{ onMutationError }
|
||||||
|
);
|
||||||
|
|
||||||
|
return render(<Wrapped />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EditGroupView', () => {
|
||||||
|
describe('Page rendering', () => {
|
||||||
|
it('should render the page header with correct title', async () => {
|
||||||
|
renderEditGroupView();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Environment group details')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render breadcrumbs with link to Groups', async () => {
|
||||||
|
renderEditGroupView();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Groups')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render group name in breadcrumbs after loading', async () => {
|
||||||
|
renderEditGroupView();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Test Group')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the Update button', async () => {
|
||||||
|
renderEditGroupView();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('button', { name: /Update/i })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loading state', () => {
|
||||||
|
it('should not show form while fetching group data', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoint_groups/2', async () => {
|
||||||
|
// Delay response to test loading state
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 100);
|
||||||
|
});
|
||||||
|
return HttpResponse.json(mockGroup);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
renderEditGroupView();
|
||||||
|
|
||||||
|
// Form should not be visible initially while loading
|
||||||
|
expect(screen.queryByLabelText(/Name/i)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// After loading completes, form should appear
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/Name/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should populate form with fetched group data', async () => {
|
||||||
|
renderEditGroupView();
|
||||||
|
|
||||||
|
// Wait for data to load and populate the form
|
||||||
|
const nameInput = await screen.findByLabelText(/Name/i);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(nameInput).toHaveValue('Test Group');
|
||||||
|
});
|
||||||
|
|
||||||
|
const descriptionInput = screen.getByLabelText(/Description/i);
|
||||||
|
expect(descriptionInput).toHaveValue('Test description');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show Associated environments section for non-unassigned groups', async () => {
|
||||||
|
renderEditGroupView();
|
||||||
|
|
||||||
|
// Wait for form to load
|
||||||
|
await screen.findByLabelText(/Name/i);
|
||||||
|
|
||||||
|
// Check that at least one "Associated environments" text exists (section + table title)
|
||||||
|
const elements = screen.getAllByText(/Associated environments/i);
|
||||||
|
expect(elements.length).toBeGreaterThan(0);
|
||||||
|
expect(elements[0]).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error state', () => {
|
||||||
|
it('should show error Alert when group fetch fails', async () => {
|
||||||
|
renderEditGroupView({ groupData: null });
|
||||||
|
|
||||||
|
// Should show the error message
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/Failed to load group details/i)
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error alert with Error title', async () => {
|
||||||
|
renderEditGroupView({ groupData: null });
|
||||||
|
|
||||||
|
// Wait for the error alert to appear by finding the error message
|
||||||
|
await screen.findByText(/Failed to load group details/i);
|
||||||
|
|
||||||
|
// Check that the Error title is shown
|
||||||
|
expect(screen.getByText('Error')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT show the form when group fetch fails', async () => {
|
||||||
|
renderEditGroupView({ groupData: null });
|
||||||
|
|
||||||
|
// Wait for the error message to appear
|
||||||
|
await screen.findByText(/Failed to load group details/i);
|
||||||
|
|
||||||
|
// Form fields should not be visible
|
||||||
|
expect(screen.queryByLabelText(/Name/i)).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: /Update/i })
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form submission and payload validation', () => {
|
||||||
|
it('should submit the correct API payload when updating', async () => {
|
||||||
|
let requestBody: DefaultBodyType;
|
||||||
|
let requestUrl: string;
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.put('/api/endpoint_groups/:id', async ({ request, params }) => {
|
||||||
|
requestBody = await request.json();
|
||||||
|
requestUrl = `/api/endpoint_groups/${params.id}`;
|
||||||
|
return HttpResponse.json({
|
||||||
|
...mockGroup,
|
||||||
|
Name: 'Updated Group',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderEditGroupView();
|
||||||
|
|
||||||
|
// Wait for form to populate
|
||||||
|
const nameInput = await screen.findByLabelText(/Name/i);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(nameInput).toHaveValue('Test Group');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear and update the name
|
||||||
|
await user.clear(nameInput);
|
||||||
|
await user.type(nameInput, 'Updated Group');
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
const submitButton = screen.getByRole('button', { name: /Update/i });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
// Verify the request URL and body
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requestUrl).toBe('/api/endpoint_groups/2');
|
||||||
|
expect(requestBody).toEqual({
|
||||||
|
Name: 'Updated Group',
|
||||||
|
Description: 'Test description',
|
||||||
|
TagIDs: [1],
|
||||||
|
AssociatedEndpoints: [1], // The associated environment ID
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should submit updated description correctly', async () => {
|
||||||
|
let requestBody: DefaultBodyType;
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.put('/api/endpoint_groups/:id', async ({ request }) => {
|
||||||
|
requestBody = await request.json();
|
||||||
|
return HttpResponse.json({
|
||||||
|
...mockGroup,
|
||||||
|
Description: 'New description',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderEditGroupView();
|
||||||
|
|
||||||
|
// Wait for form to populate
|
||||||
|
const descriptionInput = await screen.findByLabelText(/Description/i);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(descriptionInput).toHaveValue('Test description');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear and update the description
|
||||||
|
await user.clear(descriptionInput);
|
||||||
|
await user.type(descriptionInput, 'New description');
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
const submitButton = screen.getByRole('button', { name: /Update/i });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
// Verify the request body includes new description
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requestBody).toMatchObject({
|
||||||
|
Description: 'New description',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show Updating... loading state during submission', async () => {
|
||||||
|
server.use(
|
||||||
|
http.put('/api/endpoint_groups/:id', async () => {
|
||||||
|
// Delay response to test loading state
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 100);
|
||||||
|
});
|
||||||
|
return HttpResponse.json(mockGroup);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderEditGroupView();
|
||||||
|
|
||||||
|
// Wait for form to populate
|
||||||
|
const nameInput = await screen.findByLabelText(/Name/i);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(nameInput).toHaveValue('Test Group');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make a change to enable the submit button
|
||||||
|
await user.clear(nameInput);
|
||||||
|
await user.type(nameInput, 'Changed Name');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: /Update/i });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
// Should show loading state
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('button', { name: /Updating.../i })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error handling on update', () => {
|
||||||
|
it('should handle API error gracefully on update', async () => {
|
||||||
|
const consoleErrorSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const mutationError = vi.fn();
|
||||||
|
const errorMessage = 'Failed to update group';
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.put('/api/endpoint_groups/:id', () =>
|
||||||
|
HttpResponse.json({ message: errorMessage }, { status: 500 })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderEditGroupView({ onMutationError: mutationError });
|
||||||
|
|
||||||
|
// Wait for form to populate
|
||||||
|
const nameInput = await screen.findByLabelText(/Name/i);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(nameInput).toHaveValue('Test Group');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make a change
|
||||||
|
await user.clear(nameInput);
|
||||||
|
await user.type(nameInput, 'Changed Name');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: /Update/i });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mutationError).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Associated environments', () => {
|
||||||
|
it('should display initially associated environments', async () => {
|
||||||
|
renderEditGroupView({
|
||||||
|
associatedEnvironments: [
|
||||||
|
{ ...mockEnvironment, Id: 1, Name: 'Env 1' } as Partial<Environment>,
|
||||||
|
{ ...mockEnvironment, Id: 2, Name: 'Env 2' } as Partial<Environment>,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the form to load
|
||||||
|
await screen.findByLabelText(/Name/i);
|
||||||
|
|
||||||
|
// Check that at least one "Associated environments" text exists (section + table title)
|
||||||
|
const elements = screen.getAllByText(/Associated environments/i);
|
||||||
|
expect(elements.length).toBeGreaterThan(0);
|
||||||
|
expect(elements[0]).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include associated environment IDs in update payload', async () => {
|
||||||
|
let requestBody: DefaultBodyType;
|
||||||
|
|
||||||
|
const associatedEnvs = [
|
||||||
|
{
|
||||||
|
...mockEnvironment,
|
||||||
|
Id: 100,
|
||||||
|
Name: 'Env 100',
|
||||||
|
} as Partial<Environment>,
|
||||||
|
{
|
||||||
|
...mockEnvironment,
|
||||||
|
Id: 200,
|
||||||
|
Name: 'Env 200',
|
||||||
|
} as Partial<Environment>,
|
||||||
|
];
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.put('/api/endpoint_groups/:id', async ({ request }) => {
|
||||||
|
requestBody = await request.json();
|
||||||
|
return HttpResponse.json(mockGroup);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderEditGroupView({
|
||||||
|
associatedEnvironments: associatedEnvs,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for form to populate
|
||||||
|
const nameInput = await screen.findByLabelText(/Name/i);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(nameInput).toHaveValue('Test Group');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make a small change to enable submit
|
||||||
|
await user.type(nameInput, ' edited');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: /Update/i });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
// Verify the associated environments are included in payload
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requestBody).toMatchObject({
|
||||||
|
AssociatedEndpoints: [100, 200],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { FormikHelpers } from 'formik';
|
||||||
|
|
||||||
|
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import { useIdParam } from '@/react/hooks/useIdParam';
|
||||||
|
|
||||||
|
import { Widget } from '@@/Widget';
|
||||||
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
import { Alert } from '@@/Alert';
|
||||||
|
|
||||||
|
import { useGroup } from '../queries/useGroup';
|
||||||
|
import { useUpdateGroupMutation } from '../queries/useUpdateGroupMutation';
|
||||||
|
import { GroupForm, GroupFormValues } from '../components/GroupForm';
|
||||||
|
|
||||||
|
export function EditGroupView() {
|
||||||
|
const groupId = useIdParam();
|
||||||
|
const router = useRouter();
|
||||||
|
const groupQuery = useGroup(groupId);
|
||||||
|
const updateMutation = useUpdateGroupMutation();
|
||||||
|
|
||||||
|
// Fetch associated environments for this group (not for unassigned group)
|
||||||
|
const isUnassignedGroup = groupId === 1;
|
||||||
|
const environmentsQuery = useEnvironmentList(
|
||||||
|
{ groupIds: [groupId] },
|
||||||
|
{ enabled: !!groupId && !isUnassignedGroup }
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
groupQuery.isLoading || (!isUnassignedGroup && environmentsQuery.isLoading);
|
||||||
|
|
||||||
|
const initialValues: GroupFormValues = useMemo(
|
||||||
|
() => ({
|
||||||
|
name: groupQuery.data?.Name ?? '',
|
||||||
|
description: groupQuery.data?.Description ?? '',
|
||||||
|
tagIds: groupQuery.data?.TagIds ?? [],
|
||||||
|
associatedEnvironments:
|
||||||
|
environmentsQuery.environments?.map((e) => e.Id) ?? [],
|
||||||
|
}),
|
||||||
|
[groupQuery.data, environmentsQuery.environments]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Environment group details"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: 'Groups', link: 'portainer.groups' },
|
||||||
|
{ label: groupQuery.data?.Name ?? 'Edit group' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<Widget>
|
||||||
|
<Widget.Body loading={isLoading}>
|
||||||
|
{groupQuery.isError && (
|
||||||
|
<Alert color="error" title="Error">
|
||||||
|
Failed to load group details
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{!groupQuery.isError && groupQuery.data && (
|
||||||
|
<GroupForm
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitLabel="Update"
|
||||||
|
submitLoadingLabel="Updating..."
|
||||||
|
groupId={groupId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Widget.Body>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSubmit(
|
||||||
|
values: GroupFormValues,
|
||||||
|
{ resetForm }: FormikHelpers<GroupFormValues>
|
||||||
|
) {
|
||||||
|
await updateMutation.mutateAsync(
|
||||||
|
{
|
||||||
|
id: groupId,
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
tagIds: values.tagIds,
|
||||||
|
associatedEnvironments: values.associatedEnvironments,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
resetForm();
|
||||||
|
notifySuccess('Success', 'Group successfully updated');
|
||||||
|
router.stateService.go('portainer.groups');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
-61
@@ -1,61 +0,0 @@
|
|||||||
import { EnvironmentId } from '../../types';
|
|
||||||
|
|
||||||
import { GroupAssociationTable } from './GroupAssociationTable';
|
|
||||||
|
|
||||||
export function AssociatedEnvironmentsSelector({
|
|
||||||
onChange,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
onChange: (
|
|
||||||
value: EnvironmentId[],
|
|
||||||
meta: { type: 'add' | 'remove'; value: EnvironmentId }
|
|
||||||
) => void;
|
|
||||||
value: EnvironmentId[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="col-sm-12 small text-muted">
|
|
||||||
You can select which environment should be part of this group by moving
|
|
||||||
them to the associated environments table. Simply click on any
|
|
||||||
environment entry to move it from one table to the other.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-sm-12 mt-4">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="w-1/2">
|
|
||||||
<GroupAssociationTable
|
|
||||||
title="Available environments"
|
|
||||||
query={{
|
|
||||||
groupIds: [1],
|
|
||||||
excludeIds: value,
|
|
||||||
}}
|
|
||||||
onClickRow={(env) => {
|
|
||||||
if (!value.includes(env.Id)) {
|
|
||||||
onChange([...value, env.Id], { type: 'add', value: env.Id });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
data-cy="edgeGroupCreate-availableEndpoints"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-1/2">
|
|
||||||
<GroupAssociationTable
|
|
||||||
title="Associated environments"
|
|
||||||
query={{
|
|
||||||
endpointIds: value,
|
|
||||||
}}
|
|
||||||
onClickRow={(env) => {
|
|
||||||
if (value.includes(env.Id)) {
|
|
||||||
onChange(
|
|
||||||
value.filter((id) => id !== env.Id),
|
|
||||||
{ type: 'remove', value: env.Id }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
data-cy="edgeGroupCreate-associatedEndpoints"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { server } from '@/setup-tests/server';
|
||||||
|
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
||||||
|
import {
|
||||||
|
Environment,
|
||||||
|
EnvironmentId,
|
||||||
|
} from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { AssociatedEnvironmentsSelector } from './AssociatedEnvironmentsSelector';
|
||||||
|
|
||||||
|
function createEnv(id: EnvironmentId, name: string): Environment {
|
||||||
|
return createMockEnvironment({ Id: id, Name: name, GroupId: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMockServer(environments: Array<Environment> = []) {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoints', () =>
|
||||||
|
HttpResponse.json(environments, {
|
||||||
|
headers: {
|
||||||
|
'x-total-count': String(environments.length),
|
||||||
|
'x-total-available': String(environments.length),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComponent({
|
||||||
|
associatedEnvironmentIds = [] as Array<EnvironmentId>,
|
||||||
|
initialAssociatedEnvironmentIds = [] as Array<EnvironmentId>,
|
||||||
|
onChange = vi.fn(),
|
||||||
|
}: {
|
||||||
|
associatedEnvironmentIds?: Array<EnvironmentId>;
|
||||||
|
initialAssociatedEnvironmentIds?: Array<EnvironmentId>;
|
||||||
|
onChange?: (ids: Array<EnvironmentId>) => void;
|
||||||
|
} = {}) {
|
||||||
|
const Wrapped = withTestQueryProvider(() => (
|
||||||
|
<AssociatedEnvironmentsSelector
|
||||||
|
associatedEnvironmentIds={associatedEnvironmentIds}
|
||||||
|
initialAssociatedEnvironmentIds={initialAssociatedEnvironmentIds}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...render(<Wrapped />),
|
||||||
|
onChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AssociatedEnvironmentsSelector', () => {
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should render both Available and Associated environments tables', async () => {
|
||||||
|
setupMockServer();
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('heading', { name: 'Available environments' })
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('heading', { name: 'Associated environments' })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render instruction text', async () => {
|
||||||
|
setupMockServer();
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/click on any environment entry to move it/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render Associated environments table with data-cy attribute', async () => {
|
||||||
|
setupMockServer();
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('group-associatedEndpoints')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display initially associated environments in Associated table', async () => {
|
||||||
|
const envs = [createEnv(10, 'associated-env-1')];
|
||||||
|
|
||||||
|
setupMockServer(envs);
|
||||||
|
renderComponent({
|
||||||
|
associatedEnvironmentIds: [10],
|
||||||
|
initialAssociatedEnvironmentIds: [10],
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('associated-env-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Adding environments', () => {
|
||||||
|
it('should call onChange with new environment ID when clicking an available environment', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
const envs = [createEnv(1, 'available-env')];
|
||||||
|
setupMockServer(envs);
|
||||||
|
|
||||||
|
renderComponent({ onChange });
|
||||||
|
|
||||||
|
const envRow = await screen.findByText('available-env');
|
||||||
|
await user.click(envRow);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append new environment to existing associated IDs', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
const envs = [createEnv(1, 'available-env'), createEnv(10, 'existing')];
|
||||||
|
setupMockServer(envs);
|
||||||
|
|
||||||
|
renderComponent({
|
||||||
|
associatedEnvironmentIds: [10],
|
||||||
|
initialAssociatedEnvironmentIds: [10],
|
||||||
|
onChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the available table to be ready and find the row
|
||||||
|
const availableTable = await screen.findByTestId(
|
||||||
|
'group-availableEndpoints'
|
||||||
|
);
|
||||||
|
await within(availableTable).findByText('available-env');
|
||||||
|
|
||||||
|
// Find the row element that contains the text and click it
|
||||||
|
const rows = within(availableTable).getAllByRole('row');
|
||||||
|
const envRow = rows.find(
|
||||||
|
(row) => row.textContent?.includes('available-env')
|
||||||
|
);
|
||||||
|
expect(envRow).toBeDefined();
|
||||||
|
await user.click(envRow!);
|
||||||
|
|
||||||
|
// Wait for onChange to be called with the new environment ID appended
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onChange).toHaveBeenCalledWith([10, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Removing environments', () => {
|
||||||
|
it('should call onChange without the removed environment ID', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
const envs = [
|
||||||
|
createEnv(10, 'associated-env-1'),
|
||||||
|
createEnv(11, 'associated-env-2'),
|
||||||
|
];
|
||||||
|
setupMockServer(envs);
|
||||||
|
|
||||||
|
renderComponent({
|
||||||
|
associatedEnvironmentIds: [10, 11],
|
||||||
|
initialAssociatedEnvironmentIds: [10, 11],
|
||||||
|
onChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for initial query to load and row to appear in Associated table, then click
|
||||||
|
const associatedTable = await screen.findByTestId(
|
||||||
|
'group-associatedEndpoints'
|
||||||
|
);
|
||||||
|
const envRow =
|
||||||
|
await within(associatedTable).findByText('associated-env-1');
|
||||||
|
await user.click(envRow);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith([11]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onChange with empty array when removing last environment', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
const envs = [createEnv(10, 'only-env')];
|
||||||
|
setupMockServer(envs);
|
||||||
|
|
||||||
|
renderComponent({
|
||||||
|
associatedEnvironmentIds: [10],
|
||||||
|
initialAssociatedEnvironmentIds: [10],
|
||||||
|
onChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const associatedTable = await screen.findByTestId(
|
||||||
|
'group-associatedEndpoints'
|
||||||
|
);
|
||||||
|
const envRow = await within(associatedTable).findByText('only-env');
|
||||||
|
await user.click(envRow);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Computed values', () => {
|
||||||
|
it('should identify added IDs (current but not initial)', () => {
|
||||||
|
// addedIds = associatedEnvironmentIds.filter(id => !initialAssociatedEnvironmentIds.includes(id))
|
||||||
|
// When current=[1,2,3] and initial=[2,3], added=[1]
|
||||||
|
setupMockServer();
|
||||||
|
|
||||||
|
// This test validates the component's internal logic by checking the highlightIds
|
||||||
|
// passed to AssociatedEnvironmentsTable (newly added envs get "Unsaved" badge)
|
||||||
|
renderComponent({
|
||||||
|
associatedEnvironmentIds: [1, 2, 3],
|
||||||
|
initialAssociatedEnvironmentIds: [2, 3],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The component will compute addedIds=[1] internally
|
||||||
|
// We can't directly test internal state, but we verify it renders
|
||||||
|
expect(screen.getByTestId('group-associatedEndpoints')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify removed IDs (initial but not current)', () => {
|
||||||
|
// removedIds = initialAssociatedEnvironmentIds.filter(id => !associatedEnvironmentIds.includes(id))
|
||||||
|
// When current=[2,3] and initial=[1,2,3], removed=[1]
|
||||||
|
setupMockServer();
|
||||||
|
|
||||||
|
renderComponent({
|
||||||
|
associatedEnvironmentIds: [2, 3],
|
||||||
|
initialAssociatedEnvironmentIds: [1, 2, 3],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The component will compute removedIds=[1] internally
|
||||||
|
// and pass it as includeIds to AvailableEnvironmentsTable
|
||||||
|
expect(screen.getByTestId('group-availableEndpoints')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||||
|
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
|
||||||
|
import { Environment, EnvironmentId } from '../../../types';
|
||||||
|
|
||||||
|
import { EnvironmentTableData } from './types';
|
||||||
|
import { AssociatedEnvironmentsTable } from './AssociatedEnvironmentsTable';
|
||||||
|
import { AvailableEnvironmentsTable } from './AvailableEnvironmentsTable';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** IDs of currently associated environments */
|
||||||
|
associatedEnvironmentIds: Array<EnvironmentId>;
|
||||||
|
/** IDs of initially associated environments for tracking unsaved changes */
|
||||||
|
initialAssociatedEnvironmentIds: Array<EnvironmentId>;
|
||||||
|
/** Called when environment IDs change */
|
||||||
|
onChange: (ids: Array<EnvironmentId>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssociatedEnvironmentsSelector({
|
||||||
|
associatedEnvironmentIds,
|
||||||
|
initialAssociatedEnvironmentIds,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
// Track full environment objects for display (populated when clicking rows)
|
||||||
|
const [environmentCache, setEnvironmentCache] = useState<
|
||||||
|
Map<EnvironmentId, EnvironmentTableData>
|
||||||
|
>(new Map());
|
||||||
|
|
||||||
|
// Fetch initially associated environments to populate the cache
|
||||||
|
const initialEnvsQuery = useEnvironmentList(
|
||||||
|
{
|
||||||
|
endpointIds: initialAssociatedEnvironmentIds,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: initialAssociatedEnvironmentIds.length > 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const environmentMap = buildEnvironmentMap(
|
||||||
|
environmentCache,
|
||||||
|
initialEnvsQuery.environments
|
||||||
|
);
|
||||||
|
const addedIds = associatedEnvironmentIds.filter(
|
||||||
|
(id) => !initialAssociatedEnvironmentIds.includes(id)
|
||||||
|
);
|
||||||
|
const removedIds = initialAssociatedEnvironmentIds.filter(
|
||||||
|
(id) => !associatedEnvironmentIds.includes(id)
|
||||||
|
);
|
||||||
|
const associatedEnvironments = associatedEnvironmentIds
|
||||||
|
.map((id) => environmentMap.get(id))
|
||||||
|
.filter((env): env is Environment => env !== undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection title="Associated environments">
|
||||||
|
<div className="small text-muted">
|
||||||
|
You can select which environment should be part of this group by moving
|
||||||
|
them to the associated environments table. Simply click on any
|
||||||
|
environment entry to move it from one table to the other.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex mt-4 gap-5 items-stretch">
|
||||||
|
<div className="w-1/2 flex flex-col">
|
||||||
|
<AvailableEnvironmentsTable
|
||||||
|
title="Available environments"
|
||||||
|
excludeIds={associatedEnvironmentIds}
|
||||||
|
includeIds={removedIds}
|
||||||
|
onClickRow={handleAddEnvironment}
|
||||||
|
data-cy="group-availableEndpoints"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/2 flex flex-col">
|
||||||
|
<AssociatedEnvironmentsTable
|
||||||
|
title="Associated environments"
|
||||||
|
environments={associatedEnvironments}
|
||||||
|
highlightIds={addedIds}
|
||||||
|
onClickRow={handleRemoveEnvironment}
|
||||||
|
data-cy="group-associatedEndpoints"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleAddEnvironment(env: EnvironmentTableData) {
|
||||||
|
if (!associatedEnvironmentIds.includes(env.Id)) {
|
||||||
|
setEnvironmentCache((prev) => new Map(prev).set(env.Id, env));
|
||||||
|
onChange([...associatedEnvironmentIds, env.Id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveEnvironment(env: EnvironmentTableData) {
|
||||||
|
onChange(associatedEnvironmentIds.filter((id) => id !== env.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEnvironmentMap(
|
||||||
|
cache: Map<EnvironmentId, EnvironmentTableData>,
|
||||||
|
envs: Array<Environment> | undefined
|
||||||
|
): Map<EnvironmentId, EnvironmentTableData> {
|
||||||
|
return new Map([
|
||||||
|
...cache.entries(),
|
||||||
|
...(envs ?? []).map(
|
||||||
|
(env) => [env.Id, { Name: env.Name, Id: env.Id }] as const
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
import { createColumnHelper } from '@tanstack/react-table';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { truncate } from 'lodash';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import { AutomationTestingProps } from '@/types';
|
||||||
|
|
||||||
|
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
|
||||||
|
import { Datatable, TableRow } from '@@/datatables';
|
||||||
|
import { Badge } from '@@/Badge';
|
||||||
|
import { Widget } from '@@/Widget';
|
||||||
|
|
||||||
|
import { EnvironmentTableData } from './types';
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<EnvironmentTableData>();
|
||||||
|
|
||||||
|
interface Props extends AutomationTestingProps {
|
||||||
|
title: string;
|
||||||
|
environments: Array<EnvironmentTableData>;
|
||||||
|
onClickRow?: (env: EnvironmentTableData) => void;
|
||||||
|
highlightIds?: Array<EnvironmentId>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssociatedEnvironmentsTable({
|
||||||
|
title,
|
||||||
|
environments,
|
||||||
|
onClickRow,
|
||||||
|
highlightIds = [],
|
||||||
|
'data-cy': dataCy,
|
||||||
|
}: Props) {
|
||||||
|
const tableState = useTableStateWithoutStorage('Name');
|
||||||
|
const columns = useMemo(() => buildColumns(highlightIds), [highlightIds]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget className="flex-1 flex flex-col">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'h-full flex flex-col',
|
||||||
|
'[&_section.datatable]:flex-1 [&_section.datatable]:flex [&_section.datatable]:flex-col',
|
||||||
|
'[&_.footer]:!mt-auto'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Datatable<EnvironmentTableData>
|
||||||
|
// noWidget to avoid padding issues with TableContainer
|
||||||
|
noWidget
|
||||||
|
title={title}
|
||||||
|
columns={columns}
|
||||||
|
settingsManager={tableState}
|
||||||
|
dataset={environments}
|
||||||
|
renderRow={(row) => (
|
||||||
|
<TableRow<EnvironmentTableData>
|
||||||
|
cells={row.getVisibleCells()}
|
||||||
|
onClick={onClickRow ? () => onClickRow(row.original) : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
disableSelect
|
||||||
|
data-cy={dataCy || 'environment-table'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildColumns(highlightIds: Array<EnvironmentId>) {
|
||||||
|
return [
|
||||||
|
columnHelper.accessor('Name', {
|
||||||
|
header: 'Name',
|
||||||
|
id: 'Name',
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
|
||||||
|
{highlightIds.includes(row.original.Id) && (
|
||||||
|
<Badge type="muted" data-cy="unsaved-badge">
|
||||||
|
Unsaved
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
import { createColumnHelper } from '@tanstack/react-table';
|
||||||
|
import { truncate } from 'lodash';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||||
|
import { isSortType } from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import { AutomationTestingProps } from '@/types';
|
||||||
|
import { semverCompare } from '@/react/common/semver-utils';
|
||||||
|
|
||||||
|
import { createPersistedStore } from '@@/datatables/types';
|
||||||
|
import { useTableState } from '@@/datatables/useTableState';
|
||||||
|
import { Datatable, TableRow } from '@@/datatables';
|
||||||
|
import { Badge } from '@@/Badge';
|
||||||
|
import { Widget } from '@@/Widget';
|
||||||
|
|
||||||
|
import { EnvironmentTableData } from './types';
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<EnvironmentTableData>();
|
||||||
|
|
||||||
|
const tableKey = 'available-environments';
|
||||||
|
const settingsStore = createPersistedStore(tableKey, 'Name');
|
||||||
|
|
||||||
|
interface Props extends AutomationTestingProps {
|
||||||
|
title: string;
|
||||||
|
/** IDs to exclude from the query (environments already associated) */
|
||||||
|
excludeIds: Array<EnvironmentId>;
|
||||||
|
/** IDs to include in the query (e.g., recently removed from associated - will be highlighted) */
|
||||||
|
includeIds?: Array<EnvironmentId>;
|
||||||
|
onClickRow?: (env: EnvironmentTableData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvailableEnvironmentsTable({
|
||||||
|
title,
|
||||||
|
excludeIds,
|
||||||
|
includeIds = [],
|
||||||
|
onClickRow,
|
||||||
|
'data-cy': dataCy,
|
||||||
|
}: Props) {
|
||||||
|
const tableState = useTableState(settingsStore, tableKey);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const columns = useMemo(() => buildColumns(includeIds), [includeIds]);
|
||||||
|
|
||||||
|
// Query unassigned environments (group 1)
|
||||||
|
const unassignedQuery = useEnvironmentList({
|
||||||
|
pageLimit: tableState.pageSize,
|
||||||
|
page: page + 1,
|
||||||
|
search: tableState.search,
|
||||||
|
sort: isSortType(tableState.sortBy?.id) ? tableState.sortBy.id : 'Name',
|
||||||
|
order: tableState.sortBy?.desc ? 'desc' : 'asc',
|
||||||
|
groupIds: [1],
|
||||||
|
excludeIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query removed environments by ID (these are still in their original group until saved)
|
||||||
|
const removedQuery = useEnvironmentList(
|
||||||
|
{
|
||||||
|
endpointIds: includeIds,
|
||||||
|
search: tableState.search,
|
||||||
|
},
|
||||||
|
{ enabled: includeIds.length > 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Merge results: removed environments + unassigned environments (deduped)
|
||||||
|
// Only use removedQuery data when includeIds is non-empty to avoid stale cache
|
||||||
|
const environments = useMemo(() => {
|
||||||
|
const unassigned = unassignedQuery.environments || [];
|
||||||
|
const removed =
|
||||||
|
includeIds.length > 0 ? removedQuery.environments || [] : [];
|
||||||
|
|
||||||
|
if (removed.length === 0) {
|
||||||
|
return unassigned;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unassignedIds = new Set(unassigned.map((e) => e.Id));
|
||||||
|
const uniqueRemoved = removed.filter((e) => !unassignedIds.has(e.Id));
|
||||||
|
|
||||||
|
// Sort combined results by name to maintain order
|
||||||
|
const combined = [...uniqueRemoved, ...unassigned];
|
||||||
|
const isDesc = tableState.sortBy?.desc ?? false;
|
||||||
|
// useTypeGuard on tableState.sortBy.id to use as a key for sorting
|
||||||
|
const sortKey = getSortKey(tableState.sortBy?.id);
|
||||||
|
if (sortKey) {
|
||||||
|
return combined.sort((a, b) => {
|
||||||
|
const cmp = semverCompare(a[sortKey].toString(), b[sortKey].toString());
|
||||||
|
return isDesc ? -cmp : cmp;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return combined;
|
||||||
|
}, [
|
||||||
|
unassignedQuery.environments,
|
||||||
|
removedQuery.environments,
|
||||||
|
includeIds.length,
|
||||||
|
tableState.sortBy?.desc,
|
||||||
|
tableState.sortBy?.id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalCount =
|
||||||
|
unassignedQuery.totalCount +
|
||||||
|
(includeIds.length > 0 ? removedQuery.environments?.length || 0 : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget className="flex-1 flex flex-col">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'h-full flex flex-col',
|
||||||
|
'[&_section.datatable]:flex-1 [&_section.datatable]:flex [&_section.datatable]:flex-col',
|
||||||
|
'[&_.footer]:!mt-auto'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Datatable<EnvironmentTableData>
|
||||||
|
// noWidget to avoid padding issues with TableContainer
|
||||||
|
noWidget
|
||||||
|
title={title}
|
||||||
|
columns={columns}
|
||||||
|
settingsManager={tableState}
|
||||||
|
dataset={environments}
|
||||||
|
isServerSidePagination
|
||||||
|
page={page}
|
||||||
|
onPageChange={setPage}
|
||||||
|
totalCount={totalCount}
|
||||||
|
renderRow={(row) => (
|
||||||
|
<TableRow<EnvironmentTableData>
|
||||||
|
cells={row.getVisibleCells()}
|
||||||
|
onClick={onClickRow ? () => onClickRow(row.original) : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
disableSelect
|
||||||
|
data-cy={dataCy || 'available-environments-table'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildColumns(highlightIds: Array<EnvironmentId>) {
|
||||||
|
return [
|
||||||
|
columnHelper.accessor('Name', {
|
||||||
|
header: 'Name',
|
||||||
|
id: 'Name',
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
|
||||||
|
{highlightIds.includes(row.original.Id) && (
|
||||||
|
<Badge type="muted" data-cy="unsaved-badge">
|
||||||
|
Unsaved
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortKey(sortId?: string): keyof EnvironmentTableData | undefined {
|
||||||
|
if (!sortId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
switch (sortId) {
|
||||||
|
case 'Name':
|
||||||
|
return 'Name';
|
||||||
|
default:
|
||||||
|
return 'Name';
|
||||||
|
}
|
||||||
|
// extend to other keys as needed
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { Environment } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
export type EnvironmentTableData = Pick<Environment, 'Name' | 'Id'>;
|
||||||
-68
@@ -1,68 +0,0 @@
|
|||||||
import { createColumnHelper } from '@tanstack/react-table';
|
|
||||||
import { truncate } from 'lodash';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
|
||||||
import { Environment } from '@/react/portainer/environments/types';
|
|
||||||
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
|
|
||||||
import { AutomationTestingProps } from '@/types';
|
|
||||||
|
|
||||||
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
|
|
||||||
import { Datatable, TableRow } from '@@/datatables';
|
|
||||||
|
|
||||||
const columHelper = createColumnHelper<Environment>();
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
columHelper.accessor('Name', {
|
|
||||||
header: 'Name',
|
|
||||||
id: 'Name',
|
|
||||||
cell: ({ getValue }) => (
|
|
||||||
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
export function GroupAssociationTable({
|
|
||||||
title,
|
|
||||||
query,
|
|
||||||
onClickRow,
|
|
||||||
'data-cy': dataCy,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
query: EnvironmentsQueryParams;
|
|
||||||
onClickRow?: (env: Environment) => void;
|
|
||||||
} & AutomationTestingProps) {
|
|
||||||
const tableState = useTableStateWithoutStorage('Name');
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const environmentsQuery = useEnvironmentList({
|
|
||||||
pageLimit: tableState.pageSize,
|
|
||||||
page: page + 1,
|
|
||||||
search: tableState.search,
|
|
||||||
sort: tableState.sortBy?.id as 'Name',
|
|
||||||
order: tableState.sortBy?.desc ? 'desc' : 'asc',
|
|
||||||
...query,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { environments } = environmentsQuery;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Datatable<Environment>
|
|
||||||
title={title}
|
|
||||||
columns={columns}
|
|
||||||
settingsManager={tableState}
|
|
||||||
dataset={environments}
|
|
||||||
isServerSidePagination
|
|
||||||
page={page}
|
|
||||||
onPageChange={setPage}
|
|
||||||
totalCount={environmentsQuery.totalCount}
|
|
||||||
renderRow={(row) => (
|
|
||||||
<TableRow<Environment>
|
|
||||||
cells={row.getVisibleCells()}
|
|
||||||
onClick={onClickRow ? () => onClickRow(row.original) : undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
data-cy={dataCy}
|
|
||||||
disableSelect
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
|
||||||
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
|
import { server } from '@/setup-tests/server';
|
||||||
|
|
||||||
|
import { GroupForm, GroupFormValues } from './GroupForm';
|
||||||
|
|
||||||
|
vi.mock('@/react/hooks/useCanExit', () => ({
|
||||||
|
useCanExit: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderGroupForm({
|
||||||
|
initialValues = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
tagIds: [],
|
||||||
|
associatedEnvironments: [],
|
||||||
|
},
|
||||||
|
onSubmit = vi.fn(),
|
||||||
|
submitLabel = 'Create',
|
||||||
|
submitLoadingLabel = 'Creating...',
|
||||||
|
groupId,
|
||||||
|
}: {
|
||||||
|
initialValues?: GroupFormValues;
|
||||||
|
onSubmit?: (values: GroupFormValues) => Promise<void>;
|
||||||
|
submitLabel?: string;
|
||||||
|
submitLoadingLabel?: string;
|
||||||
|
groupId?: number;
|
||||||
|
} = {}) {
|
||||||
|
// Mock tag endpoints
|
||||||
|
server.use(
|
||||||
|
http.get('/api/tags', () =>
|
||||||
|
HttpResponse.json([
|
||||||
|
{ ID: 1, Name: 'production' },
|
||||||
|
{ ID: 2, Name: 'staging' },
|
||||||
|
])
|
||||||
|
),
|
||||||
|
// Mock environments list for AssociatedEnvironmentsSelector
|
||||||
|
http.get('/api/endpoints', () =>
|
||||||
|
HttpResponse.json([], {
|
||||||
|
headers: {
|
||||||
|
'x-total-count': '0',
|
||||||
|
'x-total-available': '0',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(
|
||||||
|
withUserProvider(() => (
|
||||||
|
<GroupForm
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
submitLabel={submitLabel}
|
||||||
|
submitLoadingLabel={submitLoadingLabel}
|
||||||
|
groupId={groupId}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...render(<Wrapped />),
|
||||||
|
onSubmit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GroupForm', () => {
|
||||||
|
describe('Form rendering', () => {
|
||||||
|
it('should render name and description fields', async () => {
|
||||||
|
renderGroupForm();
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText(/Name/i)).toBeVisible();
|
||||||
|
expect(screen.getByLabelText(/Description/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the submit button with correct label', async () => {
|
||||||
|
renderGroupForm({ submitLabel: 'Create' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('button', { name: /Create/i })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show Associated environments section when groupId is provided (not unassigned group)', async () => {
|
||||||
|
renderGroupForm({ groupId: 2 });
|
||||||
|
|
||||||
|
// Wait for form to render
|
||||||
|
await screen.findByLabelText(/Name/i);
|
||||||
|
|
||||||
|
// Check for section title using findByRole
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', { name: /Associated environments/i })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show Unassociated environments section when groupId is 1 (unassigned group)', async () => {
|
||||||
|
renderGroupForm({ groupId: 1 });
|
||||||
|
|
||||||
|
// Wait for form to render
|
||||||
|
await screen.findByLabelText(/Name/i);
|
||||||
|
|
||||||
|
// Check for section title using findByRole
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', {
|
||||||
|
name: /Unassociated environments/i,
|
||||||
|
})
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Should NOT show "Associated environments" section (exact match to exclude "Unassociated")
|
||||||
|
const associatedElements = screen.queryAllByText(
|
||||||
|
/^Associated environments$/i
|
||||||
|
);
|
||||||
|
expect(associatedElements).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show Associated environments section in create mode (no groupId)', async () => {
|
||||||
|
renderGroupForm();
|
||||||
|
|
||||||
|
// Wait for form to render
|
||||||
|
await screen.findByLabelText(/Name/i);
|
||||||
|
|
||||||
|
// Check for section title using findByRole
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', { name: /Associated environments/i })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form validation', () => {
|
||||||
|
it('should show validation error when name is empty', async () => {
|
||||||
|
renderGroupForm();
|
||||||
|
|
||||||
|
// The form validates on mount
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('alert', { name: /Name is required/i })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable submit button when form is invalid', async () => {
|
||||||
|
renderGroupForm();
|
||||||
|
|
||||||
|
const submitButton = await screen.findByRole('button', {
|
||||||
|
name: /Create/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(submitButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable submit button when form is not dirty', async () => {
|
||||||
|
renderGroupForm({
|
||||||
|
initialValues: {
|
||||||
|
name: 'existing-group',
|
||||||
|
description: '',
|
||||||
|
tagIds: [],
|
||||||
|
associatedEnvironments: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitButton = await screen.findByRole('button', {
|
||||||
|
name: /Create/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form is valid but not dirty, so should be disabled
|
||||||
|
expect(submitButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable submit button when form is valid and dirty', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderGroupForm();
|
||||||
|
|
||||||
|
const nameInput = await screen.findByLabelText(/Name/i);
|
||||||
|
await user.type(nameInput, 'my-new-group');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: /Create/i });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear validation error when name is provided', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderGroupForm();
|
||||||
|
|
||||||
|
// Wait for initial validation error
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('alert', { name: /Name is required/i })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use data-cy attribute to find the specific name input
|
||||||
|
const nameInput = screen.getByTestId('group-name-input');
|
||||||
|
await user.type(nameInput, 'my-group');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('alert', { name: /Name is required/i })
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form submission', () => {
|
||||||
|
it('should call onSubmit with form values when submitted', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
renderGroupForm({ onSubmit });
|
||||||
|
|
||||||
|
const nameInput = await screen.findByLabelText(/Name/i);
|
||||||
|
await user.type(nameInput, 'test-group');
|
||||||
|
|
||||||
|
const descriptionInput = screen.getByLabelText(/Description/i);
|
||||||
|
await user.type(descriptionInput, 'Test description');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: /Create/i });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'test-group',
|
||||||
|
description: 'Test description',
|
||||||
|
tagIds: [],
|
||||||
|
associatedEnvironments: [],
|
||||||
|
}),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show loading state during submission', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
// Create a promise that we can control
|
||||||
|
let resolveSubmit: () => void;
|
||||||
|
const onSubmit = vi.fn().mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
resolveSubmit = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
renderGroupForm({
|
||||||
|
onSubmit,
|
||||||
|
submitLabel: 'Create',
|
||||||
|
submitLoadingLabel: 'Creating...',
|
||||||
|
});
|
||||||
|
|
||||||
|
const nameInput = await screen.findByLabelText(/Name/i);
|
||||||
|
await user.type(nameInput, 'test-group');
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: /Create/i });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
// Should show loading state
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /Creating.../i })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve the submission
|
||||||
|
resolveSubmit!();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form field placeholders', () => {
|
||||||
|
it('should show correct placeholder for name field', async () => {
|
||||||
|
renderGroupForm();
|
||||||
|
|
||||||
|
const nameInput = await screen.findByPlaceholderText(/e\.g\. my-group/i);
|
||||||
|
expect(nameInput).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show correct placeholder for description field', async () => {
|
||||||
|
renderGroupForm();
|
||||||
|
|
||||||
|
const descriptionInput = await screen.findByPlaceholderText(
|
||||||
|
/e\.g\. production environments/i
|
||||||
|
);
|
||||||
|
expect(descriptionInput).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initial values', () => {
|
||||||
|
it('should populate form with initial values', async () => {
|
||||||
|
renderGroupForm({
|
||||||
|
initialValues: {
|
||||||
|
name: 'pre-filled-name',
|
||||||
|
description: 'pre-filled-description',
|
||||||
|
tagIds: [],
|
||||||
|
associatedEnvironments: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nameInput = await screen.findByLabelText(/Name/i);
|
||||||
|
expect(nameInput).toHaveValue('pre-filled-name');
|
||||||
|
|
||||||
|
const descriptionInput = screen.getByLabelText(/Description/i);
|
||||||
|
expect(descriptionInput).toHaveValue('pre-filled-description');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import {
|
||||||
|
Form,
|
||||||
|
Formik,
|
||||||
|
FormikHelpers,
|
||||||
|
FormikProps,
|
||||||
|
useFormikContext,
|
||||||
|
} from 'formik';
|
||||||
|
import { object, string, array, number } from 'yup';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
|
import { TagId } from '@/portainer/tags/types';
|
||||||
|
import { useIsPureAdmin } from '@/react/hooks/useUser';
|
||||||
|
import { useCanExit } from '@/react/hooks/useCanExit';
|
||||||
|
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { Input } from '@@/form-components/Input';
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
import { TagSelector } from '@@/TagSelector';
|
||||||
|
import { confirmGenericDiscard } from '@@/modals/confirm';
|
||||||
|
import { FormActions } from '@@/form-components/FormActions';
|
||||||
|
|
||||||
|
import { EnvironmentGroupId, EnvironmentId } from '../../types';
|
||||||
|
|
||||||
|
import { AssociatedEnvironmentsSelector } from './AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector';
|
||||||
|
import { AvailableEnvironmentsTable } from './AssociatedEnvironmentsSelector/AvailableEnvironmentsTable';
|
||||||
|
|
||||||
|
export interface GroupFormValues {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
tagIds: Array<TagId>;
|
||||||
|
associatedEnvironments: Array<EnvironmentId>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialValues: GroupFormValues;
|
||||||
|
/** Should return a Promise that resolves when navigation happens (to keep isSubmitting true) */
|
||||||
|
onSubmit: (
|
||||||
|
values: GroupFormValues,
|
||||||
|
helpers: FormikHelpers<GroupFormValues>
|
||||||
|
) => Promise<void>;
|
||||||
|
submitLabel: string;
|
||||||
|
submitLoadingLabel: string;
|
||||||
|
/** Group ID - if provided, shows environment selector (not for unassigned group) */
|
||||||
|
groupId?: EnvironmentGroupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationSchema = object({
|
||||||
|
name: string().required('Name is required'),
|
||||||
|
description: string(),
|
||||||
|
tagIds: array(number()),
|
||||||
|
associatedEnvironments: array(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function GroupForm({
|
||||||
|
initialValues,
|
||||||
|
onSubmit,
|
||||||
|
submitLabel,
|
||||||
|
submitLoadingLabel,
|
||||||
|
groupId,
|
||||||
|
}: Props) {
|
||||||
|
const formikRef = useRef<FormikProps<GroupFormValues>>(null);
|
||||||
|
useCanExit(() => !formikRef.current?.dirty || confirmGenericDiscard());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
innerRef={formikRef}
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={validationSchema}
|
||||||
|
validateOnMount
|
||||||
|
enableReinitialize
|
||||||
|
>
|
||||||
|
<InnerForm
|
||||||
|
initialValues={initialValues}
|
||||||
|
submitLabel={submitLabel}
|
||||||
|
submitLoadingLabel={submitLoadingLabel}
|
||||||
|
groupId={groupId}
|
||||||
|
/>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InnerFormProps {
|
||||||
|
initialValues: GroupFormValues;
|
||||||
|
submitLabel: string;
|
||||||
|
submitLoadingLabel: string;
|
||||||
|
groupId?: EnvironmentGroupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InnerForm({
|
||||||
|
initialValues,
|
||||||
|
submitLabel,
|
||||||
|
submitLoadingLabel,
|
||||||
|
groupId,
|
||||||
|
}: InnerFormProps) {
|
||||||
|
const isPureAdmin = useIsPureAdmin();
|
||||||
|
const isUnassignedGroup = groupId === 1;
|
||||||
|
const {
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
handleChange,
|
||||||
|
setFieldValue,
|
||||||
|
isValid,
|
||||||
|
dirty,
|
||||||
|
isSubmitting,
|
||||||
|
} = useFormikContext<GroupFormValues>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form className="form-horizontal">
|
||||||
|
<FormControl
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
errors={errors.name}
|
||||||
|
inputId="group-name"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="group-name"
|
||||||
|
name="name"
|
||||||
|
value={values.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="e.g. my-group"
|
||||||
|
data-cy="group-name-input"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl label="Description" inputId="group-description">
|
||||||
|
<Input
|
||||||
|
id="group-description"
|
||||||
|
name="description"
|
||||||
|
value={values.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="e.g. production environments..."
|
||||||
|
data-cy="group-description-input"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TagSelector
|
||||||
|
value={values.tagIds}
|
||||||
|
onChange={(tagIds) => setFieldValue('tagIds', tagIds)}
|
||||||
|
allowCreate={isPureAdmin}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isUnassignedGroup ? (
|
||||||
|
<FormSection title="Unassociated environments">
|
||||||
|
<AvailableEnvironmentsTable
|
||||||
|
title="Unassociated environments"
|
||||||
|
excludeIds={[]}
|
||||||
|
data-cy="group-unassociatedEndpoints"
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
) : (
|
||||||
|
<AssociatedEnvironmentsSelector
|
||||||
|
associatedEnvironmentIds={values.associatedEnvironments}
|
||||||
|
initialAssociatedEnvironmentIds={initialValues.associatedEnvironments}
|
||||||
|
onChange={(ids) => setFieldValue('associatedEnvironments', ids)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormActions
|
||||||
|
submitLabel={submitLabel}
|
||||||
|
loadingText={submitLoadingLabel}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
isValid={isValid && !isSubmitting && dirty}
|
||||||
|
errors={errors}
|
||||||
|
data-cy="group-submit-button"
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
+13
-6
@@ -17,16 +17,23 @@ import { queryKeys } from './query-keys';
|
|||||||
interface CreateGroupPayload {
|
interface CreateGroupPayload {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
associatedEndpoints?: EnvironmentId[];
|
associatedEnvironments?: EnvironmentId[];
|
||||||
tagIds?: TagId[];
|
tagIds?: TagId[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createGroup(requestPayload: CreateGroupPayload) {
|
export async function createGroup({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
associatedEnvironments,
|
||||||
|
tagIds,
|
||||||
|
}: CreateGroupPayload) {
|
||||||
try {
|
try {
|
||||||
const { data: group } = await axios.post<EnvironmentGroup>(
|
const { data: group } = await axios.post<EnvironmentGroup>(buildUrl(), {
|
||||||
buildUrl(),
|
Name: name,
|
||||||
requestPayload
|
Description: description,
|
||||||
);
|
AssociatedEndpoints: associatedEnvironments,
|
||||||
|
TagIDs: tagIds,
|
||||||
|
});
|
||||||
return group;
|
return group;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw parseAxiosError(e as Error, 'Failed to create group');
|
throw parseAxiosError(e as Error, 'Failed to create group');
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { EnvironmentGroupId } from '../../types';
|
||||||
|
import { getGroup } from '../environment-groups.service';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
|
export function useGroup(id?: EnvironmentGroupId) {
|
||||||
|
return useQuery(queryKeys.group(id), () => getGroup(id!), {
|
||||||
|
enabled: !!id,
|
||||||
|
...withGlobalError('Failed to load group'),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { TagId } from '@/portainer/tags/types';
|
||||||
|
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
|
||||||
|
import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys';
|
||||||
|
|
||||||
|
import { EnvironmentGroupId, EnvironmentId } from '../../types';
|
||||||
|
import { EnvironmentGroup } from '../types';
|
||||||
|
|
||||||
|
import { buildUrl } from './build-url';
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
|
interface UpdateGroupPayload {
|
||||||
|
id: EnvironmentGroupId;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
tagIds?: Array<TagId>;
|
||||||
|
associatedEnvironments?: Array<EnvironmentId>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGroup({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
tagIds,
|
||||||
|
associatedEnvironments,
|
||||||
|
}: UpdateGroupPayload) {
|
||||||
|
try {
|
||||||
|
const { data: group } = await axios.put<EnvironmentGroup>(buildUrl(id), {
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
TagIDs: tagIds,
|
||||||
|
AssociatedEndpoints: associatedEnvironments,
|
||||||
|
});
|
||||||
|
return group;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, 'Failed to update group');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateGroupMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: updateGroup,
|
||||||
|
...withGlobalError('Failed to update group'),
|
||||||
|
...withInvalidate(queryClient, [
|
||||||
|
queryKeys.base(),
|
||||||
|
environmentQueryKeys.base(),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -76,12 +76,18 @@ export function isUnassociatedEdgeEnvironment(env: Environment) {
|
|||||||
|
|
||||||
export function isLocalEnvironment(environment: Environment) {
|
export function isLocalEnvironment(environment: Environment) {
|
||||||
return (
|
return (
|
||||||
environment.URL.includes('unix://') ||
|
isLocalDockerEnvironment(environment.URL) ||
|
||||||
environment.URL.includes('npipe://') ||
|
|
||||||
environment.Type === EnvironmentType.KubernetesLocal
|
environment.Type === EnvironmentType.KubernetesLocal
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isLocalDockerEnvironment(environmentUrl: string) {
|
||||||
|
return (
|
||||||
|
environmentUrl.startsWith('unix://') ||
|
||||||
|
environmentUrl.startsWith('npipe://')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function isDockerAPIEnvironment(environment: Environment) {
|
export function isDockerAPIEnvironment(environment: Environment) {
|
||||||
return (
|
return (
|
||||||
environment.URL.startsWith('tcp://') &&
|
environment.URL.startsWith('tcp://') &&
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ export function ItemView() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Team details"
|
title="Team details"
|
||||||
breadcrumbs={[{ label: 'Teams' }, { label: team.Name }]}
|
breadcrumbs={[
|
||||||
|
{ label: 'Teams', link: 'portainer.teams' },
|
||||||
|
{ label: team.Name },
|
||||||
|
]}
|
||||||
reload
|
reload
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export function KubernetesSidebar({ environmentId }: Props) {
|
|||||||
to="kubernetes.moreResources.jobs"
|
to="kubernetes.moreResources.jobs"
|
||||||
pathOptions={{
|
pathOptions={{
|
||||||
includePaths: [
|
includePaths: [
|
||||||
|
'kubernetes.moreResources.jobs',
|
||||||
'kubernetes.moreResources.serviceAccounts',
|
'kubernetes.moreResources.serviceAccounts',
|
||||||
'kubernetes.moreResources.clusterRoles',
|
'kubernetes.moreResources.clusterRoles',
|
||||||
'kubernetes.moreResources.roles',
|
'kubernetes.moreResources.roles',
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
|
import * as featureFlags from '@/react/portainer/feature-flags/feature-flags.service';
|
||||||
|
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||||
|
import { PublicSettingsResponse } from '@/react/portainer/settings/types';
|
||||||
|
|
||||||
|
import { TestSidebarProvider } from './useSidebarState';
|
||||||
|
import { SettingsSidebar } from './SettingsSidebar';
|
||||||
|
|
||||||
|
vi.mock('@/react/portainer/settings/queries', () => ({
|
||||||
|
usePublicSettings: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('SettingsSidebar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(featureFlags, 'isBE', 'get').mockReturnValue(false);
|
||||||
|
// Default mock for usePublicSettings - returns data based on selector
|
||||||
|
vi.mocked(usePublicSettings).mockImplementation(((options?: {
|
||||||
|
select?: (settings: PublicSettingsResponse) => PublicSettingsResponse;
|
||||||
|
}) => {
|
||||||
|
const settings: PublicSettingsResponse = {
|
||||||
|
TeamSync: false,
|
||||||
|
EnableEdgeComputeFeatures: false,
|
||||||
|
} as unknown as PublicSettingsResponse;
|
||||||
|
return {
|
||||||
|
data: options?.select ? options.select(settings) : settings,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
};
|
||||||
|
}) as typeof usePublicSettings);
|
||||||
|
window.ddExtension = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pure Admin User', () => {
|
||||||
|
it('should render all admin sections for pure admin', () => {
|
||||||
|
renderComponent({ isPureAdmin: true, isAdmin: true });
|
||||||
|
|
||||||
|
expect(screen.getByText('Administration')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-userRelated')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-environments-area')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-registries')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('k8sSidebar-logs')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-notifications')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-settings')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render user-related submenu items', () => {
|
||||||
|
renderComponent({ isPureAdmin: true, isAdmin: true });
|
||||||
|
|
||||||
|
expect(screen.getByTestId('portainerSidebar-users')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('portainerSidebar-teams')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('portainerSidebar-roles')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render environment-related submenu items', () => {
|
||||||
|
renderComponent({ isPureAdmin: true, isAdmin: true });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-environments')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-environmentGroups')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-environmentTags')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render logs submenu items', () => {
|
||||||
|
renderComponent({ isPureAdmin: true, isAdmin: true });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-authLogs')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-activityLogs')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render settings submenu items', () => {
|
||||||
|
renderComponent({ isPureAdmin: true, isAdmin: true });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-generalSettings')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-authentication')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-edgeCompute')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render Get Help link with correct CE URL', () => {
|
||||||
|
renderComponent({ isPureAdmin: true, isAdmin: true });
|
||||||
|
|
||||||
|
const helpLink = screen.getByRole('link', { name: /Get Help/i });
|
||||||
|
expect(helpLink).toBeInTheDocument();
|
||||||
|
expect(helpLink).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'https://www.portainer.io/community_help'
|
||||||
|
);
|
||||||
|
expect(helpLink).toHaveAttribute('target', '_blank');
|
||||||
|
expect(helpLink).toHaveAttribute('rel', 'noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render Licenses sidebar item in CE', () => {
|
||||||
|
renderComponent({ isPureAdmin: true, isAdmin: true });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('portainerSidebar-licenses')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render Shared Credentials in CE', () => {
|
||||||
|
renderComponent({ isPureAdmin: true, isAdmin: true });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('portainerSidebar-cloud')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Team Leader User', () => {
|
||||||
|
it('should render user-related section for team leader when TeamSync is disabled', () => {
|
||||||
|
vi.mocked(usePublicSettings).mockReturnValue({
|
||||||
|
data: false,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
} as ReturnType<typeof usePublicSettings>);
|
||||||
|
|
||||||
|
renderComponent({
|
||||||
|
isPureAdmin: false,
|
||||||
|
isAdmin: false,
|
||||||
|
isTeamLeader: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-userRelated')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('portainerSidebar-users')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('portainerSidebar-teams')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render Roles for team leader', () => {
|
||||||
|
vi.mocked(usePublicSettings).mockReturnValue({
|
||||||
|
data: false,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
} as ReturnType<typeof usePublicSettings>);
|
||||||
|
|
||||||
|
renderComponent({
|
||||||
|
isPureAdmin: false,
|
||||||
|
isAdmin: false,
|
||||||
|
isTeamLeader: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('portainerSidebar-roles')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render user-related section when TeamSync is enabled', () => {
|
||||||
|
vi.mocked(usePublicSettings).mockReturnValue({
|
||||||
|
data: true,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
} as ReturnType<typeof usePublicSettings>);
|
||||||
|
|
||||||
|
renderComponent({
|
||||||
|
isPureAdmin: false,
|
||||||
|
isAdmin: false,
|
||||||
|
isTeamLeader: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('portainerSidebar-userRelated')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render admin-only sections for team leader', () => {
|
||||||
|
renderComponent({
|
||||||
|
isPureAdmin: false,
|
||||||
|
isAdmin: false,
|
||||||
|
isTeamLeader: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('portainerSidebar-environments-area')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('portainerSidebar-registries')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('k8sSidebar-logs')).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('portainerSidebar-settings')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render notifications for team leader', () => {
|
||||||
|
renderComponent({
|
||||||
|
isPureAdmin: false,
|
||||||
|
isAdmin: false,
|
||||||
|
isTeamLeader: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-notifications')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Regular Admin User (not Pure Admin)', () => {
|
||||||
|
it('should not render admin-only sections', () => {
|
||||||
|
renderComponent({ isPureAdmin: false, isAdmin: true });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('portainerSidebar-userRelated')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('portainerSidebar-environments-area')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('portainerSidebar-registries')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('k8sSidebar-logs')).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('portainerSidebar-settings')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render notifications for regular admin', () => {
|
||||||
|
renderComponent({ isPureAdmin: false, isAdmin: true });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-notifications')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DD Extension Environment', () => {
|
||||||
|
it('should not render user-related section when ddExtension is enabled', () => {
|
||||||
|
window.ddExtension = true;
|
||||||
|
|
||||||
|
renderComponent({ isPureAdmin: true, isAdmin: true });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('portainerSidebar-userRelated')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render authentication settings when ddExtension is enabled', () => {
|
||||||
|
window.ddExtension = true;
|
||||||
|
|
||||||
|
renderComponent({ isPureAdmin: true, isAdmin: true });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('portainerSidebar-authentication')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should still render other admin sections when ddExtension is enabled', () => {
|
||||||
|
window.ddExtension = true;
|
||||||
|
|
||||||
|
renderComponent({ isPureAdmin: true, isAdmin: true });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-environments-area')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-registries')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('portainerSidebar-settings')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Compute Features', () => {
|
||||||
|
it('should not render Update & Rollback when edge compute is disabled in CE', () => {
|
||||||
|
vi.mocked(usePublicSettings).mockImplementation(((options?: {
|
||||||
|
select?: (settings: PublicSettingsResponse) => PublicSettingsResponse;
|
||||||
|
}) => {
|
||||||
|
const settings: PublicSettingsResponse = {
|
||||||
|
TeamSync: false,
|
||||||
|
EnableEdgeComputeFeatures: false,
|
||||||
|
} as unknown as PublicSettingsResponse;
|
||||||
|
return {
|
||||||
|
data: options?.select ? options.select(settings) : settings,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
};
|
||||||
|
}) as typeof usePublicSettings);
|
||||||
|
|
||||||
|
renderComponent({ isPureAdmin: true, isAdmin: true });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('portainerSidebar-updateSchedules')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render Update & Rollback in CE even when edge compute is enabled', () => {
|
||||||
|
vi.mocked(usePublicSettings).mockImplementation(((options?: {
|
||||||
|
select?: (settings: PublicSettingsResponse) => PublicSettingsResponse;
|
||||||
|
}) => {
|
||||||
|
const settings: PublicSettingsResponse = {
|
||||||
|
TeamSync: false,
|
||||||
|
EnableEdgeComputeFeatures: true,
|
||||||
|
} as unknown as PublicSettingsResponse;
|
||||||
|
return {
|
||||||
|
data: options?.select ? options.select(settings) : settings,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
};
|
||||||
|
}) as typeof usePublicSettings);
|
||||||
|
|
||||||
|
renderComponent({ isPureAdmin: true, isAdmin: true });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('portainerSidebar-updateSchedules')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loading States', () => {
|
||||||
|
it('should handle loading state for public settings', () => {
|
||||||
|
vi.mocked(usePublicSettings).mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
isError: false,
|
||||||
|
} as ReturnType<typeof usePublicSettings>);
|
||||||
|
|
||||||
|
renderComponent({ isPureAdmin: true, isAdmin: true });
|
||||||
|
|
||||||
|
expect(screen.getByText('Administration')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle error state for public settings', () => {
|
||||||
|
vi.mocked(usePublicSettings).mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
isError: true,
|
||||||
|
} as ReturnType<typeof usePublicSettings>);
|
||||||
|
|
||||||
|
renderComponent({ isPureAdmin: true, isAdmin: true });
|
||||||
|
|
||||||
|
expect(screen.getByText('Administration')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderComponent({
|
||||||
|
isPureAdmin,
|
||||||
|
isAdmin,
|
||||||
|
isTeamLeader = false,
|
||||||
|
}: {
|
||||||
|
isPureAdmin: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isTeamLeader?: boolean;
|
||||||
|
}) {
|
||||||
|
const user = new UserViewModel({ Username: 'user' });
|
||||||
|
|
||||||
|
const Wrapped = withUserProvider(withTestRouter(SettingsSidebar), user);
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<TestSidebarProvider>
|
||||||
|
<Wrapped
|
||||||
|
isPureAdmin={isPureAdmin}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
isTeamLeader={isTeamLeader}
|
||||||
|
/>
|
||||||
|
</TestSidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,11 @@ interface Props {
|
|||||||
isTeamLeader?: boolean;
|
isTeamLeader?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSidebar({ isPureAdmin, isAdmin, isTeamLeader }: Props) {
|
export function SettingsSidebar({
|
||||||
|
isPureAdmin,
|
||||||
|
isAdmin,
|
||||||
|
isTeamLeader = false,
|
||||||
|
}: Props) {
|
||||||
const teamSyncQuery = usePublicSettings<boolean>({
|
const teamSyncQuery = usePublicSettings<boolean>({
|
||||||
select: (settings) => settings.TeamSync,
|
select: (settings) => settings.TeamSync,
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"docker": "v29.1.2"
|
"docker": "v29.2.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module github.com/portainer/portainer
|
module github.com/portainer/portainer
|
||||||
|
|
||||||
go 1.25.5
|
go 1.25.7
|
||||||
|
|
||||||
replace github.com/robfig/cron/v3 => github.com/robfig/cron/v3 v3.0.1 // Not actively maintained. Pinned to last known good version. Review needed when upgrading.
|
replace github.com/robfig/cron/v3 => github.com/robfig/cron/v3 v3.0.1 // Not actively maintained. Pinned to last known good version. Review needed when upgrading.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"author": "Portainer.io",
|
"author": "Portainer.io",
|
||||||
"name": "@portainer/ce",
|
"name": "@portainer/ce",
|
||||||
"homepage": "http://portainer.io",
|
"homepage": "http://portainer.io",
|
||||||
"version": "2.38.0",
|
"version": "2.38.1",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git@github.com:portainer/portainer.git"
|
"url": "git@github.com:portainer/portainer.git"
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package authorization
|
||||||
|
|
||||||
|
import (
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolvedAccess represents the result of dynamic authorization resolution.
|
||||||
|
// It contains both the computed role and its authorizations for convenience.
|
||||||
|
type ResolvedAccess struct {
|
||||||
|
Role *portainer.Role
|
||||||
|
Authorizations portainer.Authorizations
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolverInput contains all the data needed to resolve user access to an endpoint.
|
||||||
|
// This struct is used to pass data to the resolution functions without requiring
|
||||||
|
// database access, making it easier to test and allowing callers to control data fetching.
|
||||||
|
type ResolverInput struct {
|
||||||
|
User *portainer.User
|
||||||
|
Endpoint *portainer.Endpoint
|
||||||
|
EndpointGroup portainer.EndpointGroup
|
||||||
|
UserMemberships []portainer.TeamMembership
|
||||||
|
Roles []portainer.Role
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeBaseRole computes the user's role on an endpoint from base access settings.
|
||||||
|
// It checks access in precedence order:
|
||||||
|
// 1. User → Endpoint direct access
|
||||||
|
// 2. User → Endpoint Group access (inherited)
|
||||||
|
// 3. User's Teams → Endpoint access
|
||||||
|
// 4. User's Teams → Endpoint Group access (inherited)
|
||||||
|
//
|
||||||
|
// Returns the first matching role, or nil if no access is configured.
|
||||||
|
func ComputeBaseRole(input ResolverInput) *portainer.Role {
|
||||||
|
group := input.EndpointGroup
|
||||||
|
|
||||||
|
// 1. Check user → endpoint direct access
|
||||||
|
if role := GetRoleFromUserAccessPolicies(
|
||||||
|
input.User.ID,
|
||||||
|
input.Endpoint.UserAccessPolicies,
|
||||||
|
input.Roles,
|
||||||
|
); role != nil {
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check user → endpoint group access (inherited)
|
||||||
|
if role := GetRoleFromUserAccessPolicies(
|
||||||
|
input.User.ID,
|
||||||
|
group.UserAccessPolicies,
|
||||||
|
input.Roles,
|
||||||
|
); role != nil {
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check user's teams → endpoint access
|
||||||
|
if role := GetRoleFromTeamAccessPolicies(
|
||||||
|
input.UserMemberships,
|
||||||
|
input.Endpoint.TeamAccessPolicies,
|
||||||
|
input.Roles,
|
||||||
|
); role != nil {
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check user's teams → endpoint group access (inherited)
|
||||||
|
if role := GetRoleFromTeamAccessPolicies(
|
||||||
|
input.UserMemberships,
|
||||||
|
group.TeamAccessPolicies,
|
||||||
|
input.Roles,
|
||||||
|
); role != nil {
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveUserEndpointAccess resolves a user's effective access to an endpoint.
|
||||||
|
// In CE, this returns the base role computed from endpoint/group access settings.
|
||||||
|
// EE extends this to also consider applied RBAC policies.
|
||||||
|
//
|
||||||
|
// Returns nil if the user has no access to the endpoint.
|
||||||
|
func ResolveUserEndpointAccess(input ResolverInput) *ResolvedAccess {
|
||||||
|
role := ComputeBaseRole(input)
|
||||||
|
if role == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ResolvedAccess{
|
||||||
|
Role: role,
|
||||||
|
Authorizations: role.Authorizations,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoleFromUserAccessPolicies returns the role for a user from user access policies.
|
||||||
|
// Returns nil if the user is not in the policies.
|
||||||
|
func GetRoleFromUserAccessPolicies(
|
||||||
|
userID portainer.UserID,
|
||||||
|
policies portainer.UserAccessPolicies,
|
||||||
|
roles []portainer.Role,
|
||||||
|
) *portainer.Role {
|
||||||
|
if policies == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
policy, ok := policies[userID]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return FindRoleByID(policy.RoleID, roles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoleFromTeamAccessPolicies returns the highest priority role for a user
|
||||||
|
// based on their team memberships and the team access policies.
|
||||||
|
// If a user belongs to multiple teams with access, the role with highest priority wins.
|
||||||
|
// Returns nil if none of the user's teams have access.
|
||||||
|
func GetRoleFromTeamAccessPolicies(
|
||||||
|
memberships []portainer.TeamMembership,
|
||||||
|
policies portainer.TeamAccessPolicies,
|
||||||
|
roles []portainer.Role,
|
||||||
|
) *portainer.Role {
|
||||||
|
if policies == nil || len(memberships) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all roles from team memberships
|
||||||
|
var matchingRoles []*portainer.Role
|
||||||
|
for _, membership := range memberships {
|
||||||
|
policy, ok := policies[membership.TeamID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
role := FindRoleByID(policy.RoleID, roles)
|
||||||
|
if role != nil {
|
||||||
|
matchingRoles = append(matchingRoles, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matchingRoles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the role with highest priority
|
||||||
|
return GetHighestPriorityRole(matchingRoles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHighestPriorityRole returns the role with the highest priority from a slice.
|
||||||
|
// In Portainer's role system, higher priority numbers = higher priority (lower access usually gives higher priority).
|
||||||
|
// Current role priorities from highest to lowest: Read-only User (6), Standard User (5),
|
||||||
|
// Namespace Operator (4), Helpdesk (3), Operator (2), Environment Administrator (1).
|
||||||
|
// Returns nil if the slice is empty.
|
||||||
|
func GetHighestPriorityRole(roles []*portainer.Role) *portainer.Role {
|
||||||
|
if len(roles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
highest := roles[0]
|
||||||
|
for _, role := range roles[1:] {
|
||||||
|
if role.Priority > highest.Priority {
|
||||||
|
highest = role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return highest
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindRoleByID finds a role by its ID in a slice of roles.
|
||||||
|
// Returns nil if the role is not found.
|
||||||
|
func FindRoleByID(roleID portainer.RoleID, roles []portainer.Role) *portainer.Role {
|
||||||
|
for i := range roles {
|
||||||
|
if roles[i].ID == roleID {
|
||||||
|
return &roles[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
package authorization
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Test role fixtures
|
||||||
|
// In Portainer's role system, higher priority numbers = higher priority (more powerful).
|
||||||
|
// Order from highest to lowest: Read-only (4), Helpdesk (3), Operator (2), Admin (1).
|
||||||
|
var (
|
||||||
|
roleAdmin = portainer.Role{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Environment Administrator",
|
||||||
|
Priority: 1,
|
||||||
|
Authorizations: portainer.Authorizations{"admin": true},
|
||||||
|
}
|
||||||
|
roleOperator = portainer.Role{
|
||||||
|
ID: 2,
|
||||||
|
Name: "Operator",
|
||||||
|
Priority: 2,
|
||||||
|
Authorizations: portainer.Authorizations{"operator": true},
|
||||||
|
}
|
||||||
|
roleHelpdesk = portainer.Role{
|
||||||
|
ID: 3,
|
||||||
|
Name: "Helpdesk",
|
||||||
|
Priority: 3,
|
||||||
|
Authorizations: portainer.Authorizations{"helpdesk": true},
|
||||||
|
}
|
||||||
|
roleReadOnly = portainer.Role{
|
||||||
|
ID: 4,
|
||||||
|
Name: "Read-only",
|
||||||
|
Priority: 4,
|
||||||
|
Authorizations: portainer.Authorizations{"readonly": true},
|
||||||
|
}
|
||||||
|
|
||||||
|
allRoles = []portainer.Role{roleAdmin, roleOperator, roleHelpdesk, roleReadOnly}
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComputeBaseRole_UserEndpointAccess(t *testing.T) {
|
||||||
|
is := assert.New(t)
|
||||||
|
|
||||||
|
user := &portainer.User{ID: 1}
|
||||||
|
endpoint := &portainer.Endpoint{
|
||||||
|
ID: 1,
|
||||||
|
GroupID: 1,
|
||||||
|
UserAccessPolicies: portainer.UserAccessPolicies{
|
||||||
|
1: {RoleID: roleOperator.ID},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
input := ResolverInput{
|
||||||
|
User: user,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
EndpointGroup: portainer.EndpointGroup{},
|
||||||
|
UserMemberships: []portainer.TeamMembership{},
|
||||||
|
Roles: allRoles,
|
||||||
|
}
|
||||||
|
|
||||||
|
role := ComputeBaseRole(input)
|
||||||
|
|
||||||
|
is.NotNil(role)
|
||||||
|
is.Equal(roleOperator.ID, role.ID)
|
||||||
|
is.Equal("Operator", role.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeBaseRole_UserGroupAccess(t *testing.T) {
|
||||||
|
is := assert.New(t)
|
||||||
|
|
||||||
|
user := &portainer.User{ID: 1}
|
||||||
|
endpoint := &portainer.Endpoint{
|
||||||
|
ID: 1,
|
||||||
|
GroupID: 10,
|
||||||
|
UserAccessPolicies: portainer.UserAccessPolicies{}, // No direct access
|
||||||
|
}
|
||||||
|
groups := []portainer.EndpointGroup{
|
||||||
|
{
|
||||||
|
ID: 10,
|
||||||
|
UserAccessPolicies: portainer.UserAccessPolicies{
|
||||||
|
1: {RoleID: roleHelpdesk.ID}, // User has access via group
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
input := ResolverInput{
|
||||||
|
User: user,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
EndpointGroup: groups[0],
|
||||||
|
UserMemberships: []portainer.TeamMembership{},
|
||||||
|
Roles: allRoles,
|
||||||
|
}
|
||||||
|
|
||||||
|
role := ComputeBaseRole(input)
|
||||||
|
|
||||||
|
is.NotNil(role)
|
||||||
|
is.Equal(roleHelpdesk.ID, role.ID)
|
||||||
|
is.Equal("Helpdesk", role.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeBaseRole_TeamEndpointAccess(t *testing.T) {
|
||||||
|
is := assert.New(t)
|
||||||
|
|
||||||
|
user := &portainer.User{ID: 1}
|
||||||
|
endpoint := &portainer.Endpoint{
|
||||||
|
ID: 1,
|
||||||
|
GroupID: 1,
|
||||||
|
UserAccessPolicies: portainer.UserAccessPolicies{}, // No user access
|
||||||
|
TeamAccessPolicies: portainer.TeamAccessPolicies{
|
||||||
|
100: {RoleID: roleReadOnly.ID}, // Team 100 has access
|
||||||
|
},
|
||||||
|
}
|
||||||
|
memberships := []portainer.TeamMembership{
|
||||||
|
{UserID: 1, TeamID: 100}, // User is in team 100
|
||||||
|
}
|
||||||
|
|
||||||
|
input := ResolverInput{
|
||||||
|
User: user,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
EndpointGroup: portainer.EndpointGroup{},
|
||||||
|
UserMemberships: memberships,
|
||||||
|
Roles: allRoles,
|
||||||
|
}
|
||||||
|
|
||||||
|
role := ComputeBaseRole(input)
|
||||||
|
|
||||||
|
is.NotNil(role)
|
||||||
|
is.Equal(roleReadOnly.ID, role.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeBaseRole_TeamGroupAccess(t *testing.T) {
|
||||||
|
is := assert.New(t)
|
||||||
|
|
||||||
|
user := &portainer.User{ID: 1}
|
||||||
|
endpoint := &portainer.Endpoint{
|
||||||
|
ID: 1,
|
||||||
|
GroupID: 10,
|
||||||
|
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||||
|
TeamAccessPolicies: portainer.TeamAccessPolicies{}, // No direct team access
|
||||||
|
}
|
||||||
|
groups := []portainer.EndpointGroup{
|
||||||
|
{
|
||||||
|
ID: 10,
|
||||||
|
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||||
|
TeamAccessPolicies: portainer.TeamAccessPolicies{
|
||||||
|
100: {RoleID: roleOperator.ID}, // Team 100 has group access
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
memberships := []portainer.TeamMembership{
|
||||||
|
{UserID: 1, TeamID: 100},
|
||||||
|
}
|
||||||
|
|
||||||
|
input := ResolverInput{
|
||||||
|
User: user,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
EndpointGroup: groups[0],
|
||||||
|
UserMemberships: memberships,
|
||||||
|
Roles: allRoles,
|
||||||
|
}
|
||||||
|
|
||||||
|
role := ComputeBaseRole(input)
|
||||||
|
|
||||||
|
is.NotNil(role)
|
||||||
|
is.Equal(roleOperator.ID, role.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeBaseRole_Precedence(t *testing.T) {
|
||||||
|
is := assert.New(t)
|
||||||
|
|
||||||
|
t.Run("User endpoint access takes precedence over group access", func(t *testing.T) {
|
||||||
|
user := &portainer.User{ID: 1}
|
||||||
|
endpoint := &portainer.Endpoint{
|
||||||
|
ID: 1,
|
||||||
|
GroupID: 10,
|
||||||
|
UserAccessPolicies: portainer.UserAccessPolicies{
|
||||||
|
1: {RoleID: roleOperator.ID}, // Direct access
|
||||||
|
},
|
||||||
|
}
|
||||||
|
groups := []portainer.EndpointGroup{
|
||||||
|
{
|
||||||
|
ID: 10,
|
||||||
|
UserAccessPolicies: portainer.UserAccessPolicies{
|
||||||
|
1: {RoleID: roleAdmin.ID}, // Group access (higher role, but lower precedence)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
input := ResolverInput{
|
||||||
|
User: user,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
EndpointGroup: groups[0],
|
||||||
|
Roles: allRoles,
|
||||||
|
}
|
||||||
|
|
||||||
|
role := ComputeBaseRole(input)
|
||||||
|
is.NotNil(role)
|
||||||
|
is.Equal(roleOperator.ID, role.ID, "Direct endpoint access should take precedence")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("User access takes precedence over team access", func(t *testing.T) {
|
||||||
|
user := &portainer.User{ID: 1}
|
||||||
|
endpoint := &portainer.Endpoint{
|
||||||
|
ID: 1,
|
||||||
|
GroupID: 1,
|
||||||
|
UserAccessPolicies: portainer.UserAccessPolicies{
|
||||||
|
1: {RoleID: roleHelpdesk.ID},
|
||||||
|
},
|
||||||
|
TeamAccessPolicies: portainer.TeamAccessPolicies{
|
||||||
|
100: {RoleID: roleAdmin.ID}, // Team has higher role
|
||||||
|
},
|
||||||
|
}
|
||||||
|
memberships := []portainer.TeamMembership{
|
||||||
|
{UserID: 1, TeamID: 100},
|
||||||
|
}
|
||||||
|
|
||||||
|
input := ResolverInput{
|
||||||
|
User: user,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
UserMemberships: memberships,
|
||||||
|
Roles: allRoles,
|
||||||
|
}
|
||||||
|
|
||||||
|
role := ComputeBaseRole(input)
|
||||||
|
is.NotNil(role)
|
||||||
|
is.Equal(roleHelpdesk.ID, role.ID, "User access should take precedence over team access")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Team endpoint access takes precedence over team group access", func(t *testing.T) {
|
||||||
|
user := &portainer.User{ID: 1}
|
||||||
|
endpoint := &portainer.Endpoint{
|
||||||
|
ID: 1,
|
||||||
|
GroupID: 10,
|
||||||
|
TeamAccessPolicies: portainer.TeamAccessPolicies{
|
||||||
|
100: {RoleID: roleReadOnly.ID}, // Direct team endpoint access
|
||||||
|
},
|
||||||
|
}
|
||||||
|
groups := []portainer.EndpointGroup{
|
||||||
|
{
|
||||||
|
ID: 10,
|
||||||
|
TeamAccessPolicies: portainer.TeamAccessPolicies{
|
||||||
|
100: {RoleID: roleAdmin.ID}, // Team group access (higher role)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
memberships := []portainer.TeamMembership{
|
||||||
|
{UserID: 1, TeamID: 100},
|
||||||
|
}
|
||||||
|
|
||||||
|
input := ResolverInput{
|
||||||
|
User: user,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
EndpointGroup: groups[0],
|
||||||
|
UserMemberships: memberships,
|
||||||
|
Roles: allRoles,
|
||||||
|
}
|
||||||
|
|
||||||
|
role := ComputeBaseRole(input)
|
||||||
|
is.NotNil(role)
|
||||||
|
is.Equal(roleReadOnly.ID, role.ID, "Team endpoint access should take precedence over team group access")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeBaseRole_NoAccess(t *testing.T) {
|
||||||
|
is := assert.New(t)
|
||||||
|
|
||||||
|
user := &portainer.User{ID: 1}
|
||||||
|
endpoint := &portainer.Endpoint{
|
||||||
|
ID: 1,
|
||||||
|
GroupID: 10,
|
||||||
|
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||||
|
TeamAccessPolicies: portainer.TeamAccessPolicies{},
|
||||||
|
}
|
||||||
|
groups := []portainer.EndpointGroup{
|
||||||
|
{
|
||||||
|
ID: 10,
|
||||||
|
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||||
|
TeamAccessPolicies: portainer.TeamAccessPolicies{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
input := ResolverInput{
|
||||||
|
User: user,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
EndpointGroup: groups[0],
|
||||||
|
UserMemberships: []portainer.TeamMembership{},
|
||||||
|
Roles: allRoles,
|
||||||
|
}
|
||||||
|
|
||||||
|
role := ComputeBaseRole(input)
|
||||||
|
is.Nil(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeBaseRole_MultipleTeams_HighestPriorityWins(t *testing.T) {
|
||||||
|
is := assert.New(t)
|
||||||
|
|
||||||
|
user := &portainer.User{ID: 1}
|
||||||
|
endpoint := &portainer.Endpoint{
|
||||||
|
ID: 1,
|
||||||
|
GroupID: 1,
|
||||||
|
TeamAccessPolicies: portainer.TeamAccessPolicies{
|
||||||
|
100: {RoleID: roleReadOnly.ID}, // Highest priority (4)
|
||||||
|
200: {RoleID: roleAdmin.ID}, // Lowest priority (1)
|
||||||
|
300: {RoleID: roleOperator.ID}, // Medium priority (2)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
memberships := []portainer.TeamMembership{
|
||||||
|
{UserID: 1, TeamID: 100},
|
||||||
|
{UserID: 1, TeamID: 200},
|
||||||
|
{UserID: 1, TeamID: 300},
|
||||||
|
}
|
||||||
|
|
||||||
|
input := ResolverInput{
|
||||||
|
User: user,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
EndpointGroup: portainer.EndpointGroup{},
|
||||||
|
UserMemberships: memberships,
|
||||||
|
Roles: allRoles,
|
||||||
|
}
|
||||||
|
|
||||||
|
role := ComputeBaseRole(input)
|
||||||
|
|
||||||
|
is.NotNil(role)
|
||||||
|
is.Equal(roleReadOnly.ID, role.ID, "Highest priority role should be selected when user is in multiple teams")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUserEndpointAccess(t *testing.T) {
|
||||||
|
is := assert.New(t)
|
||||||
|
|
||||||
|
t.Run("Returns resolved access with role and authorizations", func(t *testing.T) {
|
||||||
|
user := &portainer.User{ID: 1}
|
||||||
|
endpoint := &portainer.Endpoint{
|
||||||
|
ID: 1,
|
||||||
|
UserAccessPolicies: portainer.UserAccessPolicies{
|
||||||
|
1: {RoleID: roleOperator.ID},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
input := ResolverInput{
|
||||||
|
User: user,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
Roles: allRoles,
|
||||||
|
}
|
||||||
|
|
||||||
|
access := ResolveUserEndpointAccess(input)
|
||||||
|
|
||||||
|
is.NotNil(access)
|
||||||
|
is.Equal(roleOperator.ID, access.Role.ID)
|
||||||
|
is.True(access.Authorizations["operator"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Returns nil when no access", func(t *testing.T) {
|
||||||
|
user := &portainer.User{ID: 1}
|
||||||
|
endpoint := &portainer.Endpoint{ID: 1}
|
||||||
|
|
||||||
|
input := ResolverInput{
|
||||||
|
User: user,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
Roles: allRoles,
|
||||||
|
}
|
||||||
|
|
||||||
|
access := ResolveUserEndpointAccess(input)
|
||||||
|
is.Nil(access)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindRoleByID(t *testing.T) {
|
||||||
|
is := assert.New(t)
|
||||||
|
|
||||||
|
t.Run("Finds existing role", func(t *testing.T) {
|
||||||
|
role := FindRoleByID(roleOperator.ID, allRoles)
|
||||||
|
is.NotNil(role)
|
||||||
|
is.Equal(roleOperator.ID, role.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Returns nil for non-existent role", func(t *testing.T) {
|
||||||
|
role := FindRoleByID(999, allRoles)
|
||||||
|
is.Nil(role)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Returns nil for empty roles slice", func(t *testing.T) {
|
||||||
|
role := FindRoleByID(1, []portainer.Role{})
|
||||||
|
is.Nil(role)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHighestPriorityRole(t *testing.T) {
|
||||||
|
is := assert.New(t)
|
||||||
|
|
||||||
|
t.Run("Returns nil for empty slice", func(t *testing.T) {
|
||||||
|
result := GetHighestPriorityRole([]*portainer.Role{})
|
||||||
|
is.Nil(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Returns single role", func(t *testing.T) {
|
||||||
|
result := GetHighestPriorityRole([]*portainer.Role{&roleOperator})
|
||||||
|
is.Equal(roleOperator.ID, result.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Returns highest priority from multiple roles", func(t *testing.T) {
|
||||||
|
result := GetHighestPriorityRole([]*portainer.Role{&roleReadOnly, &roleAdmin, &roleOperator})
|
||||||
|
is.Equal(roleReadOnly.ID, result.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
package options
|
package options
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
// UninstallOptions are portainer supported options for `helm uninstall`
|
// UninstallOptions are portainer supported options for `helm uninstall`
|
||||||
type UninstallOptions struct {
|
type UninstallOptions struct {
|
||||||
Name string
|
Name string
|
||||||
Namespace string
|
Namespace string
|
||||||
KubernetesClusterAccess *KubernetesClusterAccess
|
KubernetesClusterAccess *KubernetesClusterAccess
|
||||||
|
|
||||||
|
// Wait blocks until all resources are deleted before returning (helm uninstall --wait).
|
||||||
|
// Use when a restore will be applied immediately after uninstall so resources are gone first.
|
||||||
|
Wait bool
|
||||||
|
// Timeout is how long to wait for resources to be deleted when Wait is true (default 15m).
|
||||||
|
Timeout time.Duration
|
||||||
|
|
||||||
Env []string
|
Env []string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package sdk
|
package sdk
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"helm.sh/helm/v3/pkg/action"
|
"helm.sh/helm/v3/pkg/action"
|
||||||
|
"helm.sh/helm/v3/pkg/storage/driver"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Uninstall implements the HelmPackageManager interface by using the Helm SDK to uninstall a release.
|
// Uninstall implements the HelmPackageManager interface by using the Helm SDK to uninstall a release.
|
||||||
@@ -34,6 +37,12 @@ func (hspm *HelmSDKPackageManager) Uninstall(uninstallOpts options.UninstallOpti
|
|||||||
uninstallClient := action.NewUninstall(actionConfig)
|
uninstallClient := action.NewUninstall(actionConfig)
|
||||||
// 'foreground' means the parent object remains in a "terminating" state until all of its children are deleted. This ensures that all dependent resources are completely removed before finalizing the deletion of the parent resource.
|
// 'foreground' means the parent object remains in a "terminating" state until all of its children are deleted. This ensures that all dependent resources are completely removed before finalizing the deletion of the parent resource.
|
||||||
uninstallClient.DeletionPropagation = "foreground" // "background" or "orphan"
|
uninstallClient.DeletionPropagation = "foreground" // "background" or "orphan"
|
||||||
|
uninstallClient.Wait = uninstallOpts.Wait
|
||||||
|
if uninstallOpts.Timeout == 0 {
|
||||||
|
uninstallClient.Timeout = 15 * time.Minute
|
||||||
|
} else {
|
||||||
|
uninstallClient.Timeout = uninstallOpts.Timeout
|
||||||
|
}
|
||||||
|
|
||||||
// Run the uninstallation
|
// Run the uninstallation
|
||||||
log.Info().
|
log.Info().
|
||||||
@@ -63,3 +72,53 @@ func (hspm *HelmSDKPackageManager) Uninstall(uninstallOpts options.UninstallOpti
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForceRemoveRelease removes all release history (Helm secrets) without attempting
|
||||||
|
// to delete Kubernetes resources. This is a last-resort recovery mechanism for when
|
||||||
|
// a standard Uninstall fails because CRDs are missing and Helm can't build kubernetes
|
||||||
|
// objects for deletion, leaving the release stuck with no way to recover.
|
||||||
|
func (hspm *HelmSDKPackageManager) ForceRemoveRelease(uninstallOpts options.UninstallOptions) error {
|
||||||
|
if uninstallOpts.Name == "" {
|
||||||
|
return errors.New("release name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Warn().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Str("release", uninstallOpts.Name).
|
||||||
|
Str("namespace", uninstallOpts.Namespace).
|
||||||
|
Msg("Force-removing release history (skipping resource deletion)")
|
||||||
|
|
||||||
|
actionConfig := new(action.Configuration)
|
||||||
|
err := hspm.initActionConfig(actionConfig, uninstallOpts.Namespace, uninstallOpts.KubernetesClusterAccess)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to initialize helm configuration for force-remove")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all release versions from Helm's storage (Kubernetes secrets)
|
||||||
|
versions, err := actionConfig.Releases.History(uninstallOpts.Name)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, driver.ErrReleaseNotFound) {
|
||||||
|
log.Debug().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Str("release", uninstallOpts.Name).
|
||||||
|
Msg("Release not found in storage, nothing to force-remove")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.Wrap(err, "failed to get release history for force-remove")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete each release version from storage
|
||||||
|
for _, v := range versions {
|
||||||
|
if _, err := actionConfig.Releases.Delete(v.Name, v.Version); err != nil {
|
||||||
|
return errors.Wrapf(err, "failed to delete release version %d for force-remove", v.Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Str("release", uninstallOpts.Name).
|
||||||
|
Int("versions_removed", len(versions)).
|
||||||
|
Msg("Successfully force-removed all release history")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ func (hpm helmMockPackageManager) Uninstall(uninstallOpts options.UninstallOptio
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForceRemoveRelease removes release history without deleting resources (not thread safe)
|
||||||
|
func (hpm helmMockPackageManager) ForceRemoveRelease(uninstallOpts options.UninstallOptions) error {
|
||||||
|
return hpm.Uninstall(uninstallOpts)
|
||||||
|
}
|
||||||
|
|
||||||
// List a helm chart (not thread safe)
|
// List a helm chart (not thread safe)
|
||||||
func (hpm helmMockPackageManager) List(listOpts options.ListOptions) ([]release.ReleaseElement, error) {
|
func (hpm helmMockPackageManager) List(listOpts options.ListOptions) ([]release.ReleaseElement, error) {
|
||||||
return mockCharts, nil
|
return mockCharts, nil
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ type HelmPackageManager interface {
|
|||||||
List(listOpts options.ListOptions) ([]release.ReleaseElement, error)
|
List(listOpts options.ListOptions) ([]release.ReleaseElement, error)
|
||||||
Upgrade(upgradeOpts options.InstallOptions) (*release.Release, error)
|
Upgrade(upgradeOpts options.InstallOptions) (*release.Release, error)
|
||||||
Uninstall(uninstallOpts options.UninstallOptions) error
|
Uninstall(uninstallOpts options.UninstallOptions) error
|
||||||
|
// ForceRemoveRelease removes all release history (Helm secrets) without attempting
|
||||||
|
// to delete Kubernetes resources. Use as a last resort when Uninstall fails because
|
||||||
|
// CRDs are missing and Helm can't build kubernetes objects for deletion.
|
||||||
|
ForceRemoveRelease(uninstallOpts options.UninstallOptions) error
|
||||||
Get(getOpts options.GetOptions) (*release.Release, error)
|
Get(getOpts options.GetOptions) (*release.Release, error)
|
||||||
GetHistory(historyOpts options.HistoryOptions) ([]*release.Release, error)
|
GetHistory(historyOpts options.HistoryOptions) ([]*release.Release, error)
|
||||||
Rollback(rollbackOpts options.RollbackOptions) (*release.Release, error)
|
Rollback(rollbackOpts options.RollbackOptions) (*release.Release, error)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
"k8s.io/apimachinery/pkg/api/meta"
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
@@ -160,8 +161,9 @@ func (c *Client) applyResource(ctx context.Context, dynamicClient dynamic.Interf
|
|||||||
return "", fmt.Errorf("failed to marshal object to JSON: %w", err)
|
return "", fmt.Errorf("failed to marshal object to JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply using Server-Side Apply
|
// Apply using Server-Side Apply (Patch). If the resource does not exist (404),
|
||||||
// This is more efficient and handles field ownership better than traditional apply
|
// fall back to Create so restoration can create Deployments and other resources
|
||||||
|
// that were removed (e.g. by Helm uninstall).
|
||||||
patchOptions := metav1.PatchOptions{
|
patchOptions := metav1.PatchOptions{
|
||||||
FieldManager: "portainer",
|
FieldManager: "portainer",
|
||||||
Force: boolPtr(true),
|
Force: boolPtr(true),
|
||||||
@@ -175,7 +177,14 @@ func (c *Client) applyResource(ctx context.Context, dynamicClient dynamic.Interf
|
|||||||
patchOptions,
|
patchOptions,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to apply %s %s/%s: %w", gvk.Kind, namespace, name, err)
|
if apierrors.IsNotFound(err) {
|
||||||
|
_, createErr := resourceClient.Create(ctx, obj, metav1.CreateOptions{})
|
||||||
|
if createErr != nil {
|
||||||
|
return "", fmt.Errorf("failed to create %s %s/%s: %w", gvk.Kind, namespace, name, createErr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "", fmt.Errorf("failed to apply %s %s/%s: %w", gvk.Kind, namespace, name, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format output message
|
// Format output message
|
||||||
|
|||||||
Reference in New Issue
Block a user