From 2ba8b582e2981e6db5cf2a2226652a6b2810d406 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Wed, 3 Jun 2026 14:37:39 +0300 Subject: [PATCH] feat(api): use generated api client [BE-12901] (#2727) --- api/datastore/datastore_test.go | 16 +- .../test_data/output_24_to_latest.json | 24 +- .../endpointgroups/endpointgroup_list.go | 26 +- .../endpointgroups/endpointgroup_list_test.go | 16 +- api/http/handler/endpoints/endpoint_create.go | 6 +- api/http/handler/endpoints/endpoint_list.go | 13 +- .../handler/endpoints/endpoint_list_test.go | 8 +- api/http/handler/endpoints/filter.go | 70 +- api/http/handler/endpoints/filter_test.go | 42 +- api/portainer.go | 231 ++--- app/react-tools/test-mocks.ts | 24 + app/react/docker/snapshots/types/index.ts | 21 +- .../StackDuplicationForm/EnvSelector.test.tsx | 71 +- .../StackDuplicationForm.test.tsx | 15 +- .../StackDuplicationFormInner.test.tsx | 13 +- .../CreateAccessInnerForm.tsx | 2 +- .../EnvironmentCard.stories.tsx | 5 +- .../EnvironmentList/EnvironmentList.test.tsx | 148 +-- .../AzureEnvironmentForm.test.tsx | 7 +- .../AzureEnvironmentForm.tsx | 2 +- .../EdgeEnvironmentForm.test.tsx | 9 +- .../GeneralEnvironmentForm/helpers.test.ts | 10 +- .../GeneralEnvironmentForm/helpers.ts | 2 +- .../ItemView/EditGroupView.test.tsx | 16 +- .../EnvironmentGroupsTable.tsx | 4 +- .../environment-groups/queries.ts | 41 +- .../environment-groups/queries/query-keys.ts | 5 +- .../queries/useCreateGroupMutation.ts | 2 +- .../queries/useEnvironmentGroups.ts | 28 - .../environment-groups/queries/useGroup.ts | 39 +- .../environment-groups/queries/useGroups.ts | 30 + .../queries/useUpdateGroupMutation.ts | 2 +- .../environments/environment-groups/types.ts | 30 +- .../environment.service/create.ts | 31 +- .../environments/environment.service/index.ts | 56 +- .../environments/environment.service/utils.ts | 103 +- .../queries/useParseApiSortParams.test.ts | 5 +- .../queries/useUpdateEnvironmentMutation.ts | 42 +- app/react/portainer/environments/types.ts | 92 +- .../WizardKubernetes.test.tsx | 3 +- .../generated-api/portainer/sdk.gen.ts | 41 +- .../generated-api/portainer/types.gen.ts | 955 ++++++++++++++---- .../generated-api/portainer/zod.gen.ts | 464 ++++++--- openapi-ts.config.ts | 20 + pkg/libhttp/request/request.go | 26 +- pkg/libhttp/request/request_test.go | 106 +- 46 files changed, 1832 insertions(+), 1090 deletions(-) delete mode 100644 app/react/portainer/environments/environment-groups/queries/useEnvironmentGroups.ts create mode 100644 app/react/portainer/environments/environment-groups/queries/useGroups.ts diff --git a/api/datastore/datastore_test.go b/api/datastore/datastore_test.go index 51fc4b644..2a7653054 100644 --- a/api/datastore/datastore_test.go +++ b/api/datastore/datastore_test.go @@ -72,12 +72,16 @@ func newEndpoint(endpointType portainer.EndpointType, id portainer.EndpointID, n TLSConfig: portainer.TLSConfiguration{ TLS: false, }, - UserAccessPolicies: portainer.UserAccessPolicies{}, - TeamAccessPolicies: portainer.TeamAccessPolicies{}, - TagIDs: []portainer.TagID{}, - Status: portainer.EndpointStatusUp, - Snapshots: []portainer.DockerSnapshot{}, - Kubernetes: portainer.KubernetesDefault(), + TagIDs: nil, + Status: portainer.EndpointStatusUp, + Snapshots: nil, + Kubernetes: portainer.KubernetesData{ + Configuration: portainer.KubernetesConfiguration{ + UseLoadBalancer: false, + UseServerMetrics: false, + EnableResourceOverCommit: true, + }, + }, } if TLS { diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json index 0c364a8c3..ca4cb9c2c 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -33,11 +33,7 @@ ], "endpoints": [ { - "Agent": { - "Version": "" - }, - "AuthorizedTeams": null, - "AuthorizedUsers": null, + "Agent": {}, "AzureCredentials": { "ApplicationID": "", "AuthenticationKey": "", @@ -53,7 +49,6 @@ }, "EdgeCheckinInterval": 0, "EdgeKey": "", - "Gpus": [], "GroupId": 1, "Heartbeat": false, "Id": 1, @@ -62,10 +57,8 @@ "AllowNoneIngressClass": false, "EnableResourceOverCommit": false, "IngressAvailabilityPerNamespace": true, - "IngressClasses": null, "ResourceOverCommitPercentage": 0, "RestrictDefaultNamespace": false, - "StorageClasses": null, "UseLoadBalancer": false, "UseServerMetrics": false }, @@ -73,8 +66,7 @@ "IsServerIngressClassDetected": false, "IsServerMetricsDetected": false, "IsServerStorageDetected": false - }, - "Snapshots": [] + } }, "LastCheckInDate": 0, "Name": "local", @@ -96,18 +88,13 @@ "allowVolumeBrowserForRegularUsers": false, "enableHostManagementFeatures": false }, - "Snapshots": [], "Status": 1, "TLSConfig": { "TLS": false, "TLSSkipVerify": false }, - "TagIds": [], - "Tags": null, - "TeamAccessPolicies": {}, "Type": 1, - "URL": "unix:///var/run/docker.sock", - "UserAccessPolicies": {} + "URL": "unix:///var/run/docker.sock" } ], "extension": null, @@ -669,8 +656,6 @@ "ContainerCount": 0, "DiagnosticsData": {}, "DockerSnapshotRaw": { - "Containers": null, - "Images": null, "Info": { "Architecture": "", "CDISpecDirs": null, @@ -746,7 +731,6 @@ "SystemTime": "", "Warnings": null }, - "Networks": null, "Version": { "ApiVersion": "", "Arch": "", @@ -765,12 +749,10 @@ }, "DockerVersion": "20.10.13", "GpuUseAll": false, - "GpuUseList": null, "HealthyContainerCount": 0, "ImageCount": 9, "IsPodman": false, "NodeCount": 0, - "PerformanceMetrics": null, "RunningContainerCount": 5, "ServiceCount": 0, "StackCount": 2, diff --git a/api/http/handler/endpointgroups/endpointgroup_list.go b/api/http/handler/endpointgroups/endpointgroup_list.go index 8b6f5060b..a574905f8 100644 --- a/api/http/handler/endpointgroups/endpointgroup_list.go +++ b/api/http/handler/endpointgroups/endpointgroup_list.go @@ -13,14 +13,14 @@ import ( "github.com/portainer/portainer/pkg/libhttp/response" ) -func computeGroupSizeInfo(endpointGroups []portainer.EndpointGroup, endpoints []portainer.Endpoint) (map[portainer.EndpointGroupID]int, map[portainer.EndpointGroupID]endpointGroupTypeInfo) { +func computeGroupSizeInfo(endpointGroups []portainer.EndpointGroup, endpoints []portainer.Endpoint) (map[portainer.EndpointGroupID]int, map[portainer.EndpointGroupID]EndpointGroupTypeInfo) { groupSet := set.Set[portainer.EndpointGroupID]{} for i := range endpointGroups { groupSet[endpointGroups[i].ID] = true } countMap := make(map[portainer.EndpointGroupID]int) - typeInfoMap := make(map[portainer.EndpointGroupID]endpointGroupTypeInfo) + typeInfoMap := make(map[portainer.EndpointGroupID]EndpointGroupTypeInfo) for _, endpoint := range endpoints { if _, ok := groupSet[endpoint.GroupID]; !ok { @@ -57,17 +57,17 @@ func computeGroupSizeInfo(endpointGroups []portainer.EndpointGroup, endpoints [] return countMap, typeInfoMap } -type endpointGroupTypeInfo struct { - Docker int `json:"Docker"` - Kubernetes int `json:"Kubernetes"` - Podman int `json:"Podman"` - Mixed bool `json:"Mixed"` +type EndpointGroupTypeInfo struct { + Docker int `json:"Docker" validate:"required"` + Kubernetes int `json:"Kubernetes" validate:"required"` + Podman int `json:"Podman" validate:"required"` + Mixed bool `json:"Mixed" validate:"required"` } -type endpointGroupResponse struct { +type EndpointGroupResponse struct { portainer.EndpointGroup Total int `json:"Total,omitzero"` - TypeInfo endpointGroupTypeInfo `json:"TypeInfo,omitzero"` + TypeInfo EndpointGroupTypeInfo `json:"TypeInfo,omitzero"` } // @id EndpointGroupList @@ -81,7 +81,7 @@ type endpointGroupResponse struct { // @security jwt // @produce json // @param size query boolean false "If true, each environment(endpoint) group will include the number of environments(endpoints) associated to it and breakdown by type" -// @success 200 {array} endpointGroupResponse "Environment(Endpoint) group" +// @success 200 {array} EndpointGroupResponse "Environment(Endpoint) group" // @failure 500 "Server error" // @router /endpoint_groups [get] func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -129,16 +129,16 @@ func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request } var endpointGroupCountMap map[portainer.EndpointGroupID]int - var endpointGroupTypeInfoMap map[portainer.EndpointGroupID]endpointGroupTypeInfo + var endpointGroupTypeInfoMap map[portainer.EndpointGroupID]EndpointGroupTypeInfo if includeSize { endpointGroupCountMap, endpointGroupTypeInfoMap = computeGroupSizeInfo(endpointGroups, endpoints) } - endpointGroupsResponse := make([]endpointGroupResponse, len(endpointGroups)) + endpointGroupsResponse := make([]EndpointGroupResponse, len(endpointGroups)) for i := range endpointGroups { groupID := endpointGroups[i].ID - endpointGroupsResponse[i] = endpointGroupResponse{ + endpointGroupsResponse[i] = EndpointGroupResponse{ EndpointGroup: endpointGroups[i], } diff --git a/api/http/handler/endpointgroups/endpointgroup_list_test.go b/api/http/handler/endpointgroups/endpointgroup_list_test.go index 5da4e5264..46d2e46f2 100644 --- a/api/http/handler/endpointgroups/endpointgroup_list_test.go +++ b/api/http/handler/endpointgroups/endpointgroup_list_test.go @@ -30,7 +30,7 @@ func TestHandler_endpointGroupList(t *testing.T) { handler.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) - res := make([]endpointGroupResponse, 0) + res := make([]EndpointGroupResponse, 0) require.NoError(t, json.NewDecoder(w.Body).Decode(&res)) require.Len(t, res, len(groups)+1, "should contain an additional default group") for _, group := range res { @@ -53,7 +53,7 @@ func TestHandler_endpointGroupList(t *testing.T) { handler.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) - res := make([]endpointGroupResponse, 0) + res := make([]EndpointGroupResponse, 0) require.NoError(t, json.NewDecoder(w.Body).Decode(&res)) for _, group := range res { assert.Zero(t, group.Total) @@ -79,10 +79,10 @@ func TestHandler_endpointGroupList(t *testing.T) { handler.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) - res := make([]endpointGroupResponse, 0) + res := make([]EndpointGroupResponse, 0) require.NoError(t, json.NewDecoder(w.Body).Decode(&res)) - var group1 *endpointGroupResponse + var group1 *EndpointGroupResponse for i := range res { if res[i].ID == groups[0].ID { group1 = &res[i] @@ -124,10 +124,10 @@ func TestHandler_endpointGroupList(t *testing.T) { handler.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) - res := make([]endpointGroupResponse, 0) + res := make([]EndpointGroupResponse, 0) require.NoError(t, json.NewDecoder(w.Body).Decode(&res)) - var group2 *endpointGroupResponse + var group2 *EndpointGroupResponse for i := range res { if res[i].ID == groups[1].ID { group2 = &res[i] @@ -170,10 +170,10 @@ func TestHandler_endpointGroupList(t *testing.T) { handler.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) - res := make([]endpointGroupResponse, 0) + res := make([]EndpointGroupResponse, 0) require.NoError(t, json.NewDecoder(w.Body).Decode(&res)) - var group1 *endpointGroupResponse + var group1 *EndpointGroupResponse for i := range res { if res[i].ID == groups[0].ID { group1 = &res[i] diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 0f584cd76..7d95a31ea 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -195,7 +195,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { // @accept multipart/form-data // @produce json // @param Name formData string true "Name that will be used to identify this environment(endpoint) (example: my-environment)" -// @param EndpointCreationType formData integer true "Environment(Endpoint) type. Value must be one of: 1 (Local Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge agent environment) or 5 (Local Kubernetes Environment)" Enum(1,2,3,4,5) +// @param EndpointCreationType formData endpointCreationEnum true "Environment(Endpoint) type. Value must be one of: 1 (Local Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge agent environment) or 5 (Local Kubernetes Environment)" Enum(1,2,3,4,5) // @param ContainerEngine formData string false "Container engine used by the environment(endpoint). Value must be one of: 'docker' or 'podman'" // @param URL formData string false "URL or IP address of a Docker host (example: docker.mydomain.tld:2375). Defaults to local if not specified (Linux: /var/run/docker.sock, Windows: //./pipe/docker_engine). Cannot be empty if EndpointCreationType is set to 4 (Edge agent environment)" // @param PublicURL formData string false "URL or IP address where exposed containers will be reachable. Defaults to URL if not specified (example: docker.mydomain.tld:2375)" @@ -209,9 +209,9 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { // @param AzureApplicationID formData string false "Azure application ID. Required if environment(endpoint) type is set to 3" // @param AzureTenantID formData string false "Azure tenant ID. Required if environment(endpoint) type is set to 3" // @param AzureAuthenticationKey formData string false "Azure authentication key. Required if environment(endpoint) type is set to 3" -// @param TagIds formData []int false "List of tag identifiers to which this environment(endpoint) is associated" +// @param TagIds formData string false "JSON-parsable array of tag identifiers to which this environment(endpoint) is associated" // @param EdgeCheckinInterval formData int false "The check in interval for edge agent (in seconds)" -// @param EdgeTunnelServerAddress formData string true "URL or IP address that will be used to establish a reverse tunnel" +// @param EdgeTunnelServerAddress formData string false "URL or IP address that will be used to establish a reverse tunnel" // @param Gpus formData string false "List of GPUs - json stringified array of {name, value} structs" // @success 200 {object} portainer.Endpoint "Success" // @failure 400 "Invalid request" diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index 86b1c7ca1..2996df847 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -30,25 +30,26 @@ const ( // @param start query int false "Start searching from" // @param limit query int false "Limit results to this value" // @param sort query sortKey false "Sort results by this value" Enum("Name", "Group", "Status", "LastCheckIn", "EdgeID") -// @param order query int false "Order sorted results by desc/asc" Enum("asc", "desc") +// @param order query string false "Order sorted results by desc/asc" Enum("asc", "desc") // @param search query string false "Search query" // @param groupIds query []int false "List environments(endpoints) of these groups" // @param status query []int false "List environments(endpoints) by this status" -// @param types query []int false "List environments(endpoints) of this type" +// @param types query []portainer.EndpointType false "List environments(endpoints) of this type" +// @param platformTypes query []portainer.PlatformType false "Filter environments by platform type" +// @param outdated query bool false "If true, return only environments with an outdated agent" +// @param excludeGroupIds query []int false "Exclude environments of these groups" // @param tagIds query []int false "search environments(endpoints) with these tags (depends on tagsPartialMatch)" // @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags" // @param endpointIds query []int false "will return only these environments(endpoints)" // @param excludeIds query []int false "will exclude these environments(endpoints)" -// @param excludeGroupIds query []int false "will exclude environments(endpoints) belonging to these endpoint groups" -// @param provisioned query bool false "If true, will return environment(endpoint) that were provisioned" // @param agentVersions query []string false "will return only environments with on of these agent versions" // @param edgeAsync query bool false "if exists true show only edge async agents, false show only standard edge agents. if missing, will show both types (relevant only for edge agents)" // @param edgeDeviceUntrusted query bool false "if true, show only untrusted edge agents, if false show only trusted edge agents (relevant only for edge agents)" // @param edgeCheckInPassedSeconds query number false "if bigger then zero, show only edge agents that checked-in in the last provided seconds (relevant only for edge agents)" // @param excludeSnapshots query bool false "if true, the snapshot data won't be retrieved" // @param name query string false "will return only environments(endpoints) with this name" -// @param edgeStackId query portainer.EdgeStackID false "will return the environements of the specified edge stack" -// @param edgeStackStatus query string false "only applied when edgeStackId exists. Filter the returned environments based on their deployment status in the stack (not the environment status!)" Enum("Pending", "Ok", "Error", "Acknowledged", "Remove", "RemoteUpdateSuccess", "ImagesPulled") +// @param edgeStackId query int false "will return the environments of the specified edge stack" +// @param edgeStackStatus query portainer.EdgeStackStatusType false "only applied when edgeStackId exists. Filter the returned environments based on their deployment status in the stack (not the environment status!)" Enum("Pending", "Ok", "Error", "Acknowledged", "Remove", "RemoteUpdateSuccess", "ImagesPulled") // @param edgeGroupIds query []int false "List environments(endpoints) of these edge groups" // @param excludeEdgeGroupIds query []int false "Exclude environments(endpoints) of these edge groups" // @success 200 {array} portainer.Endpoint "Endpoints" diff --git a/api/http/handler/endpoints/endpoint_list_test.go b/api/http/handler/endpoints/endpoint_list_test.go index e94c87bce..28e4d97df 100644 --- a/api/http/handler/endpoints/endpoint_list_test.go +++ b/api/http/handler/endpoints/endpoint_list_test.go @@ -30,17 +30,13 @@ func Test_EndpointList_AgentVersion(t *testing.T) { ID: 1, GroupID: 1, Type: portainer.AgentOnDockerEnvironment, - Agent: struct { - Version string `example:"1.0.0"` - }{Version: "1.0.0"}, + Agent: portainer.EnvironmentAgentData{Version: "1.0.0"}, } version2Endpoint := portainer.Endpoint{ ID: 2, GroupID: 1, Type: portainer.AgentOnDockerEnvironment, - Agent: struct { - Version string `example:"1.0.0"` - }{Version: "2.0.0"}, + Agent: portainer.EnvironmentAgentData{Version: "2.0.0"}, } noVersionEndpoint := portainer.Endpoint{ID: 3, Type: portainer.AgentOnDockerEnvironment, GroupID: 1} notAgentEnvironments := portainer.Endpoint{ID: 4, Type: portainer.DockerEnvironment, GroupID: 1} diff --git a/api/http/handler/endpoints/filter.go b/api/http/handler/endpoints/filter.go index 9fedfc24f..6e886a7b4 100644 --- a/api/http/handler/endpoints/filter.go +++ b/api/http/handler/endpoints/filter.go @@ -24,25 +24,25 @@ import ( type EnvironmentsQuery struct { search string - types []portainer.EndpointType - platformTypes []portainer.PlatformType - tagIds []portainer.TagID - endpointIds []portainer.EndpointID - tagsPartialMatch bool groupIds []portainer.EndpointGroupID status []portainer.EndpointStatus + types []portainer.EndpointType + platformTypes []portainer.PlatformType + outdated bool + excludeGroupIds []portainer.EndpointGroupID + tagIds []portainer.TagID + tagsPartialMatch bool + endpointIds []portainer.EndpointID + excludeIds []portainer.EndpointID + agentVersions []string // if edgeAsync not nil, will filter edge endpoints based on this value edgeAsync *bool edgeDeviceUntrusted bool + edgeCheckInPassedSeconds int excludeSnapshots bool name string - agentVersions []string - outdated bool - edgeCheckInPassedSeconds int edgeStackId portainer.EdgeStackID edgeStackStatus *portainer.EdgeStackStatusType - excludeIds []portainer.EndpointID - excludeGroupIds []portainer.EndpointGroupID edgeGroupIds []portainer.EdgeGroupID excludeEdgeGroupIds []portainer.EdgeGroupID } @@ -53,59 +53,59 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) { search = strings.ToLower(search) } - status, err := getNumberArrayQueryParameter[portainer.EndpointStatus](r, "status") + status, err := request.RetrieveNumberArrayQueryParameter[portainer.EndpointStatus](r, "status") if err != nil { return EnvironmentsQuery{}, err } - groupIDs, err := getNumberArrayQueryParameter[portainer.EndpointGroupID](r, "groupIds") + groupIDs, err := request.RetrieveNumberArrayQueryParameter[portainer.EndpointGroupID](r, "groupIds") if err != nil { return EnvironmentsQuery{}, err } - endpointTypes, err := getNumberArrayQueryParameter[portainer.EndpointType](r, "types") + endpointTypes, err := request.RetrieveNumberArrayQueryParameter[portainer.EndpointType](r, "types") if err != nil { return EnvironmentsQuery{}, err } - platformTypes, err := getNumberArrayQueryParameter[portainer.PlatformType](r, "platformTypes") + platformTypes, err := request.RetrieveNumberArrayQueryParameter[portainer.PlatformType](r, "platformTypes") if err != nil { return EnvironmentsQuery{}, err } - tagIDs, err := getNumberArrayQueryParameter[portainer.TagID](r, "tagIds") + tagIDs, err := request.RetrieveNumberArrayQueryParameter[portainer.TagID](r, "tagIds") if err != nil { return EnvironmentsQuery{}, err } tagsPartialMatch, _ := request.RetrieveBooleanQueryParameter(r, "tagsPartialMatch", true) - endpointIDs, err := getNumberArrayQueryParameter[portainer.EndpointID](r, "endpointIds") + endpointIDs, err := request.RetrieveNumberArrayQueryParameter[portainer.EndpointID](r, "endpointIds") if err != nil { return EnvironmentsQuery{}, err } - excludeIDs, err := getNumberArrayQueryParameter[portainer.EndpointID](r, "excludeIds") + excludeIDs, err := request.RetrieveNumberArrayQueryParameter[portainer.EndpointID](r, "excludeIds") if err != nil { return EnvironmentsQuery{}, err } - excludeGroupIDs, err := getNumberArrayQueryParameter[portainer.EndpointGroupID](r, "excludeGroupIds") + excludeGroupIDs, err := request.RetrieveNumberArrayQueryParameter[portainer.EndpointGroupID](r, "excludeGroupIds") if err != nil { return EnvironmentsQuery{}, err } - edgeGroupIDs, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "edgeGroupIds") + edgeGroupIDs, err := request.RetrieveNumberArrayQueryParameter[portainer.EdgeGroupID](r, "edgeGroupIds") if err != nil { return EnvironmentsQuery{}, err } - excludeEdgeGroupIds, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "excludeEdgeGroupIds") + excludeEdgeGroupIds, err := request.RetrieveNumberArrayQueryParameter[portainer.EdgeGroupID](r, "excludeEdgeGroupIds") if err != nil { return EnvironmentsQuery{}, err } - agentVersions := getArrayQueryParameter(r, "agentVersions") + agentVersions := request.RetrieveArrayQueryParameter(r, "agentVersions") outdated, _ := request.RetrieveBooleanQueryParameter(r, "outdated", true) @@ -696,34 +696,6 @@ func filter(endpoints []portainer.Endpoint, predicate func(endpoint portainer.En return endpoints[:n] } -func getArrayQueryParameter(r *http.Request, parameter string) []string { - list, exists := r.Form[parameter+"[]"] - if !exists { - list = []string{} - } - - return list -} - -func getNumberArrayQueryParameter[T ~int](r *http.Request, parameter string) ([]T, error) { - list := getArrayQueryParameter(r, parameter) - if list == nil { - return []T{}, nil - } - - var result []T - for _, item := range list { - number, err := strconv.Atoi(item) - if err != nil { - return nil, errors.Wrapf(err, "Unable to parse parameter %s", parameter) - } - - result = append(result, T(number)) - } - - return result, nil -} - func getEdgeStackStatusParam(r *http.Request) (*portainer.EdgeStackStatusType, error) { edgeStackStatusQuery, _ := request.RetrieveQueryParameter(r, "edgeStackStatus", true) if edgeStackStatusQuery == "" { diff --git a/api/http/handler/endpoints/filter_test.go b/api/http/handler/endpoints/filter_test.go index 10caa6fae..7505c26b1 100644 --- a/api/http/handler/endpoints/filter_test.go +++ b/api/http/handler/endpoints/filter_test.go @@ -1,6 +1,8 @@ package endpoints import ( + "net/http" + "net/url" "strconv" "testing" @@ -25,15 +27,11 @@ type filterTest struct { func Test_Filter_AgentVersion(t *testing.T) { t.Parallel() version1Endpoint := portainer.Endpoint{ID: 1, GroupID: 1, - Type: portainer.AgentOnDockerEnvironment, - Agent: struct { - Version string `example:"1.0.0"` - }{Version: "1.0.0"}} + Type: portainer.AgentOnDockerEnvironment, + Agent: portainer.EnvironmentAgentData{Version: "1.0.0"}} version2Endpoint := portainer.Endpoint{ID: 2, GroupID: 1, - Type: portainer.AgentOnDockerEnvironment, - Agent: struct { - Version string `example:"1.0.0"` - }{Version: "2.0.0"}} + Type: portainer.AgentOnDockerEnvironment, + Agent: portainer.EnvironmentAgentData{Version: "2.0.0"}} noVersionEndpoint := portainer.Endpoint{ID: 3, GroupID: 1, Type: portainer.AgentOnDockerEnvironment, } @@ -726,3 +724,31 @@ func Test_FilterQuery_Outdated(t *testing.T) { runTests(tests, t, handler, endpoints) } + +func Test_parseQuery_emptyArrayParams(t *testing.T) { + t.Parallel() + + makeRequest := func(rawQuery string) *http.Request { + r := &http.Request{URL: &url.URL{RawQuery: rawQuery}} + require.NoError(t, r.ParseForm()) + return r + } + + tests := []struct { + name string + query string + }{ + {name: "empty status", query: "status="}, + {name: "empty endpointIds", query: "endpointIds="}, + {name: "empty groupIds", query: "groupIds="}, + {name: "empty tagIds", query: "tagIds="}, + {name: "multiple empty params", query: "status=&groupIds=&tagIds="}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseQuery(makeRequest(tt.query)) + require.NoError(t, err) + }) + } +} diff --git a/api/portainer.go b/api/portainer.go index 1e73e535c..aca2dc475 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -29,7 +29,7 @@ type ( // AccessPolicy represent a policy that can be associated to a user or team AccessPolicy struct { // Role identifier. Reference the role that will be associated to this access policy - RoleID RoleID `json:"RoleId" example:"1"` + RoleID RoleID `json:"RoleId" example:"1" validate:"required"` // Namespaces is a list of namespaces that this access policy applies to. Only used for namespaced level roles Namespaces []string `json:"Namespaces,omitempty"` } @@ -64,11 +64,11 @@ type ( // environment(endpoint). AzureCredentials struct { // Azure application ID - ApplicationID string `json:"ApplicationID" example:"eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4"` + ApplicationID string `json:"ApplicationID" example:"eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4" validate:"required"` // Azure tenant ID - TenantID string `json:"TenantID" example:"34ddc78d-4fel-2358-8cc1-df84c8o839f5"` + TenantID string `json:"TenantID" example:"34ddc78d-4fel-2358-8cc1-df84c8o839f5" validate:"required"` // Azure authentication key - AuthenticationKey string `json:"AuthenticationKey" example:"cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk="` + AuthenticationKey string `json:"AuthenticationKey" example:"cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=" validate:"required"` } // CLIFlags represents the available flags on the CLI @@ -185,27 +185,27 @@ type ( // DockerSnapshot represents a snapshot of a specific Docker environment(endpoint) at a specific time DockerSnapshot struct { - Time int64 `json:"Time"` - DockerVersion string `json:"DockerVersion"` - Swarm bool `json:"Swarm"` - TotalCPU int `json:"TotalCPU"` - TotalMemory int64 `json:"TotalMemory"` - ContainerCount int `json:"ContainerCount"` - RunningContainerCount int `json:"RunningContainerCount"` - StoppedContainerCount int `json:"StoppedContainerCount"` - HealthyContainerCount int `json:"HealthyContainerCount"` - UnhealthyContainerCount int `json:"UnhealthyContainerCount"` - VolumeCount int `json:"VolumeCount"` - ImageCount int `json:"ImageCount"` - ServiceCount int `json:"ServiceCount"` - StackCount int `json:"StackCount"` + Time int64 `json:"Time" validate:"required"` + DockerVersion string `json:"DockerVersion" validate:"required"` + Swarm bool `json:"Swarm" validate:"required"` + TotalCPU int `json:"TotalCPU" validate:"required"` + TotalMemory int64 `json:"TotalMemory" validate:"required"` + ContainerCount int `json:"ContainerCount" validate:"required"` + RunningContainerCount int `json:"RunningContainerCount" validate:"required"` + StoppedContainerCount int `json:"StoppedContainerCount" validate:"required"` + HealthyContainerCount int `json:"HealthyContainerCount" validate:"required"` + UnhealthyContainerCount int `json:"UnhealthyContainerCount" validate:"required"` + VolumeCount int `json:"VolumeCount" validate:"required"` + ImageCount int `json:"ImageCount" validate:"required"` + ServiceCount int `json:"ServiceCount" validate:"required"` + StackCount int `json:"StackCount" validate:"required"` SnapshotRaw DockerSnapshotRaw `json:"DockerSnapshotRaw"` - NodeCount int `json:"NodeCount"` - GpuUseAll bool `json:"GpuUseAll"` - GpuUseList []string `json:"GpuUseList"` - IsPodman bool `json:"IsPodman"` - DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"` - PerformanceMetrics *PerformanceMetrics `json:"PerformanceMetrics"` + NodeCount int `json:"NodeCount" validate:"required"` + GpuUseAll bool `json:"GpuUseAll" validate:"required"` + GpuUseList []string `json:"GpuUseList,omitempty"` + IsPodman bool `json:"IsPodman" validate:"required"` + DiagnosticsData *DiagnosticsData `json:"DiagnosticsData,omitempty"` + PerformanceMetrics *PerformanceMetrics `json:"PerformanceMetrics,omitempty"` } // PerformanceMetrics represents the performance metrics of a Docker, Swarm, Podman, and Kubernetes environments @@ -225,10 +225,10 @@ type ( // DockerSnapshotRaw represents all the information related to a snapshot as returned by the Docker API DockerSnapshotRaw struct { - Containers []DockerContainerSnapshot `json:"Containers" swaggerignore:"true"` + Containers []DockerContainerSnapshot `json:"Containers,omitempty" swaggerignore:"true"` Volumes volume.ListResponse `json:"Volumes" swaggerignore:"true"` - Networks []network.Summary `json:"Networks" swaggerignore:"true"` - Images []image.Summary `json:"Images" swaggerignore:"true"` + Networks []network.Summary `json:"Networks,omitempty" swaggerignore:"true"` + Images []image.Summary `json:"Images,omitempty" swaggerignore:"true"` Info system.Info `json:"Info" swaggerignore:"true"` Version types.Version `json:"Version" swaggerignore:"true"` } @@ -240,7 +240,7 @@ type ( Name string `json:"Name"` Dynamic bool `json:"Dynamic"` TagIDs []TagID `json:"TagIds"` - EndpointIDs roar.Roar[EndpointID] `json:"EndpointIds"` + EndpointIDs roar.Roar[EndpointID] `json:"EndpointIds" validate:"optional"` PartialMatch bool `json:"PartialMatch"` // Deprecated: only used for API responses @@ -439,46 +439,46 @@ type ( // to connect to it Endpoint struct { // Environment(Endpoint) Identifier - ID EndpointID `json:"Id" example:"1"` + ID EndpointID `json:"Id" example:"1" validate:"required"` // Environment(Endpoint) name - Name string `json:"Name" example:"my-environment"` + Name string `json:"Name" example:"my-environment" validate:"required"` // Environment(Endpoint) environment(endpoint) type. 1 for a Docker environment(endpoint), 2 for an agent on Docker environment(endpoint) or 3 for an Azure environment(endpoint). - Type EndpointType `json:"Type" example:"1"` - // ContainerEngine represents the container engine type. This can be 'docker' or 'podman' when interacting directly with these environmentes, otherwise '' for kubernetes environments. - ContainerEngine string `json:"ContainerEngine" example:"docker"` + Type EndpointType `json:"Type" example:"1" validate:"required"` + // ContainerEngine represents the container engine type. This can be 'docker' or 'podman' when interacting directly with these environments, otherwise '' for kubernetes environments. + ContainerEngine string `json:"ContainerEngine" example:"docker" validate:"required"` // URL or IP address of the Docker host associated to this environment(endpoint) - URL string `json:"URL" example:"docker.mydomain.tld:2375"` + URL string `json:"URL" example:"docker.mydomain.tld:2375" validate:"required"` // Environment(Endpoint) group identifier - GroupID EndpointGroupID `json:"GroupId" example:"1"` + GroupID EndpointGroupID `json:"GroupId" example:"1" validate:"required"` // URL or IP address where exposed containers will be reachable - PublicURL string `json:"PublicURL" example:"docker.mydomain.tld:2375"` - Gpus []Pair `json:"Gpus"` - TLSConfig TLSConfiguration `json:"TLSConfig"` + PublicURL string `json:"PublicURL" example:"docker.mydomain.tld:2375" validate:"required"` + Gpus []Pair `json:"Gpus,omitempty"` + TLSConfig TLSConfiguration `json:"TLSConfig" validate:"required"` AzureCredentials AzureCredentials `json:"AzureCredentials,omitzero"` // List of tag identifiers to which this environment(endpoint) is associated - TagIDs []TagID `json:"TagIds"` + TagIDs []TagID `json:"TagIds,omitempty"` // The status of the environment(endpoint) (1 - up, 2 - down) - Status EndpointStatus `json:"Status" example:"1"` + Status EndpointStatus `json:"Status" example:"1" validate:"required"` // List of snapshots - Snapshots []DockerSnapshot `json:"Snapshots"` + Snapshots []DockerSnapshot `json:"Snapshots,omitempty"` // List of user identifiers authorized to connect to this environment(endpoint) - UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` + UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies,omitempty"` // List of team identifiers authorized to connect to this environment(endpoint) - TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` + TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies,omitempty"` // The identifier of the edge agent associated with this environment(endpoint) EdgeID string `json:"EdgeID,omitempty"` // The key which is used to map the agent to Portainer - EdgeKey string `json:"EdgeKey"` + EdgeKey string `json:"EdgeKey" validate:"required"` // The check in interval for edge agent (in seconds) - EdgeCheckinInterval int `json:"EdgeCheckinInterval" example:"5"` + EdgeCheckinInterval int `json:"EdgeCheckinInterval" example:"5" validate:"required"` // Associated Kubernetes data - Kubernetes KubernetesData `json:"Kubernetes"` + Kubernetes KubernetesData `json:"Kubernetes" validate:"required"` // Maximum version of docker-compose - ComposeSyntaxMaxVersion string `json:"ComposeSyntaxMaxVersion" example:"3.8"` + ComposeSyntaxMaxVersion string `json:"ComposeSyntaxMaxVersion" example:"3.8" validate:"required"` // Environment(Endpoint) specific security settings - SecuritySettings EndpointSecuritySettings + SecuritySettings EndpointSecuritySettings `validate:"required"` // LastCheckInDate mark last check-in date on checkin - LastCheckInDate int64 + LastCheckInDate int64 `validate:"required"` // Heartbeat indicates the heartbeat status of an edge environment Heartbeat bool `json:"Heartbeat" example:"true"` @@ -486,43 +486,46 @@ type ( UserTrusted bool `json:"UserTrusted,omitempty"` // Whether we need to run any "post init migrations". - PostInitMigrations EndpointPostInitMigrations `json:"PostInitMigrations"` + PostInitMigrations EndpointPostInitMigrations `json:"PostInitMigrations" swaggerignore:"true"` - Edge EnvironmentEdgeSettings + Edge EnvironmentEdgeSettings `validate:"required"` - Agent struct { - Version string `example:"1.0.0"` - } + Agent EnvironmentAgentData `validate:"required"` EnableGPUManagement bool `json:"EnableGPUManagement,omitempty"` // Deprecated fields // Deprecated in DBVersion == 4 - TLS bool `json:"TLS,omitempty"` - TLSCACertPath string `json:"TLSCACert,omitempty"` - TLSCertPath string `json:"TLSCert,omitempty"` - TLSKeyPath string `json:"TLSKey,omitempty"` + TLS bool `json:"TLS,omitempty" swaggerignore:"true"` + TLSCACertPath string `json:"TLSCACert,omitempty" swaggerignore:"true"` + TLSCertPath string `json:"TLSCert,omitempty" swaggerignore:"true"` + TLSKeyPath string `json:"TLSKey,omitempty" swaggerignore:"true"` // Deprecated in DBVersion == 18 - AuthorizedUsers []UserID `json:"AuthorizedUsers"` - AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + AuthorizedUsers []UserID `json:"AuthorizedUsers,omitempty" swaggerignore:"true"` + AuthorizedTeams []TeamID `json:"AuthorizedTeams,omitempty" swaggerignore:"true"` // Deprecated in DBVersion == 22 - Tags []string `json:"Tags"` + Tags []string `json:"Tags,omitempty" swaggerignore:"true"` // Deprecated v2.18 - IsEdgeDevice bool `json:"IsEdgeDevice,omitempty"` + IsEdgeDevice bool `json:"IsEdgeDevice,omitempty" swaggerignore:"true"` + } + + // EnvironmentAgentData represents the data associated to an agent deployed + EnvironmentAgentData struct { + Version string `json:"Version,omitempty" example:"1.0.0"` } EnvironmentEdgeSettings struct { // Whether the device has been started in edge async mode - AsyncMode bool + AsyncMode bool `validate:"required"` // The ping interval for edge agent - used in edge async mode [seconds] - PingInterval int `json:"PingInterval" example:"60"` + PingInterval int `json:"PingInterval" example:"60" validate:"required"` // The snapshot interval for edge agent - used in edge async mode [seconds] - SnapshotInterval int `json:"SnapshotInterval" example:"60"` + SnapshotInterval int `json:"SnapshotInterval" example:"60" validate:"required"` // The command list interval for edge agent - used in edge async mode [seconds] - CommandInterval int `json:"CommandInterval" example:"60"` + CommandInterval int `json:"CommandInterval" example:"60" validate:"required"` } // EndpointAuthorizations represents the authorizations associated to a set of environments(endpoints) @@ -533,25 +536,25 @@ type ( // An environment(endpoint) may belong to only 1 environment(endpoint) group. EndpointGroup struct { // Environment(Endpoint) group Identifier - ID EndpointGroupID `json:"Id" example:"1"` + ID EndpointGroupID `json:"Id" example:"1" validate:"required"` // Environment(Endpoint) group name - Name string `json:"Name" example:"my-environment-group"` + Name string `json:"Name" example:"my-environment-group" validate:"required"` // Description associated to the environment(endpoint) group - Description string `json:"Description" example:"Environment(Endpoint) group description"` - UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` - TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` + Description string `json:"Description" example:"Environment(Endpoint) group description" validate:"required"` + UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies,omitempty"` + TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies,omitempty"` // List of tags associated to this environment(endpoint) group - TagIDs []TagID `json:"TagIds"` + TagIDs []TagID `json:"TagIds,omitempty"` // Deprecated fields - Labels []Pair `json:"Labels"` + Labels []Pair `json:"Labels,omitempty" swaggerignore:"true"` // Deprecated in DBVersion == 18 - AuthorizedUsers []UserID `json:"AuthorizedUsers"` - AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + AuthorizedUsers []UserID `json:"AuthorizedUsers,omitempty" swaggerignore:"true"` + AuthorizedTeams []TeamID `json:"AuthorizedTeams,omitempty" swaggerignore:"true"` // Deprecated in DBVersion == 22 - Tags []string `json:"Tags,omitempty"` + Tags []string `json:"Tags,omitempty" swaggerignore:"true"` } PolicyChartSummary struct { @@ -639,25 +642,25 @@ type ( // EndpointSecuritySettings represents settings for an environment(endpoint) EndpointSecuritySettings struct { // Whether non-administrator should be able to use bind mounts when creating containers - AllowBindMountsForRegularUsers bool `json:"allowBindMountsForRegularUsers" example:"false"` + AllowBindMountsForRegularUsers bool `json:"allowBindMountsForRegularUsers" example:"false" validate:"required"` // Whether non-administrator should be able to use privileged mode when creating containers - AllowPrivilegedModeForRegularUsers bool `json:"allowPrivilegedModeForRegularUsers" example:"false"` + AllowPrivilegedModeForRegularUsers bool `json:"allowPrivilegedModeForRegularUsers" example:"false" validate:"required"` // Whether non-administrator should be able to browse volumes - AllowVolumeBrowserForRegularUsers bool `json:"allowVolumeBrowserForRegularUsers" example:"true"` + AllowVolumeBrowserForRegularUsers bool `json:"allowVolumeBrowserForRegularUsers" example:"true" validate:"required"` // Whether non-administrator should be able to use the host pid - AllowHostNamespaceForRegularUsers bool `json:"allowHostNamespaceForRegularUsers" example:"true"` + AllowHostNamespaceForRegularUsers bool `json:"allowHostNamespaceForRegularUsers" example:"true" validate:"required"` // Whether non-administrator should be able to use device mapping - AllowDeviceMappingForRegularUsers bool `json:"allowDeviceMappingForRegularUsers" example:"true"` + AllowDeviceMappingForRegularUsers bool `json:"allowDeviceMappingForRegularUsers" example:"true" validate:"required"` // Whether non-administrator should be able to manage stacks - AllowStackManagementForRegularUsers bool `json:"allowStackManagementForRegularUsers" example:"true"` + AllowStackManagementForRegularUsers bool `json:"allowStackManagementForRegularUsers" example:"true" validate:"required"` // Whether non-administrator should be able to use container capabilities - AllowContainerCapabilitiesForRegularUsers bool `json:"allowContainerCapabilitiesForRegularUsers" example:"true"` + AllowContainerCapabilitiesForRegularUsers bool `json:"allowContainerCapabilitiesForRegularUsers" example:"true" validate:"required"` // Whether non-administrator should be able to use sysctl settings - AllowSysctlSettingForRegularUsers bool `json:"allowSysctlSettingForRegularUsers" example:"true"` + AllowSysctlSettingForRegularUsers bool `json:"allowSysctlSettingForRegularUsers" example:"true" validate:"required"` // Whether non-administrator should be able to use security-opt settings - AllowSecurityOptForRegularUsers bool `json:"allowSecurityOptForRegularUsers" example:"true"` + AllowSecurityOptForRegularUsers bool `json:"allowSecurityOptForRegularUsers" example:"true" validate:"required"` // Whether host management features are enabled - EnableHostManagementFeatures bool `json:"enableHostManagementFeatures" example:"true"` + EnableHostManagementFeatures bool `json:"enableHostManagementFeatures" example:"true" validate:"required"` } // EndpointType represents the type of an environment(endpoint) @@ -768,28 +771,28 @@ type ( // KubernetesData contains all the Kubernetes related environment(endpoint) information KubernetesData struct { - Snapshots []KubernetesSnapshot `json:"Snapshots"` - Configuration KubernetesConfiguration `json:"Configuration"` - Flags KubernetesFlags `json:"Flags"` + Snapshots []KubernetesSnapshot `json:"Snapshots,omitempty"` + Configuration KubernetesConfiguration `json:"Configuration" validate:"required"` + Flags KubernetesFlags `json:"Flags" validate:"required"` } // KubernetesFlags are used to detect if we need to run initial cluster // detection again. KubernetesFlags struct { - IsServerMetricsDetected bool `json:"IsServerMetricsDetected"` - IsServerIngressClassDetected bool `json:"IsServerIngressClassDetected"` - IsServerStorageDetected bool `json:"IsServerStorageDetected"` + IsServerMetricsDetected bool `json:"IsServerMetricsDetected" validate:"required"` + IsServerIngressClassDetected bool `json:"IsServerIngressClassDetected" validate:"required"` + IsServerStorageDetected bool `json:"IsServerStorageDetected" validate:"required"` } // KubernetesSnapshot represents a snapshot of a specific Kubernetes environment(endpoint) at a specific time KubernetesSnapshot struct { - Time int64 `json:"Time"` - KubernetesVersion string `json:"KubernetesVersion"` - NodeCount int `json:"NodeCount"` - TotalCPU int64 `json:"TotalCPU"` - TotalMemory int64 `json:"TotalMemory"` - DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"` - PerformanceMetrics *PerformanceMetrics `json:"PerformanceMetrics"` + Time int64 `json:"Time" validate:"required"` + KubernetesVersion string `json:"KubernetesVersion" validate:"required"` + NodeCount int `json:"NodeCount" validate:"required"` + TotalCPU int64 `json:"TotalCPU" validate:"required"` + TotalMemory int64 `json:"TotalMemory" validate:"required"` + DiagnosticsData *DiagnosticsData `json:"DiagnosticsData,omitempty"` + PerformanceMetrics *PerformanceMetrics `json:"PerformanceMetrics,omitempty"` } // KubernetesConfiguration represents the configuration of a Kubernetes environment(endpoint) @@ -798,27 +801,27 @@ type ( UseServerMetrics bool `json:"UseServerMetrics"` EnableResourceOverCommit bool `json:"EnableResourceOverCommit"` ResourceOverCommitPercentage int `json:"ResourceOverCommitPercentage"` - StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses"` - IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses"` + StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses,omitempty"` + IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses,omitempty"` RestrictDefaultNamespace bool `json:"RestrictDefaultNamespace"` - IngressAvailabilityPerNamespace bool `json:"IngressAvailabilityPerNamespace"` - AllowNoneIngressClass bool `json:"AllowNoneIngressClass"` + IngressAvailabilityPerNamespace bool `json:"IngressAvailabilityPerNamespace" validate:"required"` + AllowNoneIngressClass bool `json:"AllowNoneIngressClass" validate:"required"` } // KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration KubernetesStorageClassConfig struct { - Name string `json:"Name"` - AccessModes []string `json:"AccessModes"` - Provisioner string `json:"Provisioner"` - AllowVolumeExpansion bool `json:"AllowVolumeExpansion"` + Name string `json:"Name" validate:"required"` + AccessModes []string `json:"AccessModes,omitempty"` + Provisioner string `json:"Provisioner" validate:"required"` + AllowVolumeExpansion bool `json:"AllowVolumeExpansion" validate:"required"` } // KubernetesIngressClassConfig represents a Kubernetes Ingress Class configuration KubernetesIngressClassConfig struct { - Name string `json:"Name"` - Type string `json:"Type"` + Name string `json:"Name" validate:"required"` + Type string `json:"Type" validate:"required"` GloballyBlocked bool `json:"Blocked"` - BlockedNamespaces []string `json:"BlockedNamespaces"` + BlockedNamespaces []string `json:"BlockedNamespaces,omitempty"` } // KubernetesShellPod represents a Kubectl Shell details to facilitate pod exec functionality @@ -910,8 +913,8 @@ type ( // Pair defines a key/value string pair Pair struct { - Name string `json:"name" example:"name"` - Value string `json:"value" example:"value"` + Name string `json:"name" example:"name" validate:"required"` + Value string `json:"value" example:"value" validate:"required"` } // Registry represents a Docker registry with all the info required @@ -1445,9 +1448,9 @@ type ( // TLSConfiguration represents a TLS configuration TLSConfiguration struct { // Use TLS - TLS bool `json:"TLS" example:"true"` + TLS bool `json:"TLS" example:"true" validate:"required"` // Skip the verification of the server TLS certificate - TLSSkipVerify bool `json:"TLSSkipVerify" example:"false"` + TLSSkipVerify bool `json:"TLSSkipVerify" example:"false" validate:"required"` // Path to the TLS CA certificate file TLSCACertPath string `json:"TLSCACert,omitempty" example:"/data/tls/ca.pem"` // Path to the TLS client certificate file diff --git a/app/react-tools/test-mocks.ts b/app/react-tools/test-mocks.ts index 6b98c729f..90c3b125e 100644 --- a/app/react-tools/test-mocks.ts +++ b/app/react-tools/test-mocks.ts @@ -9,6 +9,7 @@ import { } from '@/react/portainer/environments/types'; import { Stack, StackStatus, StackType } from '@/react/common/stacks/types'; import { ContainerDetailsViewModel } from '@/docker/models/containerDetails'; +import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types'; export function createMockUser(overrides: Partial = {}) { return { @@ -157,6 +158,29 @@ export function createMockEnvironment( detail: '', summary: '', }, + AzureCredentials: { + ApplicationID: '', + AuthenticationKey: '', + TenantID: '', + }, + EdgeCheckinInterval: 0, + PublicURL: '', + TLSConfig: { + TLS: false, + TLSSkipVerify: false, + }, + LastCheckInDate: 0, + ...overrides, + }; +} + +export function createMockEnvironmentGroup( + overrides: Partial = {} +): EnvironmentGroup { + return { + Id: 1, + Name: 'Unassigned', + Description: '', ...overrides, }; } diff --git a/app/react/docker/snapshots/types/index.ts b/app/react/docker/snapshots/types/index.ts index 850e305f9..e38689234 100644 --- a/app/react/docker/snapshots/types/index.ts +++ b/app/react/docker/snapshots/types/index.ts @@ -1,3 +1,5 @@ +import { PortainerDockerSnapshot } from '@api/types.gen'; + import { ContainerListViewModel } from '@/react/docker/containers/types'; export type DockerContainerSnapshot = ContainerListViewModel & { @@ -9,23 +11,6 @@ export type DockerSnapshotRaw = { SnapshotTime: string; }; -export interface DockerSnapshot { - TotalCPU: number; - TotalMemory: number; - NodeCount: number; - ImageCount: number; - VolumeCount: number; - ContainerCount: number; - RunningContainerCount: number; - StoppedContainerCount: number; - HealthyContainerCount: number; - UnhealthyContainerCount: number; - Time: number; - StackCount: number; - ServiceCount: number; - Swarm: boolean; - DockerVersion: string; - GpuUseAll: boolean; +export interface DockerSnapshot extends PortainerDockerSnapshot { GpuUseList: string[]; - SnapshotRaw: DockerSnapshotRaw; } diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackDuplicationForm/EnvSelector.test.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackDuplicationForm/EnvSelector.test.tsx index c7ec3f734..4bb5eb9b2 100644 --- a/app/react/docker/stacks/ItemView/StackInfoTab/StackDuplicationForm/EnvSelector.test.tsx +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackDuplicationForm/EnvSelector.test.tsx @@ -3,31 +3,35 @@ import { http, HttpResponse } from 'msw'; import { server } from '@/setup-tests/server'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; -import { Environment } from '@/react/portainer/environments/types'; import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types'; +import { + createMockEnvironment, + createMockEnvironmentGroup, +} from '@/react-tools/test-mocks'; +import { Environment } from '@/react/portainer/environments/types'; import { EnvSelector, getEnvironmentOptions } from './EnvSelector'; describe('EnvSelector', () => { it('should render when environment options are available', async () => { - const mockEnvironments: Environment[] = [ - { + const mockEnvironments = [ + createMockEnvironment({ Id: 1, Name: 'Environment 1', GroupId: 1, - } as Environment, - { + }), + createMockEnvironment({ Id: 2, Name: 'Environment 2', GroupId: 1, - } as Environment, + }), ]; - const mockGroups: EnvironmentGroup[] = [ - { + const mockGroups = [ + createMockEnvironmentGroup({ Id: 1, Name: 'Unassigned', - } as EnvironmentGroup, + }), ]; renderComponent({ @@ -61,19 +65,19 @@ describe('EnvSelector', () => { }); it('should display FormError when error prop is provided', async () => { - const mockEnvironments: Environment[] = [ - { + const mockEnvironments = [ + createMockEnvironment({ Id: 1, Name: 'Environment 1', GroupId: 1, - } as Environment, + }), ]; const mockGroups: EnvironmentGroup[] = [ - { + createMockEnvironmentGroup({ Id: 1, Name: 'Group 1', - } as EnvironmentGroup, + }), ]; const error = 'Environment is required'; @@ -120,10 +124,10 @@ describe('getEnvironmentOptions', () => { expect( getEnvironmentOptions( [ - { + createMockEnvironmentGroup({ Id: 1, Name: 'Group 1', - } as EnvironmentGroup, + }), ], [] ) @@ -132,11 +136,14 @@ describe('getEnvironmentOptions', () => { it('should exclude current environment when currentEnvironmentId is provided', () => { const groups: EnvironmentGroup[] = [ - { Id: 1, Name: 'Group 1' } as EnvironmentGroup, + createMockEnvironmentGroup({ + Id: 1, + Name: 'Group 1', + }), ]; - const environments: Environment[] = [ - { Id: 1, Name: 'Env 1', GroupId: 1 } as Environment, - { Id: 2, Name: 'Env 2', GroupId: 1 } as Environment, + const environments = [ + createMockEnvironment({ Id: 1, Name: 'Env 1', GroupId: 1 }), + createMockEnvironment({ Id: 2, Name: 'Env 2', GroupId: 1 }), ]; const result = getEnvironmentOptions(groups, environments, 1); @@ -148,13 +155,19 @@ describe('getEnvironmentOptions', () => { it('should group environments by GroupId with correct structure', () => { const groups: EnvironmentGroup[] = [ - { Id: 1, Name: 'Group 1' } as EnvironmentGroup, - { Id: 2, Name: 'Group 2' } as EnvironmentGroup, + createMockEnvironmentGroup({ + Id: 1, + Name: 'Group 1', + }), + createMockEnvironmentGroup({ + Id: 2, + Name: 'Group 2', + }), ]; - const environments: Environment[] = [ - { Id: 1, Name: 'Env 1', GroupId: 1 } as Environment, - { Id: 2, Name: 'Env 2', GroupId: 1 } as Environment, - { Id: 3, Name: 'Env 3', GroupId: 2 } as Environment, + const environments = [ + createMockEnvironment({ Id: 1, Name: 'Env 1', GroupId: 1 }), + createMockEnvironment({ Id: 2, Name: 'Env 2', GroupId: 1 }), + createMockEnvironment({ Id: 3, Name: 'Env 3', GroupId: 2 }), ]; const result = getEnvironmentOptions(groups, environments); @@ -174,9 +187,9 @@ describe('getEnvironmentOptions', () => { }); it('should auto create an Others group if group is missing', () => { - const environments: Environment[] = [ - { Id: 1, Name: 'Env 1', GroupId: 1 } as Environment, - { Id: 2, Name: 'Env 2', GroupId: 2 } as Environment, + const environments = [ + createMockEnvironment({ Id: 1, Name: 'Env 1', GroupId: 1 }), + createMockEnvironment({ Id: 2, Name: 'Env 2', GroupId: 2 }), ]; const groups: EnvironmentGroup[] = []; diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackDuplicationForm/StackDuplicationForm.test.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackDuplicationForm/StackDuplicationForm.test.tsx index 2fc1aef8d..75a7bd49b 100644 --- a/app/react/docker/stacks/ItemView/StackInfoTab/StackDuplicationForm/StackDuplicationForm.test.tsx +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackDuplicationForm/StackDuplicationForm.test.tsx @@ -4,10 +4,13 @@ import { UIRouterContext, UIRouterReact } from '@uirouter/react'; import { server } from '@/setup-tests/server'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; -import { Environment } from '@/react/portainer/environments/types'; import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types'; import { Stack } from '@/react/common/stacks/types'; -import { createMockStack } from '@/react-tools/test-mocks'; +import { + createMockEnvironment, + createMockEnvironmentGroup, + createMockStack, +} from '@/react-tools/test-mocks'; import { StackDuplicationForm } from './StackDuplicationForm'; @@ -57,13 +60,13 @@ function renderComponent({ yamlError?: string; originalFileContent?: string; } = {}) { - const mockEnvironments: Environment[] = [ - { Id: 1, Name: 'Current Environment', GroupId: 1 } as Environment, - { Id: 2, Name: 'Target Environment', GroupId: 1 } as Environment, + const mockEnvironments = [ + createMockEnvironment({ Id: 1, Name: 'Current Environment', GroupId: 1 }), + createMockEnvironment({ Id: 2, Name: 'Target Environment', GroupId: 1 }), ]; const mockGroups: EnvironmentGroup[] = [ - { Id: 1, Name: 'Unassigned' } as EnvironmentGroup, + createMockEnvironmentGroup({ Id: 1, Name: 'Unassigned' }), ]; server.use( diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackDuplicationForm/StackDuplicationFormInner.test.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackDuplicationForm/StackDuplicationFormInner.test.tsx index f0b5a8fa3..9c53d8d08 100644 --- a/app/react/docker/stacks/ItemView/StackInfoTab/StackDuplicationForm/StackDuplicationFormInner.test.tsx +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackDuplicationForm/StackDuplicationFormInner.test.tsx @@ -5,8 +5,11 @@ import { http, HttpResponse } from 'msw'; import { server } from '@/setup-tests/server'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; -import { Environment } from '@/react/portainer/environments/types'; import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types'; +import { + createMockEnvironment, + createMockEnvironmentGroup, +} from '@/react-tools/test-mocks'; import { StackDuplicationFormInner } from './StackDuplicationFormInner'; import { FormSubmitValues } from './StackDuplicationForm.types'; @@ -491,13 +494,13 @@ function renderFormInner({ initialValues?: FormSubmitValues; isLoading?: boolean; } = {}) { - const mockEnvironments: Environment[] = [ - { Id: 1, Name: 'Current Environment', GroupId: 1 } as Environment, - { Id: 2, Name: 'Target Environment', GroupId: 1 } as Environment, + const mockEnvironments = [ + createMockEnvironment({ Id: 1, Name: 'Current Environment', GroupId: 1 }), + createMockEnvironment({ Id: 2, Name: 'Target Environment', GroupId: 1 }), ]; const mockGroups: EnvironmentGroup[] = [ - { Id: 1, Name: 'Unassigned' } as EnvironmentGroup, + createMockEnvironmentGroup({ Id: 1, Name: 'Unassigned' }), ]; server.use( diff --git a/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/CreateAccessInnerForm.tsx b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/CreateAccessInnerForm.tsx index 78797394d..25bc73979 100644 --- a/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/CreateAccessInnerForm.tsx +++ b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/CreateAccessInnerForm.tsx @@ -3,7 +3,7 @@ import { Plus } from 'lucide-react'; import { useMemo } from 'react'; import { useEnvironment } from '@/react/portainer/environments/queries'; -import { useGroup } from '@/react/portainer/environments/environment-groups/queries'; +import { useGroup } from '@/react/portainer/environments/environment-groups/queries/useGroup'; import { useUsers } from '@/portainer/users/queries'; import { useTeams } from '@/react/portainer/users/teams/queries/useTeams'; import { User } from '@/portainer/users/types'; diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentCard.stories.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentCard.stories.tsx index 8e5d3b778..c21c0a21d 100644 --- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentCard.stories.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentCard.stories.tsx @@ -163,10 +163,7 @@ function mockDockerSnapshot(): DockerSnapshot { DockerVersion: '24.0.7', GpuUseAll: false, GpuUseList: [], - SnapshotRaw: { - Containers: [], - SnapshotTime: '', - }, + IsPodman: false, }; } diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.test.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.test.tsx index 4ad43c137..af5141dba 100644 --- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.test.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.test.tsx @@ -1,21 +1,14 @@ -import { - useState, - useEffect, - useRef, - useContext, - createContext, - ReactNode, -} from 'react'; +import { useState, useEffect } from 'react'; import { http, HttpResponse } from 'msw'; import type { HttpHandler } from 'msw'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { - Environment, EnvironmentStatus, EnvironmentType, PlatformType, + Environment, } from '@/react/portainer/environments/types'; import { UserViewModel } from '@/portainer/models/user'; import { server } from '@/setup-tests/server'; @@ -26,7 +19,11 @@ import { createMockEnvironment } from '@/react-tools/test-mocks'; import { EnvironmentList } from './EnvironmentList'; -// vi.hoisted ensures these are initialised before the vi.mock factory runs. +function getArrayParam(params: URLSearchParams, key: string): number[] { + return params.get(key)?.split(',').filter(Boolean).map(Number) ?? []; +} + +// vi.hoisted ensures these are initialized before the vi.mock factory runs. // The store is reactive: stateService.go updates it and notifies React subscribers. const { mockUrlParamsStore, mockStateServiceGo } = vi.hoisted(() => { let snapshot: Record = {}; @@ -84,12 +81,6 @@ vi.mock('@uirouter/react', async (importOriginal) => { }; }); -type MenuCtxType = { - isOpen: boolean; - setOpen: (v: boolean) => void; - menuRef: React.RefObject; -}; - beforeEach(() => { sessionStorage.clear(); localStorage.clear(); @@ -97,102 +88,7 @@ beforeEach(() => { mockStateServiceGo.mockClear(); }); -vi.mock('@reach/menu-button', () => { - const MenuCtx = createContext(null); - - function Menu({ children }: { children?: ReactNode }) { - const [isOpen, setOpen] = useState(false); - const menuRef = useRef(null); - - useEffect(() => { - function handleDocDown(e: MouseEvent) { - const target = e.target as Node | null; - if ( - isOpen && - menuRef.current && - target && - !menuRef.current.contains(target) - ) { - setOpen(false); - } - } - - document.addEventListener('mousedown', handleDocDown); - return () => document.removeEventListener('mousedown', handleDocDown); - }, [isOpen]); - - return ( - -
{children}
-
- ); - } - - function MenuButton({ - children, - onClick: externalOnClick, - ...props - }: { - children?: ReactNode; - onClick?: () => void; - [key: string]: unknown; - }) { - const ctx = useContext(MenuCtx); - - function handleClick() { - externalOnClick?.(); - ctx?.setOpen(!ctx.isOpen); - } - - return ( - - ); - } - - function MenuList({ - children, - className, - }: { - children?: ReactNode; - className?: string; - }) { - const ctx = useContext(MenuCtx); - if (!ctx?.isOpen) return null; - return ( -
- {children} -
- ); - } - - function MenuItem({ - children, - onSelect, - className, - }: { - children?: ReactNode; - onSelect?: () => void; - className?: string; - }) { - const ctx = useContext(MenuCtx); - - function handleClick() { - onSelect?.(); - ctx?.setOpen(false); - } - - return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus -
- {children} -
- ); - } - - return { Menu, MenuButton, MenuList, MenuItem }; -}); +vi.mock('@reach/menu-button'); test('when no environments for query should show empty list message', async () => { const { findByText } = await renderComponent(false, []); @@ -285,12 +181,11 @@ test('Platform filter groups Docker, AgentOnDocker, and EdgeAgentOnDocker togeth }) ), http.get('/api/endpoints', ({ request }) => { - const platformTypes = new URL(request.url).searchParams - .getAll('platformTypes[]') - .map(Number); - const envTypesByPlatform: Partial< - Record - > = { + const platformTypes = getArrayParam( + new URL(request.url).searchParams, + 'platformTypes' + ); + const envTypesByPlatform: Partial> = { [PlatformType.Docker]: [ EnvironmentType.Docker, EnvironmentType.AgentOnDocker, @@ -363,9 +258,10 @@ test('Health filter shows only environments matching the selected status', async }) ), http.get('/api/endpoints', ({ request }) => { - const statuses = new URL(request.url).searchParams - .getAll('status[]') - .map(Number); + const statuses = getArrayParam( + new URL(request.url).searchParams, + 'status' + ); const filtered = statuses.length ? allEnvironments.filter((e) => statuses.includes(e.Status)) : allEnvironments; @@ -421,8 +317,8 @@ test('Heartbeat filter shows only edge envs with an active heartbeat', async () ), http.get('/api/endpoints', ({ request }) => { const params = new URL(request.url).searchParams; - const types = params.getAll('types[]').map(Number); - const statuses = params.getAll('status[]').map(Number); + const types = getArrayParam(params, 'types'); + const statuses = getArrayParam(params, 'status'); let filtered = allEnvironments; if (types.length > 0) { @@ -496,7 +392,7 @@ test('URL param groupBy=health&groupFilter=up activates Up status filter in API await waitFor(() => { // EnvironmentStatus.Up = 1 - expect(capturedParams?.getAll('status[]')).toContain('1'); + expect(capturedParams?.get('status')).toBe('1'); }); }); @@ -520,7 +416,7 @@ test('URL param groupBy=platform&groupFilter=docker activates Docker platform fi await waitFor(() => { // PlatformType.Docker = 0 - expect(capturedParams?.getAll('platformTypes[]')).toContain('0'); + expect(capturedParams?.get('platformTypes')).toBe('0'); }); }); @@ -547,7 +443,7 @@ test('URL param groupFilter without groupBy is ignored (default Id sort used)', await waitFor(() => { expect(capturedParams).not.toBeNull(); }); - expect(capturedParams!.getAll('status[]')).toHaveLength(0); + expect(capturedParams!.get('status')).toBeNull(); expect(capturedParams!.get('sort')).toBe('Id'); }); diff --git a/app/react/portainer/environments/ItemView/AzureEnvironmentForm/AzureEnvironmentForm.test.tsx b/app/react/portainer/environments/ItemView/AzureEnvironmentForm/AzureEnvironmentForm.test.tsx index 5fb633894..08ec3c586 100644 --- a/app/react/portainer/environments/ItemView/AzureEnvironmentForm/AzureEnvironmentForm.test.tsx +++ b/app/react/portainer/environments/ItemView/AzureEnvironmentForm/AzureEnvironmentForm.test.tsx @@ -8,6 +8,7 @@ import { withTestRouter } from '@/react/test-utils/withRouter'; import { createMockUsers, createMockEnvironment, + createMockEnvironmentGroup, } from '@/react-tools/test-mocks'; import { withCurrentUser } from '@/react-tools/withCurrentUser'; @@ -84,7 +85,7 @@ test('submits form with updated values', async () => { expect(requestBody).toMatchObject({ Name: 'updated-azure-env', GroupID: 1, - TagIds: [], + TagIDs: [], AzureApplicationID: 'app-123', AzureTenantID: 'tenant-456', }); @@ -106,7 +107,9 @@ function renderComponent({ HttpResponse.json({ value: [], totalCount: 0 }) ), http.get('/api/endpoint_groups', () => - HttpResponse.json([{ Id: 1, Name: 'Default' }]) + HttpResponse.json([ + createMockEnvironmentGroup({ Id: 1, Name: 'Default' }), + ]) ), http.get('/api/tags', () => HttpResponse.json([])) ); diff --git a/app/react/portainer/environments/ItemView/AzureEnvironmentForm/AzureEnvironmentForm.tsx b/app/react/portainer/environments/ItemView/AzureEnvironmentForm/AzureEnvironmentForm.tsx index 781cb82c9..1e555b1e4 100644 --- a/app/react/portainer/environments/ItemView/AzureEnvironmentForm/AzureEnvironmentForm.tsx +++ b/app/react/portainer/environments/ItemView/AzureEnvironmentForm/AzureEnvironmentForm.tsx @@ -87,7 +87,7 @@ export function AzureEnvironmentForm({ environment, onSuccess }: Props) { payload: { Name: values.name, GroupID: values.meta.groupId, - TagIds: values.meta.tagIds, + TagIDs: values.meta.tagIds, AzureApplicationID: values.azure.applicationId || undefined, AzureTenantID: values.azure.tenantId || undefined, AzureAuthenticationKey: values.azure.authenticationKey || undefined, diff --git a/app/react/portainer/environments/ItemView/EdgeEnvironmentForm/EdgeEnvironmentForm.test.tsx b/app/react/portainer/environments/ItemView/EdgeEnvironmentForm/EdgeEnvironmentForm.test.tsx index 01a351f99..0075969b4 100644 --- a/app/react/portainer/environments/ItemView/EdgeEnvironmentForm/EdgeEnvironmentForm.test.tsx +++ b/app/react/portainer/environments/ItemView/EdgeEnvironmentForm/EdgeEnvironmentForm.test.tsx @@ -7,7 +7,10 @@ import { server, http } from '@/setup-tests/server'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { withUserProvider } from '@/react/test-utils/withUserProvider'; import { withTestRouter } from '@/react/test-utils/withRouter'; -import { createMockEnvironment } from '@/react-tools/test-mocks'; +import { + createMockEnvironment, + createMockEnvironmentGroup, +} from '@/react-tools/test-mocks'; import { EnvironmentType } from '@/react/portainer/environments/types'; import { EdgeEnvironmentForm } from './EdgeEnvironmentForm'; @@ -45,8 +48,8 @@ function setupDefaultMocks() { // Groups endpoint http.get('/api/endpoint_groups', () => HttpResponse.json([ - { Id: 1, Name: 'Default' }, - { Id: 2, Name: 'Production' }, + createMockEnvironmentGroup({ Id: 1, Name: 'Default' }), + createMockEnvironmentGroup({ Id: 2, Name: 'Production' }), ]) ), // Tags endpoint diff --git a/app/react/portainer/environments/ItemView/GeneralEnvironmentForm/helpers.test.ts b/app/react/portainer/environments/ItemView/GeneralEnvironmentForm/helpers.test.ts index 91e12d371..9bedaefde 100644 --- a/app/react/portainer/environments/ItemView/GeneralEnvironmentForm/helpers.test.ts +++ b/app/react/portainer/environments/ItemView/GeneralEnvironmentForm/helpers.test.ts @@ -92,7 +92,13 @@ describe('buildInitialValues', () => { environment: { URL: 'tcp://10.0.0.1:2375', Type: EnvironmentType.Docker, - TLSConfig: { TLS: true, TLSSkipVerify: false }, + TLSConfig: { + TLS: true, + TLSSkipVerify: false, + TLSCACert: '', + TLSCert: '', + TLSKey: '', + }, }, expectedUrl: '10.0.0.1:2375', expectedTls: { @@ -205,6 +211,6 @@ describe('buildUpdatePayload', () => { 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); + expect(payload.TagIDs).toEqual(values.meta.tagIds); }); }); diff --git a/app/react/portainer/environments/ItemView/GeneralEnvironmentForm/helpers.ts b/app/react/portainer/environments/ItemView/GeneralEnvironmentForm/helpers.ts index f2abc4843..569e433f0 100644 --- a/app/react/portainer/environments/ItemView/GeneralEnvironmentForm/helpers.ts +++ b/app/react/portainer/environments/ItemView/GeneralEnvironmentForm/helpers.ts @@ -49,7 +49,7 @@ export function buildUpdatePayload({ Name: values.name, PublicURL: values.publicUrl, GroupID: values.meta.groupId, - TagIds: values.meta.tagIds, + TagIDs: values.meta.tagIds, URL: formatURL({ url: values.environmentUrl, diff --git a/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.test.tsx b/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.test.tsx index 89c119145..c1aa4c0cc 100644 --- a/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.test.tsx +++ b/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.test.tsx @@ -13,6 +13,10 @@ import { EnvironmentType, EnvironmentStatus, } from '@/react/portainer/environments/types'; +import { + createMockEnvironment, + createMockEnvironmentGroup, +} from '@/react-tools/test-mocks'; import { EnvironmentGroup } from '../types'; @@ -53,35 +57,35 @@ vi.mock('@@/modals/confirm', () => { }; }); -const mockGroup: EnvironmentGroup = { +const mockGroup = createMockEnvironmentGroup({ Id: 2, Name: 'Test Group', Description: 'Test description', TagIds: [1], -}; +}); -const mockEnvironment: Partial = { +const mockEnvironment = createMockEnvironment({ Id: 1, Name: 'Test Environment', Type: EnvironmentType.Docker, Status: EnvironmentStatus.Up, GroupId: 2, TagIds: [], -}; +}); function buildMockEnvironment( id: number, name: string, groupId: number = 1 ): Partial { - return { + return createMockEnvironment({ Id: id, Name: name, Type: EnvironmentType.Docker, Status: EnvironmentStatus.Up, GroupId: groupId, TagIds: [], - }; + }); } function renderEditGroupView({ diff --git a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsTable/EnvironmentGroupsTable.tsx b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsTable/EnvironmentGroupsTable.tsx index 18d7c5a14..07f10de44 100644 --- a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsTable/EnvironmentGroupsTable.tsx +++ b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsTable/EnvironmentGroupsTable.tsx @@ -9,7 +9,7 @@ import { } from '@@/SortableList/SortableList'; import { useSortableListState } from '@@/SortableList/sortable-list.store'; -import { useEnvironmentGroups } from '../../queries/useEnvironmentGroups'; +import { useGroups } from '../../queries/useGroups'; import { EnvironmentGroup } from '../../types'; import { isUngoverned } from '../../utils/getPlatformLabel'; @@ -18,7 +18,7 @@ import { EnvironmentGroupRow } from './EnvironmentGroupRow'; const SORT_OPTIONS: SortOption[] = [{ key: 'Name', label: 'Name' }]; export function EnvironmentGroupsTable() { - const groupsQuery = useEnvironmentGroups(); + const groupsQuery = useGroups(); const tagsQuery = useTags(); const tableState = useSortableListState('environment_groups', 'Name'); diff --git a/app/react/portainer/environments/environment-groups/queries.ts b/app/react/portainer/environments/environment-groups/queries.ts index 49e8f9c4d..d49814eb6 100644 --- a/app/react/portainer/environments/environment-groups/queries.ts +++ b/app/react/portainer/environments/environment-groups/queries.ts @@ -1,40 +1 @@ -import { useQuery } from '@tanstack/react-query'; - -import { withError } from '@/react-tools/react-query'; - -import { EnvironmentGroupId } from '../types'; - -import { EnvironmentGroup } from './types'; -import { getGroup, getGroups } from './environment-groups.service'; -import { queryKeys } from './queries/query-keys'; - -export function useGroups({ - select, - enabled = true, -}: { select?: (group: EnvironmentGroup[]) => T; enabled?: boolean } = {}) { - return useQuery(queryKeys.base(), getGroups, { - select, - enabled, - }); -} - -export function useGroup( - groupId?: EnvironmentGroupId, - select?: (group: EnvironmentGroup | null) => T -) { - return useQuery( - queryKeys.group(groupId), - () => { - if (groupId === undefined) { - return null; - } - return getGroup(groupId); - }, - { - staleTime: 50, - select, - enabled: groupId !== undefined, - ...withError('Failed loading group'), - } - ); -} +export { useGroups } from './queries/useGroups'; diff --git a/app/react/portainer/environments/environment-groups/queries/query-keys.ts b/app/react/portainer/environments/environment-groups/queries/query-keys.ts index bb534997d..e1cc6b98e 100644 --- a/app/react/portainer/environments/environment-groups/queries/query-keys.ts +++ b/app/react/portainer/environments/environment-groups/queries/query-keys.ts @@ -1,6 +1,7 @@ import { EnvironmentGroupId } from '../../types'; export const queryKeys = { - base: (size: boolean = false) => ['environment-groups', size] as const, - group: (id?: EnvironmentGroupId) => [...queryKeys.base(false), id] as const, + base: () => ['environment-groups'] as const, + list: (size = false) => [...queryKeys.base(), { size }] as const, + group: (id?: EnvironmentGroupId) => [...queryKeys.base(), id] as const, }; diff --git a/app/react/portainer/environments/environment-groups/queries/useCreateGroupMutation.ts b/app/react/portainer/environments/environment-groups/queries/useCreateGroupMutation.ts index 000921490..85270b3c3 100644 --- a/app/react/portainer/environments/environment-groups/queries/useCreateGroupMutation.ts +++ b/app/react/portainer/environments/environment-groups/queries/useCreateGroupMutation.ts @@ -47,7 +47,7 @@ export function useCreateGroupMutation() { createGroup, mutationOptions( withError('Failed to create group'), - withInvalidate(queryClient, [queryKeys.base(), queryKeys.base(true)]) + withInvalidate(queryClient, [queryKeys.base(), queryKeys.list(true)]) ) ); } diff --git a/app/react/portainer/environments/environment-groups/queries/useEnvironmentGroups.ts b/app/react/portainer/environments/environment-groups/queries/useEnvironmentGroups.ts deleted file mode 100644 index 942bc6c80..000000000 --- a/app/react/portainer/environments/environment-groups/queries/useEnvironmentGroups.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import axios, { parseAxiosError } from '@/portainer/services/axios/axios'; -import { withError } from '@/react-tools/react-query'; - -import { EnvironmentGroup } from '../types'; - -import { queryKeys } from './query-keys'; -import { buildUrl } from './build-url'; - -export function useEnvironmentGroups() { - return useQuery({ - queryKey: queryKeys.base(true), - queryFn: () => getEnvironmentGroups(), - ...withError('Unable to retrieve environment groups'), - }); -} - -async function getEnvironmentGroups() { - try { - const { data } = await axios.get>(buildUrl(), { - params: { size: true }, - }); - return data; - } catch (e) { - throw parseAxiosError(e, 'Unable to retrieve environment groups'); - } -} diff --git a/app/react/portainer/environments/environment-groups/queries/useGroup.ts b/app/react/portainer/environments/environment-groups/queries/useGroup.ts index 1e63f7063..f15c8d3d6 100644 --- a/app/react/portainer/environments/environment-groups/queries/useGroup.ts +++ b/app/react/portainer/environments/environment-groups/queries/useGroup.ts @@ -1,15 +1,38 @@ import { useQuery } from '@tanstack/react-query'; +import { getEndpointGroupsById } from '@api/sdk.gen'; +import { PortainerEndpointGroup } from '@api/types.gen'; + import { withError } 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, - ...withError('Failed to load group'), - }); +/** Get the environment group by id. **Only portainer administrators can access this endpoint.** */ +export function useGroup( + groupId?: PortainerEndpointGroup['Id'], + { + select, + enabled = true, + }: { + select?: (group: PortainerEndpointGroup | null) => T; + enabled?: boolean; + } = {} +) { + return useQuery( + queryKeys.group(groupId), + async () => { + if (groupId === undefined) { + return null; + } + const { data } = await getEndpointGroupsById({ path: { id: groupId } }); + + return data; + }, + { + staleTime: 50, + select, + enabled: enabled && groupId !== undefined, + ...withError('Failed loading group'), + } + ); } diff --git a/app/react/portainer/environments/environment-groups/queries/useGroups.ts b/app/react/portainer/environments/environment-groups/queries/useGroups.ts new file mode 100644 index 000000000..aa0fc7914 --- /dev/null +++ b/app/react/portainer/environments/environment-groups/queries/useGroups.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query'; + +import { endpointGroupList } from '@api/sdk.gen'; + +import { EnvironmentGroup } from '../types'; + +import { queryKeys } from './query-keys'; + +/** Get all environment groups. **Only portainer administrators can access this endpoint.** */ +export function useGroups({ + select, + enabled = true, + size = false, +}: { + select?: (group: EnvironmentGroup[]) => T; + enabled?: boolean; + size?: boolean; +} = {}) { + return useQuery( + queryKeys.list(size), + async () => { + const { data } = await endpointGroupList({ query: { size } }); + return data; + }, + { + select, + enabled, + } + ); +} diff --git a/app/react/portainer/environments/environment-groups/queries/useUpdateGroupMutation.ts b/app/react/portainer/environments/environment-groups/queries/useUpdateGroupMutation.ts index b648cade3..4072f4e4f 100644 --- a/app/react/portainer/environments/environment-groups/queries/useUpdateGroupMutation.ts +++ b/app/react/portainer/environments/environment-groups/queries/useUpdateGroupMutation.ts @@ -47,7 +47,7 @@ export function useUpdateGroupMutation() { mutationFn: updateGroup, onSuccess: () => { queryClient.invalidateQueries(queryKeys.base()); - queryClient.invalidateQueries(queryKeys.base(true)); + queryClient.invalidateQueries(queryKeys.list(true)); queryClient.invalidateQueries(environmentQueryKeys.base()); notifySuccess('Success', 'Group successfully updated'); }, diff --git a/app/react/portainer/environments/environment-groups/types.ts b/app/react/portainer/environments/environment-groups/types.ts index d8c411721..7a1c01b99 100644 --- a/app/react/portainer/environments/environment-groups/types.ts +++ b/app/react/portainer/environments/environment-groups/types.ts @@ -1,29 +1,3 @@ -import { TagId } from '@/portainer/tags/types'; +import { EndpointgroupsEndpointGroupResponse } from '@api/types.gen'; -import { - TeamAccessPolicies, - UserAccessPolicies, - EnvironmentGroupId, -} from '../types'; - -export interface EnvironmentGroupTypeInfo { - Docker: number; - Kubernetes: number; - Podman: number; - Mixed: boolean; -} - -export interface EnvironmentGroup { - // Environment(Endpoint) group Identifier - Id: EnvironmentGroupId; - // Environment(Endpoint) group name - Name: string; - // Description associated to the environment(endpoint) group - Description: string; - // List of tags associated to this environment(endpoint) group - TagIds: TagId[]; - UserAccessPolicies?: UserAccessPolicies; - TeamAccessPolicies?: TeamAccessPolicies; - Total?: number; - TypeInfo?: EnvironmentGroupTypeInfo; -} +export type EnvironmentGroup = EndpointgroupsEndpointGroupResponse; diff --git a/app/react/portainer/environments/environment.service/create.ts b/app/react/portainer/environments/environment.service/create.ts index 8d742a71f..3f589f557 100644 --- a/app/react/portainer/environments/environment.service/create.ts +++ b/app/react/portainer/environments/environment.service/create.ts @@ -1,15 +1,17 @@ -import axios, { parseAxiosError } from '@/portainer/services/axios/axios'; +import { endpointCreate } from '@api/sdk.gen'; +import { EndpointCreateData } from '@api/types.gen'; + +import { arrayToJson } from '@/portainer/helpers/json'; +import { parseAxiosError } from '@/portainer/services/axios/axios'; import { type EnvironmentGroupId, - type Environment, ContainerEngine, EnvironmentCreationTypes, } from '@/react/portainer/environments/types'; import { type TagId } from '@/portainer/tags/types'; import { EdgeAsyncIntervalsValues } from '@/react/edge/components/EdgeAsyncIntervalsForm'; -import { arrayToJson, json2formData } from '@/portainer/helpers/json'; -import { buildUrl } from './utils'; +import { toEnvironment } from './utils'; export interface EnvironmentMetadata { groupId?: EnvironmentGroupId; @@ -208,7 +210,11 @@ async function createEnvironment( creationType: EnvironmentCreationTypes, options?: EnvironmentOptions ) { - let payload: Record = { + if (creationType === EnvironmentCreationTypes.KubeConfigEnvironment) { + throw new Error('Kube config creation is not supported on CE'); + } + + let payload: EndpointCreateData['body'] = { Name: name, EndpointCreationType: creationType, }; @@ -247,23 +253,12 @@ async function createEnvironment( AzureAuthenticationKey: azure.authenticationKey, }; } - - if (options.edge?.asyncMode) { - payload = { - ...payload, - EdgeAsyncMode: true, - EdgePingInterval: options.edge?.PingInterval, - EdgeSnapshotInterval: options.edge?.SnapshotInterval, - EdgeCommandInterval: options.edge?.CommandInterval, - }; - } } - const formPayload = json2formData(payload); try { - const { data } = await axios.post(buildUrl(), formPayload); + const { data } = await endpointCreate({ body: payload }); - return data; + return toEnvironment(data); } catch (e) { throw parseAxiosError(e as Error); } diff --git a/app/react/portainer/environments/environment.service/index.ts b/app/react/portainer/environments/environment.service/index.ts index 8f3317e7f..a23c53a72 100644 --- a/app/react/portainer/environments/environment.service/index.ts +++ b/app/react/portainer/environments/environment.service/index.ts @@ -1,3 +1,5 @@ +import { endpointList } from '@api/sdk.gen'; + import axios, { parseAxiosError } from '@/portainer/services/axios/axios'; import { Environment, @@ -18,8 +20,9 @@ import { } from '@/react/edge/edge-stacks/types'; import { getPublicSettings } from '../../settings/settings.service'; +import { SortType } from '../queries/useEnvironmentList'; -import { buildUrl } from './utils'; +import { buildUrl, toEnvironment } from './utils'; export type EdgeStackEnvironmentsQueryParams = | { @@ -43,7 +46,6 @@ export interface BaseEnvironmentsQueryParams { edgeAsync?: boolean; edgeDeviceUntrusted?: boolean; excludeSnapshots?: boolean; - provisioned?: boolean; name?: string; /** Filter environments by partial name match (case-insensitive, searches name only) */ nameFilter?: string; @@ -62,7 +64,10 @@ export type EnvironmentsQueryParams = BaseEnvironmentsQueryParams & export interface GetEnvironmentsOptions { start?: number; limit?: number; - sort?: { by?: string; order?: 'asc' | 'desc' }; + sort?: { + by?: SortType; + order?: 'asc' | 'desc'; + }; query?: EnvironmentsQueryParams; } @@ -70,7 +75,7 @@ export async function getEnvironments( { start, limit, - sort = { by: '', order: 'asc' }, + sort = { by: undefined, order: 'asc' }, query = {}, }: GetEnvironmentsOptions = { query: {} } ) { @@ -86,31 +91,28 @@ export async function getEnvironments( }; } - const url = buildUrl(); + const response = await endpointList({ + query: { + start, + limit, + sort: sort.by, + order: sort.order, + ...query, + types: query.types ? [...query.types] : undefined, + }, + }); - const params: Record = { - start, - limit, - sort: sort.by, - order: sort.order, - ...query, + const totalCount = (response.headers['x-total-count'] || '0') as string; + const totalAvailable = (response.headers['x-total-available'] || + '0') as string; + const updateAvailable = response.headers['x-update-available'] === 'true'; + + return { + totalCount: parseInt(totalCount, 10), + value: (response.data ?? []).map(toEnvironment), + totalAvailable: parseInt(totalAvailable, 10), + updateAvailable, }; - - try { - const response = await axios.get(url, { params }); - const totalCount = response.headers['x-total-count']; - const totalAvailable = response.headers['x-total-available']; - const updateAvailable = response.headers['x-update-available'] === 'true'; - - return { - totalCount: parseInt(totalCount, 10), - value: response.data, - totalAvailable: parseInt(totalAvailable, 10), - updateAvailable, - }; - } catch (e) { - throw parseAxiosError(e as Error); - } } export interface GroupCount { diff --git a/app/react/portainer/environments/environment.service/utils.ts b/app/react/portainer/environments/environment.service/utils.ts index 58466bfc4..b1fdf22bf 100644 --- a/app/react/portainer/environments/environment.service/utils.ts +++ b/app/react/portainer/environments/environment.service/utils.ts @@ -1,4 +1,23 @@ -import { EnvironmentId } from '../types'; +import { + PortainerEndpoint, + PortainerDockerSnapshot, + PortainerKubernetesData, + PortainerEnvironmentEdgeSettings, + PortainerKubernetesStorageClassConfig, +} from '@api/types.gen'; + +import { DockerSnapshot } from '@/react/docker/snapshots/types'; + +import { + ContainerEngine, + Environment, + EnvironmentEdge, + EnvironmentId, + EnvironmentStatus, + EnvironmentType, + KubernetesSettings, + StorageClass, +} from '../types'; export function buildUrl(id?: EnvironmentId, action?: string) { let baseUrl = 'endpoints'; @@ -12,3 +31,85 @@ export function buildUrl(id?: EnvironmentId, action?: string) { return baseUrl; } + +export function toEnvironment(endpoint: PortainerEndpoint): Environment { + return { + ...endpoint, + Type: endpoint.Type !== 0 ? endpoint.Type : EnvironmentType.Docker, + Status: endpoint.Status !== 0 ? endpoint.Status : EnvironmentStatus.Down, + ContainerEngine: toContainerEngine(endpoint.ContainerEngine), + TagIds: endpoint.TagIds ?? [], + Gpus: endpoint.Gpus ?? [], + EnableGPUManagement: endpoint.EnableGPUManagement ?? false, + Snapshots: endpoint.Snapshots?.map(toDockerSnapshot) || [], + Agent: { + Version: endpoint.Agent?.Version || '', + // EE only + IsOutdated: false, + }, + Edge: toEdge(endpoint.Edge), + Kubernetes: toKubernetesSettings(endpoint.Kubernetes), + + // EE only + ChangeWindow: { Enabled: false, EndTime: '', StartTime: '' }, + DeploymentOptions: { + hideAddWithForm: false, + hideFileUpload: false, + hideWebEditor: false, + overrideGlobalOptions: false, + }, + EnableImageNotification: false, + }; +} + +function toKubernetesSettings( + data: PortainerKubernetesData +): KubernetesSettings { + return { + ...data, + Configuration: { + ...data.Configuration, + StorageClasses: + data.Configuration?.StorageClasses?.map(toStorageClass) ?? [], + IngressClasses: data.Configuration?.IngressClasses ?? [], + }, + }; + + function toStorageClass( + original: PortainerKubernetesStorageClassConfig + ): StorageClass { + return { + ...original, + AccessModes: original.AccessModes ?? [], + }; + } +} + +function toContainerEngine(engine: string): ContainerEngine { + switch (engine) { + case ContainerEngine.Docker: + return ContainerEngine.Docker; + case ContainerEngine.Podman: + return ContainerEngine.Podman; + case '': + return ContainerEngine.Kubernetes; + default: + return ContainerEngine.Docker; + } +} + +function toDockerSnapshot(snapshot: PortainerDockerSnapshot): DockerSnapshot { + return { + ...snapshot, + GpuUseList: snapshot.GpuUseList ?? [], + }; +} + +function toEdge(edge?: PortainerEnvironmentEdgeSettings): EnvironmentEdge { + return { + AsyncMode: edge?.AsyncMode ?? false, + PingInterval: edge?.PingInterval ?? 0, + SnapshotInterval: edge?.SnapshotInterval ?? 0, + CommandInterval: edge?.CommandInterval ?? 0, + }; +} diff --git a/app/react/portainer/environments/queries/useParseApiSortParams.test.ts b/app/react/portainer/environments/queries/useParseApiSortParams.test.ts index ba44d9222..93fe9f14d 100644 --- a/app/react/portainer/environments/queries/useParseApiSortParams.test.ts +++ b/app/react/portainer/environments/queries/useParseApiSortParams.test.ts @@ -7,12 +7,13 @@ import { PlatformType, } from '@/react/portainer/environments/types'; import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types'; +import { createMockEnvironmentGroup } from '@/react-tools/test-mocks'; import { useParseSortGroupApiParams } from './useParseApiSortParams'; const groups: EnvironmentGroup[] = [ - { Id: 1, Name: 'Unassigned' } as EnvironmentGroup, - { Id: 2, Name: 'Production' } as EnvironmentGroup, + createMockEnvironmentGroup({ Id: 1, Name: 'Unassigned' }), + createMockEnvironmentGroup({ Id: 2, Name: 'Production' }), ]; describe('useParseSortGroupApiParams', () => { diff --git a/app/react/portainer/environments/queries/useUpdateEnvironmentMutation.ts b/app/react/portainer/environments/queries/useUpdateEnvironmentMutation.ts index b40f5d893..4c9c86acd 100644 --- a/app/react/portainer/environments/queries/useUpdateEnvironmentMutation.ts +++ b/app/react/portainer/environments/queries/useUpdateEnvironmentMutation.ts @@ -1,17 +1,15 @@ import { useQueryClient, useMutation } from '@tanstack/react-query'; +import { EndpointsEndpointUpdatePayload } from '@api/types.gen'; + import { withError, withInvalidate } from '@/react-tools/react-query'; import { EnvironmentId, - EnvironmentStatusMessage, Environment, + EnvironmentStatusMessage, KubernetesSettings, - DeploymentOptions, - EndpointChangeWindow, - EnvironmentGroupId, } from '@/react/portainer/environments/types'; import axios, { parseAxiosError } from '@/portainer/services/axios/axios'; -import { TagId } from '@/portainer/tags/types'; import { buildUrl } from '../environment.service/utils'; @@ -26,31 +24,17 @@ export function useUpdateEnvironmentMutation() { }); } -export interface UpdateEnvironmentPayload extends Partial { - TLSCACert?: File; - TLSCert?: File; - TLSKey?: File; - - Name: string; - PublicURL: string; - GroupID: EnvironmentGroupId; - TagIds: TagId[]; - - EdgeCheckinInterval: number; - - TLS: boolean; - TLSSkipVerify: boolean; - TLSSkipClientVerify: boolean; - AzureApplicationID?: string; - AzureTenantID?: string; - AzureAuthenticationKey?: string; - - IsSetStatusMessage: boolean; - StatusMessage: EnvironmentStatusMessage; +export type UpdateEnvironmentPayload = Omit< + EndpointsEndpointUpdatePayload, + 'Kubernetes' +> & { Kubernetes?: KubernetesSettings; - DeploymentOptions?: DeploymentOptions | null; - ChangeWindow?: EndpointChangeWindow; -} + TLSCACert: File | undefined; + TLSCert: File | undefined; + TLSKey: File | undefined; + IsSetStatusMessage?: boolean; + StatusMessage?: EnvironmentStatusMessage; +}; export async function updateEnvironment({ id, diff --git a/app/react/portainer/environments/types.ts b/app/react/portainer/environments/types.ts index 10b3d1837..d8efb8a9a 100644 --- a/app/react/portainer/environments/types.ts +++ b/app/react/portainer/environments/types.ts @@ -1,8 +1,11 @@ +import { + PortainerEndpoint, + PortainerEndpointSecuritySettings, +} from '@api/types.gen'; + import { TagId } from '@/portainer/tags/types'; import { DockerSnapshot } from '@/react/docker/snapshots/types'; -import { TLSConfiguration } from '../settings/types'; - export type EnvironmentGroupId = number; export type EdgeGroupId = number; @@ -61,7 +64,7 @@ export type IngressClass = { Name: string; Type: string; Blocked?: boolean; - BlockedNamespaces?: string[] | null; + BlockedNamespaces?: string[]; }; export interface StorageClass { @@ -86,7 +89,7 @@ export interface KubernetesConfiguration { } export interface KubernetesSettings { - Snapshots?: KubernetesSnapshot[] | null; + Snapshots?: KubernetesSnapshot[]; Configuration: KubernetesConfiguration; Flags: { IsServerMetricsDetected: boolean; @@ -102,28 +105,7 @@ export type EnvironmentEdge = { CommandInterval: number; }; -export interface EnvironmentSecuritySettings { - // Whether non-administrator should be able to use bind mounts when creating containers - allowBindMountsForRegularUsers: boolean; - // Whether non-administrator should be able to use privileged mode when creating containers - allowPrivilegedModeForRegularUsers: boolean; - // Whether non-administrator should be able to browse volumes - allowVolumeBrowserForRegularUsers: boolean; - // Whether non-administrator should be able to use the host pid - allowHostNamespaceForRegularUsers: boolean; - // Whether non-administrator should be able to use device mapping - allowDeviceMappingForRegularUsers: boolean; - // Whether non-administrator should be able to manage stacks - allowStackManagementForRegularUsers: boolean; - // Whether non-administrator should be able to use container capabilities - allowContainerCapabilitiesForRegularUsers: boolean; - // Whether non-administrator should be able to use sysctl settings - allowSysctlSettingForRegularUsers: boolean; - // Whether non-administrator should be able to use security-opt settings - allowSecurityOptForRegularUsers: boolean; - // Whether host management features are enabled - enableHostManagementFeatures: boolean; -} +export type EnvironmentSecuritySettings = PortainerEndpointSecuritySettings; export type DeploymentOptions = { overrideGlobalOptions: boolean; @@ -145,49 +127,27 @@ export interface EnvironmentStatusMessage { detail: string; } -export type Environment = { - Agent: { Version: string; IsOutdated?: boolean }; - Id: EnvironmentId; - Type: EnvironmentType; - ContainerEngine?: ContainerEngine; - TagIds: TagId[]; - GroupId: EnvironmentGroupId; - DeploymentOptions: DeploymentOptions | null; - EnableGPUManagement: boolean; - EdgeID?: string; - EdgeKey: string; - EdgeCheckinInterval?: number; - Heartbeat?: boolean; - LastCheckInDate?: number; - Name: string; - Status: EnvironmentStatus; - URL: string; - Snapshots: DockerSnapshot[]; - Kubernetes: KubernetesSettings; - PublicURL?: string; - UserTrusted?: boolean; - Edge: EnvironmentEdge; - SecuritySettings: EnvironmentSecuritySettings; - Gpus?: { name: string; value: string }[]; - TLSConfig?: TLSConfiguration; - AzureCredentials?: { - ApplicationID: string; - TenantID: string; - AuthenticationKey: string; - }; - ComposeSyntaxMaxVersion: string; - EnableImageNotification: boolean; - LocalTimeZone?: string; +type EnvironmentBase = Omit; - /** GitOps update change window restriction for stacks and apps */ +export interface Environment extends EnvironmentBase { + Status: EnvironmentStatus; + Type: EnvironmentType; + ContainerEngine: ContainerEngine; + + TagIds: TagId[]; + Snapshots: DockerSnapshot[]; + Agent: { Version: string; IsOutdated?: boolean }; + Edge: EnvironmentEdge; + EnableGPUManagement: boolean; + Kubernetes: KubernetesSettings; + + // Fields not in CE PortainerEndpoint (EE-only in server) + LocalTimeZone?: string; + EnableImageNotification: boolean; ChangeWindow: EndpointChangeWindow; - /** - * A message that describes the status. Should be included for Status Provisioning or Error. - */ + DeploymentOptions: DeploymentOptions | null; StatusMessage?: EnvironmentStatusMessage; - UserAccessPolicies?: UserAccessPolicies; - TeamAccessPolicies?: TeamAccessPolicies; -}; +} /** * TS reference of endpoint_create.go#EndpointCreationType iota diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/WizardKubernetes.test.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/WizardKubernetes.test.tsx index 13a7528e9..485606a45 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/WizardKubernetes.test.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/WizardKubernetes.test.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'; import { withTestRouter } from '@/react/test-utils/withRouter'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { server, http } from '@/setup-tests/server'; +import { createMockEnvironment } from '@/react-tools/test-mocks'; import { WizardKubernetes } from './WizardKubernetes'; @@ -70,7 +71,7 @@ describe('WizardKubernetes', () => { key, typeof value === 'string' ? value : 'binary', ]); - return HttpResponse.json({}); + return HttpResponse.json(createMockEnvironment()); }) ); diff --git a/app/react/portainer/generated-api/portainer/sdk.gen.ts b/app/react/portainer/generated-api/portainer/sdk.gen.ts index 8cbea220d..158a830c1 100644 --- a/app/react/portainer/generated-api/portainer/sdk.gen.ts +++ b/app/react/portainer/generated-api/portainer/sdk.gen.ts @@ -361,9 +361,12 @@ import type { GetKubernetesNamespacesData, GetKubernetesNamespacesErrors, GetKubernetesNamespacesResponses, + GetKubernetesNodesData, + GetKubernetesNodesErrors, GetKubernetesNodesLimitsData, GetKubernetesNodesLimitsErrors, GetKubernetesNodesLimitsResponses, + GetKubernetesNodesResponses, GetKubernetesPersistentVolumeClaimData, GetKubernetesPersistentVolumeClaimErrors, GetKubernetesPersistentVolumeClaimResponses, @@ -1043,6 +1046,8 @@ import { zGetKubernetesNamespacesResponse, zGetKubernetesNodesLimitsPath, zGetKubernetesNodesLimitsResponse, + zGetKubernetesNodesPath, + zGetKubernetesNodesResponse, zGetKubernetesPersistentVolumeClaimPath, zGetKubernetesPersistentVolumeClaimResponse, zGetKubernetesPersistentVolumeClaimsInNamespacePath, @@ -2991,10 +2996,11 @@ export const endpointList = ( groupIds: { array: { explode: false } }, status: { array: { explode: false } }, types: { array: { explode: false } }, + platformTypes: { array: { explode: false } }, + excludeGroupIds: { array: { explode: false } }, tagIds: { array: { explode: false } }, endpointIds: { array: { explode: false } }, excludeIds: { array: { explode: false } }, - excludeGroupIds: { array: { explode: false } }, agentVersions: { array: { explode: false } }, edgeGroupIds: { array: { explode: false } }, excludeEdgeGroupIds: { array: { explode: false } }, @@ -6013,6 +6019,39 @@ export const getKubernetesNamespacesCount = < ...options, }); +/** + * Get Kubernetes cluster nodes + * + * Returns the list of Kubernetes nodes for the selected environment. + * **Access policy**: Authenticated user. + */ +export const getKubernetesNodes = ( + options: Options +) => + (options.client ?? client).get< + GetKubernetesNodesResponses, + GetKubernetesNodesErrors, + ThrowOnError + >({ + requestValidator: async (data) => + await z + .object({ + body: z.never().optional(), + path: zGetKubernetesNodesPath, + query: z.never().optional(), + }) + .parseAsync(data), + responseType: 'json', + responseValidator: async (data) => + await zGetKubernetesNodesResponse.parseAsync(data), + security: [ + { name: 'X-API-KEY', type: 'apiKey' }, + { name: 'Authorization', type: 'apiKey' }, + ], + url: '/kubernetes/{id}/nodes', + ...options, + }); + /** * Drain a Kubernetes node * diff --git a/app/react/portainer/generated-api/portainer/types.gen.ts b/app/react/portainer/generated-api/portainer/types.gen.ts index 9cb584750..e913b3a61 100644 --- a/app/react/portainer/generated-api/portainer/types.gen.ts +++ b/app/react/portainer/generated-api/portainer/types.gen.ts @@ -848,6 +848,46 @@ export const V1TerminationMessagePolicy = { export type V1TerminationMessagePolicy = (typeof V1TerminationMessagePolicy)[keyof typeof V1TerminationMessagePolicy]; +export const V1TaintEffect = { + /** + * TaintEffectNoSchedule + */ + TAINT_EFFECT_NO_SCHEDULE: 'NoSchedule', + /** + * TaintEffectPreferNoSchedule + */ + TAINT_EFFECT_PREFER_NO_SCHEDULE: 'PreferNoSchedule', + /** + * TaintEffectNoExecute + */ + TAINT_EFFECT_NO_EXECUTE: 'NoExecute', +} as const; + +export type V1TaintEffect = (typeof V1TaintEffect)[keyof typeof V1TaintEffect]; + +export type V1Taint = { + /** + * Required. The effect of the taint on pods + * that do not tolerate the taint. + * Valid effects are NoSchedule, PreferNoSchedule and NoExecute. + */ + effect?: V1TaintEffect; + /** + * Required. The taint key to be applied to a node. + */ + key?: string; + /** + * TimeAdded represents the time at which the taint was added. + * +optional + */ + timeAdded?: string; + /** + * The taint value corresponding to the taint key. + * +optional + */ + value?: string; +}; + export type V1TcpSocketAction = { /** * Optional: Host name to connect to, defaults to the pod IP. @@ -2124,6 +2164,511 @@ export type V1ObjectFieldSelector = { fieldPath?: string; }; +export type V1NodeSystemInfo = { + /** + * The Architecture reported by the node + */ + architecture?: string; + /** + * Boot ID reported by the node. + */ + bootID?: string; + /** + * ContainerRuntime Version reported by the node through runtime remote API (e.g. containerd://1.4.2). + */ + containerRuntimeVersion?: string; + /** + * Kernel Version reported by the node from 'uname -r' (e.g. 3.16.0-0.bpo.4-amd64). + */ + kernelVersion?: string; + /** + * Deprecated: KubeProxy Version reported by the node. + */ + kubeProxyVersion?: string; + /** + * Kubelet Version reported by the node. + */ + kubeletVersion?: string; + /** + * MachineID reported by the node. For unique machine identification + * in the cluster this field is preferred. Learn more from man(5) + * machine-id: http://man7.org/linux/man-pages/man5/machine-id.5.html + */ + machineID?: string; + /** + * The Operating System reported by the node + */ + operatingSystem?: string; + /** + * OS Image reported by the node from /etc/os-release (e.g. Debian GNU/Linux 7 (wheezy)). + */ + osImage?: string; + /** + * Swap Info reported by the node. + */ + swap?: V1NodeSwapStatus; + /** + * SystemUUID reported by the node. For unique machine identification + * MachineID is preferred. This field is specific to Red Hat hosts + * https://access.redhat.com/documentation/en-us/red_hat_subscription_management/1/html/rhsm/uuid + */ + systemUUID?: string; +}; + +export type V1NodeSwapStatus = { + /** + * Total amount of swap memory in bytes. + * +optional + */ + capacity?: number; +}; + +export type V1NodeStatus = { + /** + * List of addresses reachable to the node. + * Queried from cloud provider, if available. + * More info: https://kubernetes.io/docs/reference/node/node-status/#addresses + * Note: This field is declared as mergeable, but the merge key is not sufficiently + * unique, which can cause data corruption when it is merged. Callers should instead + * use a full-replacement patch. See https://pr.k8s.io/79391 for an example. + * Consumers should assume that addresses can change during the + * lifetime of a Node. However, there are some exceptions where this may not + * be possible, such as Pods that inherit a Node's address in its own status or + * consumers of the downward API (status.hostIP). + * +optional + * +patchMergeKey=type + * +patchStrategy=merge + * +listType=map + * +listMapKey=type + */ + addresses?: Array; + /** + * Allocatable represents the resources of a node that are available for scheduling. + * Defaults to Capacity. + * +optional + */ + allocatable?: V1ResourceList; + /** + * Capacity represents the total resources of a node. + * More info: https://kubernetes.io/docs/reference/node/node-status/#capacity + * +optional + */ + capacity?: V1ResourceList; + /** + * Conditions is an array of current observed node conditions. + * More info: https://kubernetes.io/docs/reference/node/node-status/#condition + * +optional + * +patchMergeKey=type + * +patchStrategy=merge + * +listType=map + * +listMapKey=type + */ + conditions?: Array; + /** + * Status of the config assigned to the node via the dynamic Kubelet config feature. + * +optional + */ + config?: V1NodeConfigStatus; + /** + * Endpoints of daemons running on the Node. + * +optional + */ + daemonEndpoints?: V1NodeDaemonEndpoints; + /** + * DeclaredFeatures represents the features related to feature gates that are declared by the node. + * +featureGate=NodeDeclaredFeatures + * +optional + * +listType=atomic + */ + declaredFeatures?: Array; + /** + * Features describes the set of features implemented by the CRI implementation. + * +featureGate=SupplementalGroupsPolicy + * +optional + */ + features?: V1NodeFeatures; + /** + * List of container images on this node + * +optional + * +listType=atomic + */ + images?: Array; + /** + * Set of ids/uuids to uniquely identify the node. + * More info: https://kubernetes.io/docs/reference/node/node-status/#info + * +optional + */ + nodeInfo?: V1NodeSystemInfo; + /** + * NodePhase is the recently observed lifecycle phase of the node. + * More info: https://kubernetes.io/docs/concepts/nodes/node/#phase + * The field is never populated, and now is deprecated. + * +optional + */ + phase?: V1NodePhase; + /** + * The available runtime handlers. + * +featureGate=UserNamespacesSupport + * +optional + * +listType=atomic + */ + runtimeHandlers?: Array; + /** + * List of volumes that are attached to the node. + * +optional + * +listType=atomic + */ + volumesAttached?: Array; + /** + * List of attachable volumes in use (mounted) by the node. + * +optional + * +listType=atomic + */ + volumesInUse?: Array; +}; + +export type V1AttachedVolume = { + /** + * DevicePath represents the device path where the volume should be available + */ + devicePath?: string; + /** + * Name of the attached volume + */ + name?: string; +}; + +export type V1NodeRuntimeHandlerFeatures = { + /** + * RecursiveReadOnlyMounts is set to true if the runtime handler supports RecursiveReadOnlyMounts. + * +optional + */ + recursiveReadOnlyMounts?: boolean; + /** + * UserNamespaces is set to true if the runtime handler supports UserNamespaces, including for volumes. + * +featureGate=UserNamespacesSupport + * +optional + */ + userNamespaces?: boolean; +}; + +export type V1NodeRuntimeHandler = { + /** + * Supported features. + * +optional + */ + features?: V1NodeRuntimeHandlerFeatures; + /** + * Runtime handler name. + * Empty for the default runtime handler. + * +optional + */ + name?: string; +}; + +export const V1NodePhase = { + /** + * NodePending + */ + NODE_PENDING: 'Pending', + /** + * NodeRunning + */ + NODE_RUNNING: 'Running', + /** + * NodeTerminated + */ + NODE_TERMINATED: 'Terminated', +} as const; + +export type V1NodePhase = (typeof V1NodePhase)[keyof typeof V1NodePhase]; + +export type V1ContainerImage = { + /** + * Names by which this image is known. + * e.g. ["kubernetes.example/hyperkube:v1.0.7", "cloud-vendor.registry.example/cloud-vendor/hyperkube:v1.0.7"] + * +optional + * +listType=atomic + */ + names?: Array; + /** + * The size of the image in bytes. + * +optional + */ + sizeBytes?: number; +}; + +export type V1NodeFeatures = { + /** + * SupplementalGroupsPolicy is set to true if the runtime supports SupplementalGroupsPolicy and ContainerUser. + * +optional + */ + supplementalGroupsPolicy?: boolean; +}; + +export type V1DaemonEndpoint = { + /** + * Port number of the given endpoint. + */ + Port?: number; +}; + +export type V1NodeDaemonEndpoints = { + /** + * Endpoint on which Kubelet is listening. + * +optional + */ + kubeletEndpoint?: V1DaemonEndpoint; +}; + +export type V1ConfigMapNodeConfigSource = { + /** + * KubeletConfigKey declares which key of the referenced ConfigMap corresponds to the KubeletConfiguration structure + * This field is required in all cases. + */ + kubeletConfigKey?: string; + /** + * Name is the metadata.name of the referenced ConfigMap. + * This field is required in all cases. + */ + name?: string; + /** + * Namespace is the metadata.namespace of the referenced ConfigMap. + * This field is required in all cases. + */ + namespace?: string; + /** + * ResourceVersion is the metadata.ResourceVersion of the referenced ConfigMap. + * This field is forbidden in Node.Spec, and required in Node.Status. + * +optional + */ + resourceVersion?: string; + /** + * UID is the metadata.UID of the referenced ConfigMap. + * This field is forbidden in Node.Spec, and required in Node.Status. + * +optional + */ + uid?: string; +}; + +export type V1NodeConfigSource = { + /** + * ConfigMap is a reference to a Node's ConfigMap + */ + configMap?: V1ConfigMapNodeConfigSource; +}; + +export type V1NodeConfigStatus = { + /** + * Active reports the checkpointed config the node is actively using. + * Active will represent either the current version of the Assigned config, + * or the current LastKnownGood config, depending on whether attempting to use the + * Assigned config results in an error. + * +optional + */ + active?: V1NodeConfigSource; + /** + * Assigned reports the checkpointed config the node will try to use. + * When Node.Spec.ConfigSource is updated, the node checkpoints the associated + * config payload to local disk, along with a record indicating intended + * config. The node refers to this record to choose its config checkpoint, and + * reports this record in Assigned. Assigned only updates in the status after + * the record has been checkpointed to disk. When the Kubelet is restarted, + * it tries to make the Assigned config the Active config by loading and + * validating the checkpointed payload identified by Assigned. + * +optional + */ + assigned?: V1NodeConfigSource; + /** + * Error describes any problems reconciling the Spec.ConfigSource to the Active config. + * Errors may occur, for example, attempting to checkpoint Spec.ConfigSource to the local Assigned + * record, attempting to checkpoint the payload associated with Spec.ConfigSource, attempting + * to load or validate the Assigned config, etc. + * Errors may occur at different points while syncing config. Earlier errors (e.g. download or + * checkpointing errors) will not result in a rollback to LastKnownGood, and may resolve across + * Kubelet retries. Later errors (e.g. loading or validating a checkpointed config) will result in + * a rollback to LastKnownGood. In the latter case, it is usually possible to resolve the error + * by fixing the config assigned in Spec.ConfigSource. + * You can find additional information for debugging by searching the error message in the Kubelet log. + * Error is a human-readable description of the error state; machines can check whether or not Error + * is empty, but should not rely on the stability of the Error text across Kubelet versions. + * +optional + */ + error?: string; + /** + * LastKnownGood reports the checkpointed config the node will fall back to + * when it encounters an error attempting to use the Assigned config. + * The Assigned config becomes the LastKnownGood config when the node determines + * that the Assigned config is stable and correct. + * This is currently implemented as a 10-minute soak period starting when the local + * record of Assigned config is updated. If the Assigned config is Active at the end + * of this period, it becomes the LastKnownGood. Note that if Spec.ConfigSource is + * reset to nil (use local defaults), the LastKnownGood is also immediately reset to nil, + * because the local default config is always assumed good. + * You should not make assumptions about the node's method of determining config stability + * and correctness, as this may change or become configurable in the future. + * +optional + */ + lastKnownGood?: V1NodeConfigSource; +}; + +export const V1NodeConditionType = { + /** + * NodeReady + */ + NODE_READY: 'Ready', + /** + * NodeMemoryPressure + */ + NODE_MEMORY_PRESSURE: 'MemoryPressure', + /** + * NodeDiskPressure + */ + NODE_DISK_PRESSURE: 'DiskPressure', + /** + * NodePIDPressure + */ + NODE_PID_PRESSURE: 'PIDPressure', + /** + * NodeNetworkUnavailable + */ + NODE_NETWORK_UNAVAILABLE: 'NetworkUnavailable', +} as const; + +export type V1NodeConditionType = + (typeof V1NodeConditionType)[keyof typeof V1NodeConditionType]; + +export const K8sIoApiCoreV1ConditionStatus = { + /** + * ConditionTrue + */ + CONDITION_TRUE: 'True', + /** + * ConditionFalse + */ + CONDITION_FALSE: 'False', + /** + * ConditionUnknown + */ + CONDITION_UNKNOWN: 'Unknown', +} as const; + +export type K8sIoApiCoreV1ConditionStatus = + (typeof K8sIoApiCoreV1ConditionStatus)[keyof typeof K8sIoApiCoreV1ConditionStatus]; + +export type V1NodeCondition = { + /** + * Last time we got an update on a given condition. + * +optional + */ + lastHeartbeatTime?: string; + /** + * Last time the condition transit from one status to another. + * +optional + */ + lastTransitionTime?: string; + /** + * Human readable message indicating details about last transition. + * +optional + */ + message?: string; + /** + * (brief) reason for the condition's last transition. + * +optional + */ + reason?: string; + /** + * Status of the condition, one of True, False, Unknown. + */ + status?: K8sIoApiCoreV1ConditionStatus; + /** + * Type of node condition. + */ + type?: V1NodeConditionType; +}; + +export const V1NodeAddressType = { + /** + * NodeHostName + */ + NODE_HOST_NAME: 'Hostname', + /** + * NodeInternalIP + */ + NODE_INTERNAL_IP: 'InternalIP', + /** + * NodeExternalIP + */ + NODE_EXTERNAL_IP: 'ExternalIP', + /** + * NodeInternalDNS + */ + NODE_INTERNAL_DNS: 'InternalDNS', + /** + * NodeExternalDNS + */ + NODE_EXTERNAL_DNS: 'ExternalDNS', +} as const; + +export type V1NodeAddressType = + (typeof V1NodeAddressType)[keyof typeof V1NodeAddressType]; + +export type V1NodeAddress = { + /** + * The node address. + */ + address?: string; + /** + * Node address type, one of Hostname, ExternalIP or InternalIP. + */ + type?: V1NodeAddressType; +}; + +export type V1NodeSpec = { + /** + * Deprecated: Previously used to specify the source of the node's configuration for the DynamicKubeletConfig feature. This feature is removed. + * +optional + */ + configSource?: V1NodeConfigSource; + /** + * Deprecated. Not all kubelets will set this field. Remove field after 1.13. + * see: https://issues.k8s.io/61966 + * +optional + */ + externalID?: string; + /** + * PodCIDR represents the pod IP range assigned to the node. + * +optional + */ + podCIDR?: string; + /** + * podCIDRs represents the IP ranges assigned to the node for usage by Pods on that node. If this + * field is specified, the 0th entry must match the podCIDR field. It may contain at most 1 value for + * each of IPv4 and IPv6. + * +optional + * +patchStrategy=merge + * +listType=set + */ + podCIDRs?: Array; + /** + * ID of the node assigned by the cloud provider in the format: :// + * +optional + */ + providerID?: string; + /** + * If specified, the node's taints. + * +optional + * +listType=atomic + */ + taints?: Array; + /** + * Unschedulable controls node schedulability of new pods. By default, node is schedulable. + * More info: https://kubernetes.io/docs/concepts/nodes/node/#manual-node-administration + * +optional + */ + unschedulable?: boolean; +}; + export type V1NamespaceStatus = { /** * Represents the latest available observations of a namespace's current state. @@ -2182,24 +2727,6 @@ export const V1NamespaceConditionType = { export type V1NamespaceConditionType = (typeof V1NamespaceConditionType)[keyof typeof V1NamespaceConditionType]; -export const K8sIoApiCoreV1ConditionStatus = { - /** - * ConditionTrue - */ - CONDITION_TRUE: 'True', - /** - * ConditionFalse - */ - CONDITION_FALSE: 'False', - /** - * ConditionUnknown - */ - CONDITION_UNKNOWN: 'Unknown', -} as const; - -export type K8sIoApiCoreV1ConditionStatus = - (typeof K8sIoApiCoreV1ConditionStatus)[keyof typeof K8sIoApiCoreV1ConditionStatus]; - export type V1NamespaceCondition = { /** * Last time the condition transitioned from one status to another. @@ -3108,8 +3635,8 @@ export type PortainerTemplateRepository = { }; export type PortainerPair = { - name?: string; - value?: string; + name: string; + value: string; }; export type PortainerTemplateEnvSelect = { @@ -3895,7 +4422,7 @@ export type PortainerTlsConfiguration = { /** * Use TLS */ - TLS?: boolean; + TLS: boolean; /** * Path to the TLS CA certificate file */ @@ -3911,7 +4438,7 @@ export type PortainerTlsConfiguration = { /** * Skip the verification of the server TLS certificate */ - TLSSkipVerify?: boolean; + TLSSkipVerify: boolean; }; export type PortainerLdapSearchSettings = { @@ -4559,7 +5086,7 @@ export type PortainerAccessPolicy = { /** * Role identifier. Reference the role that will be associated to this access policy */ - RoleId?: number; + RoleId: number; }; export type PortainerTeamAccessPolicies = { @@ -5502,19 +6029,19 @@ export type PortainerPerformanceMetrics = { export type PortainerKubernetesStorageClassConfig = { AccessModes?: Array; - AllowVolumeExpansion?: boolean; - Name?: string; - Provisioner?: string; + AllowVolumeExpansion: boolean; + Name: string; + Provisioner: string; }; export type PortainerKubernetesSnapshot = { DiagnosticsData?: PortainerDiagnosticsData; - KubernetesVersion?: string; - NodeCount?: number; + KubernetesVersion: string; + NodeCount: number; PerformanceMetrics?: PortainerPerformanceMetrics; - Time?: number; - TotalCPU?: number; - TotalMemory?: number; + Time: number; + TotalCPU: number; + TotalMemory: number; }; export type PortainerDiagnosticsData = { @@ -5533,26 +6060,26 @@ export type PortainerDiagnosticsData = { export type PortainerKubernetesIngressClassConfig = { Blocked?: boolean; BlockedNamespaces?: Array; - Name?: string; - Type?: string; + Name: string; + Type: string; }; export type PortainerKubernetesFlags = { - IsServerIngressClassDetected?: boolean; - IsServerMetricsDetected?: boolean; - IsServerStorageDetected?: boolean; + IsServerIngressClassDetected: boolean; + IsServerMetricsDetected: boolean; + IsServerStorageDetected: boolean; }; export type PortainerKubernetesData = { - Configuration?: PortainerKubernetesConfiguration; - Flags?: PortainerKubernetesFlags; + Configuration: PortainerKubernetesConfiguration; + Flags: PortainerKubernetesFlags; Snapshots?: Array; }; export type PortainerKubernetesConfiguration = { - AllowNoneIngressClass?: boolean; + AllowNoneIngressClass: boolean; EnableResourceOverCommit?: boolean; - IngressAvailabilityPerNamespace?: boolean; + IngressAvailabilityPerNamespace: boolean; IngressClasses?: Array; ResourceOverCommitPercentage?: number; RestrictDefaultNamespace?: boolean; @@ -5589,19 +6116,23 @@ export type PortainerEnvironmentEdgeSettings = { /** * Whether the device has been started in edge async mode */ - AsyncMode?: boolean; + AsyncMode: boolean; /** * The command list interval for edge agent - used in edge async mode [seconds] */ - CommandInterval?: number; + CommandInterval: number; /** * The ping interval for edge agent - used in edge async mode [seconds] */ - PingInterval?: number; + PingInterval: number; /** * The snapshot interval for edge agent - used in edge async mode [seconds] */ - SnapshotInterval?: number; + SnapshotInterval: number; +}; + +export type PortainerEnvironmentAgentData = { + Version?: string; }; export const PortainerEndpointType = { @@ -5664,108 +6195,82 @@ export type PortainerEndpointSecuritySettings = { /** * Whether non-administrator should be able to use bind mounts when creating containers */ - allowBindMountsForRegularUsers?: boolean; + allowBindMountsForRegularUsers: boolean; /** * Whether non-administrator should be able to use container capabilities */ - allowContainerCapabilitiesForRegularUsers?: boolean; + allowContainerCapabilitiesForRegularUsers: boolean; /** * Whether non-administrator should be able to use device mapping */ - allowDeviceMappingForRegularUsers?: boolean; + allowDeviceMappingForRegularUsers: boolean; /** * Whether non-administrator should be able to use the host pid */ - allowHostNamespaceForRegularUsers?: boolean; + allowHostNamespaceForRegularUsers: boolean; /** * Whether non-administrator should be able to use privileged mode when creating containers */ - allowPrivilegedModeForRegularUsers?: boolean; + allowPrivilegedModeForRegularUsers: boolean; /** * Whether non-administrator should be able to use security-opt settings */ - allowSecurityOptForRegularUsers?: boolean; + allowSecurityOptForRegularUsers: boolean; /** * Whether non-administrator should be able to manage stacks */ - allowStackManagementForRegularUsers?: boolean; + allowStackManagementForRegularUsers: boolean; /** * Whether non-administrator should be able to use sysctl settings */ - allowSysctlSettingForRegularUsers?: boolean; + allowSysctlSettingForRegularUsers: boolean; /** * Whether non-administrator should be able to browse volumes */ - allowVolumeBrowserForRegularUsers?: boolean; + allowVolumeBrowserForRegularUsers: boolean; /** * Whether host management features are enabled */ - enableHostManagementFeatures?: boolean; -}; - -export type PortainerEndpointPostInitMigrations = { - MigrateGPUs?: boolean; - MigrateIngresses?: boolean; - MigrateRegistrySASecrets?: boolean; + enableHostManagementFeatures: boolean; }; export type PortainerEndpointGroup = { - AuthorizedTeams?: Array; - /** - * Deprecated in DBVersion == 18 - */ - AuthorizedUsers?: Array; /** * Description associated to the environment(endpoint) group */ - Description?: string; + Description: string; /** * Environment(Endpoint) group Identifier */ - Id?: number; - /** - * Deprecated fields - */ - Labels?: Array; + Id: number; /** * Environment(Endpoint) group name */ - Name?: string; + Name: string; /** * List of tags associated to this environment(endpoint) group */ TagIds?: Array; - /** - * Deprecated in DBVersion == 22 - */ - Tags?: Array; TeamAccessPolicies?: PortainerTeamAccessPolicies; UserAccessPolicies?: PortainerUserAccessPolicies; }; export type PortainerEndpoint = { - Agent?: { - Version?: string; - }; - AuthorizedTeams?: Array; - /** - * Deprecated in DBVersion == 18 - */ - AuthorizedUsers?: Array; + Agent: PortainerEnvironmentAgentData; AzureCredentials?: PortainerAzureCredentials; /** * Maximum version of docker-compose */ - ComposeSyntaxMaxVersion?: string; + ComposeSyntaxMaxVersion: string; /** - * ContainerEngine represents the container engine type. This can be 'docker' or 'podman' when interacting directly with these environmentes, otherwise '' for kubernetes environments. + * ContainerEngine represents the container engine type. This can be 'docker' or 'podman' when interacting directly with these environments, otherwise '' for kubernetes environments. */ - ContainerEngine?: string; - Edge?: PortainerEnvironmentEdgeSettings; + ContainerEngine: string; + Edge: PortainerEnvironmentEdgeSettings; /** * The check in interval for edge agent (in seconds) */ - EdgeCheckinInterval?: number; + EdgeCheckinInterval: number; /** * The identifier of the edge agent associated with this environment(endpoint) */ @@ -5773,13 +6278,13 @@ export type PortainerEndpoint = { /** * The key which is used to map the agent to Portainer */ - EdgeKey?: string; + EdgeKey: string; EnableGPUManagement?: boolean; Gpus?: Array; /** * Environment(Endpoint) group identifier */ - GroupId?: number; + GroupId: number; /** * Heartbeat indicates the heartbeat status of an edge environment */ @@ -5787,35 +6292,27 @@ export type PortainerEndpoint = { /** * Environment(Endpoint) Identifier */ - Id?: number; - /** - * Deprecated v2.18 - */ - IsEdgeDevice?: boolean; + Id: number; /** * Associated Kubernetes data */ - Kubernetes?: PortainerKubernetesData; + Kubernetes: PortainerKubernetesData; /** * LastCheckInDate mark last check-in date on checkin */ - LastCheckInDate?: number; + LastCheckInDate: number; /** * Environment(Endpoint) name */ - Name?: string; - /** - * Whether we need to run any "post init migrations". - */ - PostInitMigrations?: PortainerEndpointPostInitMigrations; + Name: string; /** * URL or IP address where exposed containers will be reachable */ - PublicURL?: string; + PublicURL: string; /** * Environment(Endpoint) specific security settings */ - SecuritySettings?: PortainerEndpointSecuritySettings; + SecuritySettings: PortainerEndpointSecuritySettings; /** * List of snapshots */ @@ -5823,24 +6320,12 @@ export type PortainerEndpoint = { /** * The status of the environment(endpoint) (1 - up, 2 - down) */ - Status?: PortainerEndpointStatus; - /** - * Deprecated fields - * Deprecated in DBVersion == 4 - */ - TLS?: boolean; - TLSCACert?: string; - TLSCert?: string; - TLSConfig?: PortainerTlsConfiguration; - TLSKey?: string; + Status: PortainerEndpointStatus; + TLSConfig: PortainerTlsConfiguration; /** * List of tag identifiers to which this environment(endpoint) is associated */ TagIds?: Array; - /** - * Deprecated in DBVersion == 22 - */ - Tags?: Array; /** * List of team identifiers authorized to connect to this environment(endpoint) */ @@ -5848,11 +6333,11 @@ export type PortainerEndpoint = { /** * Environment(Endpoint) environment(endpoint) type. 1 for a Docker environment(endpoint), 2 for an agent on Docker environment(endpoint) or 3 for an Azure environment(endpoint). */ - Type?: PortainerEndpointType; + Type: PortainerEndpointType; /** * URL or IP address of the Docker host associated to this environment(endpoint) */ - URL?: string; + URL: string; /** * List of user identifiers authorized to connect to this environment(endpoint) */ @@ -5868,42 +6353,42 @@ export type PortainerDockerSnapshotRaw = { }; export type PortainerDockerSnapshot = { - ContainerCount?: number; + ContainerCount: number; DiagnosticsData?: PortainerDiagnosticsData; DockerSnapshotRaw?: PortainerDockerSnapshotRaw; - DockerVersion?: string; - GpuUseAll?: boolean; + DockerVersion: string; + GpuUseAll: boolean; GpuUseList?: Array; - HealthyContainerCount?: number; - ImageCount?: number; - IsPodman?: boolean; - NodeCount?: number; + HealthyContainerCount: number; + ImageCount: number; + IsPodman: boolean; + NodeCount: number; PerformanceMetrics?: PortainerPerformanceMetrics; - RunningContainerCount?: number; - ServiceCount?: number; - StackCount?: number; - StoppedContainerCount?: number; - Swarm?: boolean; - Time?: number; - TotalCPU?: number; - TotalMemory?: number; - UnhealthyContainerCount?: number; - VolumeCount?: number; + RunningContainerCount: number; + ServiceCount: number; + StackCount: number; + StoppedContainerCount: number; + Swarm: boolean; + Time: number; + TotalCPU: number; + TotalMemory: number; + UnhealthyContainerCount: number; + VolumeCount: number; }; export type PortainerAzureCredentials = { /** * Azure application ID */ - ApplicationID?: string; + ApplicationID: string; /** * Azure authentication key */ - AuthenticationKey?: string; + AuthenticationKey: string; /** * Azure tenant ID */ - TenantID?: string; + TenantID: string; }; export const PortainerEdgeStackStatusType = { @@ -6322,6 +6807,46 @@ export type KubernetesMetadata = { }; }; +export type KubernetesKubernetesNodeResponse = { + /** + * APIVersion defines the versioned schema of this representation of an object. + * Servers should convert recognized schemas to the latest internal value, and + * may reject unrecognized values. + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + * +optional + */ + apiVersion?: string; + /** + * Kind is a string value representing the REST resource this object represents. + * Servers may infer this from the endpoint the client submits requests to. + * Cannot be updated. + * In CamelCase. + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + * +optional + */ + kind?: string; + /** + * Standard object's metadata. + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + * +optional + */ + metadata?: V1ObjectMeta; + /** + * Spec defines the behavior of a node. + * https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + * +optional + */ + spec?: V1NodeSpec; + /** + * Most recently observed status of the node. + * Populated by the system. + * Read-only. + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + * +optional + */ + status?: V1NodeStatus; +}; + export type KubernetesK8sVolumeInfo = { persistentVolume?: KubernetesK8sPersistentVolume; persistentVolumeClaim?: KubernetesK8sPersistentVolumeClaim; @@ -7040,49 +7565,6 @@ export type EndpointgroupsEndpointGroupUpdatePayload = { UserAccessPolicies?: PortainerUserAccessPolicies; }; -export type EndpointgroupsEndpointGroupTypeInfo = { - Docker?: number; - Kubernetes?: number; - Mixed?: boolean; - Podman?: number; -}; - -export type EndpointgroupsEndpointGroupResponse = { - AuthorizedTeams?: Array; - /** - * Deprecated in DBVersion == 18 - */ - AuthorizedUsers?: Array; - /** - * Description associated to the environment(endpoint) group - */ - Description?: string; - /** - * Environment(Endpoint) group Identifier - */ - Id?: number; - /** - * Deprecated fields - */ - Labels?: Array; - /** - * Environment(Endpoint) group name - */ - Name?: string; - /** - * List of tags associated to this environment(endpoint) group - */ - TagIds?: Array; - /** - * Deprecated in DBVersion == 22 - */ - Tags?: Array; - TeamAccessPolicies?: PortainerTeamAccessPolicies; - Total?: number; - TypeInfo?: EndpointgroupsEndpointGroupTypeInfo; - UserAccessPolicies?: PortainerUserAccessPolicies; -}; - export type EndpointgroupsEndpointGroupCreatePayload = { /** * List of environment(endpoint) identifiers that will be part of this group @@ -7102,6 +7584,36 @@ export type EndpointgroupsEndpointGroupCreatePayload = { TagIDs?: Array; }; +export type EndpointgroupsEndpointGroupTypeInfo = { + Docker: number; + Kubernetes: number; + Mixed: boolean; + Podman: number; +}; + +export type EndpointgroupsEndpointGroupResponse = { + /** + * Description associated to the environment(endpoint) group + */ + Description: string; + /** + * Environment(Endpoint) group Identifier + */ + Id: number; + /** + * Environment(Endpoint) group name + */ + Name: string; + /** + * List of tags associated to this environment(endpoint) group + */ + TagIds?: Array; + TeamAccessPolicies?: PortainerTeamAccessPolicies; + Total?: number; + TypeInfo?: EndpointgroupsEndpointGroupTypeInfo; + UserAccessPolicies?: PortainerUserAccessPolicies; +}; + export type EdgestacksUpdateStatusPayload = { EndpointID?: number; Error?: string; @@ -9399,7 +9911,7 @@ export type EndpointListData = { /** * Order sorted results by desc/asc */ - order?: number; + order?: string; /** * Search query */ @@ -9415,7 +9927,19 @@ export type EndpointListData = { /** * List environments(endpoints) of this type */ - types?: Array; + types?: Array<0 | 1 | 2 | 3 | 4 | 5 | 6 | 7>; + /** + * Filter environments by platform type + */ + platformTypes?: Array<0 | 1 | 2 | 3 | 4>; + /** + * If true, return only environments with an outdated agent + */ + outdated?: boolean; + /** + * Exclude environments of these groups + */ + excludeGroupIds?: Array; /** * search environments(endpoints) with these tags (depends on tagsPartialMatch) */ @@ -9432,14 +9956,6 @@ export type EndpointListData = { * will exclude these environments(endpoints) */ excludeIds?: Array; - /** - * will exclude environments(endpoints) belonging to these endpoint groups - */ - excludeGroupIds?: Array; - /** - * If true, will return environment(endpoint) that were provisioned - */ - provisioned?: boolean; /** * will return only environments with on of these agent versions */ @@ -9464,10 +9980,14 @@ export type EndpointListData = { * will return only environments(endpoints) with this name */ name?: string; + /** + * will return the environments of the specified edge stack + */ + edgeStackId?: number; /** * only applied when edgeStackId exists. Filter the returned environments based on their deployment status in the stack (not the environment status!) */ - edgeStackStatus?: string; + edgeStackStatus?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13; /** * List environments(endpoints) of these edge groups */ @@ -9506,7 +10026,7 @@ export type EndpointCreateData = { /** * Environment(Endpoint) type. Value must be one of: 1 (Local Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge agent environment) or 5 (Local Kubernetes Environment) */ - EndpointCreationType: number; + EndpointCreationType: 0 | 1 | 2 | 3 | 4 | 5; /** * Container engine used by the environment(endpoint). Value must be one of: 'docker' or 'podman' */ @@ -9560,9 +10080,9 @@ export type EndpointCreateData = { */ AzureAuthenticationKey?: string; /** - * List of tag identifiers to which this environment(endpoint) is associated + * JSON-parsable array of tag identifiers to which this environment(endpoint) is associated */ - TagIds?: Array; + TagIds?: string; /** * The check in interval for edge agent (in seconds) */ @@ -9570,7 +10090,7 @@ export type EndpointCreateData = { /** * URL or IP address that will be used to establish a reverse tunnel */ - EdgeTunnelServerAddress: string; + EdgeTunnelServerAddress?: string; /** * List of GPUs - json stringified array of {name, value} structs */ @@ -13555,6 +14075,43 @@ export type GetKubernetesNamespacesCountResponses = { export type GetKubernetesNamespacesCountResponse = GetKubernetesNamespacesCountResponses[keyof GetKubernetesNamespacesCountResponses]; +export type GetKubernetesNodesData = { + body?: never; + path: { + /** + * Environment(Endpoint) identifier + */ + id: number; + }; + query?: never; + url: '/kubernetes/{id}/nodes'; +}; + +export type GetKubernetesNodesErrors = { + /** + * Unauthorized access - the user is not authenticated or does not have the necessary permissions. + */ + 401: unknown; + /** + * Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource. + */ + 403: unknown; + /** + * Server error occurred while attempting to retrieve the list of nodes. + */ + 500: unknown; +}; + +export type GetKubernetesNodesResponses = { + /** + * Success + */ + 200: Array; +}; + +export type GetKubernetesNodesResponse = + GetKubernetesNodesResponses[keyof GetKubernetesNodesResponses]; + export type DrainNodeData = { body?: never; path: { diff --git a/app/react/portainer/generated-api/portainer/zod.gen.ts b/app/react/portainer/generated-api/portainer/zod.gen.ts index 5fa6dc976..b5435152a 100644 --- a/app/react/portainer/generated-api/portainer/zod.gen.ts +++ b/app/react/portainer/generated-api/portainer/zod.gen.ts @@ -34,6 +34,9 @@ import { V1MountPropagationMode, V1NamespaceConditionType, V1NamespacePhase, + V1NodeAddressType, + V1NodeConditionType, + V1NodePhase, V1PersistentVolumeAccessMode, V1PersistentVolumeClaimPhase, V1PersistentVolumeMode, @@ -49,6 +52,7 @@ import { V1ScopeSelectorOperator, V1SeccompProfileType, V1Signal, + V1TaintEffect, V1TerminationMessagePolicy, V1UriScheme, WorkflowsDeploymentPlatform, @@ -278,6 +282,15 @@ export const zV1UriScheme = z.enum(V1UriScheme); export const zV1TerminationMessagePolicy = z.enum(V1TerminationMessagePolicy); +export const zV1TaintEffect = z.enum(V1TaintEffect); + +export const zV1Taint = z.object({ + effect: zV1TaintEffect.optional(), + key: z.string().optional(), + timeAdded: z.string().optional(), + value: z.string().optional(), +}); + export const zIntstrType = z.enum(IntstrType); export const zIntstrIntOrString = z.object({ @@ -488,14 +501,130 @@ export const zV1ObjectFieldSelector = z.object({ fieldPath: z.string().optional(), }); -export const zV1NamespacePhase = z.enum(V1NamespacePhase); +export const zV1NodeSwapStatus = z.object({ + capacity: z.int().optional(), +}); -export const zV1NamespaceConditionType = z.enum(V1NamespaceConditionType); +export const zV1NodeSystemInfo = z.object({ + architecture: z.string().optional(), + bootID: z.string().optional(), + containerRuntimeVersion: z.string().optional(), + kernelVersion: z.string().optional(), + kubeProxyVersion: z.string().optional(), + kubeletVersion: z.string().optional(), + machineID: z.string().optional(), + operatingSystem: z.string().optional(), + osImage: z.string().optional(), + swap: zV1NodeSwapStatus.optional(), + systemUUID: z.string().optional(), +}); + +export const zV1AttachedVolume = z.object({ + devicePath: z.string().optional(), + name: z.string().optional(), +}); + +export const zV1NodeRuntimeHandlerFeatures = z.object({ + recursiveReadOnlyMounts: z.boolean().optional(), + userNamespaces: z.boolean().optional(), +}); + +export const zV1NodeRuntimeHandler = z.object({ + features: zV1NodeRuntimeHandlerFeatures.optional(), + name: z.string().optional(), +}); + +export const zV1NodePhase = z.enum(V1NodePhase); + +export const zV1ContainerImage = z.object({ + names: z.array(z.string()).optional(), + sizeBytes: z.int().optional(), +}); + +export const zV1NodeFeatures = z.object({ + supplementalGroupsPolicy: z.boolean().optional(), +}); + +export const zV1DaemonEndpoint = z.object({ + Port: z.int().optional(), +}); + +export const zV1NodeDaemonEndpoints = z.object({ + kubeletEndpoint: zV1DaemonEndpoint.optional(), +}); + +export const zV1ConfigMapNodeConfigSource = z.object({ + kubeletConfigKey: z.string().optional(), + name: z.string().optional(), + namespace: z.string().optional(), + resourceVersion: z.string().optional(), + uid: z.string().optional(), +}); + +export const zV1NodeConfigSource = z.object({ + configMap: zV1ConfigMapNodeConfigSource.optional(), +}); + +export const zV1NodeConfigStatus = z.object({ + active: zV1NodeConfigSource.optional(), + assigned: zV1NodeConfigSource.optional(), + error: z.string().optional(), + lastKnownGood: zV1NodeConfigSource.optional(), +}); + +export const zV1NodeConditionType = z.enum(V1NodeConditionType); export const zK8sIoApiCoreV1ConditionStatus = z.enum( K8sIoApiCoreV1ConditionStatus ); +export const zV1NodeCondition = z.object({ + lastHeartbeatTime: z.string().optional(), + lastTransitionTime: z.string().optional(), + message: z.string().optional(), + reason: z.string().optional(), + status: zK8sIoApiCoreV1ConditionStatus.optional(), + type: zV1NodeConditionType.optional(), +}); + +export const zV1NodeAddressType = z.enum(V1NodeAddressType); + +export const zV1NodeAddress = z.object({ + address: z.string().optional(), + type: zV1NodeAddressType.optional(), +}); + +export const zV1NodeStatus = z.object({ + addresses: z.array(zV1NodeAddress).optional(), + allocatable: zV1ResourceList.optional(), + capacity: zV1ResourceList.optional(), + conditions: z.array(zV1NodeCondition).optional(), + config: zV1NodeConfigStatus.optional(), + daemonEndpoints: zV1NodeDaemonEndpoints.optional(), + declaredFeatures: z.array(z.string()).optional(), + features: zV1NodeFeatures.optional(), + images: z.array(zV1ContainerImage).optional(), + nodeInfo: zV1NodeSystemInfo.optional(), + phase: zV1NodePhase.optional(), + runtimeHandlers: z.array(zV1NodeRuntimeHandler).optional(), + volumesAttached: z.array(zV1AttachedVolume).optional(), + volumesInUse: z.array(z.string()).optional(), +}); + +export const zV1NodeSpec = z.object({ + configSource: zV1NodeConfigSource.optional(), + externalID: z.string().optional(), + podCIDR: z.string().optional(), + podCIDRs: z.array(z.string()).optional(), + providerID: z.string().optional(), + taints: z.array(zV1Taint).optional(), + unschedulable: z.boolean().optional(), +}); + +export const zV1NamespacePhase = z.enum(V1NamespacePhase); + +export const zV1NamespaceConditionType = z.enum(V1NamespaceConditionType); + export const zV1NamespaceCondition = z.object({ lastTransitionTime: z.string().optional(), message: z.string().optional(), @@ -735,8 +864,8 @@ export const zPortainerTemplateRepository = z.object({ }); export const zPortainerPair = z.object({ - name: z.string().optional(), - value: z.string().optional(), + name: z.string(), + value: z.string(), }); export const zPortainerTemplateEnvSelect = z.object({ @@ -1114,11 +1243,11 @@ export const zPortainerOAuthSettings = z.object({ }); export const zPortainerTlsConfiguration = z.object({ - TLS: z.boolean().optional(), + TLS: z.boolean(), TLSCACert: z.string().optional(), TLSCert: z.string().optional(), TLSKey: z.string().optional(), - TLSSkipVerify: z.boolean().optional(), + TLSSkipVerify: z.boolean(), }); export const zPortainerLdapSearchSettings = z.object({ @@ -1356,7 +1485,7 @@ export const zReleaseRelease = z.object({ export const zPortainerAccessPolicy = z.object({ Namespaces: z.array(z.string()).optional(), - RoleId: z.int().optional(), + RoleId: z.int(), }); export const zPortainerUserAccessPolicies = z.record( @@ -1773,9 +1902,9 @@ export const zPortainerPerformanceMetrics = z.object({ export const zPortainerKubernetesStorageClassConfig = z.object({ AccessModes: z.array(z.string()).optional(), - AllowVolumeExpansion: z.boolean().optional(), - Name: z.string().optional(), - Provisioner: z.string().optional(), + AllowVolumeExpansion: z.boolean(), + Name: z.string(), + Provisioner: z.string(), }); export const zPortainerDiagnosticsData = z.object({ @@ -1787,31 +1916,31 @@ export const zPortainerDiagnosticsData = z.object({ export const zPortainerKubernetesSnapshot = z.object({ DiagnosticsData: zPortainerDiagnosticsData.optional(), - KubernetesVersion: z.string().optional(), - NodeCount: z.int().optional(), + KubernetesVersion: z.string(), + NodeCount: z.int(), PerformanceMetrics: zPortainerPerformanceMetrics.optional(), - Time: z.int().optional(), - TotalCPU: z.int().optional(), - TotalMemory: z.int().optional(), + Time: z.int(), + TotalCPU: z.int(), + TotalMemory: z.int(), }); export const zPortainerKubernetesIngressClassConfig = z.object({ Blocked: z.boolean().optional(), BlockedNamespaces: z.array(z.string()).optional(), - Name: z.string().optional(), - Type: z.string().optional(), + Name: z.string(), + Type: z.string(), }); export const zPortainerKubernetesFlags = z.object({ - IsServerIngressClassDetected: z.boolean().optional(), - IsServerMetricsDetected: z.boolean().optional(), - IsServerStorageDetected: z.boolean().optional(), + IsServerIngressClassDetected: z.boolean(), + IsServerMetricsDetected: z.boolean(), + IsServerStorageDetected: z.boolean(), }); export const zPortainerKubernetesConfiguration = z.object({ - AllowNoneIngressClass: z.boolean().optional(), + AllowNoneIngressClass: z.boolean(), EnableResourceOverCommit: z.boolean().optional(), - IngressAvailabilityPerNamespace: z.boolean().optional(), + IngressAvailabilityPerNamespace: z.boolean(), IngressClasses: z.array(zPortainerKubernetesIngressClassConfig).optional(), ResourceOverCommitPercentage: z.int().optional(), RestrictDefaultNamespace: z.boolean().optional(), @@ -1821,8 +1950,8 @@ export const zPortainerKubernetesConfiguration = z.object({ }); export const zPortainerKubernetesData = z.object({ - Configuration: zPortainerKubernetesConfiguration.optional(), - Flags: zPortainerKubernetesFlags.optional(), + Configuration: zPortainerKubernetesConfiguration, + Flags: zPortainerKubernetesFlags, Snapshots: z.array(zPortainerKubernetesSnapshot).optional(), }); @@ -1850,10 +1979,14 @@ export const zPortainerK8sNamespaceInfo = z.object({ }); export const zPortainerEnvironmentEdgeSettings = z.object({ - AsyncMode: z.boolean().optional(), - CommandInterval: z.int().optional(), - PingInterval: z.int().optional(), - SnapshotInterval: z.int().optional(), + AsyncMode: z.boolean(), + CommandInterval: z.int(), + PingInterval: z.int(), + SnapshotInterval: z.int(), +}); + +export const zPortainerEnvironmentAgentData = z.object({ + Version: z.string().optional(), }); export const zPortainerEndpointType = z.enum(PortainerEndpointType); @@ -1861,33 +1994,23 @@ export const zPortainerEndpointType = z.enum(PortainerEndpointType); export const zPortainerEndpointStatus = z.enum(PortainerEndpointStatus); export const zPortainerEndpointSecuritySettings = z.object({ - allowBindMountsForRegularUsers: z.boolean().optional(), - allowContainerCapabilitiesForRegularUsers: z.boolean().optional(), - allowDeviceMappingForRegularUsers: z.boolean().optional(), - allowHostNamespaceForRegularUsers: z.boolean().optional(), - allowPrivilegedModeForRegularUsers: z.boolean().optional(), - allowSecurityOptForRegularUsers: z.boolean().optional(), - allowStackManagementForRegularUsers: z.boolean().optional(), - allowSysctlSettingForRegularUsers: z.boolean().optional(), - allowVolumeBrowserForRegularUsers: z.boolean().optional(), - enableHostManagementFeatures: z.boolean().optional(), -}); - -export const zPortainerEndpointPostInitMigrations = z.object({ - MigrateGPUs: z.boolean().optional(), - MigrateIngresses: z.boolean().optional(), - MigrateRegistrySASecrets: z.boolean().optional(), + allowBindMountsForRegularUsers: z.boolean(), + allowContainerCapabilitiesForRegularUsers: z.boolean(), + allowDeviceMappingForRegularUsers: z.boolean(), + allowHostNamespaceForRegularUsers: z.boolean(), + allowPrivilegedModeForRegularUsers: z.boolean(), + allowSecurityOptForRegularUsers: z.boolean(), + allowStackManagementForRegularUsers: z.boolean(), + allowSysctlSettingForRegularUsers: z.boolean(), + allowVolumeBrowserForRegularUsers: z.boolean(), + enableHostManagementFeatures: z.boolean(), }); export const zPortainerEndpointGroup = z.object({ - AuthorizedTeams: z.array(z.int()).optional(), - AuthorizedUsers: z.array(z.int()).optional(), - Description: z.string().optional(), - Id: z.int().optional(), - Labels: z.array(zPortainerPair).optional(), - Name: z.string().optional(), + Description: z.string(), + Id: z.int(), + Name: z.string(), TagIds: z.array(z.int()).optional(), - Tags: z.array(z.string()).optional(), TeamAccessPolicies: zPortainerTeamAccessPolicies.optional(), UserAccessPolicies: zPortainerUserAccessPolicies.optional(), }); @@ -1895,82 +2018,61 @@ export const zPortainerEndpointGroup = z.object({ export const zPortainerDockerSnapshotRaw = z.record(z.string(), z.unknown()); export const zPortainerDockerSnapshot = z.object({ - ContainerCount: z.int().optional(), + ContainerCount: z.int(), DiagnosticsData: zPortainerDiagnosticsData.optional(), DockerSnapshotRaw: zPortainerDockerSnapshotRaw.optional(), - DockerVersion: z.string().optional(), - GpuUseAll: z.boolean().optional(), + DockerVersion: z.string(), + GpuUseAll: z.boolean(), GpuUseList: z.array(z.string()).optional(), - HealthyContainerCount: z.int().optional(), - ImageCount: z.int().optional(), - IsPodman: z.boolean().optional(), - NodeCount: z.int().optional(), + HealthyContainerCount: z.int(), + ImageCount: z.int(), + IsPodman: z.boolean(), + NodeCount: z.int(), PerformanceMetrics: zPortainerPerformanceMetrics.optional(), - RunningContainerCount: z.int().optional(), - ServiceCount: z.int().optional(), - StackCount: z.int().optional(), - StoppedContainerCount: z.int().optional(), - Swarm: z.boolean().optional(), - Time: z.int().optional(), - TotalCPU: z.int().optional(), - TotalMemory: z.int().optional(), - UnhealthyContainerCount: z.int().optional(), - VolumeCount: z.int().optional(), + RunningContainerCount: z.int(), + ServiceCount: z.int(), + StackCount: z.int(), + StoppedContainerCount: z.int(), + Swarm: z.boolean(), + Time: z.int(), + TotalCPU: z.int(), + TotalMemory: z.int(), + UnhealthyContainerCount: z.int(), + VolumeCount: z.int(), }); export const zPortainerAzureCredentials = z.object({ - ApplicationID: z.string().optional(), - AuthenticationKey: z.string().optional(), - TenantID: z.string().optional(), + ApplicationID: z.string(), + AuthenticationKey: z.string(), + TenantID: z.string(), }); export const zPortainerEndpoint = z.object({ - Agent: z - .object({ - Version: z.string().optional(), - }) - .optional(), - AuthorizedTeams: z.array(z.int()).optional(), - AuthorizedUsers: z.array(z.int()).optional(), + Agent: zPortainerEnvironmentAgentData, AzureCredentials: zPortainerAzureCredentials.optional(), - ComposeSyntaxMaxVersion: z.string().optional(), - ContainerEngine: z.string().optional(), - Edge: zPortainerEnvironmentEdgeSettings.optional(), - EdgeCheckinInterval: z.int().optional(), + ComposeSyntaxMaxVersion: z.string(), + ContainerEngine: z.string(), + Edge: zPortainerEnvironmentEdgeSettings, + EdgeCheckinInterval: z.int(), EdgeID: z.string().optional(), - EdgeKey: z.string().optional(), + EdgeKey: z.string(), EnableGPUManagement: z.boolean().optional(), Gpus: z.array(zPortainerPair).optional(), - GroupId: z.int().optional(), + GroupId: z.int(), Heartbeat: z.boolean().optional(), - Id: z.int().optional(), - IsEdgeDevice: z.boolean().optional(), - Kubernetes: zPortainerKubernetesData.optional(), - LastCheckInDate: z.coerce - .bigint() - .min(BigInt('-9223372036854775808'), { - error: 'Invalid value: Expected int64 to be >= -9223372036854775808', - }) - .max(BigInt('9223372036854775807'), { - error: 'Invalid value: Expected int64 to be <= 9223372036854775807', - }) - .optional(), - Name: z.string().optional(), - PostInitMigrations: zPortainerEndpointPostInitMigrations.optional(), - PublicURL: z.string().optional(), - SecuritySettings: zPortainerEndpointSecuritySettings.optional(), + Id: z.int(), + Kubernetes: zPortainerKubernetesData, + LastCheckInDate: z.int(), + Name: z.string(), + PublicURL: z.string(), + SecuritySettings: zPortainerEndpointSecuritySettings, Snapshots: z.array(zPortainerDockerSnapshot).optional(), - Status: zPortainerEndpointStatus.optional(), - TLS: z.boolean().optional(), - TLSCACert: z.string().optional(), - TLSCert: z.string().optional(), - TLSConfig: zPortainerTlsConfiguration.optional(), - TLSKey: z.string().optional(), + Status: zPortainerEndpointStatus, + TLSConfig: zPortainerTlsConfiguration, TagIds: z.array(z.int()).optional(), - Tags: z.array(z.string()).optional(), TeamAccessPolicies: zPortainerTeamAccessPolicies.optional(), - Type: zPortainerEndpointType.optional(), - URL: z.string().optional(), + Type: zPortainerEndpointType, + URL: z.string(), UserAccessPolicies: zPortainerUserAccessPolicies.optional(), UserTrusted: z.boolean().optional(), }); @@ -2175,6 +2277,14 @@ export const zKubernetesMetadata = z.object({ labels: z.record(z.string(), z.string()).optional(), }); +export const zKubernetesKubernetesNodeResponse = z.object({ + apiVersion: z.string().optional(), + kind: z.string().optional(), + metadata: zV1ObjectMeta.optional(), + spec: zV1NodeSpec.optional(), + status: zV1NodeStatus.optional(), +}); + export const zKubernetesK8sStorageClass = z.object({ allowVolumeExpansion: z.boolean().optional(), annotations: z.record(z.string(), z.string()).optional(), @@ -2723,28 +2833,6 @@ export const zEndpointgroupsEndpointGroupUpdatePayload = z.object({ UserAccessPolicies: zPortainerUserAccessPolicies.optional(), }); -export const zEndpointgroupsEndpointGroupTypeInfo = z.object({ - Docker: z.int().optional(), - Kubernetes: z.int().optional(), - Mixed: z.boolean().optional(), - Podman: z.int().optional(), -}); - -export const zEndpointgroupsEndpointGroupResponse = z.object({ - AuthorizedTeams: z.array(z.int()).optional(), - AuthorizedUsers: z.array(z.int()).optional(), - Description: z.string().optional(), - Id: z.int().optional(), - Labels: z.array(zPortainerPair).optional(), - Name: z.string().optional(), - TagIds: z.array(z.int()).optional(), - Tags: z.array(z.string()).optional(), - TeamAccessPolicies: zPortainerTeamAccessPolicies.optional(), - Total: z.int().optional(), - TypeInfo: zEndpointgroupsEndpointGroupTypeInfo.optional(), - UserAccessPolicies: zPortainerUserAccessPolicies.optional(), -}); - export const zEndpointgroupsEndpointGroupCreatePayload = z.object({ AssociatedEndpoints: z.array(z.int()).optional(), Description: z.string().optional(), @@ -2752,6 +2840,24 @@ export const zEndpointgroupsEndpointGroupCreatePayload = z.object({ TagIDs: z.array(z.int()).optional(), }); +export const zEndpointgroupsEndpointGroupTypeInfo = z.object({ + Docker: z.int(), + Kubernetes: z.int(), + Mixed: z.boolean(), + Podman: z.int(), +}); + +export const zEndpointgroupsEndpointGroupResponse = z.object({ + Description: z.string(), + Id: z.int(), + Name: z.string(), + TagIds: z.array(z.int()).optional(), + TeamAccessPolicies: zPortainerTeamAccessPolicies.optional(), + Total: z.int().optional(), + TypeInfo: zEndpointgroupsEndpointGroupTypeInfo.optional(), + UserAccessPolicies: zPortainerUserAccessPolicies.optional(), +}); + export const zEdgestacksUpdateStatusPayload = z.object({ EndpointID: z.int().optional(), Error: z.string().optional(), @@ -3098,7 +3204,7 @@ export const zCustomTemplateCreateFileBody = z.object({ Note: z.string(), Platform: z.union([z.literal(1), z.literal(2)]), Type: z.union([z.literal(1), z.literal(2), z.literal(3)]), - File: z.string(), + File: z.union([z.instanceof(Blob), z.instanceof(File)]), Logo: z.string().optional(), Variables: z.string().optional(), }); @@ -3296,7 +3402,7 @@ export const zEdgeJobTasksCollectPath = z.object({ export const zEdgeJobTasksCollectResponse = z.void(); export const zEdgeJobCreateFileBody = z.object({ - file: z.string(), + file: z.union([z.instanceof(Blob), z.instanceof(File)]), Name: z.string(), CronExpression: z.string(), EdgeGroups: z.string(), @@ -3386,7 +3492,7 @@ export const zEdgeStackStatusUpdateResponse = zPortainerEdgeStack; export const zEdgeStackCreateFileBody = z.object({ Name: z.string(), - file: z.string(), + file: z.union([z.instanceof(Blob), z.instanceof(File)]), EdgeGroups: z.string(), DeploymentType: z.int(), Registries: z.string().optional(), @@ -3523,24 +3629,66 @@ export const zEndpointListQuery = z.object({ 'Id', ]) .optional(), - order: z.int().optional(), + order: z.string().optional(), search: z.string().optional(), groupIds: z.array(z.int()).optional(), status: z.array(z.int()).optional(), - types: z.array(z.int()).optional(), + types: z + .array( + z.union([ + z.literal(0), + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + z.literal(5), + z.literal(6), + z.literal(7), + ]) + ) + .optional(), + platformTypes: z + .array( + z.union([ + z.literal(0), + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + ]) + ) + .optional(), + outdated: z.boolean().optional(), + excludeGroupIds: z.array(z.int()).optional(), tagIds: z.array(z.int()).optional(), tagsPartialMatch: z.boolean().optional(), endpointIds: z.array(z.int()).optional(), excludeIds: z.array(z.int()).optional(), - excludeGroupIds: z.array(z.int()).optional(), - provisioned: z.boolean().optional(), agentVersions: z.array(z.string()).optional(), edgeAsync: z.boolean().optional(), edgeDeviceUntrusted: z.boolean().optional(), edgeCheckInPassedSeconds: z.number().optional(), excludeSnapshots: z.boolean().optional(), name: z.string().optional(), - edgeStackStatus: z.string().optional(), + edgeStackId: z.int().optional(), + edgeStackStatus: z + .union([ + z.literal(0), + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + z.literal(5), + z.literal(6), + z.literal(7), + z.literal(8), + z.literal(9), + z.literal(10), + z.literal(11), + z.literal(12), + z.literal(13), + ]) + .optional(), edgeGroupIds: z.array(z.int()).optional(), excludeEdgeGroupIds: z.array(z.int()).optional(), }); @@ -3552,7 +3700,14 @@ export const zEndpointListResponse = z.array(zPortainerEndpoint); export const zEndpointCreateBody = z.object({ Name: z.string(), - EndpointCreationType: z.int(), + EndpointCreationType: z.union([ + z.literal(0), + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + z.literal(5), + ]), ContainerEngine: z.string().optional(), URL: z.string().optional(), PublicURL: z.string().optional(), @@ -3560,15 +3715,15 @@ export const zEndpointCreateBody = z.object({ TLS: z.boolean().optional(), TLSSkipVerify: z.boolean().optional(), TLSSkipClientVerify: z.boolean().optional(), - TLSCACertFile: z.string().optional(), - TLSCertFile: z.string().optional(), - TLSKeyFile: z.string().optional(), + TLSCACertFile: z.union([z.instanceof(Blob), z.instanceof(File)]).optional(), + TLSCertFile: z.union([z.instanceof(Blob), z.instanceof(File)]).optional(), + TLSKeyFile: z.union([z.instanceof(Blob), z.instanceof(File)]).optional(), AzureApplicationID: z.string().optional(), AzureTenantID: z.string().optional(), AzureAuthenticationKey: z.string().optional(), - TagIds: z.array(z.int()).optional(), + TagIds: z.string().optional(), EdgeCheckinInterval: z.int().optional(), - EdgeTunnelServerAddress: z.string(), + EdgeTunnelServerAddress: z.string().optional(), Gpus: z.string().optional(), }); @@ -3624,7 +3779,7 @@ export const zEndpointAssociationDeleteResponse = z.void(); export const zPostEndpointsByIdDockerV2BrowsePutBody = z.object({ Path: z.string(), - file: z.string(), + file: z.union([z.instanceof(Blob), z.instanceof(File)]), }); export const zPostEndpointsByIdDockerV2BrowsePutPath = z.object({ @@ -4642,6 +4797,17 @@ export const zGetKubernetesNamespacesCountPath = z.object({ */ export const zGetKubernetesNamespacesCountResponse = z.int(); +export const zGetKubernetesNodesPath = z.object({ + id: z.int(), +}); + +/** + * Success + */ +export const zGetKubernetesNodesResponse = z.array( + zKubernetesKubernetesNodeResponse +); + export const zDrainNodePath = z.object({ id: z.int(), name: z.string(), @@ -5359,7 +5525,7 @@ export const zStackCreateKubernetesUrlResponse = zPortainerStack; export const zStackCreateDockerStandaloneFileBody = z.object({ Name: z.string(), Env: z.string().optional(), - file: z.string().optional(), + file: z.union([z.instanceof(Blob), z.instanceof(File)]).optional(), }); export const zStackCreateDockerStandaloneFileQuery = z.object({ @@ -5405,7 +5571,7 @@ export const zStackCreateDockerSwarmFileBody = z.object({ Name: z.string().optional(), SwarmID: z.string().optional(), Env: z.string().optional(), - file: z.string().optional(), + file: z.union([z.instanceof(Blob), z.instanceof(File)]).optional(), }); export const zStackCreateDockerSwarmFileQuery = z.object({ @@ -5657,7 +5823,7 @@ export const zHelmShowResponse = z.string(); export const zUploadTlsBody = z.object({ folder: z.string(), - file: z.string(), + file: z.union([z.instanceof(Blob), z.instanceof(File)]), }); export const zUploadTlsPath = z.object({ diff --git a/openapi-ts.config.ts b/openapi-ts.config.ts index b8b70804c..2ff2e2aed 100644 --- a/openapi-ts.config.ts +++ b/openapi-ts.config.ts @@ -32,6 +32,26 @@ export default defineConfig({ }, { name: 'zod', + // Upstream bug: format: binary falls through to z.string() because + // the formatNode switch has no 'binary' case. This resolver patches + // it globally to match the TypeScript plugin's Blob | File output. + // Remove once https://github.com/hey-api/openapi-ts/pull/3627 is + // merged and released. + '~resolvers': { + string({ $, schema, symbols }) { + if (schema.format === 'binary') { + const { z } = symbols; + return $(z) + .attr('union') + .call( + $.array( + $(z).attr('instanceof').call($('Blob')), + $(z).attr('instanceof').call($('File')) + ) + ); + } + }, + }, }, ], }); diff --git a/pkg/libhttp/request/request.go b/pkg/libhttp/request/request.go index 6c26f18a5..a990212a9 100644 --- a/pkg/libhttp/request/request.go +++ b/pkg/libhttp/request/request.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "strconv" + "strings" "github.com/gorilla/mux" "github.com/portainer/portainer/api/logs" @@ -168,14 +169,31 @@ func RetrieveJSONQueryParameter(request *http.Request, name string, target any, // Example: // // GET /api/resource?filter=foo&filter=bar +// GET /api/resource?filter[]=foo&filter[]=bar +// GET /api/resource?filter=foo,bar // RetrieveArrayQueryParameter(request, "filter") => []string{"foo", "bar"} func RetrieveArrayQueryParameter(r *http.Request, parameter string) []string { - list, exists := r.Form[parameter+"[]"] - if !exists { - return nil + // bracket notation: param[]=1¶m[]=2 + if list, exists := r.Form[parameter+"[]"]; exists { + return list } - return list + // plain repeated or comma-separated: param=1¶m=2 or param=1,2 + if list, exists := r.Form[parameter]; exists { + var result []string + for _, v := range list { + for item := range strings.SplitSeq(v, ",") { + if item != "" { + result = append(result, item) + } + } + } + if len(result) > 0 { + return result + } + } + + return nil } func RetrieveNumberArrayQueryParameter[T ~int](r *http.Request, parameter string) ([]T, error) { diff --git a/pkg/libhttp/request/request_test.go b/pkg/libhttp/request/request_test.go index 1ca6de886..e7d4f9c99 100644 --- a/pkg/libhttp/request/request_test.go +++ b/pkg/libhttp/request/request_test.go @@ -1,8 +1,13 @@ package request import ( + "net/http" "net/http/httptest" + "net/url" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestRetrieveQueryParameter(t *testing.T) { @@ -221,66 +226,77 @@ func TestRetrieveArrayQueryParameter(t *testing.T) { } } -func TestRetrieveNumberArrayQueryParameter(t *testing.T) { +func Test_getArrayQueryParameter(t *testing.T) { t.Parallel() + + makeRequest := func(rawQuery string) *http.Request { + r := &http.Request{URL: &url.URL{RawQuery: rawQuery}} + require.NoError(t, r.ParseForm()) + + return r + } + tests := []struct { - name string - url string - param string - want []int - wantErr bool + name string + query string + param string + expected []string }{ { - name: "valid integer array", - url: "http://example.com?ids[]=1&ids[]=2&ids[]=3", - param: "ids", - want: []int{1, 2, 3}, - wantErr: false, + name: "bracket notation", + query: "ids[]=1&ids[]=2", + param: "ids", + expected: []string{"1", "2"}, }, { - name: "single value", - url: "http://example.com?ids[]=42", - param: "ids", - want: []int{42}, - wantErr: false, + name: "comma-separated", + query: "ids=1,2", + param: "ids", + expected: []string{"1", "2"}, }, { - name: "no values returns nil", - url: "http://example.com", - param: "ids", - want: nil, - wantErr: false, + name: "plain repeated", + query: "ids=1&ids=2", + param: "ids", + expected: []string{"1", "2"}, }, { - name: "invalid number in array", - url: "http://example.com?ids[]=1&ids[]=abc&ids[]=3", - param: "ids", - want: nil, - wantErr: true, + name: "single value", + query: "ids=1", + param: "ids", + expected: []string{"1"}, + }, + { + name: "missing param returns nil", + query: "", + param: "ids", + expected: nil, + }, + { + name: "bracket notation takes priority over plain", + query: "ids[]=1&ids=2", + param: "ids", + expected: []string{"1"}, + }, + { + name: "empty plain param returns nil", + query: "ids=", + param: "ids", + expected: nil, + }, + { + name: "trailing comma is ignored", + query: "ids=1,2,", + param: "ids", + expected: []string{"1", "2"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest("GET", tt.url, nil) - got, err := RetrieveNumberArrayQueryParameter[int](req, tt.param) - - if (err != nil) != tt.wantErr { - t.Errorf("RetrieveNumberArrayQueryParameter() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if !tt.wantErr { - if len(got) != len(tt.want) { - t.Errorf("RetrieveNumberArrayQueryParameter() length = %v, want %v", len(got), len(tt.want)) - return - } - for i := range got { - if got[i] != tt.want[i] { - t.Errorf("RetrieveNumberArrayQueryParameter()[%d] = %v, want %v", i, got[i], tt.want[i]) - } - } - } + r := makeRequest(tt.query) + result := RetrieveArrayQueryParameter(r, tt.param) + assert.Equal(t, tt.expected, result) }) } }