From cc676124328e0d803d50bd272d79bdc21da84c57 Mon Sep 17 00:00:00 2001 From: James Carppe <85850129+jamescarppe@users.noreply.github.com> Date: Thu, 12 Jun 2025 02:26:25 +0100 Subject: [PATCH 01/46] Update bug report template for 2.31.0 (#793) --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 4324bf5a8..089ad06fb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -94,6 +94,7 @@ body: description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed. multiple: false options: + - '2.31.0' - '2.30.1' - '2.30.0' - '2.29.2' From 6e89ccc0ae893f74a1af15ec1fb0b52490e580d4 Mon Sep 17 00:00:00 2001 From: Yajith Dayarathna Date: Thu, 12 Jun 2025 13:39:34 +1200 Subject: [PATCH 02/46] fix(api-documentation): swagger document genration error (#795) --- api/http/handler/kubernetes/event.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/http/handler/kubernetes/event.go b/api/http/handler/kubernetes/event.go index 0e226d5ec..25f024303 100644 --- a/api/http/handler/kubernetes/event.go +++ b/api/http/handler/kubernetes/event.go @@ -20,7 +20,7 @@ import ( // @param id path int true "Environment identifier" // @param namespace path string true "The namespace name the events are associated to" // @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa" -// @success 200 {object} models.Event[] "Success" +// @success 200 {object} []kubernetes.K8sEvent "Success" // @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." // @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." // @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." @@ -68,7 +68,7 @@ func (handler *Handler) getKubernetesEventsForNamespace(w http.ResponseWriter, r // @produce json // @param id path int true "Environment identifier" // @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa" -// @success 200 {object} models.Event[] "Success" +// @success 200 {object} []kubernetes.K8sEvent "Success" // @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." // @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." // @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." From f07a3b1875d7ba238296f2540bb81b778cddd3c0 Mon Sep 17 00:00:00 2001 From: Steven Kang Date: Thu, 12 Jun 2025 17:30:53 +1200 Subject: [PATCH 03/46] security: cve-2025-22874 & cve-2025-22871 bump go to 1.23.10 (#798) --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 3a2ba0bf5..dd66b95e2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/portainer/portainer -go 1.23.8 +go 1.23.10 require ( github.com/Masterminds/semver v1.5.0 From 036b87b6498329bbbb681862a647eeab9d65a0af Mon Sep 17 00:00:00 2001 From: andres-portainer <91705312+andres-portainer@users.noreply.github.com> Date: Mon, 16 Jun 2025 12:56:51 -0300 Subject: [PATCH 04/46] fix(middlewares): fix data race in WithEndpoint() BE-11949 (#803) --- api/http/middlewares/endpoint.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/api/http/middlewares/endpoint.go b/api/http/middlewares/endpoint.go index c88731dd3..0050e4300 100644 --- a/api/http/middlewares/endpoint.go +++ b/api/http/middlewares/endpoint.go @@ -25,12 +25,12 @@ type key int const contextEndpoint key = 0 func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam string) mux.MiddlewareFunc { + if endpointIDParam == "" { + endpointIDParam = "id" + } + return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) { - if endpointIDParam == "" { - endpointIDParam = "id" - } - endpointID, err := requesthelpers.RetrieveNumericRouteVariableValue(request, endpointIDParam) if err != nil { httperror.WriteError(rw, http.StatusBadRequest, "Invalid environment identifier route variable", err) @@ -51,7 +51,6 @@ func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam ctx := context.WithValue(request.Context(), contextEndpoint, endpoint) next.ServeHTTP(rw, request.WithContext(ctx)) - }) } } From a7009eb8d5109019e53ab8f4af79ed27eeccc838 Mon Sep 17 00:00:00 2001 From: James Carppe <85850129+jamescarppe@users.noreply.github.com> Date: Tue, 17 Jun 2025 01:52:12 +0100 Subject: [PATCH 05/46] Update bug report template for 2.27.7 (#805) --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 089ad06fb..c049c23fe 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -102,6 +102,7 @@ body: - '2.29.0' - '2.28.1' - '2.28.0' + - '2.27.7' - '2.27.6' - '2.27.5' - '2.27.4' From c2b48cd003caceed88e4580eb181315d5f40fa76 Mon Sep 17 00:00:00 2001 From: Steven Kang Date: Thu, 19 Jun 2025 09:03:52 +1200 Subject: [PATCH 06/46] feat(k8s): CloudNativePG in applications list and details - [R8S-357] (#777) --- api/http/models/kubernetes/application.go | 26 +++++++++++++++---- .../components/ViewLoading/ViewLoading.tsx | 10 +------ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/api/http/models/kubernetes/application.go b/api/http/models/kubernetes/application.go index fcb49b23d..4759d9214 100644 --- a/api/http/models/kubernetes/application.go +++ b/api/http/models/kubernetes/application.go @@ -38,14 +38,30 @@ type K8sApplication struct { Labels map[string]string `json:"Labels,omitempty"` Resource K8sApplicationResource `json:"Resource,omitempty"` HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"` + CustomResourceMetadata CustomResourceMetadata `json:"CustomResourceMetadata,omitempty"` } type Metadata struct { Labels map[string]string `json:"labels"` } +type CustomResourceMetadata struct { + Kind string `json:"kind"` + APIVersion string `json:"apiVersion"` + Plural string `json:"plural"` +} + type Pod struct { - Status string `json:"Status"` + Name string `json:"Name"` + ContainerName string `json:"ContainerName"` + Image string `json:"Image"` + ImagePullPolicy string `json:"ImagePullPolicy"` + Status string `json:"Status"` + NodeName string `json:"NodeName"` + PodIP string `json:"PodIP"` + UID string `json:"Uid"` + Resource K8sApplicationResource `json:"Resource,omitempty"` + CreationDate time.Time `json:"CreationDate"` } type Configuration struct { @@ -72,8 +88,8 @@ type TLSInfo struct { // Existing types type K8sApplicationResource struct { - CPURequest float64 `json:"CpuRequest"` - CPULimit float64 `json:"CpuLimit"` - MemoryRequest int64 `json:"MemoryRequest"` - MemoryLimit int64 `json:"MemoryLimit"` + CPURequest float64 `json:"CpuRequest,omitempty"` + CPULimit float64 `json:"CpuLimit,omitempty"` + MemoryRequest int64 `json:"MemoryRequest,omitempty"` + MemoryLimit int64 `json:"MemoryLimit,omitempty"` } diff --git a/app/react/components/ViewLoading/ViewLoading.tsx b/app/react/components/ViewLoading/ViewLoading.tsx index 5ade961bb..139c67637 100644 --- a/app/react/components/ViewLoading/ViewLoading.tsx +++ b/app/react/components/ViewLoading/ViewLoading.tsx @@ -1,7 +1,4 @@ import clsx from 'clsx'; -import { Settings } from 'lucide-react'; - -import { Icon } from '@@/Icon'; import styles from './ViewLoading.module.css'; @@ -18,12 +15,7 @@ export function ViewLoading({ message }: Props) {
- {message && ( - - {message} - - - )} + {message && {message}}
); } From e051c86bb586b251961fa93e893b08c481e05f43 Mon Sep 17 00:00:00 2001 From: James Carppe <85850129+jamescarppe@users.noreply.github.com> Date: Thu, 19 Jun 2025 03:07:18 +0100 Subject: [PATCH 07/46] Updates for release 2.31.1 (#816) --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c049c23fe..c3a7d8d28 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -94,6 +94,7 @@ body: description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed. multiple: false options: + - '2.31.1' - '2.31.0' - '2.30.1' - '2.30.0' From d51e9205d9d8828574f4ee4fcb2860ec904151e3 Mon Sep 17 00:00:00 2001 From: andres-portainer <91705312+andres-portainer@users.noreply.github.com> Date: Fri, 20 Jun 2025 20:03:35 -0300 Subject: [PATCH 08/46] fix(endpointrelation): use a read-write transaction for mutations BE-11964 (#819) --- api/dataservices/endpointrelation/endpointrelation.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/dataservices/endpointrelation/endpointrelation.go b/api/dataservices/endpointrelation/endpointrelation.go index a81c258b9..556a046bb 100644 --- a/api/dataservices/endpointrelation/endpointrelation.go +++ b/api/dataservices/endpointrelation/endpointrelation.go @@ -112,13 +112,13 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID, } func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error { - return service.connection.ViewTx(func(tx portainer.Transaction) error { + return service.connection.UpdateTx(func(tx portainer.Transaction) error { return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStackID) }) } func (service *Service) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error { - return service.connection.ViewTx(func(tx portainer.Transaction) error { + return service.connection.UpdateTx(func(tx portainer.Transaction) error { return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID) }) } From c897baad201e3328588adfb47658a9035dd94e9d Mon Sep 17 00:00:00 2001 From: Steven Kang Date: Tue, 24 Jun 2025 15:46:10 +1200 Subject: [PATCH 09/46] fix: fetching values from both install and upgrade views - develop [R8S-368] (#820) --- .../ChartActions/UpgradeButton.tsx | 20 ++++-- .../ChartActions/UpgradeHelmModal.tsx | 46 +++++++------ .../helm/queries/useHelmRepoVersions.ts | 7 +- app/react/kubernetes/helm/types.ts | 3 +- pkg/libhelm/release/release.go | 2 + pkg/libhelm/sdk/search_repo.go | 14 +++- pkg/libhelm/sdk/show.go | 64 ++++++++++++++----- 7 files changed, 111 insertions(+), 45 deletions(-) diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx index 2c3f05ab6..648e3628d 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx @@ -44,7 +44,6 @@ export function UpgradeButton({ useCache ); const versions = helmRepoVersionsQuery.data; - const repo = versions?.[0]?.Repo; // Combined loading state const isLoading = @@ -63,17 +62,28 @@ export function UpgradeButton({ latestVersionQuery?.data && semverCompare(latestVersionAvailable, latestVersionQuery?.data) === 1 ); - const currentVersion = release?.chart.metadata?.version; + + const currentRepo = versions?.find( + (v) => + v.Chart === release?.chart.metadata?.name && + v.AppVersion === release?.chart.metadata?.appVersion && + v.Version === release?.chart.metadata?.version + )?.Repo; const editableHelmRelease: UpdateHelmReleasePayload = { name: releaseName, namespace: namespace || '', values: release?.values?.userSuppliedValues, chart: release?.chart.metadata?.name || '', - version: currentVersion, - repo, + appVersion: release?.chart.metadata?.appVersion, + version: release?.chart.metadata?.version, + repo: currentRepo ?? '', }; + const filteredVersions = currentRepo + ? versions?.filter((v) => v.Repo === currentRepo) || [] + : versions || []; + return (
; - values: UpdateHelmReleasePayload; + payload: UpdateHelmReleasePayload; versions: ChartVersion[]; chartName: string; - repo: string; } export function UpgradeHelmModal({ - values, + payload, versions, onSubmit, chartName, - repo, }: Props) { const versionOptions: Option[] = versions.map((version) => { - const isCurrentVersion = version.Version === values.version; - const label = `${version.Repo}@${version.Version}${ + const repo = payload.repo === version.Repo ? version.Repo : ''; + const isCurrentVersion = + version.AppVersion === payload.appVersion && + version.Version === payload.version; + + const label = `${repo}@${version.Version}${ isCurrentVersion ? ' (current)' : '' }`; + return { + repo, label, value: version, }; }); + const defaultVersion = - versionOptions.find((v) => v.value.Version === values.version)?.value || - versionOptions[0]?.value; + versionOptions.find( + (v) => + v.value.AppVersion === payload.appVersion && + v.value.Version === payload.version && + v.value.Repo === payload.repo + )?.value || versionOptions[0]?.value; const [version, setVersion] = useState(defaultVersion); - const [userValues, setUserValues] = useState(values.values || ''); + const [userValues, setUserValues] = useState(payload.values || ''); const [atomic, setAtomic] = useState(true); const chartValuesRefQuery = useHelmChartValues({ chart: chartName, - repo, + repo: version.Repo, version: version.Version, }); @@ -75,7 +84,7 @@ export function UpgradeHelmModal({ > onSubmit({ - name: values.name, + name: payload.name, values: userValues, - namespace: values.namespace, - chart: values.chart, + namespace: payload.namespace, + chart: payload.chart, repo: version.Repo, version: version.Version, atomic, @@ -165,13 +174,12 @@ export function UpgradeHelmModal({ } export async function openUpgradeHelmModal( - values: UpdateHelmReleasePayload, + payload: UpdateHelmReleasePayload, versions: ChartVersion[] ) { return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), { - values, + payload, versions, - chartName: values.chart, - repo: values.repo ?? '', + chartName: payload.chart, }); } diff --git a/app/react/kubernetes/helm/queries/useHelmRepoVersions.ts b/app/react/kubernetes/helm/queries/useHelmRepoVersions.ts index 006b74599..5543de3d7 100644 --- a/app/react/kubernetes/helm/queries/useHelmRepoVersions.ts +++ b/app/react/kubernetes/helm/queries/useHelmRepoVersions.ts @@ -10,12 +10,15 @@ interface HelmSearch { } interface Entries { - [key: string]: { version: string }[]; + [key: string]: { version: string; appVersion: string }[]; } export interface ChartVersion { + Chart?: string; Repo: string; + Label?: string; Version: string; + AppVersion?: string; } /** @@ -77,8 +80,10 @@ async function getSearchHelmRepo( const versions = data.entries[chart]; return ( versions?.map((v) => ({ + Chart: chart, Repo: repo, Version: v.version, + AppVersion: v.appVersion, })) ?? [] ); } catch (err) { diff --git a/app/react/kubernetes/helm/types.ts b/app/react/kubernetes/helm/types.ts index 3094f84b2..b1e958471 100644 --- a/app/react/kubernetes/helm/types.ts +++ b/app/react/kubernetes/helm/types.ts @@ -120,9 +120,10 @@ export interface InstallChartPayload { export interface UpdateHelmReleasePayload { namespace: string; values?: string; - repo?: string; + repo: string; name: string; chart: string; + appVersion?: string; version?: string; atomic?: boolean; } diff --git a/pkg/libhelm/release/release.go b/pkg/libhelm/release/release.go index 56888291c..acc3328b1 100644 --- a/pkg/libhelm/release/release.go +++ b/pkg/libhelm/release/release.go @@ -36,6 +36,8 @@ type Release struct { Manifest string `json:"manifest,omitempty"` // Hooks are all of the hooks declared for this release. Hooks []*Hook `json:"hooks,omitempty"` + // AppVersion is the app version of the release. + AppVersion string `json:"appVersion,omitempty"` // Version is an int which represents the revision of the release. Version int `json:"version,omitempty"` // Namespace is the kubernetes namespace of the release. diff --git a/pkg/libhelm/sdk/search_repo.go b/pkg/libhelm/sdk/search_repo.go index 63015330c..a97b7d606 100644 --- a/pkg/libhelm/sdk/search_repo.go +++ b/pkg/libhelm/sdk/search_repo.go @@ -90,8 +90,17 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO return nil, errors.Wrap(err, "failed to ensure Helm directories exist") } + repoName, err := getRepoNameFromURL(repoURL.String()) + if err != nil { + log.Error(). + Str("context", "HelmClient"). + Err(err). + Msg("Failed to get hostname from URL") + return nil, err + } + // Download the index file and update repository configuration - indexPath, err := downloadRepoIndex(repoURL.String(), repoSettings, searchRepoOpts.Repo) + indexPath, err := downloadRepoIndex(repoURL.String(), repoSettings, repoName) if err != nil { log.Error(). Str("context", "HelmClient"). @@ -163,7 +172,8 @@ func downloadRepoIndex(repoURLString string, repoSettings *cli.EnvSettings, repo // Create chart repository object rep, err := repo.NewChartRepository( &repo.Entry{ - URL: repoURLString, + Name: repoName, + URL: repoURLString, }, getter.All(repoSettings), ) diff --git a/pkg/libhelm/sdk/show.go b/pkg/libhelm/sdk/show.go index 4dc9c402d..9ad2d6007 100644 --- a/pkg/libhelm/sdk/show.go +++ b/pkg/libhelm/sdk/show.go @@ -2,7 +2,8 @@ package sdk import ( "fmt" - "os" + "net/url" + "strings" "github.com/pkg/errors" "github.com/portainer/portainer/pkg/libhelm/options" @@ -32,24 +33,32 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e Str("output_format", string(showOpts.OutputFormat)). Msg("Showing chart information") - // Initialize action configuration (no namespace or cluster access needed) - actionConfig := new(action.Configuration) - err := hspm.initActionConfig(actionConfig, "", nil) + repoURL, err := parseRepoURL(showOpts.Repo) if err != nil { - // error is already logged in initActionConfig - return nil, fmt.Errorf("failed to initialize helm configuration: %w", err) + log.Error(). + Str("context", "HelmClient"). + Str("repo", showOpts.Repo). + Err(err). + Msg("Invalid repository URL") + return nil, err } - // Create temporary directory for chart download - tempDir, err := os.MkdirTemp("", "helm-show-*") + repoName, err := getRepoNameFromURL(repoURL.String()) if err != nil { log.Error(). Str("context", "HelmClient"). Err(err). - Msg("Failed to create temp directory") - return nil, fmt.Errorf("failed to create temp directory: %w", err) + Msg("Failed to get hostname from URL") + return nil, err + } + + // Initialize action configuration (no namespace or cluster access needed) + actionConfig := new(action.Configuration) + err = hspm.initActionConfig(actionConfig, "", nil) + if err != nil { + // error is already logged in initActionConfig + return nil, fmt.Errorf("failed to initialize helm configuration: %w", err) } - defer os.RemoveAll(tempDir) // Create showClient action showClient, err := initShowClient(actionConfig, showOpts) @@ -68,11 +77,12 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e Str("repo", showOpts.Repo). Msg("Locating chart") - chartPath, err := showClient.ChartPathOptions.LocateChart(showOpts.Chart, hspm.settings) + fullChartPath := fmt.Sprintf("%s/%s", repoName, showOpts.Chart) + chartPath, err := showClient.ChartPathOptions.LocateChart(fullChartPath, hspm.settings) if err != nil { log.Error(). Str("context", "HelmClient"). - Str("chart", showOpts.Chart). + Str("chart", fullChartPath). Str("repo", showOpts.Repo). Err(err). Msg("Failed to locate chart") @@ -104,13 +114,10 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e // and return the show client. func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOptions) (*action.Show, error) { showClient := action.NewShowWithConfig(action.ShowAll, actionConfig) - showClient.ChartPathOptions.RepoURL = showOpts.Repo - showClient.ChartPathOptions.Version = showOpts.Version // If version is "", it will use the latest version + showClient.ChartPathOptions.Version = showOpts.Version // Set output type based on ShowOptions switch showOpts.OutputFormat { - case options.ShowAll: - showClient.OutputFormat = action.ShowAll case options.ShowChart: showClient.OutputFormat = action.ShowChart case options.ShowValues: @@ -127,3 +134,26 @@ func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOpt return showClient, nil } + +// getRepoNameFromURL extracts a unique repository identifier from a URL string. +// It combines hostname and path to ensure uniqueness across different repositories on the same host. +// Examples: +// - https://portainer.github.io/test-public-repo/ -> portainer.github.io-test-public-repo +// - https://portainer.github.io/another-repo/ -> portainer.github.io-another-repo +// - https://charts.helm.sh/stable -> charts.helm.sh-stable +func getRepoNameFromURL(urlStr string) (string, error) { + parsedURL, err := url.Parse(urlStr) + if err != nil { + return "", fmt.Errorf("failed to parse URL: %w", err) + } + + hostname := parsedURL.Hostname() + path := parsedURL.Path + path = strings.Trim(path, "/") + path = strings.ReplaceAll(path, "/", "-") + + if path == "" { + return hostname, nil + } + return fmt.Sprintf("%s-%s", hostname, path), nil +} From 97838e614dbb9c691bd43ea11e57c98503a33732 Mon Sep 17 00:00:00 2001 From: James Carppe <85850129+jamescarppe@users.noreply.github.com> Date: Wed, 25 Jun 2025 06:11:58 +0100 Subject: [PATCH 10/46] Updates for release 2.27.8 (#827) --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c3a7d8d28..e8ed7316a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -103,6 +103,7 @@ body: - '2.29.0' - '2.28.1' - '2.28.0' + - '2.27.8' - '2.27.7' - '2.27.6' - '2.27.5' From 7d7ae243514b8448727c9360800486959e3a305c Mon Sep 17 00:00:00 2001 From: James Carppe <85850129+jamescarppe@users.noreply.github.com> Date: Thu, 26 Jun 2025 04:41:23 +0100 Subject: [PATCH 11/46] Updates for release 2.31.2 (#834) --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e8ed7316a..49ca94eff 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -94,6 +94,7 @@ body: description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed. multiple: false options: + - '2.31.2' - '2.31.1' - '2.31.0' - '2.30.1' From 8d29b5ae711e822c896b575b2f1d082439de759e Mon Sep 17 00:00:00 2001 From: Steven Kang Date: Fri, 27 Jun 2025 09:38:04 +1200 Subject: [PATCH 12/46] fix: kubeconfig download button inconsistency between http and https (#829) --- .../KubeconfigButton/KubeconfigButton.tsx | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx b/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx index f2b20857d..393c55f9c 100644 --- a/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx @@ -7,6 +7,7 @@ import { trackEvent } from '@/angulartics.matomo/analytics-services'; import { Query } from '@/react/portainer/environments/queries/useEnvironmentList'; import { Button } from '@@/buttons'; +import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; import { KubeconfigPrompt } from './KubeconfigPrompt'; @@ -23,23 +24,41 @@ export function KubeconfigButton({ environments, envQueryParams }: Props) { isKubernetesEnvironment(env.Type) ); - if (!isKubeconfigButtonVisible()) { - return null; + const isHttp = window.location.protocol === 'http:'; + const noKubeEnvs = kubeEnvs.length === 0; + const isDisabled = noKubeEnvs || isHttp; + + let tooltipMessage = ''; + if (isHttp) { + tooltipMessage = + 'Kubeconfig download is not available when Portainer is accessed via HTTP. Please use HTTPS'; + } else if (noKubeEnvs) { + tooltipMessage = 'No Kubernetes environments detected'; } + const button = ( + + ); + return ( <> - + {isDisabled ? ( + + {button} + + ) : ( + button + )} {prompt()} ); @@ -60,10 +79,6 @@ export function KubeconfigButton({ environments, envQueryParams }: Props) { setIsOpen(false); } - function isKubeconfigButtonVisible() { - return window.location.protocol === 'https:'; - } - function prompt() { return ( isOpen && ( From 303047656e7a040c7f7d89bb11c2b69b9e0e1d99 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Fri, 27 Jun 2025 22:48:40 +1200 Subject: [PATCH 13/46] fix(k8s-services): avoid rerendering services table [r8s-387] (#832) --- .../queries/useClusterRoles.ts | 2 +- .../namespaces/queries/useNamespacesQuery.ts | 9 +- .../ServicesDatatable.test.tsx | 148 ++++++++++++++++++ .../ServicesDatatable/ServicesDatatable.tsx | 57 +++---- app/react/kubernetes/services/service.ts | 13 +- .../volumes/ListView/VolumesDatatable.tsx | 28 ++-- .../volumes/queries/useDeleteVolumes.ts | 2 +- .../volumes/queries/useNamespaceVolumes.ts | 51 ------ .../volumes/queries/useVolumesQuery.ts | 5 +- 9 files changed, 216 insertions(+), 99 deletions(-) create mode 100644 app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.test.tsx delete mode 100644 app/react/kubernetes/volumes/queries/useNamespaceVolumes.ts diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/useClusterRoles.ts b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/useClusterRoles.ts index dbacc4f4c..f27ffe642 100644 --- a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/useClusterRoles.ts +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/useClusterRoles.ts @@ -21,7 +21,7 @@ export function useClusterRoles( }, { ...withGlobalError('Unable to get cluster roles'), - ...options, + refetchInterval: options?.autoRefreshRate, } ); } diff --git a/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts b/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts index 6e521e0aa..b4cd2d712 100644 --- a/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts +++ b/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts @@ -8,9 +8,13 @@ import { PortainerNamespace } from '../types'; import { queryKeys } from './queryKeys'; -export function useNamespacesQuery( +export function useNamespacesQuery( environmentId: EnvironmentId, - options?: { autoRefreshRate?: number; withResourceQuota?: boolean } + options?: { + autoRefreshRate?: number; + withResourceQuota?: boolean; + select?: (namespaces: PortainerNamespace[]) => T; + } ) { return useQuery( queryKeys.list(environmentId, { @@ -22,6 +26,7 @@ export function useNamespacesQuery( refetchInterval() { return options?.autoRefreshRate ?? false; }, + select: options?.select, } ); } diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.test.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.test.tsx new file mode 100644 index 000000000..0d4dfe7fd --- /dev/null +++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.test.tsx @@ -0,0 +1,148 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { HttpResponse } from 'msw'; + +import { withTestRouter } from '@/react/test-utils/withRouter'; +import { withUserProvider } from '@/react/test-utils/withUserProvider'; +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { createMockUsers } from '@/react-tools/test-mocks'; +import { server, http } from '@/setup-tests/server'; + +import { ServicesDatatable } from './ServicesDatatable'; + +vi.mock('@/react/hooks/useEnvironmentId', () => ({ + useEnvironmentId: () => 1, +})); + +vi.mock('@/portainer/services/notifications', () => ({ + notifyError: vi.fn(), + notifySuccess: vi.fn(), +})); +function createMockServices(count: number) { + return Array.from({ length: count }, (_, i) => { + let namespace = 'default'; + if (i % 3 === 0) { + namespace = 'kube-system'; + } else if (i % 2 !== 0) { + namespace = 'my-namespace'; + } + + let type = 'ClusterIP'; + if (i % 4 === 1) { + type = 'NodePort'; + } else if (i % 4 === 2) { + type = 'LoadBalancer'; + } else if (i % 4 === 3) { + type = 'ExternalName'; + } + + return { + UID: `service-${i}`, + Name: `service-${i}`, + Namespace: namespace, + Type: type, + Ports: [{ Port: 80 + i, TargetPort: 8080 + i, Protocol: 'TCP' }], + Selector: { app: `app-${i}` }, + CreationTimestamp: new Date(Date.now() - i * 1000 * 60).toISOString(), + ApplicationOwner: '', + Applications: [{ Name: `app-${i}` }], + }; + }); +} + +const mockServices = createMockServices(4); + +const mockNamespaces = [ + { + Name: 'default', + IsSystem: false, + Status: 'Active', + CreationTimestamp: '2024-01-01T00:00:00Z', + }, + { + Name: 'kube-system', + IsSystem: true, + Status: 'Active', + CreationTimestamp: '2024-01-01T00:00:00Z', + }, + { + Name: 'my-namespace', + IsSystem: false, + Status: 'Active', + CreationTimestamp: '2024-01-01T00:00:00Z', + }, +]; + +beforeEach(() => { + server.use( + http.get('/api/kubernetes/1/services', () => + HttpResponse.json(mockServices) + ), + http.get('/api/kubernetes/1/namespaces', () => + HttpResponse.json(mockNamespaces) + ) + ); +}); +const mockUser = { + ...createMockUsers(1)[0], + PortainerAuthorizations: { + K8sAccessSystemNamespaces: true, + K8sServiceW: true, + }, +}; + +function createTestComponent() { + return withTestRouter( + withUserProvider(withTestQueryProvider(ServicesDatatable), mockUser), + { + route: '/kubernetes/services', + stateConfig: [ + { + name: 'kubernetes.services', + url: '/kubernetes/services', + params: { endpointId: '1' }, + }, + ], + } + ); +} + +describe('ServicesDatatable', () => { + it('renders services data correctly', async () => { + const TestComponent = createTestComponent(); + render(); + + expect(await screen.findByText('service-1')).toBeInTheDocument(); + expect(screen.getByText('service-2')).toBeInTheDocument(); + }); + + it('should filter system resources correctly when toggled', async () => { + const TestComponent = createTestComponent(); + render(); + + const settingsButton = screen.getByRole('button', { name: /settings/i }); + await userEvent.click(settingsButton); + + await waitFor(() => { + expect(screen.queryByText('service-0')).not.toBeInTheDocument(); + }); + + const systemToggle = await screen.findByTestId('show-system-resources'); + await userEvent.click(systemToggle); + + await waitFor(() => { + expect(screen.queryByText('service-0')).toBeInTheDocument(); + }); + + expect(screen.getByText('service-3')).toBeInTheDocument(); + expect(screen.getByText('service-1')).toBeInTheDocument(); + expect(screen.getByText('service-2')).toBeInTheDocument(); + }); + + it('should show loading state when data is loading', async () => { + const TestComponent = createTestComponent(); + render(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); +}); diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx index 4786eaf2f..265808564 100644 --- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx +++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx @@ -16,6 +16,7 @@ import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultD import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; import { CreateFromManifestButton } from '@/react/kubernetes/components/CreateFromManifestButton'; +import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; import { Datatable, Table, TableSettingsMenu } from '@@/datatables'; import { useTableState } from '@@/datatables/useTableState'; @@ -25,7 +26,6 @@ import { useMutationDeleteServices, useClusterServices } from '../../service'; import { Service } from '../../types'; import { columns } from './columns'; -import { createStore } from './datatable-store'; import { ServiceRowData } from './types'; const storageKey = 'k8sServicesDatatable'; @@ -34,52 +34,53 @@ const settingsStore = createStore(storageKey); export function ServicesDatatable() { const tableState = useTableState(settingsStore, storageKey); const environmentId = useEnvironmentId(); - const { data: namespacesArray, ...namespacesQuery } = - useNamespacesQuery(environmentId); + const { data: namespaces, ...namespacesQuery } = useNamespacesQuery( + environmentId, + { + select: (namespacesArray) => + namespacesArray?.reduce>( + (acc, namespace) => { + acc[namespace.Name] = namespace; + return acc; + }, + {} + ), + } + ); + const { authorized: canWrite } = useAuthorizations(['K8sServiceW']); + const { authorized: canAccessSystemResources } = useAuthorizations( + 'K8sAccessSystemNamespaces' + ); const { data: services, ...servicesQuery } = useClusterServices( environmentId, { autoRefreshRate: tableState.autoRefreshRate * 1000, withApplications: true, + select: (services) => + services?.filter( + (service) => + (canAccessSystemResources && tableState.showSystemResources) || + !namespaces?.[service.Namespace]?.IsSystem + ), } ); - const namespaces: Record = {}; - if (Array.isArray(namespacesArray)) { - for (let i = 0; i < namespacesArray.length; i++) { - const namespace = namespacesArray[i]; - namespaces[namespace.Name] = namespace; - } - } - - const { authorized: canWrite } = useAuthorizations(['K8sServiceW']); - const readOnly = !canWrite; - const { authorized: canAccessSystemResources } = useAuthorizations( - 'K8sAccessSystemNamespaces' - ); - const filteredServices = services?.filter( - (service) => - (canAccessSystemResources && tableState.showSystemResources) || - !namespaces?.[service.Namespace]?.IsSystem - ); - - const servicesWithIsSystem = useServicesRowData( - filteredServices || [], - namespaces - ); + const servicesWithIsSystem = useServicesRowData(services || [], namespaces); return ( row.UID} isRowSelectable={(row) => !namespaces?.[row.original.Namespace]?.IsSystem} - disableSelect={readOnly} + disableSelect={!canWrite} renderTableActions={(selectedRows) => ( )} diff --git a/app/react/kubernetes/services/service.ts b/app/react/kubernetes/services/service.ts index a36f25db9..a7cdbc7f2 100644 --- a/app/react/kubernetes/services/service.ts +++ b/app/react/kubernetes/services/service.ts @@ -23,18 +23,21 @@ export const queryKeys = { * * @returns The result of the query. */ -export function useClusterServices( +export function useClusterServices( environmentId: EnvironmentId, - options?: { autoRefreshRate?: number; withApplications?: boolean } + options?: { + autoRefreshRate?: number; + withApplications?: boolean; + select?: (services: Service[]) => T; + } ) { return useQuery( queryKeys.clusterServices(environmentId), async () => getClusterServices(environmentId, options?.withApplications), { ...withGlobalError('Unable to get services.'), - refetchInterval() { - return options?.autoRefreshRate ?? false; - }, + refetchInterval: options?.autoRefreshRate, + select: options?.select, } ); } diff --git a/app/react/kubernetes/volumes/ListView/VolumesDatatable.tsx b/app/react/kubernetes/volumes/ListView/VolumesDatatable.tsx index b81f60e9a..032fe63aa 100644 --- a/app/react/kubernetes/volumes/ListView/VolumesDatatable.tsx +++ b/app/react/kubernetes/volumes/ListView/VolumesDatatable.tsx @@ -16,12 +16,17 @@ import { } from '../../datatables/DefaultDatatableSettings'; import { SystemResourceDescription } from '../../datatables/SystemResourceDescription'; import { useNamespacesQuery } from '../../namespaces/queries/useNamespacesQuery'; -import { useAllVolumesQuery } from '../queries/useVolumesQuery'; +import { + convertToVolumeViewModels, + useAllVolumesQuery, +} from '../queries/useVolumesQuery'; import { isSystemNamespace } from '../../namespaces/queries/useIsSystemNamespace'; import { useDeleteVolumes } from '../queries/useDeleteVolumes'; import { isVolumeUsed } from '../utils'; +import { K8sVolumeInfo } from '../types'; import { columns } from './columns'; +import { VolumeViewModel } from './types'; export function VolumesDatatable() { const tableState = useTableStateWithStorage( @@ -45,21 +50,15 @@ export function VolumesDatatable() { const namespaces = namespaceListQuery.data ?? []; const volumesQuery = useAllVolumesQuery(envId, { refetchInterval: tableState.autoRefreshRate * 1000, + select: transformAndFilterVolumes, }); const volumes = volumesQuery.data ?? []; - const filteredVolumes = tableState.showSystemResources - ? volumes - : volumes.filter( - (volume) => - !isSystemNamespace(volume.ResourcePool.Namespace.Name, namespaces) - ); - return ( ); + + function transformAndFilterVolumes( + volumes: K8sVolumeInfo[] + ): VolumeViewModel[] { + const transformedVolumes = convertToVolumeViewModels(volumes); + return transformedVolumes.filter( + (volume) => + tableState.showSystemResources || + !isSystemNamespace(volume.ResourcePool.Namespace.Name, namespaces) + ); + } } diff --git a/app/react/kubernetes/volumes/queries/useDeleteVolumes.ts b/app/react/kubernetes/volumes/queries/useDeleteVolumes.ts index 78d5a295d..7732c2b58 100644 --- a/app/react/kubernetes/volumes/queries/useDeleteVolumes.ts +++ b/app/react/kubernetes/volumes/queries/useDeleteVolumes.ts @@ -36,7 +36,7 @@ export function useDeleteVolumes(environmentId: EnvironmentId) { ); } queryClient.invalidateQueries(queryKeys.storages(environmentId)); - queryClient.invalidateQueries(queryKeys.volumes(environmentId)); + return queryClient.invalidateQueries(queryKeys.volumes(environmentId)); }, ...withGlobalError('Unable to remove volumes'), }); diff --git a/app/react/kubernetes/volumes/queries/useNamespaceVolumes.ts b/app/react/kubernetes/volumes/queries/useNamespaceVolumes.ts deleted file mode 100644 index cdf6b6ee0..000000000 --- a/app/react/kubernetes/volumes/queries/useNamespaceVolumes.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import { EnvironmentId } from '@/react/portainer/environments/types'; -import { withGlobalError } from '@/react-tools/react-query'; -import axios, { parseAxiosError } from '@/portainer/services/axios'; - -import { K8sVolumeInfo } from '../types'; - -import { queryKeys } from './query-keys'; -import { convertToVolumeViewModels } from './useVolumesQuery'; - -// useQuery to get a list of all volumes in a cluster -export function useNamespaceVolumes( - environmentId: EnvironmentId, - namespace: string, - queryOptions?: { - refetchInterval?: number; - withApplications?: boolean; - } -) { - return useQuery( - queryKeys.volumes(environmentId), - () => - getNamespaceVolumes(environmentId, namespace, { - withApplications: queryOptions?.withApplications ?? false, - }), - { - enabled: !!namespace, - refetchInterval: queryOptions?.refetchInterval, - select: convertToVolumeViewModels, - ...withGlobalError('Unable to retrieve volumes'), - } - ); -} - -// get all volumes in a cluster -async function getNamespaceVolumes( - environmentId: EnvironmentId, - namespace: string, - params?: { withApplications: boolean } -) { - try { - const { data } = await axios.get( - `/kubernetes/${environmentId}/namespaces/${namespace}/volumes`, - { params } - ); - return data; - } catch (e) { - throw parseAxiosError(e, 'Unable to retrieve volumes'); - } -} diff --git a/app/react/kubernetes/volumes/queries/useVolumesQuery.ts b/app/react/kubernetes/volumes/queries/useVolumesQuery.ts index 94b30345b..646065758 100644 --- a/app/react/kubernetes/volumes/queries/useVolumesQuery.ts +++ b/app/react/kubernetes/volumes/queries/useVolumesQuery.ts @@ -14,10 +14,11 @@ import { appOwnerLabel } from '../../applications/constants'; import { queryKeys } from './query-keys'; // useQuery to get a list of all volumes in a cluster -export function useAllVolumesQuery( +export function useAllVolumesQuery( environmentId: EnvironmentId, queryOptions?: { refetchInterval?: number; + select?: (volumes: K8sVolumeInfo[]) => T[]; } ) { return useQuery( @@ -25,7 +26,7 @@ export function useAllVolumesQuery( () => getAllVolumes(environmentId, { withApplications: true }), { refetchInterval: queryOptions?.refetchInterval, - select: convertToVolumeViewModels, + select: queryOptions?.select, ...withGlobalError('Unable to retrieve volumes'), } ); From 0556ffb4a11def2c7cc58a43117a2c41e1b219ea Mon Sep 17 00:00:00 2001 From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com> Date: Sat, 28 Jun 2025 08:41:10 +1200 Subject: [PATCH 14/46] feat(csrf): add trusted origins cli flags [BE-11972] (#836) --- api/cli/cli.go | 1 + api/cmd/portainer/main.go | 14 ++ api/http/csrf/csrf.go | 42 ++++- .../middlewares/plaintext_http_request.go | 42 ++++- .../plaintext_http_request_test.go | 173 ++++++++++++++++++ api/http/server.go | 3 +- api/portainer.go | 3 + pkg/validate/validate.go | 29 +++ pkg/validate/validate_test.go | 61 ++++++ 9 files changed, 359 insertions(+), 9 deletions(-) create mode 100644 api/http/middlewares/plaintext_http_request_test.go diff --git a/api/cli/cli.go b/api/cli/cli.go index f6035f298..7f6d80e68 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -61,6 +61,7 @@ func CLIFlags() *portainer.CLIFlags { LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"), KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(), PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(), + TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(), } } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 6261efbd9..90fd34fd2 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -52,6 +52,7 @@ import ( "github.com/portainer/portainer/pkg/libhelm" libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types" "github.com/portainer/portainer/pkg/libstack/compose" + "github.com/portainer/portainer/pkg/validate" "github.com/gofrs/uuid" "github.com/rs/zerolog/log" @@ -330,6 +331,18 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags) } + trustedOrigins := []string{} + if *flags.TrustedOrigins != "" { + // validate if the trusted origins are valid urls + for _, origin := range strings.Split(*flags.TrustedOrigins, ",") { + if !validate.IsTrustedOrigin(origin) { + log.Fatal().Str("trusted_origin", origin).Msg("invalid url for trusted origin. Please check the trusted origins flag.") + } + + trustedOrigins = append(trustedOrigins, origin) + } + } + fileService := initFileService(*flags.Data) encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName) if encryptionKey == nil { @@ -578,6 +591,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { PendingActionsService: pendingActionsService, PlatformService: platformService, PullLimitCheckDisabled: *flags.PullLimitCheckDisabled, + TrustedOrigins: trustedOrigins, } } diff --git a/api/http/csrf/csrf.go b/api/http/csrf/csrf.go index 857d72c8b..6205c9290 100644 --- a/api/http/csrf/csrf.go +++ b/api/http/csrf/csrf.go @@ -2,6 +2,7 @@ package csrf import ( "crypto/rand" + "errors" "fmt" "net/http" "os" @@ -9,7 +10,8 @@ import ( "github.com/portainer/portainer/api/http/security" httperror "github.com/portainer/portainer/pkg/libhttp/error" - gorillacsrf "github.com/gorilla/csrf" + gcsrf "github.com/gorilla/csrf" + "github.com/rs/zerolog/log" "github.com/urfave/negroni" ) @@ -19,7 +21,7 @@ func SkipCSRFToken(w http.ResponseWriter) { w.Header().Set(csrfSkipHeader, "1") } -func WithProtect(handler http.Handler) (http.Handler, error) { +func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, error) { // IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck) // DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml isDockerDesktopExtension := false @@ -34,10 +36,12 @@ func WithProtect(handler http.Handler) (http.Handler, error) { return nil, fmt.Errorf("failed to generate CSRF token: %w", err) } - handler = gorillacsrf.Protect( + handler = gcsrf.Protect( token, - gorillacsrf.Path("/"), - gorillacsrf.Secure(false), + gcsrf.Path("/"), + gcsrf.Secure(false), + gcsrf.TrustedOrigins(trustedOrigins), + gcsrf.ErrorHandler(withErrorHandler(trustedOrigins)), )(handler) return withSkipCSRF(handler, isDockerDesktopExtension), nil @@ -55,7 +59,7 @@ func withSendCSRFToken(handler http.Handler) http.Handler { } if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 { - sw.Header().Set("X-CSRF-Token", gorillacsrf.Token(r)) + sw.Header().Set("X-CSRF-Token", gcsrf.Token(r)) } }) @@ -73,9 +77,33 @@ func withSkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Hand } if skip { - r = gorillacsrf.UnsafeSkipCheck(r) + r = gcsrf.UnsafeSkipCheck(r) } handler.ServeHTTP(w, r) }) } + +func withErrorHandler(trustedOrigins []string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := gcsrf.FailureReason(r) + + if errors.Is(err, gcsrf.ErrBadOrigin) || errors.Is(err, gcsrf.ErrBadReferer) || errors.Is(err, gcsrf.ErrNoReferer) { + log.Error().Err(err). + Str("request_url", r.URL.String()). + Str("host", r.Host). + Str("x_forwarded_proto", r.Header.Get("X-Forwarded-Proto")). + Str("forwarded", r.Header.Get("Forwarded")). + Str("origin", r.Header.Get("Origin")). + Str("referer", r.Header.Get("Referer")). + Strs("trusted_origins", trustedOrigins). + Msg("Failed to validate Origin or Referer") + } + + http.Error( + w, + http.StatusText(http.StatusForbidden)+" - "+err.Error(), + http.StatusForbidden, + ) + }) +} diff --git a/api/http/middlewares/plaintext_http_request.go b/api/http/middlewares/plaintext_http_request.go index 668346098..e746fd819 100644 --- a/api/http/middlewares/plaintext_http_request.go +++ b/api/http/middlewares/plaintext_http_request.go @@ -3,6 +3,7 @@ package middlewares import ( "net/http" "slices" + "strings" "github.com/gorilla/csrf" ) @@ -16,6 +17,45 @@ type plainTextHTTPRequestHandler struct { next http.Handler } +// parseForwardedHeaderProto parses the Forwarded header and extracts the protocol. +// The Forwarded header format supports: +// - Single proxy: Forwarded: by=;for=;host=;proto= +// - Multiple proxies: Forwarded: for=192.0.2.43, for=198.51.100.17 +// We take the first (leftmost) entry as it represents the original client +func parseForwardedHeaderProto(forwarded string) string { + if forwarded == "" { + return "" + } + + // Parse the first part (leftmost proxy, closest to original client) + firstPart, _, _ := strings.Cut(forwarded, ",") + firstPart = strings.TrimSpace(firstPart) + + // Split by semicolon to get key-value pairs within this proxy entry + // Format: key=value;key=value;key=value + pairs := strings.Split(firstPart, ";") + for _, pair := range pairs { + // Split by equals sign to separate key and value + key, value, found := strings.Cut(pair, "=") + if !found { + continue + } + + if strings.EqualFold(strings.TrimSpace(key), "proto") { + return strings.Trim(strings.TrimSpace(value), `"'`) + } + } + + return "" +} + +// isHTTPSRequest checks if the original request was made over HTTPS +// by examining both X-Forwarded-Proto and Forwarded headers +func isHTTPSRequest(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") || + strings.EqualFold(parseForwardedHeaderProto(r.Header.Get("Forwarded")), "https") +} + func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if slices.Contains(safeMethods, r.Method) { h.next.ServeHTTP(w, r) @@ -24,7 +64,7 @@ func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.R req := r // If original request was HTTPS (via proxy), keep CSRF checks. - if xfproto := r.Header.Get("X-Forwarded-Proto"); xfproto != "https" { + if !isHTTPSRequest(r) { req = csrf.PlaintextHTTPRequest(r) } diff --git a/api/http/middlewares/plaintext_http_request_test.go b/api/http/middlewares/plaintext_http_request_test.go new file mode 100644 index 000000000..33912be80 --- /dev/null +++ b/api/http/middlewares/plaintext_http_request_test.go @@ -0,0 +1,173 @@ +package middlewares + +import ( + "testing" +) + +var tests = []struct { + name string + forwarded string + expected string +}{ + { + name: "empty header", + forwarded: "", + expected: "", + }, + { + name: "single proxy with proto=https", + forwarded: "proto=https", + expected: "https", + }, + { + name: "single proxy with proto=http", + forwarded: "proto=http", + expected: "http", + }, + { + name: "single proxy with multiple directives", + forwarded: "for=192.0.2.60;proto=https;by=203.0.113.43", + expected: "https", + }, + { + name: "single proxy with proto in middle", + forwarded: "for=192.0.2.60;proto=https;host=example.com", + expected: "https", + }, + { + name: "single proxy with proto at end", + forwarded: "for=192.0.2.60;host=example.com;proto=https", + expected: "https", + }, + { + name: "multiple proxies - takes first", + forwarded: "proto=https, proto=http", + expected: "https", + }, + { + name: "multiple proxies with complex format", + forwarded: "for=192.0.2.43;proto=https, for=198.51.100.17;proto=http", + expected: "https", + }, + { + name: "multiple proxies with for directive only", + forwarded: "for=192.0.2.43, for=198.51.100.17", + expected: "", + }, + { + name: "multiple proxies with proto only in second", + forwarded: "for=192.0.2.43, proto=https", + expected: "", + }, + { + name: "multiple proxies with proto only in first", + forwarded: "proto=https, for=198.51.100.17", + expected: "https", + }, + { + name: "quoted protocol value", + forwarded: "proto=\"https\"", + expected: "https", + }, + { + name: "single quoted protocol value", + forwarded: "proto='https'", + expected: "https", + }, + { + name: "mixed case protocol", + forwarded: "proto=HTTPS", + expected: "HTTPS", + }, + { + name: "no proto directive", + forwarded: "for=192.0.2.60;by=203.0.113.43", + expected: "", + }, + { + name: "empty proto value", + forwarded: "proto=", + expected: "", + }, + { + name: "whitespace around values", + forwarded: " proto = https ", + expected: "https", + }, + { + name: "whitespace around semicolons", + forwarded: "for=192.0.2.60 ; proto=https ; by=203.0.113.43", + expected: "https", + }, + { + name: "whitespace around commas", + forwarded: "proto=https , proto=http", + expected: "https", + }, + { + name: "IPv6 address in for directive", + forwarded: "for=\"[2001:db8:cafe::17]:4711\";proto=https", + expected: "https", + }, + { + name: "complex multiple proxies with IPv6", + forwarded: "for=192.0.2.43;proto=https, for=\"[2001:db8:cafe::17]\";proto=http", + expected: "https", + }, + { + name: "obfuscated identifiers", + forwarded: "for=_mdn;proto=https", + expected: "https", + }, + { + name: "unknown identifier", + forwarded: "for=unknown;proto=https", + expected: "https", + }, + { + name: "malformed key-value pair", + forwarded: "proto", + expected: "", + }, + { + name: "malformed key-value pair with equals", + forwarded: "proto=", + expected: "", + }, + { + name: "multiple equals signs", + forwarded: "proto=https=extra", + expected: "https=extra", + }, + { + name: "mixed case directive name", + forwarded: "PROTO=https", + expected: "https", + }, + { + name: "mixed case directive name with spaces", + forwarded: " Proto = https ", + expected: "https", + }, +} + +func TestParseForwardedHeaderProto(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseForwardedHeaderProto(tt.forwarded) + if result != tt.expected { + t.Errorf("parseForwardedHeader(%q) = %q, want %q", tt.forwarded, result, tt.expected) + } + }) + } +} + +func FuzzParseForwardedHeaderProto(f *testing.F) { + for _, t := range tests { + f.Add(t.forwarded) + } + + f.Fuzz(func(t *testing.T, forwarded string) { + parseForwardedHeaderProto(forwarded) + }) +} diff --git a/api/http/server.go b/api/http/server.go index 88d131650..e876ca5d2 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -113,6 +113,7 @@ type Server struct { PendingActionsService *pendingactions.PendingActionsService PlatformService platform.Service PullLimitCheckDisabled bool + TrustedOrigins []string } // Start starts the HTTP server @@ -336,7 +337,7 @@ func (server *Server) Start() error { handler = middlewares.WithPanicLogger(middlewares.WithSlowRequestsLogger(handler)) - handler, err := csrf.WithProtect(handler) + handler, err := csrf.WithProtect(handler, server.TrustedOrigins) if err != nil { return errors.Wrap(err, "failed to create CSRF middleware") } diff --git a/api/portainer.go b/api/portainer.go index b3b917a08..1e5f2ea6f 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -139,6 +139,7 @@ type ( LogMode *string KubectlShellImage *string PullLimitCheckDisabled *bool + TrustedOrigins *string } // CustomTemplateVariableDefinition @@ -1787,6 +1788,8 @@ const ( LicenseServerBaseURL = "https://api.portainer.io" // URL to validate licenses along with system metadata. LicenseCheckInURL = LicenseServerBaseURL + "/licenses/checkin" + // TrustedOriginsEnvVar is the environment variable used to set the trusted origins for CSRF protection + TrustedOriginsEnvVar = "TRUSTED_ORIGINS" ) // List of supported features diff --git a/pkg/validate/validate.go b/pkg/validate/validate.go index f647d1e17..8ad69df72 100644 --- a/pkg/validate/validate.go +++ b/pkg/validate/validate.go @@ -80,3 +80,32 @@ func IsDNSName(s string) bool { return !IsIP(s) && dnsNameRegex.MatchString(s) } + +func IsTrustedOrigin(s string) bool { + // Reject if a scheme is present + if strings.Contains(s, "://") { + return false + } + + // Prepend http:// for parsing + strTemp := "http://" + s + parsedOrigin, err := url.Parse(strTemp) + if err != nil { + return false + } + + // Validate host, and ensure no user, path, query, fragment, port, etc. + if parsedOrigin.Host == "" || + parsedOrigin.User != nil || + parsedOrigin.Path != "" || + parsedOrigin.RawQuery != "" || + parsedOrigin.Fragment != "" || + parsedOrigin.Opaque != "" || + parsedOrigin.RawFragment != "" || + parsedOrigin.RawPath != "" || + parsedOrigin.Port() != "" { + return false + } + + return true +} diff --git a/pkg/validate/validate_test.go b/pkg/validate/validate_test.go index f3cb6a01c..ca054190d 100644 --- a/pkg/validate/validate_test.go +++ b/pkg/validate/validate_test.go @@ -437,3 +437,64 @@ func Test_IsDNSName(t *testing.T) { }) } } + +func Test_IsTrustedOrigin(t *testing.T) { + f := func(s string, expected bool) { + t.Helper() + + result := IsTrustedOrigin(s) + if result != expected { + t.Fatalf("unexpected result for %q; got %t; want %t", s, result, expected) + } + } + + // Valid trusted origins - host only + f("localhost", true) + f("example.com", true) + f("192.168.1.1", true) + f("api.example.com", true) + f("subdomain.example.org", true) + + // Invalid trusted origins - host with port (no longer allowed) + f("localhost:8080", false) + f("example.com:3000", false) + f("192.168.1.1:443", false) + f("api.example.com:9000", false) + + // Invalid trusted origins - empty or malformed + f("", false) + f("invalid url", false) + f("://example.com", false) + + // Invalid trusted origins - with scheme + f("http://example.com", false) + f("https://localhost", false) + f("ftp://192.168.1.1", false) + + // Invalid trusted origins - with user info + f("user@example.com", false) + f("user:pass@localhost", false) + + // Invalid trusted origins - with path + f("example.com/path", false) + f("localhost/api", false) + f("192.168.1.1/static", false) + + // Invalid trusted origins - with query parameters + f("example.com?param=value", false) + f("localhost:8080?query=test", false) + + // Invalid trusted origins - with fragment + f("example.com#fragment", false) + f("localhost:3000#section", false) + + // Invalid trusted origins - with multiple invalid components + f("https://user@example.com/path?query=value#fragment", false) + f("http://localhost:8080/api/v1?param=test", false) + + // Edge cases - ports are no longer allowed + f("example.com:0", false) // port 0 is no longer valid + f("example.com:65535", false) // max port number is no longer valid + f("example.com:99999", false) // invalid port number + f("example.com:-1", false) // negative port +} From b43f864511c00cfbee53b8ed2ddb4bba4d55bf7f Mon Sep 17 00:00:00 2001 From: LP B Date: Mon, 30 Jun 2025 15:35:51 +0200 Subject: [PATCH 15/46] fix(api/endpoints): filter out waiting room environments for non admins (#810) --- api/http/handler/endpoints/endpoint_list.go | 5 ++--- api/http/handler/endpoints/filter.go | 7 +++++++ api/http/handler/endpoints/filter_test.go | 2 ++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index 86f1b1d3c..43b14ad6a 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -95,12 +95,11 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht return httperror.BadRequest("Invalid query parameters", err) } - filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext) - - filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(filteredEndpoints, query, endpointGroups, edgeGroups, settings) + filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(endpoints, query, endpointGroups, edgeGroups, settings, securityContext) if err != nil { return httperror.InternalServerError("Unable to filter endpoints", err) } + filteredEndpoints = security.FilterEndpoints(filteredEndpoints, endpointGroups, securityContext) sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc") diff --git a/api/http/handler/endpoints/filter.go b/api/http/handler/endpoints/filter.go index 9b6004d1c..c8b4ede08 100644 --- a/api/http/handler/endpoints/filter.go +++ b/api/http/handler/endpoints/filter.go @@ -11,6 +11,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/http/handler/edgegroups" + "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/edge" "github.com/portainer/portainer/api/internal/endpointutils" "github.com/portainer/portainer/api/slicesx" @@ -140,6 +141,7 @@ func (handler *Handler) filterEndpointsByQuery( groups []portainer.EndpointGroup, edgeGroups []portainer.EdgeGroup, settings *portainer.Settings, + context *security.RestrictedRequestContext, ) ([]portainer.Endpoint, int, error) { totalAvailableEndpoints := len(filteredEndpoints) @@ -181,11 +183,16 @@ func (handler *Handler) filterEndpointsByQuery( } // filter edge environments by trusted/untrusted + // only portainer admins are allowed to see untrusted environments filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool { if !endpointutils.IsEdgeEndpoint(&endpoint) { return true } + if query.edgeDeviceUntrusted { + return !endpoint.UserTrusted && context.IsAdmin + } + return endpoint.UserTrusted == !query.edgeDeviceUntrusted }) diff --git a/api/http/handler/endpoints/filter_test.go b/api/http/handler/endpoints/filter_test.go index f19d0a276..8abc76fb7 100644 --- a/api/http/handler/endpoints/filter_test.go +++ b/api/http/handler/endpoints/filter_test.go @@ -6,6 +6,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/slicesx" @@ -263,6 +264,7 @@ func runTest(t *testing.T, test filterTest, handler *Handler, endpoints []portai []portainer.EndpointGroup{}, []portainer.EdgeGroup{}, &portainer.Settings{}, + &security.RestrictedRequestContext{IsAdmin: true}, ) is.NoError(err) From b6f3682a62fead59829ca8a00977196705591de4 Mon Sep 17 00:00:00 2001 From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com> Date: Tue, 1 Jul 2025 06:15:56 +1200 Subject: [PATCH 16/46] refactor(edge): init endpoint relation when endpoint is created [BE-11928] (#814) --- api/datastore/migrator/migrate_2_32_0.go | 33 +++++++++++++++++++ api/datastore/migrator/migrator.go | 2 ++ .../handler/edgegroups/edgegroup_update.go | 8 +---- .../endpointedge_status_inspect.go | 3 -- api/http/handler/endpointgroups/endpoints.go | 9 +---- api/http/handler/endpoints/endpoint_create.go | 4 +++ .../endpoints/update_edge_relations.go | 12 +------ api/http/handler/tags/tag_delete.go | 15 +++------ api/internal/edge/edgestacks/service.go | 3 -- api/internal/endpointutils/endpointutils.go | 16 +++++++++ 10 files changed, 62 insertions(+), 43 deletions(-) create mode 100644 api/datastore/migrator/migrate_2_32_0.go diff --git a/api/datastore/migrator/migrate_2_32_0.go b/api/datastore/migrator/migrate_2_32_0.go new file mode 100644 index 000000000..c32a63cad --- /dev/null +++ b/api/datastore/migrator/migrate_2_32_0.go @@ -0,0 +1,33 @@ +package migrator + +import ( + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + perrors "github.com/portainer/portainer/api/dataservices/errors" + "github.com/portainer/portainer/api/internal/endpointutils" +) + +func (m *Migrator) addEndpointRelationForEdgeAgents_2_32_0() error { + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + if endpointutils.IsEdgeEndpoint(&endpoint) { + _, err := m.endpointRelationService.EndpointRelation(endpoint.ID) + if err != nil && errors.Is(err, perrors.ErrObjectNotFound) { + relation := &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: make(map[portainer.EdgeStackID]bool), + } + + if err := m.endpointRelationService.Create(relation); err != nil { + return err + } + } + } + } + + return nil +} diff --git a/api/datastore/migrator/migrator.go b/api/datastore/migrator/migrator.go index 992dd0b9d..4c2a50e59 100644 --- a/api/datastore/migrator/migrator.go +++ b/api/datastore/migrator/migrator.go @@ -249,6 +249,8 @@ func (m *Migrator) initMigrations() { m.addMigrations("2.31.0", m.migrateEdgeStacksStatuses_2_31_0) + m.addMigrations("2.32.0", m.addEndpointRelationForEdgeAgents_2_32_0) + // Add new migrations above... // One function per migration, each versions migration funcs in the same file. } diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go index 7831b634e..a51ae33d4 100644 --- a/api/http/handler/edgegroups/edgegroup_update.go +++ b/api/http/handler/edgegroups/edgegroup_update.go @@ -163,7 +163,7 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error { relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) - if err != nil && !handler.DataStore.IsErrObjectNotFound(err) { + if err != nil { return err } @@ -179,12 +179,6 @@ func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoi edgeStackSet[edgeStackID] = true } - if relation == nil { - relation = &portainer.EndpointRelation{ - EndpointID: endpoint.ID, - EdgeStacks: make(map[portainer.EdgeStackID]bool), - } - } relation.EdgeStacks = edgeStackSet return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation) diff --git a/api/http/handler/endpointedge/endpointedge_status_inspect.go b/api/http/handler/endpointedge/endpointedge_status_inspect.go index 4d6368493..9bd341561 100644 --- a/api/http/handler/endpointedge/endpointedge_status_inspect.go +++ b/api/http/handler/endpointedge/endpointedge_status_inspect.go @@ -264,9 +264,6 @@ func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpointID p func (handler *Handler) buildEdgeStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) ([]stackStatusResponse, *httperror.HandlerError) { relation, err := tx.EndpointRelation().EndpointRelation(endpointID) if err != nil { - if tx.IsErrObjectNotFound(err) { - return nil, nil - } return nil, httperror.InternalServerError("Unable to retrieve relation object from the database", err) } diff --git a/api/http/handler/endpointgroups/endpoints.go b/api/http/handler/endpointgroups/endpoints.go index b34032d9e..8b420f2a6 100644 --- a/api/http/handler/endpointgroups/endpoints.go +++ b/api/http/handler/endpointgroups/endpoints.go @@ -21,17 +21,10 @@ func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, end } endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) - if err != nil && !tx.IsErrObjectNotFound(err) { + if err != nil { return err } - if endpointRelation == nil { - endpointRelation = &portainer.EndpointRelation{ - EndpointID: endpoint.ID, - EdgeStacks: make(map[portainer.EdgeStackID]bool), - } - } - edgeGroups, err := tx.EdgeGroup().ReadAll() if err != nil { return err diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 1c6415023..3cfe934bc 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -563,6 +563,10 @@ func (handler *Handler) saveEndpointAndUpdateAuthorizations(tx dataservices.Data return err } + if err := endpointutils.InitializeEdgeEndpointRelation(endpoint, tx); err != nil { + return err + } + for _, tagID := range endpoint.TagIDs { if err := tx.Tag().UpdateTagFunc(tagID, func(tag *portainer.Tag) { tag.Endpoints[endpoint.ID] = true diff --git a/api/http/handler/endpoints/update_edge_relations.go b/api/http/handler/endpoints/update_edge_relations.go index 1390c9fd4..c487519f0 100644 --- a/api/http/handler/endpoints/update_edge_relations.go +++ b/api/http/handler/endpoints/update_edge_relations.go @@ -17,17 +17,7 @@ func (handler *Handler) updateEdgeRelations(tx dataservices.DataStoreTx, endpoin relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) if err != nil { - if !tx.IsErrObjectNotFound(err) { - return errors.WithMessage(err, "Unable to retrieve environment relation inside the database") - } - - relation = &portainer.EndpointRelation{ - EndpointID: endpoint.ID, - EdgeStacks: map[portainer.EdgeStackID]bool{}, - } - if err := tx.EndpointRelation().Create(relation); err != nil { - return errors.WithMessage(err, "Unable to create environment relation inside the database") - } + return errors.WithMessage(err, "Unable to retrieve environment relation inside the database") } endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID) diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go index 4f8554faf..1fa98e394 100644 --- a/api/http/handler/tags/tag_delete.go +++ b/api/http/handler/tags/tag_delete.go @@ -8,6 +8,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/internal/edge" + "github.com/portainer/portainer/api/internal/endpointutils" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" @@ -104,9 +105,8 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error { } for _, endpoint := range endpoints { - if (tag.Endpoints[endpoint.ID] || tag.EndpointGroups[endpoint.GroupID]) && (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) { - err = updateEndpointRelations(tx, endpoint, edgeGroups, edgeStacks) - if err != nil { + if (tag.Endpoints[endpoint.ID] || tag.EndpointGroups[endpoint.GroupID]) && endpointutils.IsEdgeEndpoint(&endpoint) { + if err := updateEndpointRelations(tx, endpoint, edgeGroups, edgeStacks); err != nil { return httperror.InternalServerError("Unable to update environment relations in the database", err) } } @@ -133,17 +133,10 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error { func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error { endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) - if err != nil && !tx.IsErrObjectNotFound(err) { + if err != nil { return err } - if endpointRelation == nil { - endpointRelation = &portainer.EndpointRelation{ - EndpointID: endpoint.ID, - EdgeStacks: make(map[portainer.EdgeStackID]bool), - } - } - endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID) if err != nil { return err diff --git a/api/internal/edge/edgestacks/service.go b/api/internal/edge/edgestacks/service.go index 5932a5ec8..c0ecb5caf 100644 --- a/api/internal/edge/edgestacks/service.go +++ b/api/internal/edge/edgestacks/service.go @@ -129,9 +129,6 @@ func (service *Service) updateEndpointRelations(tx dataservices.DataStoreTx, edg for _, endpointID := range relatedEndpointIds { relation, err := endpointRelationService.EndpointRelation(endpointID) if err != nil { - if tx.IsErrObjectNotFound(err) { - continue - } return fmt.Errorf("unable to find endpoint relation in database: %w", err) } diff --git a/api/internal/endpointutils/endpointutils.go b/api/internal/endpointutils/endpointutils.go index 6b7eb1c2d..f596ae0d5 100644 --- a/api/internal/endpointutils/endpointutils.go +++ b/api/internal/endpointutils/endpointutils.go @@ -249,3 +249,19 @@ func getEndpointCheckinInterval(endpoint *portainer.Endpoint, settings *portaine return defaultInterval } + +func InitializeEdgeEndpointRelation(endpoint *portainer.Endpoint, tx dataservices.DataStoreTx) error { + if !IsEdgeEndpoint(endpoint) { + return nil + } + + relation := &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: make(map[portainer.EdgeStackID]bool), + } + + if err := tx.EndpointRelation().Create(relation); err != nil { + return err + } + return nil +} From cf5990ccbab23bb2baafe49c8ed437e3ea29238e Mon Sep 17 00:00:00 2001 From: andres-portainer <91705312+andres-portainer@users.noreply.github.com> Date: Mon, 30 Jun 2025 20:54:16 -0300 Subject: [PATCH 17/46] fix(edgestackstatus): improve error handling BE-11963 (#844) --- api/http/handler/edgestacks/edgestack_status_update.go | 4 +++- api/http/handler/endpoints/filter.go | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/api/http/handler/edgestacks/edgestack_status_update.go b/api/http/handler/edgestacks/edgestack_status_update.go index 4f99e7ab3..0ff6a9eff 100644 --- a/api/http/handler/edgestacks/edgestack_status_update.go +++ b/api/http/handler/edgestacks/edgestack_status_update.go @@ -133,7 +133,9 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, stack } environmentStatus, err := tx.EdgeStackStatus().Read(stackID, payload.EndpointID) - if err != nil { + if err != nil && !tx.IsErrObjectNotFound(err) { + return err + } else if tx.IsErrObjectNotFound(err) { environmentStatus = &portainer.EdgeStackStatusForEnv{ EndpointID: payload.EndpointID, Status: []portainer.EdgeStackDeploymentStatus{}, diff --git a/api/http/handler/endpoints/filter.go b/api/http/handler/endpoints/filter.go index c8b4ede08..7cf7b9760 100644 --- a/api/http/handler/endpoints/filter.go +++ b/api/http/handler/endpoints/filter.go @@ -297,7 +297,9 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port n := 0 for _, envId := range envIds { edgeStackStatus, err := datastore.EdgeStackStatus().Read(edgeStackId, envId) - if err != nil { + if dataservices.IsErrObjectNotFound(err) { + continue + } else if err != nil { return nil, errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId) } From c6ee9a5a525f86e644c017f7240eb0b01e82dbdc Mon Sep 17 00:00:00 2001 From: James Player Date: Tue, 1 Jul 2025 12:58:31 +1200 Subject: [PATCH 18/46] feat(ui): Rebranding - r8s-374 (#840) --- app/assets/css/button.css | 102 ++-- app/assets/css/colors.json | 25 + app/assets/css/theme.css | 53 +- app/assets/css/vendor-override.css | 20 +- app/assets/ico/android-chrome-192x192.png | Bin 5654 -> 1922 bytes app/assets/ico/android-chrome-256x256.png | Bin 8347 -> 2510 bytes app/assets/ico/apple-touch-icon.png | Bin 6116 -> 1840 bytes app/assets/ico/favicon-16x16.png | Bin 772 -> 368 bytes app/assets/ico/favicon-32x32.png | Bin 1137 -> 491 bytes app/assets/ico/favicon.ico | Bin 2334 -> 7550 bytes app/assets/ico/logomark.svg | 39 +- app/assets/ico/mstile-150x150.png | Bin 8697 -> 1535 bytes app/assets/ico/safari-pinned-tab.svg | 7 +- app/assets/images/logo.png | Bin 5074 -> 0 bytes app/assets/images/logo_alt.png | Bin 11073 -> 0 bytes app/assets/images/logo_alt.svg | 72 +-- app/assets/images/logo_alt_black.svg | 14 + app/assets/images/logo_ico.png | Bin 4253 -> 0 bytes app/assets/images/logo_small.png | Bin 3105 -> 0 bytes app/assets/images/logo_small_alt.png | Bin 2941 -> 0 bytes app/assets/images/purple-gradient.svg | 522 ++++++++++++++++++ app/index.html | 7 +- app/portainer/views/auth/auth.html | 5 +- app/portainer/views/init/admin/initAdmin.html | 5 +- app/portainer/views/logout/logout.html | 5 +- app/react/components/InformationPanel.tsx | 2 +- .../account/AccountView/theme-options.tsx | 6 +- .../sidebar/EnvironmentSidebar.module.css | 5 +- app/react/sidebar/EnvironmentSidebar.tsx | 4 +- app/react/sidebar/Footer/Footer.tsx | 4 +- app/react/sidebar/Footer/portainer_logo.svg | 36 -- app/react/sidebar/Header.module.css | 6 +- app/react/sidebar/Header.tsx | 16 +- app/react/sidebar/SidebarItem/SidebarItem.tsx | 4 +- .../sidebar/SidebarItem/SidebarParent.tsx | 4 +- .../sidebar/SidebarItem/SidebarTooltip.tsx | 2 +- .../SidebarItem/useSidebarSrefActive.tsx | 2 +- app/react/sidebar/logomark-BE.svg | 11 + app/react/sidebar/logomark-CE.svg | 11 + app/react/sidebar/portainer_logo-BE.svg | 78 ++- app/react/sidebar/portainer_logo-CE.svg | 96 ++-- 41 files changed, 821 insertions(+), 342 deletions(-) delete mode 100644 app/assets/images/logo.png delete mode 100644 app/assets/images/logo_alt.png create mode 100644 app/assets/images/logo_alt_black.svg delete mode 100644 app/assets/images/logo_ico.png delete mode 100644 app/assets/images/logo_small.png delete mode 100644 app/assets/images/logo_small_alt.png create mode 100644 app/assets/images/purple-gradient.svg delete mode 100644 app/react/sidebar/Footer/portainer_logo.svg create mode 100644 app/react/sidebar/logomark-BE.svg create mode 100644 app/react/sidebar/logomark-CE.svg diff --git a/app/assets/css/button.css b/app/assets/css/button.css index 0aece2a15..547d9fdc0 100644 --- a/app/assets/css/button.css +++ b/app/assets/css/button.css @@ -29,43 +29,79 @@ fieldset[disabled] .btn { } .btn.btn-primary { - @apply border-blue-8 bg-blue-8 text-white; - @apply hover:border-blue-9 hover:bg-blue-9 hover:text-white; - @apply th-dark:hover:border-blue-7 th-dark:hover:bg-blue-7; + @apply border-graphite-700 bg-graphite-700 text-mist-100; + @apply hover:border-graphite-700/90 hover:bg-graphite-700/90 hover:text-mist-100; + @apply focus:border-blue-5 focus:shadow-graphite-700/80 focus:text-mist-100; + + @apply th-dark:border-mist-100 th-dark:bg-mist-100 th-dark:text-graphite-700; + @apply th-dark:hover:border-mist-100/90 th-dark:hover:bg-mist-100/90 th-dark:hover:text-graphite-700; + @apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-graphite-700; + + @apply th-highcontrast:border-mist-100 th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700; + @apply th-highcontrast:hover:border-mist-100/90 th-highcontrast:hover:bg-mist-100/90 th-highcontrast:hover:text-graphite-700; + @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80 th-highcontrast:focus:text-graphite-700; +} + +/* Sidebar background is always dark, so we need to override the primary button styles */ +.btn.btn-primary.sidebar { + @apply border-mist-100 bg-mist-100 text-graphite-700; + @apply hover:border-mist-100/90 hover:bg-mist-100/90 hover:text-graphite-700; + @apply focus:border-blue-5 focus:shadow-white/80 focus:text-graphite-700; } .btn.btn-primary:active, .btn.btn-primary.active, .open > .dropdown-toggle.btn-primary { - @apply border-blue-5 bg-blue-9; + @apply border-graphite-700/80 bg-graphite-700 text-mist-100; + @apply th-dark:border-white/80 th-dark:bg-mist-100 th-dark:text-graphite-700; + @apply th-highcontrast:border-white/80 th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700; } .nav-pills > li.active > a, .nav-pills > li.active > a:hover, .nav-pills > li.active > a:focus { - @apply bg-blue-8; + @apply bg-graphite-700 text-mist-100; + @apply th-dark:bg-mist-100 th-dark:text-graphite-700; + @apply th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700; } /* Button Secondary */ .btn.btn-secondary { @apply border border-solid; - @apply border-blue-8 bg-blue-2 text-blue-9; - @apply hover:bg-blue-3; + @apply border-graphite-700 bg-mist-100 text-graphite-700; + @apply hover:border-graphite-700 hover:bg-graphite-700/10 hover:text-graphite-700; + @apply focus:border-blue-5 focus:shadow-graphite-700/20 focus:text-graphite-700; - @apply th-dark:border-blue-7 th-dark:bg-gray-10 th-dark:text-blue-3; - @apply th-dark:hover:bg-blue-11; + @apply th-dark:border-mist-100 th-dark:bg-graphite-700 th-dark:text-mist-100; + @apply th-dark:hover:border-mist-100 th-dark:hover:bg-mist-100/20 th-dark:hover:text-mist-100; + @apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-mist-100; + + @apply th-highcontrast:border-mist-100 th-highcontrast:bg-graphite-700 th-highcontrast:text-mist-100; + @apply th-highcontrast:hover:border-mist-100 th-highcontrast:hover:bg-mist-100/20 th-highcontrast:hover:text-mist-100; + @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80 th-highcontrast:focus:text-mist-100; +} + +.btn.btn-secondary:active, +.btn.btn-secondary.active, +.open > .dropdown-toggle.btn-secondary { + @apply border-graphite-700 bg-graphite-700/10 text-graphite-700; + @apply th-dark:border-mist-100 th-dark:bg-mist-100/20 th-dark:text-mist-100; + @apply th-highcontrast:border-mist-100 th-highcontrast:bg-mist-100/20 th-highcontrast:text-mist-100; } .btn.btn-danger { @apply border-error-8 bg-error-8; @apply hover:border-error-7 hover:bg-error-7 hover:text-white; + @apply focus:border-blue-5 focus:shadow-error-8/20 focus:text-white; + @apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-white; + @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80 th-highcontrast:focus:text-white; } .btn.btn-danger:active, .btn.btn-danger.active, .open > .dropdown-toggle.btn-danger { - @apply border-blue-5 bg-error-8 text-white; + @apply border-error-5 bg-error-8 text-white; } .btn.btn-dangerlight { @@ -74,6 +110,10 @@ fieldset[disabled] .btn { @apply hover:bg-error-2 th-dark:hover:bg-error-11; @apply border-error-5 th-highcontrast:border-error-7 th-dark:border-error-7; @apply border border-solid; + + @apply focus:border-blue-5 focus:shadow-error-8/20 focus:text-error-9; + @apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-white; + @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80; } .btn.btn-icon.btn-dangerlight { @apply hover:text-error-11 th-dark:hover:text-error-7; @@ -90,15 +130,18 @@ fieldset[disabled] .btn { /* secondary-grey */ .btn.btn-default, .btn.btn-light { - @apply border-gray-5 bg-white text-gray-7; - @apply hover:border-gray-5 hover:bg-gray-3 hover:text-gray-9; + @apply border-gray-5 bg-white text-gray-8; + @apply hover:border-gray-5 hover:bg-gray-3 hover:text-gray-10; + @apply focus:border-blue-5 focus:shadow-graphite-700/20 focus:text-gray-8; /* dark mode */ @apply th-dark:border-gray-warm-7 th-dark:bg-gray-iron-10 th-dark:text-gray-warm-4; @apply th-dark:hover:border-gray-6 th-dark:hover:bg-gray-iron-9 th-dark:hover:text-gray-warm-4; + @apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-gray-warm-4; @apply th-highcontrast:border-gray-2 th-highcontrast:bg-black th-highcontrast:text-white; @apply th-highcontrast:hover:border-gray-6 th-highcontrast:hover:bg-gray-9 th-highcontrast:hover:text-gray-warm-4; + @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80 th-highcontrast:focus:text-white; } .btn.btn-light:active, @@ -119,42 +162,17 @@ fieldset[disabled] .btn { .input-group-btn .btn.active, .btn-group .btn.active { - @apply border-blue-5 bg-blue-2 text-blue-10; - @apply th-dark:border-blue-9 th-dark:bg-blue-11 th-dark:text-blue-2; -} - -/* focus */ - -.btn.btn-primary:focus, -.btn.btn-secondary:focus, -.btn.btn-light:focus { - @apply border-blue-5; -} - -.btn.btn-danger:focus, -.btn.btn-dangerlight:focus { - @apply border-blue-6; -} - -.btn.btn-primary:focus, -.btn.btn-secondary:focus, -.btn.btn-light:focus, -.btn.btn-danger:focus, -.btn.btn-dangerlight:focus { - --btn-focus-color: var(--ui-blue-3); - box-shadow: 0px 0px 0px 4px var(--btn-focus-color); + @apply border-graphite-700/80 bg-graphite-700 text-mist-100; + @apply th-dark:border-white/80 th-dark:bg-mist-100 th-dark:text-graphite-700; + @apply th-highcontrast:border-white/80 th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700; } .btn.btn-icon:focus { box-shadow: none !important; } -[theme='dark'] .btn.btn-primary:focus, -[theme='dark'] .btn.btn-secondary:focus, -[theme='dark'] .btn.btn-light:focus, -[theme='dark'] .btn.btn-danger:focus, -[theme='dark'] .btn.btn-dangerlight:focus { - --btn-focus-color: var(--ui-blue-11); +.btn:focus { + box-shadow: 0px 0px 0px 2px var(--tw-shadow-color); } a.no-link, diff --git a/app/assets/css/colors.json b/app/assets/css/colors.json index 55f2922e5..94d3c2015 100644 --- a/app/assets/css/colors.json +++ b/app/assets/css/colors.json @@ -1,6 +1,31 @@ { "black": "#000000", "white": "#ffffff", + "graphite": { + "10": "#f5f5f6", + "50": "#e5e6e8", + "100": "#ced0d3", + "200": "#abafb5", + "300": "#7b8089", + "400": "#5c6066", + "500": "#484a4e", + "600": "#3a3b3f", + "700": "#2e2f33", + "800": "#222326", + "900": "#161719" + }, + "mist": { + "50": "#fcfbfa", + "100": "#f7f6f3", + "200": "#f0f0ec", + "300": "#e8e7e2", + "400": "#e2e1db", + "500": "#d9d8d2", + "600": "#ceccc4", + "700": "#bebcb4", + "800": "#a7a6a0", + "900": "#8b8983" + }, "gray": { "1": "#fcfcfd", "2": "#f9fafb", diff --git a/app/assets/css/theme.css b/app/assets/css/theme.css index eb2d36882..318e0d9e4 100644 --- a/app/assets/css/theme.css +++ b/app/assets/css/theme.css @@ -3,6 +3,16 @@ --black-color: var(--ui-black); --white-color: var(--ui-white); + --graphite-600: #3a3b3f; + --graphite-700: #2e2f33; + --graphite-800: #222326; + --graphite-900: #161719; + + --mist-50: #fcfbfa; + --mist-100: #f7f6f3; + --mist-200: #f0f0ec; + --mist-300: #e8e7e2; + --grey-1: #212121; --grey-2: #181818; --grey-3: #383838; @@ -58,6 +68,8 @@ --grey-58: #ebf4f8; --grey-59: #e6e6e6; --grey-61: rgb(231, 231, 231); + --grey-62: #fdfdfd; + --grey-63: #121212; --blue-1: #219; --blue-2: #337ab7; @@ -99,17 +111,16 @@ /* Default Theme */ --bg-card-color: var(--white-color); --bg-main-color: var(--white-color); - --bg-body-color: var(--grey-9); + --bg-body-color: var(--grey-62); --bg-checkbox-border-color: var(--grey-49); - --bg-sidebar-color: var(--ui-blue-10); - --bg-sidebar-nav-color: var(--ui-blue-11); + --bg-sidebar-color: var(--graphite-700); + --bg-sidebar-nav-color: var(--graphite-600); --bg-widget-color: var(--white-color); --bg-widget-header-color: var(--grey-10); --bg-widget-table-color: var(--ui-gray-3); --bg-header-color: var(--white-color); --bg-hover-table-color: var(--grey-14); --bg-input-group-addon-color: var(--ui-gray-3); - --bg-btn-default-color: var(--ui-blue-10); --bg-blocklist-hover-color: var(--ui-blue-2); --bg-table-color: var(--white-color); --bg-md-checkbox-color: var(--grey-12); @@ -128,7 +139,8 @@ --border-pagination-color: var(--ui-white); --bg-pagination-span-color: var(--white-color); --bg-pagination-hover-color: var(--ui-blue-3); - --bg-motd-body-color: var(--grey-20); + --bg-motd-body-color: var(--mist-50); + --bg-motd-btn-color: var(--graphite-700); --bg-item-highlighted-color: var(--grey-21); --bg-item-highlighted-null-color: var(--grey-14); --bg-panel-body-color: var(--white-color); @@ -144,8 +156,6 @@ --bg-daterangepicker-in-range: var(--grey-58); --bg-daterangepicker-active: var(--blue-14); --bg-input-autofill-color: var(--bg-inputbox); - --bg-btn-default-hover-color: var(--ui-blue-9); - --bg-btn-focus: var(--grey-59); --bg-small-select-color: var(--white-color); --bg-stepper-item-active: var(--white-color); --bg-stepper-item-counter: var(--grey-61); @@ -177,7 +187,6 @@ --text-navtabs-color: var(--grey-7); --text-navtabs-hover-color: var(--grey-6); --text-nav-tab-active-color: var(--grey-25); - --text-dropdown-menu-color: var(--grey-6); --text-log-viewer-color: var(--black-color); --text-json-tree-color: var(--blue-3); @@ -189,6 +198,8 @@ --text-pagination-color: var(--grey-26); --text-pagination-span-color: var(--grey-3); --text-pagination-span-hover-color: var(--grey-3); + --text-motd-body-color: var(--black-color); + --text-motd-btn-color: var(--mist-100); --text-summary-color: var(--black-color); --text-tooltip-color: var(--white-color); --text-rzslider-color: var(--grey-36); @@ -203,6 +214,7 @@ --text-button-group-color: var(--ui-gray-9); --text-button-dangerlight-color: var(--ui-error-5); --text-stepper-active-color: var(--ui-blue-8); + --border-color: var(--grey-42); --border-widget-color: var(--grey-43); --border-sidebar-color: var(--ui-blue-9); @@ -218,7 +230,8 @@ --border-pre-color: var(--grey-43); --border-pagination-span-color: var(--ui-white); --border-pagination-hover-color: var(--ui-white); - --border-panel-color: var(--white-color); + --border-motd-body-color: var(--mist-300); + --border-panel-color: var(--mist-300); --border-input-sm-color: var(--grey-47); --border-daterangepicker-color: var(--grey-19); --border-calendar-table: var(--white-color); @@ -265,8 +278,7 @@ --text-log-viewer-color-json-red: var(--text-log-viewer-color); --text-log-viewer-color-json-blue: var(--text-log-viewer-color); - --bg-body-color: var(--grey-2); - --bg-btn-default-color: var(--grey-3); + --bg-body-color: var(--grey-63); --bg-blocklist-hover-color: var(--ui-gray-iron-10); --bg-blocklist-item-selected-color: var(--ui-gray-iron-10); --bg-card-color: var(--grey-1); @@ -274,8 +286,6 @@ --bg-code-color: var(--grey-2); --bg-dropdown-menu-color: var(--ui-gray-warm-8); --bg-main-color: var(--grey-2); - --bg-sidebar-color: var(--grey-1); - --bg-sidebar-nav-color: var(--grey-2); --bg-widget-color: var(--grey-1); --bg-widget-header-color: var(--grey-3); --bg-widget-table-color: var(--grey-3); @@ -296,7 +306,8 @@ --bg-pagination-color: var(--grey-3); --bg-pagination-span-color: var(--grey-1); --bg-pagination-hover-color: var(--grey-3); - --bg-motd-body-color: var(--grey-1); + --bg-motd-body-color: var(--graphite-800); + --bg-motd-btn-color: var(--mist-100); --bg-item-highlighted-color: var(--grey-2); --bg-item-highlighted-null-color: var(--grey-2); --bg-panel-body-color: var(--grey-1); @@ -316,8 +327,6 @@ --bg-daterangepicker-in-range: var(--ui-gray-warm-11); --bg-daterangepicker-active: var(--blue-14); --bg-input-autofill-color: var(--bg-inputbox); - --bg-btn-default-hover-color: var(--grey-4); - --bg-btn-focus: var(--grey-3); --bg-small-select-color: var(--grey-2); --bg-stepper-item-active: var(--grey-1); --bg-stepper-item-counter: var(--grey-7); @@ -348,7 +357,6 @@ --text-navtabs-color: var(--grey-8); --text-navtabs-hover-color: var(--grey-9); --text-nav-tab-active-color: var(--white-color); - --text-dropdown-menu-color: var(--white-color); --text-log-viewer-color: var(--white-color); --text-json-tree-color: var(--grey-40); @@ -360,6 +368,8 @@ --text-pagination-color: var(--white-color); --text-pagination-span-color: var(--ui-white); --text-pagination-span-hover-color: var(--ui-white); + --text-motd-body-color: var(--mist-100); + --text-motd-btn-color: var(--graphite-700); --text-summary-color: var(--white-color); --text-tooltip-color: var(--white-color); --text-rzslider-color: var(--white-color); @@ -374,6 +384,7 @@ --text-button-group-color: var(--ui-white); --text-button-dangerlight-color: var(--ui-error-7); --text-stepper-active-color: var(--ui-white); + --border-color: var(--grey-3); --border-widget-color: var(--grey-1); --border-sidebar-color: var(--ui-gray-8); @@ -391,6 +402,7 @@ --border-blocklist-item-selected-color: var(--grey-31); --border-pagination-span-color: var(--grey-1); --border-pagination-hover-color: var(--grey-3); + --border-motd-body-color: var(--graphite-800); --border-panel-color: var(--grey-2); --border-input-sm-color: var(--grey-3); --border-daterangepicker-color: var(--grey-3); @@ -450,6 +462,7 @@ --bg-panel-body-color: var(--black-color); --bg-dropdown-menu-color: var(--ui-gray-warm-8); --bg-motd-body-color: var(--black-color); + --bg-motd-btn-color: var(--white-color); --bg-blocklist-hover-color: var(--black-color); --bg-blocklist-item-selected-color: var(--black-color); --bg-input-group-addon-color: var(--grey-3); @@ -481,11 +494,8 @@ --bg-navtabs-hover-color: var(--grey-3); --bg-nav-tab-active-color: var(--ui-black); - --bg-btn-default-color: var(--black-color); --bg-input-autofill-color: var(--bg-inputbox); --bg-code-color: var(--ui-black); - --bg-btn-default-hover-color: var(--grey-4); - --bg-btn-focus: var(--black-color); --bg-small-select-color: var(--black-color); --bg-stepper-item-active: var(--black-color); --bg-stepper-item-counter: var(--grey-3); @@ -523,6 +533,8 @@ --text-daterangepicker-end-date: var(--ui-white); --text-daterangepicker-in-range: var(--white-color); --text-daterangepicker-active: var(--white-color); + --text-motd-body-color: var(--white-color); + --text-motd-btn-color: var(--black-color); --text-json-tree-color: var(--white-color); --text-json-tree-leaf-color: var(--white-color); --text-json-tree-branch-preview-color: var(--white-color); @@ -553,6 +565,7 @@ --border-input-sm-color: var(--white-color); --border-pagination-color: var(--grey-1); --border-pagination-span-color: var(--grey-1); + --border-motd-body-color: var(--white-color); --border-daterangepicker-color: var(--white-color); --border-calendar-table: var(--black-color); --border-daterangepicker: var(--black-color); diff --git a/app/assets/css/vendor-override.css b/app/assets/css/vendor-override.css index 74fa94d4e..12e0fc947 100644 --- a/app/assets/css/vendor-override.css +++ b/app/assets/css/vendor-override.css @@ -201,8 +201,18 @@ pre { background-color: var(--bg-progress-color); } -.motd-body { - background-color: var(--bg-motd-body-color) !important; +.widget-body.motd-body { + border: 1px solid var(--border-motd-body-color); + color: var(--text-motd-body-color); + background: var(--bg-motd-body-color) url(../images/purple-gradient.svg) top right / 40% no-repeat; +} + +.widget-body.motd-body .btn.btn-link, +.widget-body.motd-body .btn.btn-link:hover { + padding: 0 5px 0 4px; + border-radius: 4px; + background-color: var(--bg-motd-btn-color); + color: var(--text-motd-btn-color); } .panel-body { @@ -408,14 +418,10 @@ input:-webkit-autofill { } .sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before { - border-right: 8px solid var(--ui-blue-9); + border-right: 8px solid var(--graphite-600); border-width: 6px 8px 6px 0; } -[theme='dark'] .sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before { - border-right: 8px solid var(--ui-gray-true-9); -} - [theme='highcontrast'] .sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before { border-right: 8px solid var(--ui-white); } diff --git a/app/assets/ico/android-chrome-192x192.png b/app/assets/ico/android-chrome-192x192.png index 8f31e405aa2053308dc7f557f95cc7607ef44b9e..236db0e2b8f0d39741d3c7f5690c09bd7d4bf33c 100644 GIT binary patch literal 1922 zcmb7FdpOj27ytf#Gu9x?jB4bP%S35&+ce5;5C-cKv4~u^MlM@BMi#G`X|S7HqLFeh z*1H?VXp>7~+L}@%Gj1!yyRim&gqcxeXlMU^pMBnU|2XG7=kh$~Jm>N`IVXHbs>)i* z0031FcNc%THGC@%;lrFf3BmtnJ@fSg$tU^<6gp&M8&VW6jvmjTHWG8PY z09p!FWKjwLDB?U^oC3asmZykbfk8Ul)#<~!dO5Wh-8w^tTlpg_HSgmG^GuswJ&H9X zSlR?Pjb5S_854A$WZlzcT&+i--S#zd8}JrX*iGY2n3Se&#@qjE`ec$=Hn6hk5&k6d zcS`q4)#ge?F_Iz`b(H%T>n#%Z;%YRT>+Xr)pGp4K9X@7!%i@Dy*W0^+Y5GvM3gb~> z3SM|rWext?fVYF}aX2Ruvs&7Ncj@E=;^gW?T*&h6Of>6!SD z8W-nQWv}<`9(V&9JZdNt&;K;plajK5oKS(eW`zc5@TSH#P7h%FB{@-%{BZc!}K#OntXAk_~UENv*GK!@-llg;*N$pu#@d@~ER3&TTQK(F_T zMnLy6E)NBqgdZ8alny2ED7fpS7eKicg3+oyh5(t|Dlor}2F$pwlmy`cM}Qg-yr3Z& zGZHf%))sa&C<|)J+XdC2rJRcF2ak~qXQ3@mYIEit3S_rfs2YNu_0PB?`mkdZRcq&F zt)Nl0Y^YXG@l0G={g3p9{n>MC5)ys%7^RffdH<6afBf*2pLw4TSh>m`t4_RBCo%J@ zTF$RkXZ?5&9r!NlutIJYyT7r)ioWeyZI@B^s7dg#qNQc?gLOtLvU7{jo%B%VsZK$<5B zX~>^O;;?ww;_0H)snr{H^tocanfk zjv8%C(q{I@$f4i#@y?yauTv1#{RxB&^h2c9l09$*7(O(;ai)2EX=^hl!vqI^=XY6M=7jN*4VD~aw{Ce?=ZR)n(CbK(C z!8z(s<5_QTpkD>i5TXH;A7j8b3o~t;4n6tGTpJ=ttndI?x}0_%g7n+@mbThb3}6b* z(^Z1#UmzJZRAS7Rwi6nFd9Ht}ph9*Q1s>U@J47!|M99N9)H$*_?1+{R{<15QFvco$POvZ_{E(GDGQT<6ywa8nW+*dc6xXuyci~~#-g=w!$H9Ow zG9vg+r|kZ;I<(J6vBTjW!PeS5N$mR~hY**G2WakdXm|!7*#X z*G=2Ms9oH$Xga43&DJ5zp12KmUZ*9J-=7^~CD2WEXQj5fLMh#{KF;Puz4-3MWbT;=}t{r5v6}J zws)cMRie`EqP4wI=AXh*gm1?2hx}qztH3_6L`4#xUiignSTPit2aCV literal 5654 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 diff --git a/app/assets/ico/android-chrome-256x256.png b/app/assets/ico/android-chrome-256x256.png index cc95d0044215f6cd8e6cfba7ac1effda52371387..52848e01967c1126ad7b975041e1fb4966fa91fd 100644 GIT binary patch literal 2510 zcmd6pX*`sB8^^DEOqPt&sFO9sLzZJm8V}i~7(&N#kgX9}qOlaELuRBz*-ApzjuuND zd$yQS<2dM`QYmXmj6#;iKKFC?@_G4u&YS1O{kiVn|N32@&;P~$dtFze-4UwTX6elU zAZBHG$N>Psei0BB#Dd4^Vm~a1gj%|u0T7k=@gZ+T>cA<)TsD=UEn=%;Z#bkxHWRC$^HT&zC`yw3=_siBCc%1qJ8?*_VAxLx9K1 zL^pC;w>Af*X3psc+)|W`g!ic7*rxvU<8u41h{I?x5iD&$K?h-S@IaLKM^x^u_vFcZC+ncDo-q^^6^$>?RX+V@ z4$kk7Pg*E?SJv0`t;B?dSt&>t&LN}Up72)#*3NhjFowliDQmh*-=kHDqN3K9iw=Th zmN19IiJUmYZN8Q_^2OrA%3!qWX1~o>tUqY?k0lIW+oW zOx0T(k7Hja$}~DUP8b#h6J{7Iy~C}{`YlYQGMP9NbS`+Mt6=6}~m zrn~&e9tQel`rCP43=A=g-OSprg6*{E30f1#&1nGU^?nOdHjEOdw-g})#U zrQ*8GnO(8(OlR@TQqE|qaP&9+_|q9YQ{A)SbVIeKwoHFE*gdLm)ZQIB)tC764Q6X2 zC}=5HDfi!oG>WFkp;0HCsr2q|>WD%{f~kkuU($#|Uj4;h8?R(YyD`qj&wBcv3d zH%>Prf6I25Z2k0aU9i;X7VL|(@2#ZxM4w$-m=EWyPZnnGP2F!^3;VrlgNS&!RIWr$T#LHwFce<`z3ENoW)n?w{skw^@l_{5YIj=|-DjhozHa^TaacQ@>9d(5`O>e>5MKE{~7fJ#5 zLg@JEj!puzZ?F8uv#V0z+IEuloQishn7X+PvvOA_Srv9~ui4?5`EA$Hmr*;d{kk+F zj08Pl(u%;>U-fqxAdDW3mE+t-SE+A1`}$TWhLF)LY7hNy8ak@SR~Pl_)`RT^y6iSF zXA_n9mkaVpA$f-GDmRXC>+&LrcJy+J1Q<}4JIFn^bp8MA;&8*xEz^C1gIoG6Kw4Qa zkVPGr>?o0Jx8`>nYv>VSGFA$dcf_$CFQA^-V~<>1wkpX0_7?9Y#^aaY85kq2Utjq#Q*WVBazVxXA-?0!SM4>s!5lbmFW6%3R&gwZ4 zD7k=vRTF0-3@3*gqRIyuc1uGsP20fXYAb`xZDe3IdDQ8lm^elXC%n-!m-9;~m3 zz2duwfEvf%WG#Ng`A26GFdCa_aie{$r0qvaoC&M6_4%u1f*rD*SW0~XtjIM={nQH%Oj};iL-_#P5daG;wFIsD361EmV+PmFX)d9 z1WC&F2>g#D^7#TtHjcHocVbiOWa&&%cc20ghB(|gY>Xto{8eYUyxTTY`Y6uS8wZIZ zpsWIES@3@%=p5!ZV%Y{A!7k8KeO|bZqD0EU5phPt-YT4Rxu T0RIeIzrf1;$e}+dUKjrZxoa#_ literal 8347 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 diff --git a/app/assets/ico/apple-touch-icon.png b/app/assets/ico/apple-touch-icon.png index aeea31ce8754a28959e23880001234a69386f5a0..f05e9c1612db04f85923785d1f4ba88dab243d96 100644 GIT binary patch literal 1840 zcmb7FYcw0!7EXJE&?-UEkSK{swHhs<6ZL3<2(2haI~nilM24o`@1~~FDVK&KEoz1+ zoeUMZF7XbXRz2F%NQ;j5EA^%lLe*n3|7O;@Gi%)+dw*-6eb(CRtnb_3`S(Slqp}i2 z2><{nI}z;NWmxk^faGP_x?A2<28v+>QX~LyK>d%{2YC3Sw(M|Uq`M;yP}!q9Co6zK zSQjh+P?dRLJ3tNqkbmK1kNqlc-_nRJg&C{;bakd}bw89*O|EILupm7SHv;AqGtW&O zRcgl#9V|O(F2_sOG=^UIRaoqX3;)>GTS{VFuKQpb%TQRJpI z&NQNkbt}1xdI~|gjT3-=V1bpea1A>C7?5!h1SwWUa}t<#&STN%9)HH#lEzY;svH z5l)#}O9IgNpO!tJDC}0iq+&_1X*L_4)C;ch=*ZMN?;BVG?|NeyZ0qxN_Do-JG5a_W zdOKr$?pS5Xx?ZH@zncT@6Zq?e5x`Oprb(rDc1luDSKnM2?#6o}I4bP<^Pp@b0kZM2 zBM4zhM}eR`?oBhp);)`YPfqDbpwB59ts{nQ0Dk_(EZv!HU}wiRRHLI#L5o9ui?Sp| znddct%K3TPxdms)rEIqGC>X6~m&~@i;g6s!3iG0UmoUNf_7LGOe4;G~66#bUO=VRf zeKw;+V#C!ttSW9*6&Q1NabY2Xj`zsP$@QiY5EOKu2MXzzlZX{i%ECFkXdt6D?#N?B z2(>r9CT0Ddto*4+TWbLXp$>&?G?)V!7b;B54JlV2!D~d(Fd*YK{tYA5Q-<;f-;^jq z@=1j;s%T5o&8+Tjp03=3U!P67d(E^%M@X+dP`No-7jm!Q-*X2DZ0P+C*ER^f$3GyT zY-%rc>tG!hCLJH2L&&crZdBN0elfd&u%00~#y_69GHd3;4Ol4h8jBX%4RQPrjTqj4 zsooG4?hf-@!q$Ld@S4|#jDf^$}%64*Ta?Ulo_Q4@nHc%61BR;}jr z16S$rD+o5w*3z;m;aNbK=TWRqv3_DblkL_$-bkfe;7rmsr1d?M7y}^nd*5IT`l_T^ zM7|U(#Z!oSO9s%aq~_CsV&Eeg7ix`{0vCrLq^%%H*yr%Edlv7T z?)3s>nOwD%nh;aJV`3BK>}Cv>YYjC0tL=AM%dg3i}s#WI;P)}*LcmxEN%rd3uW7iG$s>%6veE{2XDvKnR+1eElQ zp2G@tqiX0uao@X|xN`!ElOx%rPDvI0P*`?{-PEZFaT(c~%Xo64Kt@sH*+f@O&1;qA zLn_!nSBSWxqzs?NTGjM{BrK&YOoFjf<`Bsb={c3xCxh(;??u*R2LZvV+%{x)ty*#5 z^a-g3?)pdzJaS{kaKCJcXf4~Kfw$xhg%~>P0D*ltQssl_ChaS!&5Jm7tkzu{KdE%d zPZQ{)+IY%u;Of_YtfY%TXdV%HJhKR)xA`ceGt^-A`zVcmeo}h+`H2sm9MvcQgwy_c zF`jo1e8zy1BJ3{Td#6`7$IXH@K{c>CKTpgP!F*S*Db7-Jn$`X~oZtwB@RM(Ui?tb-B@2@`Zisg=m`sX<*7JW z=COkm&95<|3Sbukzw^@aIQ0Lz=il1u|8C@A^b)8vy99>c`<8}pXZ%AKIS9;`s}oOa z$N56_?e#t?Z!j2z+-%+aOhTQEptpJ~tjAVIPpwc!p6dRykNWRDm#8I#o1ub ynHWJ*@j1S;*HxhvWCl=dg<*FDWxd5w1=x-59%Ce6Q0B~|3+E?QI>AwS)Oibbc literal 6116 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 diff --git a/app/assets/ico/favicon-16x16.png b/app/assets/ico/favicon-16x16.png index f7a26b564164405686d5aa98dc2e5ceaa5b1d340..8c60e5d9f450a701a57e86f3e6f5159a274d2672 100644 GIT binary patch literal 368 zcmV-$0gwKPP)4!K~#7FrBg8v z!9W=Oh#%4JtO%>HiW()@B=`wpu`v)o0f|w>sM5K*sxr2nwR`Q{UDBqfJw5S~OMCCj z_wMC;cPT)~7s@aA5%=3Ag})h}5JiEkI9Qeqp7)Hc1p-o|4#PnYSde7}R+}w2j!W@r z;DjXAVY!$^`1H3Ek7$Lh=WEX>4Tx04R}tkv&MmKpe$iQ>7{u1v`j1WT;LSii(JnR-p(LLaorMgUO{|(4-+r zad8w}3l4rPRvlcNb#-tR1i=pwH#a9m7b)?7O`%1M2gm(*ckglc4iIW3rdb_PK+|nA z8IOtS%&Hi8MK}5}fEh$(W*Kvmlz`{>x`&UicM+cDeeTcEt7a_*_(bAaW|%hd2JzIU zZE)Tv4zYr)5}y-~nRG$oN3JU_zj4ktSm2oQpi;V zBgX>D&>%Pb;D7MDTPrs)?j{9eK&ouk{0pm<^)Q-Yq4*&oF24YJ`L;(K){{a7>y{D4^000SaNLh0L z01ejw01ejxLMWSf00007bV*G`2jvM66c82bWaw!C0096=L_t(2&#lqVDnxM<2k_5m zxRf#(MWUvZ5)wNpYLpik8}bI$Jb+iQPpO)MbL19B91Vr;0>23X;=@#Uxg+LIx&F`Y+xCWNR#>K_Os<;z)`HC2aCAIbk;#y z0(N5zQ#im#-UXWqB|M>ycl75GnhN6>Mg?sl|CO+hI~)du3TAMVXQ_@0)IvTK@IDOr zp|FO93xxA*`W9X5?I>Rj~z0000R`>DzTgP001Be1^@s6=bY090004nX+uL$Nkc;* zaB^>EX>4Tx04R}tkv&MmKpe$iQ>7{u1v`j1WT;LSii(JnR-p(LLaorMgUO{|(4-+r zad8w}3l4rPRvlcNb#-tR1i=pwH#a9m7b)?7O`%1M2gm(*ckglc4iIW3rdb_PK+|nA z8IOtS%&Hi8MK}5}fEh$(W*Kvmlz`{>x`&UicM+cDeeTcEt7a_*_(bAaW|%hd2JzIU zZE)Tv4zYr)5}y-~nRG$oN3JU_zj4ktSm2oQpi;V zBgX>D&>%Pb;D7MDTPrs)?j{9eK&ouk{0pm<^)Q-Yq4*&oF24YJ`L;(K){{a7>y{D4^000SaNLh0L z01ejw01ejxLMWSf00007bV*G`2jvM66c8|=U_34W00M1EL_t(Y$K}>fh*wn<2k_7P zjm{&PKz+DUf+=WpBZMx(Ny?|DTQ8ZG4UkU7Q-h7kCG!uq@uV#8>!khG0x~-U<|X zI@xLH@NhV(s$Z)l*<9r5L=4yCD1aM9o=z-=x{@kMe!;s7t*s@sTaRET{=gvmV_w$9 zz1~T%KHm6SY>4qcf)DW5Vv$@&zyuy5#ZUMhPZ!vRk~+yC@+jf9Cr_~>hHGt8k|Iy5 z*8kB|>z>@fNj$=zQGNf$y(d{H)1FAOQ1;`KSh+LdEG{)A=?vFPa}dB#ui?6j=^F7v zT#est#Dj&XtJR@`zj409eh!BtafQ*?+cw}!{1$Ck00000NkvXXu0mjf D+RPFA diff --git a/app/assets/ico/favicon.ico b/app/assets/ico/favicon.ico index 28ed661f941580820ed764b4700fa332ae4f04ce..066969400119533a6348a47f6cf3f7d06f849245 100644 GIT binary patch literal 7550 zcmeI1u}i~16voflQE(B#rQp;SbgR%&!O=?Bii?A_bM4%AueR(BQCau*c3wS`j+#p<{SF) zdD;?UWBt#94oeUF#loXycE4B1%gk0ogX(ETo}bmUR;`?9eO@k=Wb3*aX`Fdn*Ok+6 zPiyZVpIWBlqeJaHH@%*Ao{Y(pnNE3`#giwqb(dUzLLEg+MOpcvWHdh|Roj!ynWmXg ze-P(ngW*s7p_OjsMpTTv`@%@h-~>UKKTF>z`pwBqW7`P6+o;=^$q?OvDS zoLY|m;xLiF)fRhxx*w-))GxLETb2oY5B)xyixX2xFd_eNG{P88u1bQb)ofbFgxY4C zPI1DV@Kn$mIrKc zm}u@rTg08J$GmfsOz`zQ4z=GDlXJ_{2y7;p2E#X&KBQIJOwcFpvrJk{cy{5&59vc~ zQcUm<_6OZ|o4v;+hHwjAqn__|*W&smeE;nCzkgRhvCM}P8~-ko{^!R=uoY(_-G2ek C1AKJ= delta 1570 zcmV+-2HpAoI-U}L000310stj200031AOOe(001Tc001R00ssL30RS)q008a<004&9vrEWVv-6122kOyTEQ6c_8DfcfXoG61f~H! zz;u4*c(=O4skhXt2vr ztN{)ImjSg2#a%!*uosvDd;shMOvAKiR5lKOONUKLrc`5?wr^B*&D{u1z!Sh~QB1cY zp)dS`O1^7FLKeuAz;yI>el3dE4bvWfc71PgNR4fOj8nPOoCfR%>VSG+DzH5|FBHo* z#)<@Kd-VDyU_Nj!5CM+(e6rkVw8L!pB=-WOHVo4aqfb#P%7ABqhk<5b75eCBvvm~M zir%(#j0TniD}ZCJoo@j|=#0({^{;e4sVXyuLTv*cPc!BRlFmAmV+4VOTXG@b+U&O{ zaBehz5^7IoJCwi9zsHFF^fv1Y`G6qLCoJ`HKn7swFUPBH`<1lIV)% zB$W?Mf^0}Oyq+%qq{)m~w+HbD7`5ed!q?13M0-bHYa71OGRYw2`piDyXSeG|wDyR9 zTTUy~mF_dgFzpsA63Qo*nGhin>!I}8n{ax&={ir_ zbnHrQUmLL0t#<>H&?$>IUj6`1XFI1}ehO!QU4Oq-x8BKhoF%2Cw^K{Oa^N2uoRPHp9tLA_R_)9QMd+_#&qrfMHIj={5B-;L= z^oEJVesQSUb%0n)3j|642J4hn6UH#@Cg8>_b`(0-`VJk4F9Bu&t>E-{y+S&jZN!_$ z0f^HTSAp>tkhGC4%zTW#sic186go{l1uO(EM(67<1EXEp3V_-H97bPC&l-wYbZ#g) zS$s8%Kq;JtcT_e~Ci-$YKRGFX;C|qC;IS04HNYcidDsf779F^ofj5BGp-82|seik_ zo^krDNXS9w-iLucm*9D6gpvpXF90jiIo3JAT;MHWW?a05#GIr6I(Du)AyAF>t=E8M zSqSBW0ILJaH=}5zDwb_IU+HW2L9~xeDZq9BSXk9CINef-ILhut#dD{BVHvYGOuHPN ze@`xeNIE`3vA~)a@-!%Ir3AF|7K0GL?}fzdL8Pj*qTgC1LI77IIRacB3?3j#0M8*K z59XkM6?wuSQtnTNABx4u#YJ8RWLw%-fPY8Mr5{nR12RZ;cl^-|JOFeQfzWYL{N!~| zRzV!w5W^8Q0l1F=rLOgVXxj!Bh~m?zM@bk`rQIJHA>cy>;$bTGqJ{Qpv{O6iOyU_F z=PRqm`?(or=n(fWpIU|v&J%z?fjU86>g#Hc`O7K9iujg7Y#@Ns!TKBMP6= zfj9;@R>a%ZoIx@19~u9w_KX$k!T - - - - - - - - - - - - - - - - + + + + + - - - - - - - - - - - - - - + + + diff --git a/app/assets/ico/mstile-150x150.png b/app/assets/ico/mstile-150x150.png index 5e7eb6873cdef750103527c543ff88b06cdbe846..f48374538eec40524b252c9c57d741c46ad25334 100644 GIT binary patch literal 1535 zcmah}Yd8}M7@lf1h8>E^lgpN(hQh+Zrpa}hr!CW@uu_B*F|wqg9l4}jT1w%#jAYo{ zFLT>Eaw$6}x6^Vtxs+8SF$*(i{W^a-r{{U!?|t6y`MzJ@^S#L@+?`dFw3Gk65<6ZKmLImE~9#Gl8M<_R20?=4A0KmQr zmXd$~0Pvox1KJ~6K{SRwzK5`@V<9x{`B=;w^Du`_czn4UqziFBHPKLqsSn(K9#XeW zRIs3jKqGf#1wrt~Ztp*8r6j}|hWksDV-TcytBD6k{Il@ZHWQrr*7c1{Y=cU6$wr$b zDr)>u&JKjNb(?;jAVCisP&dQlwQDkdJ5_90i18LNtEdx`B!!)NMh||7Xu`qaz(Iyj zmD>w!!!}}3g%{Yrgk0{E)!fuC_2eoY9eFr4RrUT5 zBV)a~KF`CSHWPQ};?;IeZu`UwCw5weXtvbN#L~(Xwq-_o#LP^e4Kpew=UkT# zJRNO~Si|f7#DN;UCvS<7Eiivruif~*(=`k+Gws6AB@`r%zx#Hb_j&z^cVJT4a)N8)9 zbfKOo`h;5FXq6QSJ*HNk0M_|o#%qwUJ%RbKlOzSL=@H3XpUr%Kx*TFaW?T1D|OF=r2)U$F?$+a590j?2#$tOuj?16d7dNJehDj9C++Lv1u{Tz zXx<$>j7f{OmZq`P*~|W8Ls#fDIzT08s7LTre(ldarQkW$y-;Siv2j(SE&Cx0wj3tL zwbg$~V!;?Viub8pXQJ8{9r~o|tEAaYvBJ)j5Nq4qrkx~5`%rV4G}We3S1D45^mA#^ zprGnwbq$OcRU>q9htG`(Q=}WEBQ~l9CCeb2G+i8tLPc z)sjygGg-VlQEe$H>2gj_&XPZKnbM59>7XT>u{^b(=jcLN5|GT@+pcXF_o^qoAf0j`ej4wd$P3I71a)5qli literal 8697 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 diff --git a/app/assets/ico/safari-pinned-tab.svg b/app/assets/ico/safari-pinned-tab.svg index 79ce7b6fa..d0509a572 100644 --- a/app/assets/ico/safari-pinned-tab.svg +++ b/app/assets/ico/safari-pinned-tab.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + \ No newline at end of file diff --git a/app/assets/images/logo.png b/app/assets/images/logo.png deleted file mode 100644 index 2e46594f291f5e2e3541c3c8fdd240afb7b70217..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5074 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 diff --git a/app/assets/images/logo_alt.png b/app/assets/images/logo_alt.png deleted file mode 100644 index a6c6707ca9265d78e8ab3021fd865e83caa137aa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11073 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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/app/assets/images/logo_alt_black.svg b/app/assets/images/logo_alt_black.svg new file mode 100644 index 000000000..d9243b464 --- /dev/null +++ b/app/assets/images/logo_alt_black.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/assets/images/logo_ico.png b/app/assets/images/logo_ico.png deleted file mode 100644 index b4bfd2924fd4ebd5f2834a4fdcf2d6719b15f0d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4253 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 diff --git a/app/assets/images/purple-gradient.svg b/app/assets/images/purple-gradient.svg new file mode 100644 index 000000000..0b3bc7160 --- /dev/null +++ b/app/assets/images/purple-gradient.svg @@ -0,0 +1,522 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/index.html b/app/index.html index 370070b48..52b9b5d10 100644 --- a/app/index.html +++ b/app/index.html @@ -20,7 +20,7 @@ - + @@ -47,7 +47,10 @@
- +
+ + +
diff --git a/app/portainer/views/auth/auth.html b/app/portainer/views/auth/auth.html index 09dadf050..2504141a7 100644 --- a/app/portainer/views/auth/auth.html +++ b/app/portainer/views/auth/auth.html @@ -4,7 +4,10 @@
- +
+ + +
diff --git a/app/portainer/views/init/admin/initAdmin.html b/app/portainer/views/init/admin/initAdmin.html index b5cfcfeb4..afff165b2 100644 --- a/app/portainer/views/init/admin/initAdmin.html +++ b/app/portainer/views/init/admin/initAdmin.html @@ -5,7 +5,10 @@
- +
+ + +
diff --git a/app/portainer/views/logout/logout.html b/app/portainer/views/logout/logout.html index fe9b2513d..95299d5d0 100644 --- a/app/portainer/views/logout/logout.html +++ b/app/portainer/views/logout/logout.html @@ -4,7 +4,10 @@
- +
+ + +
diff --git a/app/react/components/InformationPanel.tsx b/app/react/components/InformationPanel.tsx index b5c9dafc9..f25afadba 100644 --- a/app/react/components/InformationPanel.tsx +++ b/app/react/components/InformationPanel.tsx @@ -19,7 +19,7 @@ export function InformationPanel({ children, }: PropsWithChildren) { return ( - +
{title && ( diff --git a/app/react/portainer/account/AccountView/theme-options.tsx b/app/react/portainer/account/AccountView/theme-options.tsx index c76ddaa2f..21c1d00d9 100644 --- a/app/react/portainer/account/AccountView/theme-options.tsx +++ b/app/react/portainer/account/AccountView/theme-options.tsx @@ -7,28 +7,24 @@ export const options = [ id: 'light', icon: , label: 'Light Theme', - description: 'Default color mode', value: 'light', }, { id: 'dark', icon: , label: 'Dark Theme', - description: 'Dark color mode', value: 'dark', }, { id: 'highcontrast', icon: , label: 'High Contrast', - description: 'High contrast color mode', value: 'highcontrast', }, { id: 'auto', icon: , - label: 'Auto', - description: 'Sync with system theme', + label: 'System Theme', value: 'auto', }, ]; diff --git a/app/react/sidebar/EnvironmentSidebar.module.css b/app/react/sidebar/EnvironmentSidebar.module.css index 56165048c..2400d635a 100644 --- a/app/react/sidebar/EnvironmentSidebar.module.css +++ b/app/react/sidebar/EnvironmentSidebar.module.css @@ -1,12 +1,11 @@ .root { background-color: var(--bg-sidebar-nav-color); - border-color: var(--border-sidebar-color); } .closeBtn { - background-color: var(--bg-btn-default-color); + background-color: transparent; } .closeBtn:hover { - background-color: var(--bg-btn-default-hover-color); + background-color: var(--graphite-500); } diff --git a/app/react/sidebar/EnvironmentSidebar.tsx b/app/react/sidebar/EnvironmentSidebar.tsx index 2e51b449c..bea5f86c1 100644 --- a/app/react/sidebar/EnvironmentSidebar.tsx +++ b/app/react/sidebar/EnvironmentSidebar.tsx @@ -36,7 +36,7 @@ export function EnvironmentSidebar() { } return ( -
+
{environment ? ( ) : ( @@ -151,7 +151,7 @@ function Title({ environment, onClear }: TitleProps) { onClick={onClear} className={clsx( styles.closeBtn, - 'ml-auto mr-2 flex h-5 w-5 items-center justify-center rounded border-0 p-1 text-sm text-gray-5 transition-colors duration-200 hover:text-white be:text-gray-6 be:hover:text-white' + 'ml-auto mr-2 flex h-5 w-5 items-center justify-center rounded border-0 p-1 text-sm text-white transition-colors duration-200' )} > diff --git a/app/react/sidebar/Footer/Footer.tsx b/app/react/sidebar/Footer/Footer.tsx index 0c6636a3e..7374857c5 100644 --- a/app/react/sidebar/Footer/Footer.tsx +++ b/app/react/sidebar/Footer/Footer.tsx @@ -7,7 +7,6 @@ import { UpdateNotification } from './UpdateNotifications'; import { BuildInfoModalButton } from './BuildInfoModal'; import '@reach/dialog/styles.css'; import styles from './Footer.module.css'; -import Logo from './portainer_logo.svg?c'; export function Footer() { return isBE ? : ; @@ -19,7 +18,6 @@ function CEFooter() { - Community Edition @@ -43,7 +41,7 @@ function BEFooter() { function FooterContent({ children }: PropsWithChildren) { return ( -
+
{children}
); diff --git a/app/react/sidebar/Footer/portainer_logo.svg b/app/react/sidebar/Footer/portainer_logo.svg deleted file mode 100644 index 1bf390370..000000000 --- a/app/react/sidebar/Footer/portainer_logo.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/react/sidebar/Header.module.css b/app/react/sidebar/Header.module.css index 002dad966..82a002e8a 100644 --- a/app/react/sidebar/Header.module.css +++ b/app/react/sidebar/Header.module.css @@ -1,9 +1,5 @@ .logo { display: inline; max-height: 55px; - max-width: min(100%, 230px); -} - -.collapseBtn:hover { - background-color: var(--bg-btn-default-hover-color); + max-width: min(100%, 220px); } diff --git a/app/react/sidebar/Header.tsx b/app/react/sidebar/Header.tsx index 60b24b883..b69aa7d3a 100644 --- a/app/react/sidebar/Header.tsx +++ b/app/react/sidebar/Header.tsx @@ -2,12 +2,13 @@ import { ChevronsLeft, ChevronsRight } from 'lucide-react'; import clsx from 'clsx'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; -import smallLogo from '@/assets/ico/logomark.svg'; import { Link } from '@@/Link'; import fullLogoBE from './portainer_logo-BE.svg'; import fullLogoCE from './portainer_logo-CE.svg'; +import smallLogoBE from './logomark-BE.svg'; +import smallLogoCE from './logomark-CE.svg'; import { useSidebarState } from './useSidebarState'; import styles from './Header.module.css'; @@ -20,7 +21,7 @@ export function Header({ logo: customLogo }: Props) { return (
-
+
); diff --git a/app/react/sidebar/SidebarItem/SidebarItem.tsx b/app/react/sidebar/SidebarItem/SidebarItem.tsx index 96fd5cbc8..7208516f3 100644 --- a/app/react/sidebar/SidebarItem/SidebarItem.tsx +++ b/app/react/sidebar/SidebarItem/SidebarItem.tsx @@ -59,7 +59,7 @@ export function SidebarItem({ return ( +
{title} -
+
{children}
diff --git a/app/react/sidebar/SidebarItem/SidebarTooltip.tsx b/app/react/sidebar/SidebarItem/SidebarTooltip.tsx index b9a2d11d4..ec25ffd99 100644 --- a/app/react/sidebar/SidebarItem/SidebarTooltip.tsx +++ b/app/react/sidebar/SidebarItem/SidebarTooltip.tsx @@ -8,7 +8,7 @@ type Props = { export function SidebarTooltip({ children, content }: Props) { return ( > = {}, options: TransitionOptions = {}, pathOptions: PathOptions = { diff --git a/app/react/sidebar/logomark-BE.svg b/app/react/sidebar/logomark-BE.svg new file mode 100644 index 000000000..beead5989 --- /dev/null +++ b/app/react/sidebar/logomark-BE.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/react/sidebar/logomark-CE.svg b/app/react/sidebar/logomark-CE.svg new file mode 100644 index 000000000..1ed2ea259 --- /dev/null +++ b/app/react/sidebar/logomark-CE.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/react/sidebar/portainer_logo-BE.svg b/app/react/sidebar/portainer_logo-BE.svg index ed7ab076d..ad9762aa8 100644 --- a/app/react/sidebar/portainer_logo-BE.svg +++ b/app/react/sidebar/portainer_logo-BE.svg @@ -1,51 +1,37 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + diff --git a/app/react/sidebar/portainer_logo-CE.svg b/app/react/sidebar/portainer_logo-CE.svg index 7b6c83a00..707c7b1a7 100644 --- a/app/react/sidebar/portainer_logo-CE.svg +++ b/app/react/sidebar/portainer_logo-CE.svg @@ -1,68 +1,38 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + + From 363a62d88560bc35c16205d72811086513467ff9 Mon Sep 17 00:00:00 2001 From: Steven Kang Date: Tue, 1 Jul 2025 20:10:39 +1200 Subject: [PATCH 19/46] fix: bump the docker binary version to v28.3.0 [r8s-390] (#837) --- binary-version.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/binary-version.json b/binary-version.json index 3f190e5d3..7a275ec82 100644 --- a/binary-version.json +++ b/binary-version.json @@ -1,5 +1,4 @@ { - "docker": "v27.5.1", - "kubectl": "v1.32.2", + "docker": "v28.3.0", "mingit": "2.49.0.1" } From e1c480d3c328cf5fff93eb86b5da7be07b7f91a7 Mon Sep 17 00:00:00 2001 From: LP B Date: Tue, 1 Jul 2025 15:04:10 +0200 Subject: [PATCH 20/46] feat(app/edge-stacks): summarize the edge stack statuses in the backend (#818) --- .../customtemplates/customtemplate_list.go | 2 +- api/http/handler/edgestacks/edgestack_list.go | 133 +++++++++++++- .../proxy/factory/docker/access_control.go | 2 +- api/slicesx/filter.go | 28 +++ api/slicesx/filter_test.go | 96 ++++++++++ api/slicesx/flatten.go | 7 + api/slicesx/flatten_test.go | 19 ++ api/slicesx/includes.go | 17 ++ api/slicesx/includes_test.go | 76 ++++++++ api/slicesx/map.go | 15 ++ api/slicesx/map_test.go | 43 +++++ api/slicesx/slices_test.go | 127 ------------- api/slicesx/slicesx_test.go | 29 +++ api/slicesx/{slices.go => unique.go} | 22 --- api/slicesx/unique_test.go | 46 +++++ .../EdgeStacksDatatable.tsx | 20 +-- .../EdgeStacksDatatable/EdgeStacksStatus.tsx | 170 ++++++------------ .../ListView/EdgeStacksDatatable/columns.tsx | 53 ++++-- .../ListView/EdgeStacksDatatable/types.ts | 20 ++- .../edge/edge-stacks/queries/useEdgeStacks.ts | 30 ++-- package.json | 2 +- 21 files changed, 645 insertions(+), 312 deletions(-) create mode 100644 api/slicesx/filter.go create mode 100644 api/slicesx/filter_test.go create mode 100644 api/slicesx/flatten.go create mode 100644 api/slicesx/flatten_test.go create mode 100644 api/slicesx/includes.go create mode 100644 api/slicesx/includes_test.go create mode 100644 api/slicesx/map.go create mode 100644 api/slicesx/map_test.go delete mode 100644 api/slicesx/slices_test.go create mode 100644 api/slicesx/slicesx_test.go rename api/slicesx/{slices.go => unique.go} (51%) create mode 100644 api/slicesx/unique_test.go diff --git a/api/http/handler/customtemplates/customtemplate_list.go b/api/http/handler/customtemplates/customtemplate_list.go index 581b219ae..c96d61523 100644 --- a/api/http/handler/customtemplates/customtemplate_list.go +++ b/api/http/handler/customtemplates/customtemplate_list.go @@ -71,7 +71,7 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques customTemplates = filterByType(customTemplates, templateTypes) if edge != nil { - customTemplates = slicesx.Filter(customTemplates, func(customTemplate portainer.CustomTemplate) bool { + customTemplates = slicesx.FilterInPlace(customTemplates, func(customTemplate portainer.CustomTemplate) bool { return customTemplate.EdgeTemplate == *edge }) } diff --git a/api/http/handler/edgestacks/edgestack_list.go b/api/http/handler/edgestacks/edgestack_list.go index b0df238c3..1ea991c4b 100644 --- a/api/http/handler/edgestacks/edgestack_list.go +++ b/api/http/handler/edgestacks/edgestack_list.go @@ -3,10 +3,39 @@ package edgestacks import ( "net/http" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/slicesx" httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" ) +type aggregatedStatusesMap map[portainer.EdgeStackStatusType]int + +type SummarizedStatus string + +const ( + sumStatusUnavailable SummarizedStatus = "Unavailable" + sumStatusDeploying SummarizedStatus = "Deploying" + sumStatusFailed SummarizedStatus = "Failed" + sumStatusPaused SummarizedStatus = "Paused" + sumStatusPartiallyRunning SummarizedStatus = "PartiallyRunning" + sumStatusCompleted SummarizedStatus = "Completed" + sumStatusRunning SummarizedStatus = "Running" +) + +type edgeStackStatusSummary struct { + AggregatedStatus aggregatedStatusesMap + Status SummarizedStatus + Reason string +} + +type edgeStackListResponseItem struct { + portainer.EdgeStack + StatusSummary edgeStackStatusSummary +} + // @id EdgeStackList // @summary Fetches the list of EdgeStacks // @description **Access policy**: administrator @@ -14,22 +43,122 @@ import ( // @security ApiKeyAuth // @security jwt // @produce json +// @param summarizeStatuses query boolean false "will summarize the statuses" // @success 200 {array} portainer.EdgeStack // @failure 500 // @failure 400 // @failure 503 "Edge compute features are disabled" // @router /edge_stacks [get] func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + summarizeStatuses, _ := request.RetrieveBooleanQueryParameter(r, "summarizeStatuses", true) + edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() if err != nil { return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err) } + res := make([]edgeStackListResponseItem, len(edgeStacks)) + for i := range edgeStacks { - if err := fillEdgeStackStatus(handler.DataStore, &edgeStacks[i]); err != nil { + res[i].EdgeStack = edgeStacks[i] + + if summarizeStatuses { + if err := fillStatusSummary(handler.DataStore, &res[i]); err != nil { + return handlerDBErr(err, "Unable to retrieve edge stack status from the database") + } + } else if err := fillEdgeStackStatus(handler.DataStore, &res[i].EdgeStack); err != nil { return handlerDBErr(err, "Unable to retrieve edge stack status from the database") } } - return response.JSON(w, edgeStacks) + return response.JSON(w, res) +} + +func fillStatusSummary(tx dataservices.DataStoreTx, edgeStack *edgeStackListResponseItem) error { + statuses, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID) + if err != nil { + return err + } + + aggregated := make(aggregatedStatusesMap) + + for _, envStatus := range statuses { + for _, status := range envStatus.Status { + aggregated[status.Type]++ + } + } + + status, reason := SummarizeStatuses(statuses, edgeStack.NumDeployments) + + edgeStack.StatusSummary = edgeStackStatusSummary{ + AggregatedStatus: aggregated, + Status: status, + Reason: reason, + } + + edgeStack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{} + + return nil +} + +func SummarizeStatuses(statuses []portainer.EdgeStackStatusForEnv, numDeployments int) (SummarizedStatus, string) { + if numDeployments == 0 { + return sumStatusUnavailable, "Your edge stack is currently unavailable due to the absence of an available environment in your edge group" + } + + allStatuses := slicesx.FlatMap(statuses, func(x portainer.EdgeStackStatusForEnv) []portainer.EdgeStackDeploymentStatus { + return x.Status + }) + + lastStatuses := slicesx.Map( + slicesx.Filter( + statuses, + func(s portainer.EdgeStackStatusForEnv) bool { + return len(s.Status) > 0 + }, + ), + func(x portainer.EdgeStackStatusForEnv) portainer.EdgeStackDeploymentStatus { + return x.Status[len(x.Status)-1] + }, + ) + + if len(lastStatuses) == 0 { + return sumStatusDeploying, "" + } + + if allFailed := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { + return s.Type == portainer.EdgeStackStatusError + }); allFailed { + return sumStatusFailed, "" + } + + if hasPaused := slicesx.Some(allStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { + return s.Type == portainer.EdgeStackStatusPausedDeploying + }); hasPaused { + return sumStatusPaused, "" + } + + if len(lastStatuses) < numDeployments { + return sumStatusDeploying, "" + } + + hasDeploying := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusDeploying }) + hasRunning := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusRunning }) + hasFailed := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusError }) + + if hasRunning && hasFailed && !hasDeploying { + return sumStatusPartiallyRunning, "" + } + + if allCompleted := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusCompleted }); allCompleted { + return sumStatusCompleted, "" + } + + if allRunning := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { + return s.Type == portainer.EdgeStackStatusRunning + }); allRunning { + return sumStatusRunning, "" + } + + return sumStatusDeploying, "" } diff --git a/api/http/proxy/factory/docker/access_control.go b/api/http/proxy/factory/docker/access_control.go index e945d38da..ac25a7b7a 100644 --- a/api/http/proxy/factory/docker/access_control.go +++ b/api/http/proxy/factory/docker/access_control.go @@ -35,7 +35,7 @@ type ( func getUniqueElements(items string) []string { xs := strings.Split(items, ",") xs = slicesx.Map(xs, strings.TrimSpace) - xs = slicesx.Filter(xs, func(x string) bool { return len(x) > 0 }) + xs = slicesx.FilterInPlace(xs, func(x string) bool { return len(x) > 0 }) return slicesx.Unique(xs) } diff --git a/api/slicesx/filter.go b/api/slicesx/filter.go new file mode 100644 index 000000000..13dc12105 --- /dev/null +++ b/api/slicesx/filter.go @@ -0,0 +1,28 @@ +package slicesx + +// Iterates over elements of collection, returning an array of all elements predicate returns truthy for. +// +// Note: Unlike `FilterInPlace`, this method returns a new array. +func Filter[T any](input []T, predicate func(T) bool) []T { + result := make([]T, 0) + for i := range input { + if predicate(input[i]) { + result = append(result, input[i]) + } + } + return result +} + +// Filter in place all elements from input that predicate returns truthy for and returns an array of the removed elements. +// +// Note: Unlike `Filter`, this method mutates input. +func FilterInPlace[T any](input []T, predicate func(T) bool) []T { + n := 0 + for _, v := range input { + if predicate(v) { + input[n] = v + n++ + } + } + return input[:n] +} diff --git a/api/slicesx/filter_test.go b/api/slicesx/filter_test.go new file mode 100644 index 000000000..36f97fa10 --- /dev/null +++ b/api/slicesx/filter_test.go @@ -0,0 +1,96 @@ +package slicesx_test + +import ( + "testing" + + "github.com/portainer/portainer/api/slicesx" +) + +func Test_Filter(t *testing.T) { + test(t, slicesx.Filter, "Filter even numbers", + []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + []int{2, 4, 6, 8}, + func(x int) bool { return x%2 == 0 }, + ) + test(t, slicesx.Filter, "Filter odd numbers", + []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + []int{1, 3, 5, 7, 9}, + func(x int) bool { return x%2 == 1 }, + ) + test(t, slicesx.Filter, "Filter strings starting with 'A'", + []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + []string{"Apple", "Avocado", "Apricot"}, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.Filter, "Filter strings longer than 5 chars", + []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + []string{"Banana", "Avocado", "Grapes", "Apricot"}, + func(s string) bool { return len(s) > 5 }, + ) +} + +func Test_Retain(t *testing.T) { + test(t, slicesx.FilterInPlace, "Filter even numbers", + []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + []int{2, 4, 6, 8}, + func(x int) bool { return x%2 == 0 }, + ) + test(t, slicesx.FilterInPlace, "Filter odd numbers", + []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + []int{1, 3, 5, 7, 9}, + func(x int) bool { return x%2 == 1 }, + ) + test(t, slicesx.FilterInPlace, "Filter strings starting with 'A'", + []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + []string{"Apple", "Avocado", "Apricot"}, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.FilterInPlace, "Filter strings longer than 5 chars", + []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + []string{"Banana", "Avocado", "Grapes", "Apricot"}, + func(s string) bool { return len(s) > 5 }, + ) +} + +func Benchmark_Filter(b *testing.B) { + n := 100000 + + source := make([]int, n) + for i := range source { + source[i] = i + } + + b.ResetTimer() + for range b.N { + e := slicesx.Filter(source, func(x int) bool { return x%2 == 0 }) + if len(e) != n/2 { + b.FailNow() + } + } +} + +func Benchmark_FilterInPlace(b *testing.B) { + n := 100000 + + source := make([]int, n) + for i := range source { + source[i] = i + } + + // Preallocate all copies before timing + // because FilterInPlace mutates the original slice + copies := make([][]int, b.N) + for i := range b.N { + buf := make([]int, len(source)) + copy(buf, source) + copies[i] = buf + } + + b.ResetTimer() + for i := range b.N { + e := slicesx.FilterInPlace(copies[i], func(x int) bool { return x%2 == 0 }) + if len(e) != n/2 { + b.FailNow() + } + } +} diff --git a/api/slicesx/flatten.go b/api/slicesx/flatten.go new file mode 100644 index 000000000..56a77f3e9 --- /dev/null +++ b/api/slicesx/flatten.go @@ -0,0 +1,7 @@ +package slicesx + +import "slices" + +func Flatten[T any](input [][]T) []T { + return slices.Concat(input...) +} diff --git a/api/slicesx/flatten_test.go b/api/slicesx/flatten_test.go new file mode 100644 index 000000000..6875c4e6b --- /dev/null +++ b/api/slicesx/flatten_test.go @@ -0,0 +1,19 @@ +package slicesx_test + +import ( + "testing" + + "github.com/portainer/portainer/api/slicesx" + "github.com/stretchr/testify/assert" +) + +func Test_Flatten(t *testing.T) { + t.Run("Flatten an array of arrays", func(t *testing.T) { + is := assert.New(t) + + source := [][]int{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}} + expected := []int{1, 2, 3, 4, 5, 6, 7, 8, 9} + is.ElementsMatch(slicesx.Flatten(source), expected) + + }) +} diff --git a/api/slicesx/includes.go b/api/slicesx/includes.go new file mode 100644 index 000000000..377a54215 --- /dev/null +++ b/api/slicesx/includes.go @@ -0,0 +1,17 @@ +package slicesx + +import "slices" + +// Checks if predicate returns truthy for any element of input. Iteration is stopped once predicate returns truthy. +func Some[T any](input []T, predicate func(T) bool) bool { + return slices.ContainsFunc(input, predicate) +} + +// Checks if predicate returns truthy for all elements of input. Iteration is stopped once predicate returns falsey. +// +// Note: This method returns true for empty collections because everything is true of elements of empty collections. +// https://en.wikipedia.org/wiki/Vacuous_truth +func Every[T any](input []T, predicate func(T) bool) bool { + // if the slice doesn't contain an inverted predicate then all items follow the predicate + return !slices.ContainsFunc(input, func(t T) bool { return !predicate(t) }) +} diff --git a/api/slicesx/includes_test.go b/api/slicesx/includes_test.go new file mode 100644 index 000000000..a3f074c1c --- /dev/null +++ b/api/slicesx/includes_test.go @@ -0,0 +1,76 @@ +package slicesx_test + +import ( + "testing" + + "github.com/portainer/portainer/api/slicesx" +) + +func Test_Every(t *testing.T) { + test(t, slicesx.Every, "All start with an A (ok)", + []string{"Apple", "Avocado", "Apricot"}, + true, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.Every, "All start with an A (ko = some don't start with A)", + []string{"Apple", "Avocado", "Banana"}, + false, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.Every, "All are under 5 (ok)", + []int{1, 2, 3}, + true, + func(i int) bool { return i < 5 }, + ) + test(t, slicesx.Every, "All are under 5 (ko = some above 10)", + []int{1, 2, 10}, + false, + func(i int) bool { return i < 5 }, + ) + test(t, slicesx.Every, "All are true (ok)", + []struct{ x bool }{{x: true}, {x: true}, {x: true}}, + true, + func(s struct{ x bool }) bool { return s.x }) + test(t, slicesx.Every, "All are true (ko = some are false)", + []struct{ x bool }{{x: true}, {x: true}, {x: false}}, + false, + func(s struct{ x bool }) bool { return s.x }) + test(t, slicesx.Every, "Must be true on empty slice", + []int{}, + true, + func(i int) bool { return i%2 == 0 }, + ) +} + +func Test_Some(t *testing.T) { + test(t, slicesx.Some, "Some start with an A (ok)", + []string{"Apple", "Avocado", "Banana"}, + true, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.Some, "Some start with an A (ko = all don't start with A)", + []string{"Banana", "Cherry", "Peach"}, + false, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.Some, "Some are under 5 (ok)", + []int{1, 2, 30}, + true, + func(i int) bool { return i < 5 }, + ) + test(t, slicesx.Some, "Some are under 5 (ko = all above 5)", + []int{10, 11, 12}, + false, + func(i int) bool { return i < 5 }, + ) + test(t, slicesx.Some, "Some are true (ok)", + []struct{ x bool }{{x: true}, {x: true}, {x: false}}, + true, + func(s struct{ x bool }) bool { return s.x }, + ) + test(t, slicesx.Some, "Some are true (ko = all are false)", + []struct{ x bool }{{x: false}, {x: false}, {x: false}}, + false, + func(s struct{ x bool }) bool { return s.x }, + ) +} diff --git a/api/slicesx/map.go b/api/slicesx/map.go new file mode 100644 index 000000000..7e24bdd0d --- /dev/null +++ b/api/slicesx/map.go @@ -0,0 +1,15 @@ +package slicesx + +// Map applies the given function to each element of the slice and returns a new slice with the results +func Map[T, U any](s []T, f func(T) U) []U { + result := make([]U, len(s)) + for i, v := range s { + result[i] = f(v) + } + return result +} + +// FlatMap applies the given function to each element of the slice and returns a new slice with the flattened results +func FlatMap[T, U any](s []T, f func(T) []U) []U { + return Flatten(Map(s, f)) +} diff --git a/api/slicesx/map_test.go b/api/slicesx/map_test.go new file mode 100644 index 000000000..a2cd2256d --- /dev/null +++ b/api/slicesx/map_test.go @@ -0,0 +1,43 @@ +package slicesx_test + +import ( + "strconv" + "testing" + + "github.com/portainer/portainer/api/slicesx" +) + +func Test_Map(t *testing.T) { + test(t, slicesx.Map, "Map integers to strings", + []int{1, 2, 3, 4, 5}, + []string{"1", "2", "3", "4", "5"}, + strconv.Itoa, + ) + test(t, slicesx.Map, "Map strings to integers", + []string{"1", "2", "3", "4", "5"}, + []int{1, 2, 3, 4, 5}, + func(s string) int { + n, _ := strconv.Atoi(s) + return n + }, + ) +} + +func Test_FlatMap(t *testing.T) { + test(t, slicesx.FlatMap, "Map integers to strings and flatten", + []int{1, 2, 3, 4, 5}, + []string{"1", "1", "2", "2", "3", "3", "4", "4", "5", "5"}, + func(i int) []string { + x := strconv.Itoa(i) + return []string{x, x} + }, + ) + test(t, slicesx.FlatMap, "Map strings to integers and flatten", + []string{"1", "2", "3", "4", "5"}, + []int{1, 1, 2, 2, 3, 3, 4, 4, 5, 5}, + func(s string) []int { + n, _ := strconv.Atoi(s) + return []int{n, n} + }, + ) +} diff --git a/api/slicesx/slices_test.go b/api/slicesx/slices_test.go deleted file mode 100644 index d75f9b559..000000000 --- a/api/slicesx/slices_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package slicesx - -import ( - "strconv" - "testing" - - "github.com/stretchr/testify/assert" -) - -type filterTestCase[T any] struct { - name string - input []T - expected []T - predicate func(T) bool -} - -func TestFilter(t *testing.T) { - intTestCases := []filterTestCase[int]{ - { - name: "Filter even numbers", - input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, - expected: []int{2, 4, 6, 8}, - - predicate: func(n int) bool { - return n%2 == 0 - }, - }, - { - name: "Filter odd numbers", - input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, - expected: []int{1, 3, 5, 7, 9}, - - predicate: func(n int) bool { - return n%2 != 0 - }, - }, - } - - runTestCases(t, intTestCases) - - stringTestCases := []filterTestCase[string]{ - { - name: "Filter strings starting with 'A'", - input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, - expected: []string{"Apple", "Avocado", "Apricot"}, - predicate: func(s string) bool { - return s[0] == 'A' - }, - }, - { - name: "Filter strings longer than 5 characters", - input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, - expected: []string{"Banana", "Avocado", "Grapes", "Apricot"}, - predicate: func(s string) bool { - return len(s) > 5 - }, - }, - } - - runTestCases(t, stringTestCases) -} - -func runTestCases[T any](t *testing.T, testCases []filterTestCase[T]) { - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - is := assert.New(t) - result := Filter(testCase.input, testCase.predicate) - - is.Equal(len(testCase.expected), len(result)) - is.ElementsMatch(testCase.expected, result) - }) - } -} - -func TestMap(t *testing.T) { - intTestCases := []struct { - name string - input []int - expected []string - mapper func(int) string - }{ - { - name: "Map integers to strings", - input: []int{1, 2, 3, 4, 5}, - expected: []string{"1", "2", "3", "4", "5"}, - mapper: strconv.Itoa, - }, - } - - runMapTestCases(t, intTestCases) - - stringTestCases := []struct { - name string - input []string - expected []int - mapper func(string) int - }{ - { - name: "Map strings to integers", - input: []string{"1", "2", "3", "4", "5"}, - expected: []int{1, 2, 3, 4, 5}, - mapper: func(s string) int { - n, _ := strconv.Atoi(s) - return n - }, - }, - } - - runMapTestCases(t, stringTestCases) -} - -func runMapTestCases[T, U any](t *testing.T, testCases []struct { - name string - input []T - expected []U - mapper func(T) U -}) { - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - is := assert.New(t) - result := Map(testCase.input, testCase.mapper) - - is.Equal(len(testCase.expected), len(result)) - is.ElementsMatch(testCase.expected, result) - }) - } -} diff --git a/api/slicesx/slicesx_test.go b/api/slicesx/slicesx_test.go new file mode 100644 index 000000000..1bb8a76fe --- /dev/null +++ b/api/slicesx/slicesx_test.go @@ -0,0 +1,29 @@ +package slicesx_test + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +type libFunc[T, U, V any] func([]T, func(T) U) V +type predicateFunc[T, U any] func(T) U + +func test[T, U, V any](t *testing.T, libFn libFunc[T, U, V], name string, input []T, expected V, predicate predicateFunc[T, U]) { + t.Helper() + + t.Run(name, func(t *testing.T) { + is := assert.New(t) + + result := libFn(input, predicate) + + switch reflect.TypeOf(result).Kind() { + case reflect.Slice, reflect.Array: + is.Equal(expected, result) + is.ElementsMatch(expected, result) + default: + is.Equal(expected, result) + } + }) +} diff --git a/api/slicesx/slices.go b/api/slicesx/unique.go similarity index 51% rename from api/slicesx/slices.go rename to api/slicesx/unique.go index b7e0aa0ef..8659b0778 100644 --- a/api/slicesx/slices.go +++ b/api/slicesx/unique.go @@ -1,27 +1,5 @@ package slicesx -// Map applies the given function to each element of the slice and returns a new slice with the results -func Map[T, U any](s []T, f func(T) U) []U { - result := make([]U, len(s)) - for i, v := range s { - result[i] = f(v) - } - return result -} - -// Filter returns a new slice containing only the elements of the slice for which the given predicate returns true -func Filter[T any](s []T, predicate func(T) bool) []T { - n := 0 - for _, v := range s { - if predicate(v) { - s[n] = v - n++ - } - } - - return s[:n] -} - func Unique[T comparable](items []T) []T { return UniqueBy(items, func(item T) T { return item diff --git a/api/slicesx/unique_test.go b/api/slicesx/unique_test.go new file mode 100644 index 000000000..8ff967ca6 --- /dev/null +++ b/api/slicesx/unique_test.go @@ -0,0 +1,46 @@ +package slicesx_test + +import ( + "testing" + + "github.com/portainer/portainer/api/slicesx" + "github.com/stretchr/testify/assert" +) + +func Test_Unique(t *testing.T) { + is := assert.New(t) + t.Run("Should extract unique numbers", func(t *testing.T) { + + source := []int{1, 1, 2, 3, 4, 4, 5, 4, 6, 7, 8, 9, 1} + result := slicesx.Unique(source) + expected := []int{1, 2, 3, 4, 5, 6, 7, 8, 9} + + is.ElementsMatch(result, expected) + }) + + t.Run("Should return empty array", func(t *testing.T) { + source := []int{} + result := slicesx.Unique(source) + expected := []int{} + is.ElementsMatch(result, expected) + }) +} + +func Test_UniqueBy(t *testing.T) { + is := assert.New(t) + t.Run("Should extract unique numbers by property", func(t *testing.T) { + + source := []struct{ int }{{1}, {1}, {2}, {3}, {4}, {4}, {5}, {4}, {6}, {7}, {8}, {9}, {1}} + result := slicesx.UniqueBy(source, func(item struct{ int }) int { return item.int }) + expected := []struct{ int }{{1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}, {9}} + + is.ElementsMatch(result, expected) + }) + + t.Run("Should return empty array", func(t *testing.T) { + source := []int{} + result := slicesx.UniqueBy(source, func(x int) int { return x }) + expected := []int{} + is.ElementsMatch(result, expected) + }) +} diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx index 7dd8e35af..be1f664e0 100644 --- a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx @@ -5,7 +5,6 @@ import { useTableState } from '@@/datatables/useTableState'; import { getColumnVisibilityState } from '@@/datatables/ColumnVisibilityMenu'; import { useEdgeStacks } from '../../queries/useEdgeStacks'; -import { EdgeStack, StatusType } from '../../types'; import { createStore } from './store'; import { columns } from './columns'; @@ -20,11 +19,7 @@ const settingsStore = createStore(tableKey); export function EdgeStacksDatatable() { const tableState = useTableState(settingsStore, tableKey); const edgeStacksQuery = useEdgeStacks>({ - select: (edgeStacks) => - edgeStacks.map((edgeStack) => ({ - ...edgeStack, - aggregatedStatus: aggregateStackStatus(edgeStack.Status), - })), + params: { summarizeStatuses: true }, refetchInterval: tableState.autoRefreshRate * 1000, }); @@ -50,16 +45,3 @@ export function EdgeStacksDatatable() { /> ); } - -function aggregateStackStatus(stackStatus: EdgeStack['Status']) { - const aggregateStatus: Partial> = {}; - return Object.values(stackStatus).reduce( - (acc, envStatus) => - envStatus.Status.reduce((acc, status) => { - const { Type } = status; - acc[Type] = (acc[Type] || 0) + 1; - return acc; - }, acc), - aggregateStatus - ); -} diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksStatus.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksStatus.tsx index 565eac41e..8c6443396 100644 --- a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksStatus.tsx +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksStatus.tsx @@ -1,4 +1,3 @@ -import _ from 'lodash'; import { AlertTriangle, CheckCircle, @@ -6,35 +5,22 @@ import { Loader2, XCircle, MinusCircle, + PauseCircle, } from 'lucide-react'; -import { useEnvironmentList } from '@/react/portainer/environments/queries'; -import { isVersionSmaller } from '@/react/common/semver-utils'; - import { Icon, IconMode } from '@@/Icon'; import { Tooltip } from '@@/Tip/Tooltip'; -import { DeploymentStatus, EdgeStack, StatusType } from '../../types'; +import { DecoratedEdgeStack, StatusSummary, SummarizedStatus } from './types'; -export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) { - const status = Object.values(edgeStack.Status); - const lastStatus = _.compact(status.map((s) => _.last(s.Status))); +export function EdgeStackStatus({ + edgeStack, +}: { + edgeStack: DecoratedEdgeStack; +}) { + const { StatusSummary } = edgeStack; - const environmentsQuery = useEnvironmentList({ edgeStackId: edgeStack.Id }); - - if (environmentsQuery.isLoading) { - return null; - } - - const hasOldVersion = environmentsQuery.environments.some((env) => - isVersionSmaller(env.Agent.Version, '2.19.0') - ); - - const { icon, label, mode, spin, tooltip } = getStatus( - edgeStack.NumDeployments, - lastStatus, - hasOldVersion - ); + const { icon, label, mode, spin, tooltip } = getStatus(StatusSummary); return (
@@ -45,106 +31,68 @@ export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) { ); } -function getStatus( - numDeployments: number, - envStatus: Array, - hasOldVersion: boolean -): { +function getStatus(summary?: StatusSummary): { label: string; icon?: LucideIcon; spin?: boolean; mode?: IconMode; tooltip?: string; } { - if (!numDeployments || hasOldVersion) { + if (!summary) { return { label: 'Unavailable', icon: MinusCircle, mode: 'secondary', - tooltip: getUnavailableTooltip(), + tooltip: 'Status summary is unavailable', }; } + const { Status, Reason } = summary; - if (!envStatus.length) { - return { - label: 'Deploying', - icon: Loader2, - spin: true, - mode: 'primary', - }; - } - - const allFailed = envStatus.every((s) => s.Type === StatusType.Error); - - if (allFailed) { - return { - label: 'Failed', - icon: XCircle, - mode: 'danger', - }; - } - - if (envStatus.length < numDeployments) { - return { - label: 'Deploying', - icon: Loader2, - spin: true, - mode: 'primary', - }; - } - - const allCompleted = envStatus.every((s) => s.Type === StatusType.Completed); - - if (allCompleted) { - return { - label: 'Completed', - icon: CheckCircle, - mode: 'success', - }; - } - - const allRunning = envStatus.every( - (s) => - s.Type === StatusType.Running || - (s.Type === StatusType.DeploymentReceived && hasOldVersion) - ); - - if (allRunning) { - return { - label: 'Running', - icon: CheckCircle, - mode: 'success', - }; - } - - const hasDeploying = envStatus.some((s) => s.Type === StatusType.Deploying); - const hasRunning = envStatus.some((s) => s.Type === StatusType.Running); - const hasFailed = envStatus.some((s) => s.Type === StatusType.Error); - - if (hasRunning && hasFailed && !hasDeploying) { - return { - label: 'Partially Running', - icon: AlertTriangle, - mode: 'warning', - }; - } - - return { - label: 'Deploying', - icon: Loader2, - spin: true, - mode: 'primary', - }; - - function getUnavailableTooltip() { - if (!numDeployments) { - return 'Your edge stack is currently unavailable due to the absence of an available environment in your edge group'; - } - - if (hasOldVersion) { - return 'Please note that the new status feature for the Edge stack is only available for Edge Agent versions 2.19.0 and above. To access the status of your edge stack, it is essential to upgrade your Edge Agent to a corresponding version that is compatible with your Portainer server.'; - } - - return ''; + switch (Status) { + case SummarizedStatus.Deploying: + return { + label: 'Deploying', + icon: Loader2, + spin: true, + mode: 'primary', + }; + case SummarizedStatus.Failed: + return { + label: 'Failed', + icon: XCircle, + mode: 'danger', + }; + case SummarizedStatus.Paused: + return { + label: 'Paused', + icon: PauseCircle, + mode: 'warning', + }; + case SummarizedStatus.PartiallyRunning: + return { + label: 'Partially Running', + icon: AlertTriangle, + mode: 'warning', + }; + case SummarizedStatus.Completed: + return { + label: 'Completed', + icon: CheckCircle, + mode: 'success', + }; + case SummarizedStatus.Running: + return { + label: 'Running', + icon: CheckCircle, + mode: 'success', + }; + case SummarizedStatus.Unavailable: + default: + return { + label: 'Unavailable', + icon: MinusCircle, + mode: 'secondary', + tooltip: Reason, + }; } } diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/columns.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/columns.tsx index 2b1a4472b..8df209598 100644 --- a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/columns.tsx +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/columns.tsx @@ -5,8 +5,9 @@ import { isoDateFromTimestamp } from '@/portainer/filters/filters'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { GitCommitLink } from '@/react/portainer/gitops/GitCommitLink'; -import { buildNameColumn } from '@@/datatables/buildNameColumn'; +import { buildNameColumnFromObject } from '@@/datatables/buildNameColumn'; import { Link } from '@@/Link'; +import { Tooltip } from '@@/Tip/Tooltip'; import { StatusType } from '../../types'; @@ -17,14 +18,15 @@ import { DeploymentCounter } from './DeploymentCounter'; const columnHelper = createColumnHelper(); export const columns = _.compact([ - buildNameColumn( - 'Name', - 'edge.stacks.edit', - 'edge-stacks-name', - 'stackId' - ), + buildNameColumnFromObject({ + nameKey: 'Name', + path: 'edge.stacks.edit', + dataCy: 'edge-stacks-name', + idParam: 'stackId', + }), columnHelper.accessor( - (item) => item.aggregatedStatus[StatusType.Acknowledged] || 0, + (item) => + item.StatusSummary?.AggregatedStatus?.[StatusType.Acknowledged] || 0, { header: 'Acknowledged', enableSorting: false, @@ -43,7 +45,8 @@ export const columns = _.compact([ ), isBE && columnHelper.accessor( - (item) => item.aggregatedStatus[StatusType.ImagesPulled] || 0, + (item) => + item.StatusSummary?.AggregatedStatus?.[StatusType.ImagesPulled] || 0, { header: 'Images pre-pulled', cell: ({ getValue, row: { original: item } }) => { @@ -67,7 +70,9 @@ export const columns = _.compact([ } ), columnHelper.accessor( - (item) => item.aggregatedStatus[StatusType.DeploymentReceived] || 0, + (item) => + item.StatusSummary?.AggregatedStatus?.[StatusType.DeploymentReceived] || + 0, { header: 'Deployments received', cell: ({ getValue, row }) => ( @@ -85,7 +90,7 @@ export const columns = _.compact([ } ), columnHelper.accessor( - (item) => item.aggregatedStatus[StatusType.Error] || 0, + (item) => item.StatusSummary?.AggregatedStatus?.[StatusType.Error] || 0, { header: 'Deployments failed', cell: ({ getValue, row }) => { @@ -123,7 +128,7 @@ export const columns = _.compact([ } ), columnHelper.accessor('Status', { - header: 'Status', + header: StatusHeader, cell: ({ row }) => (
@@ -167,3 +172,27 @@ export const columns = _.compact([ } ), ]); + +function StatusHeader() { + return ( + <> + Status + +
+ The status feature for the Edge stack is only available for Edge + Agent versions 2.19.0 and above. +
+
+ To access the status of your edge stack, it is essential to + upgrade your Edge Agent to a corresponding version that is + compatible with your Portainer server. +
+ + } + /> + + ); +} diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/types.ts b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/types.ts index 89b360342..64f37b83f 100644 --- a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/types.ts +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/types.ts @@ -1,5 +1,21 @@ import { EdgeStack, StatusType } from '../../types'; -export type DecoratedEdgeStack = EdgeStack & { - aggregatedStatus: Partial>; +export enum SummarizedStatus { + Unavailable = 'Unavailable', + Deploying = 'Deploying', + Failed = 'Failed', + Paused = 'Paused', + PartiallyRunning = 'PartiallyRunning', + Completed = 'Completed', + Running = 'Running', +} + +export type StatusSummary = { + AggregatedStatus?: Partial>; + Status: SummarizedStatus; + Reason: string; +}; + +export type DecoratedEdgeStack = EdgeStack & { + StatusSummary?: StatusSummary; }; diff --git a/app/react/edge/edge-stacks/queries/useEdgeStacks.ts b/app/react/edge/edge-stacks/queries/useEdgeStacks.ts index 6ab5a0cf1..d03e143f8 100644 --- a/app/react/edge/edge-stacks/queries/useEdgeStacks.ts +++ b/app/react/edge/edge-stacks/queries/useEdgeStacks.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; -import { withError } from '@/react-tools/react-query'; +import { withGlobalError } from '@/react-tools/react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EdgeStack } from '../types'; @@ -8,28 +8,30 @@ import { EdgeStack } from '../types'; import { buildUrl } from './buildUrl'; import { queryKeys } from './query-keys'; -export function useEdgeStacks>({ - select, - /** - * If set to a number, the query will continuously refetch at this frequency in milliseconds. - * If set to a function, the function will be executed with the latest data and query to compute a frequency - * Defaults to `false`. - */ +type QueryParams = { + summarizeStatuses?: boolean; +}; + +export function useEdgeStacks({ + params, refetchInterval, }: { - select?: (stacks: EdgeStack[]) => T; + params?: QueryParams; refetchInterval?: number | false | ((data?: T) => false | number); } = {}) { - return useQuery(queryKeys.base(), () => getEdgeStacks(), { - ...withError('Failed loading Edge stack'), - select, + return useQuery({ + queryKey: queryKeys.base(), + queryFn: () => getEdgeStacks(params), refetchInterval, + ...withGlobalError('Failed loading Edge stack'), }); } -export async function getEdgeStacks() { +async function getEdgeStacks( + params: QueryParams = {} +) { try { - const { data } = await axios.get(buildUrl()); + const { data } = await axios.get(buildUrl(), { params }); return data; } catch (e) { throw parseAxiosError(e as Error); diff --git a/package.json b/package.json index d9c180698..3bcac5b09 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "dev": "webpack-dev-server", "start": "webpack -w", "build": "webpack", - "format": "prettier --log-level warn --write \"**/*.{js,css,html,jsx,tsx,ts,json}\"", + "format": "prettier --log-level warn --write \"**/*.{js,css,html,jsx,tsx,ts}\"", "lint": "eslint --cache --fix './**/*.{js,jsx,ts,tsx}'", "test": "vitest run", "sb": "yarn storybook", From e1b9f23f73c05db3c319d4928679b9889e7c502d Mon Sep 17 00:00:00 2001 From: James Carppe <85850129+jamescarppe@users.noreply.github.com> Date: Wed, 2 Jul 2025 06:45:59 +0100 Subject: [PATCH 21/46] Updates for release 2.27.9 (#853) --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 49ca94eff..f21be9522 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -104,6 +104,7 @@ body: - '2.29.0' - '2.28.1' - '2.28.0' + - '2.27.9' - '2.27.8' - '2.27.7' - '2.27.6' From 5c6b53922a27f9859bc1c62d11cd8321ceb1b98c Mon Sep 17 00:00:00 2001 From: andres-portainer <91705312+andres-portainer@users.noreply.github.com> Date: Wed, 2 Jul 2025 18:14:29 -0300 Subject: [PATCH 22/46] feat(go): upgrade to Go v1.24.4 BE-11774 (#855) --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index dd66b95e2..f914962d0 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/portainer/portainer -go 1.23.10 +go 1.24.4 require ( github.com/Masterminds/semver v1.5.0 From 097b125e3ae881989be93557ea5d684d0317eae4 Mon Sep 17 00:00:00 2001 From: andres-portainer <91705312+andres-portainer@users.noreply.github.com> Date: Wed, 2 Jul 2025 18:17:19 -0300 Subject: [PATCH 23/46] fix(boltdb): change some options to increase performance BE-12002 (#848) --- api/database/boltdb/db.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/database/boltdb/db.go b/api/database/boltdb/db.go index a0db7f4e0..32a1b55c3 100644 --- a/api/database/boltdb/db.go +++ b/api/database/boltdb/db.go @@ -138,6 +138,8 @@ func (connection *DbConnection) Open() error { db, err := bolt.Open(databasePath, 0600, &bolt.Options{ Timeout: 1 * time.Second, InitialMmapSize: connection.InitialMmapSize, + FreelistType: bolt.FreelistMapType, + NoFreelistSync: true, }) if err != nil { return err From ce86129478b7b547b2431cc4f1ba8fe5a885e38a Mon Sep 17 00:00:00 2001 From: James Carppe <85850129+jamescarppe@users.noreply.github.com> Date: Thu, 3 Jul 2025 04:17:50 +0100 Subject: [PATCH 24/46] Updates for release 2.31.3 (#859) --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f21be9522..4b58737c3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -94,6 +94,7 @@ body: description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed. multiple: false options: + - '2.31.3' - '2.31.2' - '2.31.1' - '2.31.0' From f4df51884cdf7fe4cdbdd1e27fdfc341f27d67fb Mon Sep 17 00:00:00 2001 From: James Player Date: Thu, 3 Jul 2025 16:01:08 +1200 Subject: [PATCH 25/46] fix(tests): Fix ServicesDatatable tests - r8s-395 (#860) --- .../ServicesDatatable/ServicesDatatable.test.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.test.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.test.tsx index 0d4dfe7fd..e8539826c 100644 --- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.test.tsx +++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.test.tsx @@ -84,11 +84,8 @@ beforeEach(() => { ); }); const mockUser = { - ...createMockUsers(1)[0], - PortainerAuthorizations: { - K8sAccessSystemNamespaces: true, - K8sServiceW: true, - }, + // Admin user + ...createMockUsers(1, 1)[0], }; function createTestComponent() { From 1332f718aecb7f51fa26566a0f03fe47aca58240 Mon Sep 17 00:00:00 2001 From: Steven Kang Date: Fri, 4 Jul 2025 10:07:57 +1200 Subject: [PATCH 26/46] feat: add warning events count next to the status badge (#828) --- api/http/handler/kubernetes/namespaces.go | 15 ++++++++ api/kubernetes/cli/namespace.go | 28 +++++++++++++++ api/portainer.go | 19 ++++++----- .../SecretsDatatable/SecretsDatatable.tsx | 20 +++++------ .../CronJobsDatatable/columns/command.tsx | 2 +- .../CronJobsDatatable/columns/schedule.tsx | 2 +- .../CronJobsDatatable/columns/timezone.tsx | 2 +- .../JobsDatatable/columns/command.tsx | 2 +- .../JobsDatatable/columns/duration.tsx | 2 +- .../JobsDatatable/columns/finished.tsx | 2 +- .../JobsDatatable/columns/started.tsx | 2 +- .../JobsView/JobsDatatable/columns/status.tsx | 2 +- .../ItemView/useNamespaceFormValues.test.ts | 2 ++ .../ListView/NamespacesDatatable.tsx | 2 ++ .../ListView/columns/useColumns.tsx | 34 ++++++++++++++++--- .../namespaces/queries/queryKeys.ts | 3 +- .../namespaces/queries/useNamespacesQuery.ts | 17 ++++++++-- app/react/kubernetes/namespaces/types.ts | 1 + 18 files changed, 120 insertions(+), 37 deletions(-) diff --git a/api/http/handler/kubernetes/namespaces.go b/api/http/handler/kubernetes/namespaces.go index 2efde3b85..75dae9e69 100644 --- a/api/http/handler/kubernetes/namespaces.go +++ b/api/http/handler/kubernetes/namespaces.go @@ -22,6 +22,7 @@ import ( // @produce json // @param id path int true "Environment identifier" // @param withResourceQuota query boolean true "When set to true, include the resource quota information as part of the Namespace information. Default is false" +// @param withUnhealthyEvents query boolean true "When set to true, include the unhealthy events information as part of the Namespace information. Default is false" // @success 200 {array} portainer.K8sNamespaceInfo "Success" // @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." // @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." @@ -36,6 +37,12 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withResourceQuota. Error: ", err) } + withUnhealthyEvents, err := request.RetrieveBooleanQueryParameter(r, "withUnhealthyEvents", true) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Invalid query parameter withUnhealthyEvents") + return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withUnhealthyEvents. Error: ", err) + } + cli, httpErr := handler.prepareKubeClient(r) if httpErr != nil { log.Error().Err(httpErr).Str("context", "GetKubernetesNamespaces").Msg("Unable to get a Kubernetes client for the user") @@ -48,6 +55,14 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to retrieve namespaces from the Kubernetes cluster. Error: ", err) } + if withUnhealthyEvents { + namespaces, err = cli.CombineNamespacesWithUnhealthyEvents(namespaces) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Unable to combine namespaces with unhealthy events") + return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to combine namespaces with unhealthy events. Error: ", err) + } + } + if withResourceQuota { return cli.CombineNamespacesWithResourceQuotas(namespaces, w) } diff --git a/api/kubernetes/cli/namespace.go b/api/kubernetes/cli/namespace.go index 11307d651..bb29680b5 100644 --- a/api/kubernetes/cli/namespace.go +++ b/api/kubernetes/cli/namespace.go @@ -351,6 +351,34 @@ func (kcl *KubeClient) DeleteNamespace(namespaceName string) (*corev1.Namespace, return namespace, nil } +// CombineNamespacesWithUnhealthyEvents combines namespaces with unhealthy events across all namespaces +func (kcl *KubeClient) CombineNamespacesWithUnhealthyEvents(namespaces map[string]portainer.K8sNamespaceInfo) (map[string]portainer.K8sNamespaceInfo, error) { + allEvents, err := kcl.GetEvents("", "") + if err != nil && !k8serrors.IsNotFound(err) { + log.Error(). + Str("context", "CombineNamespacesWithUnhealthyEvents"). + Err(err). + Msg("unable to retrieve unhealthy events from the Kubernetes for an admin user") + return nil, err + } + + unhealthyEventCounts := make(map[string]int) + for _, event := range allEvents { + if event.Type == "Warning" { + unhealthyEventCounts[event.Namespace]++ + } + } + + for namespaceName, namespace := range namespaces { + if count, exists := unhealthyEventCounts[namespaceName]; exists { + namespace.UnhealthyEventCount = count + namespaces[namespaceName] = namespace + } + } + + return namespaces, nil +} + // CombineNamespacesWithResourceQuotas combines namespaces with resource quotas where matching is based on "portainer-rq-"+namespace.Name func (kcl *KubeClient) CombineNamespacesWithResourceQuotas(namespaces map[string]portainer.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError { resourceQuotas, err := kcl.GetResourceQuotas("") diff --git a/api/portainer.go b/api/portainer.go index 1e5f2ea6f..bdd52aa34 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -621,15 +621,16 @@ type ( JobType int K8sNamespaceInfo struct { - Id string `json:"Id"` - Name string `json:"Name"` - Status corev1.NamespaceStatus `json:"Status"` - Annotations map[string]string `json:"Annotations"` - CreationDate string `json:"CreationDate"` - NamespaceOwner string `json:"NamespaceOwner"` - IsSystem bool `json:"IsSystem"` - IsDefault bool `json:"IsDefault"` - ResourceQuota *corev1.ResourceQuota `json:"ResourceQuota"` + Id string `json:"Id"` + Name string `json:"Name"` + Status corev1.NamespaceStatus `json:"Status"` + Annotations map[string]string `json:"Annotations"` + CreationDate string `json:"CreationDate"` + UnhealthyEventCount int `json:"UnhealthyEventCount"` + NamespaceOwner string `json:"NamespaceOwner"` + IsSystem bool `json:"IsSystem"` + IsDefault bool `json:"IsDefault"` + ResourceQuota *corev1.ResourceQuota `json:"ResourceQuota"` } K8sNodeLimits struct { diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx b/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx index 8cbc6de59..4f0140d55 100644 --- a/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx +++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx @@ -100,18 +100,14 @@ function useSecretRowData( ): SecretRowData[] { return useMemo( () => - secrets?.map( - (secret) => - ({ - ...secret, - inUse: secret.IsUsed, - isSystem: namespaces - ? namespaces.find( - (namespace) => namespace.Name === secret.Namespace - )?.IsSystem ?? false - : false, - }) ?? [] - ), + (secrets ?? []).map((secret) => ({ + ...secret, + inUse: secret.IsUsed, + isSystem: namespaces + ? namespaces.find((namespace) => namespace.Name === secret.Namespace) + ?.IsSystem ?? false + : false, + })), [secrets, namespaces] ); } diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/command.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/command.tsx index 04e6e155f..2d1977a0a 100644 --- a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/command.tsx +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/command.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const command = columnHelper.accessor((row) => row.Command, { header: 'Command', id: 'command', - cell: ({ getValue }) => getValue(), + cell: ({ getValue }) => getValue() ?? '', }); diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/schedule.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/schedule.tsx index 60a673eb0..f979240e8 100644 --- a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/schedule.tsx +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/schedule.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const schedule = columnHelper.accessor((row) => row.Schedule, { header: 'Schedule', id: 'schedule', - cell: ({ getValue }) => getValue(), + cell: ({ getValue }) => getValue() ?? '', }); diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/timezone.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/timezone.tsx index 54bb35a79..3b262b93b 100644 --- a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/timezone.tsx +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/timezone.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const timezone = columnHelper.accessor((row) => row.Timezone, { header: 'Timezone', id: 'timezone', - cell: ({ getValue }) => getValue(), + cell: ({ getValue }) => getValue() ?? '', }); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/command.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/command.tsx index 04e6e155f..2d1977a0a 100644 --- a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/command.tsx +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/command.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const command = columnHelper.accessor((row) => row.Command, { header: 'Command', id: 'command', - cell: ({ getValue }) => getValue(), + cell: ({ getValue }) => getValue() ?? '', }); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/duration.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/duration.tsx index 23cfcaf34..b189d5a7a 100644 --- a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/duration.tsx +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/duration.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const duration = columnHelper.accessor((row) => row.Duration, { header: 'Duration', id: 'duration', - cell: ({ getValue }) => getValue(), + cell: ({ getValue }) => getValue() ?? '', }); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/finished.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/finished.tsx index b3bcb4e0f..cf8e5b5e3 100644 --- a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/finished.tsx +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/finished.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const finished = columnHelper.accessor((row) => row.FinishTime, { header: 'Finished', id: 'finished', - cell: ({ getValue }) => getValue(), + cell: ({ getValue }) => getValue() ?? '', }); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/started.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/started.tsx index eff7f2465..8ea383c64 100644 --- a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/started.tsx +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/started.tsx @@ -7,6 +7,6 @@ export const started = columnHelper.accessor( { header: 'Started', id: 'started', - cell: ({ getValue }) => getValue(), + cell: ({ getValue }) => getValue() ?? '', } ); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.tsx index 2f6ad1768..93ecb66a2 100644 --- a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.tsx +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.tsx @@ -26,7 +26,7 @@ function Cell({ row: { original: item } }: CellContext) { }, ])} /> - {item.Status} + {item.Status ?? ''} {item.Status === 'Failed' && ( remainingNamespaces ); diff --git a/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx b/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx index 541c2315f..ba5eaf159 100644 --- a/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx +++ b/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx @@ -1,13 +1,17 @@ import _ from 'lodash'; import { useMemo } from 'react'; +import { AlertTriangle } from 'lucide-react'; import { isoDate } from '@/portainer/filters/filters'; import { useAuthorizations } from '@/react/hooks/useUser'; +import { pluralize } from '@/portainer/helpers/strings'; import { Link } from '@@/Link'; import { StatusBadge } from '@@/StatusBadge'; import { Badge } from '@@/Badge'; import { SystemBadge } from '@@/Badge/SystemBadge'; +import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; +import { Icon } from '@@/Icon'; import { helper } from './helper'; import { actions } from './actions'; @@ -45,12 +49,34 @@ export function useColumns() { }), helper.accessor('Status', { header: 'Status', - cell({ getValue }) { + cell({ getValue, row: { original: item } }) { const status = getValue(); return ( - - {status.phase} - +
+ + {status.phase} + + {item.UnhealthyEventCount > 0 && ( + + + + + + {item.UnhealthyEventCount}{' '} + {pluralize(item.UnhealthyEventCount, 'warning')} + + + + + )} +
); function getColor(status?: string) { diff --git a/app/react/kubernetes/namespaces/queries/queryKeys.ts b/app/react/kubernetes/namespaces/queries/queryKeys.ts index ecfe4ea58..6c0a04676 100644 --- a/app/react/kubernetes/namespaces/queries/queryKeys.ts +++ b/app/react/kubernetes/namespaces/queries/queryKeys.ts @@ -5,7 +5,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; export const queryKeys = { list: ( environmentId: EnvironmentId, - options?: { withResourceQuota?: boolean } + options?: { withResourceQuota?: boolean; withUnhealthyEvents?: boolean } ) => compact([ 'environments', @@ -13,6 +13,7 @@ export const queryKeys = { 'kubernetes', 'namespaces', options?.withResourceQuota, + options?.withUnhealthyEvents, ]), namespace: (environmentId: EnvironmentId, namespace: string) => [ diff --git a/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts b/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts index b4cd2d712..e7977db84 100644 --- a/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts +++ b/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts @@ -13,14 +13,21 @@ export function useNamespacesQuery( options?: { autoRefreshRate?: number; withResourceQuota?: boolean; + withUnhealthyEvents?: boolean; select?: (namespaces: PortainerNamespace[]) => T; } ) { return useQuery( queryKeys.list(environmentId, { withResourceQuota: !!options?.withResourceQuota, + withUnhealthyEvents: !!options?.withUnhealthyEvents, }), - async () => getNamespaces(environmentId, options?.withResourceQuota), + async () => + getNamespaces( + environmentId, + options?.withResourceQuota, + options?.withUnhealthyEvents + ), { ...withGlobalError('Unable to get namespaces.'), refetchInterval() { @@ -34,9 +41,13 @@ export function useNamespacesQuery( // getNamespaces is used to retrieve namespaces using the Portainer backend with caching export async function getNamespaces( environmentId: EnvironmentId, - withResourceQuota?: boolean + withResourceQuota?: boolean, + withUnhealthyEvents?: boolean ) { - const params = withResourceQuota ? { withResourceQuota } : {}; + const params = { + withResourceQuota, + withUnhealthyEvents, + }; try { const { data: namespaces } = await axios.get( `kubernetes/${environmentId}/namespaces`, diff --git a/app/react/kubernetes/namespaces/types.ts b/app/react/kubernetes/namespaces/types.ts index ba4abb744..f3626e675 100644 --- a/app/react/kubernetes/namespaces/types.ts +++ b/app/react/kubernetes/namespaces/types.ts @@ -10,6 +10,7 @@ export interface PortainerNamespace { Id: string; Name: string; Status: NamespaceStatus; + UnhealthyEventCount: number; Annotations: Record | null; CreationDate: string; NamespaceOwner: string; From 8ffe4e284a9de9caacdbd3d4b7d859b63040f38d Mon Sep 17 00:00:00 2001 From: Devon Steenberg Date: Fri, 4 Jul 2025 10:48:54 +1200 Subject: [PATCH 27/46] fix(tls): set insecureSkipVerify to false in FIPS mode [BE-11932] (#849) --- api/cmd/portainer/main.go | 3 ++- api/hostmanagement/openamt/openamt.go | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 90fd34fd2..23bd540a5 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -383,7 +383,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { gitService := git.NewService(shutdownCtx) - openAMTService := openamt.NewService() + // Setting insecureSkipVerify to true to preserve the old behaviour. + openAMTService := openamt.NewService(true) cryptoService := &crypto.Service{} diff --git a/api/hostmanagement/openamt/openamt.go b/api/hostmanagement/openamt/openamt.go index b27b78878..5843c1bdb 100644 --- a/api/hostmanagement/openamt/openamt.go +++ b/api/hostmanagement/openamt/openamt.go @@ -32,9 +32,9 @@ type Service struct { } // NewService initializes a new service. -func NewService() *Service { +func NewService(insecureSkipVerify bool) *Service { tlsConfig := crypto.CreateTLSConfiguration() - tlsConfig.InsecureSkipVerify = true + tlsConfig.InsecureSkipVerify = insecureSkipVerify return &Service{ httpsClient: &http.Client{ From c20a8b5a68dcb9fdede92e707461e3302e7013c4 Mon Sep 17 00:00:00 2001 From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com> Date: Sat, 5 Jul 2025 02:49:33 +1200 Subject: [PATCH 28/46] fix(template): app template v3 error [BE-11998] (#854) --- api/http/handler/templates/utils_fetch_templates.go | 6 ++++-- .../CreateView/tests/app-templates.test.tsx | 5 +---- .../DeployFormWidget/EnvVarsFieldset.test.tsx | 11 +++++++---- .../DeployFormWidget/EnvVarsFieldset.tsx | 12 +++++++----- .../DeployFormWidget/StackDeployForm/types.ts | 2 +- .../portainer/templates/app-templates/view-model.ts | 4 +--- 6 files changed, 21 insertions(+), 19 deletions(-) diff --git a/api/http/handler/templates/utils_fetch_templates.go b/api/http/handler/templates/utils_fetch_templates.go index 73f9bad56..fc5c97125 100644 --- a/api/http/handler/templates/utils_fetch_templates.go +++ b/api/http/handler/templates/utils_fetch_templates.go @@ -40,11 +40,13 @@ func (handler *Handler) fetchTemplates() (*listResponse, *httperror.HandlerError } defer resp.Body.Close() - err = json.NewDecoder(resp.Body).Decode(&body) - if err != nil { + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return nil, httperror.InternalServerError("Unable to parse template file", err) } + for i := range body.Templates { + body.Templates[i].ID = portainer.TemplateID(i + 1) + } return body, nil } diff --git a/app/react/edge/edge-stacks/CreateView/tests/app-templates.test.tsx b/app/react/edge/edge-stacks/CreateView/tests/app-templates.test.tsx index 9cb4ba546..a0d13e43a 100644 --- a/app/react/edge/edge-stacks/CreateView/tests/app-templates.test.tsx +++ b/app/react/edge/edge-stacks/CreateView/tests/app-templates.test.tsx @@ -80,10 +80,7 @@ test('The form should submit the correct request body for a given app template', // fill in the name and select the docker edge group const user = userEvent.setup(); await user.type(getByRole('textbox', { name: 'Name *' }), 'my-stack'); - await user.type( - getByRole('textbox', { name: 'License key *' }), - 'license-123' - ); + await user.type(getByRole('textbox', { name: 'License key' }), 'license-123'); const selectElement = getByLabelText('Edge groups'); await selectEvent.select(selectElement, 'docker'); diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.test.tsx b/app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.test.tsx index d5dbb2633..12c836d0d 100644 --- a/app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.test.tsx +++ b/app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.test.tsx @@ -51,7 +51,7 @@ test('calls onChange when input value changes', async () => { const inputElement = screen.getByDisplayValue(value.VAR1); await user.clear(inputElement); - expect(onChange).toHaveBeenCalledWith({ VAR1: '' }); + expect(onChange).toHaveBeenCalledWith({ VAR1: undefined }); const newValue = 'New Value'; await user.type(inputElement, newValue); @@ -107,11 +107,14 @@ test('validates env vars fieldset', () => { ]); const validData = { VAR1: 'Value 1', VAR2: 'Value 2' }; - const invalidData = { VAR1: '', VAR2: 'Value 2' }; + const emptyData = { VAR1: '', VAR2: 'Value 2' }; + const undefinedData = { VAR1: undefined, VAR2: 'Value 2' }; const validResult = schema.isValidSync(validData); - const invalidResult = schema.isValidSync(invalidData); + const emptyResult = schema.isValidSync(emptyData); + const undefinedResult = schema.isValidSync(undefinedData); expect(validResult).toBe(true); - expect(invalidResult).toBe(false); + expect(emptyResult).toBe(true); + expect(undefinedResult).toBe(true); }); diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.tsx b/app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.tsx index dad33af07..a1281772f 100644 --- a/app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.tsx +++ b/app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.tsx @@ -6,7 +6,7 @@ import { TemplateEnv } from '@/react/portainer/templates/app-templates/types'; import { FormControl } from '@@/form-components/FormControl'; import { Input, Select } from '@@/form-components/Input'; -type Value = Record; +type Value = Record; export { type Value as EnvVarsValue }; @@ -27,7 +27,7 @@ export function EnvVarsFieldset({ handleChange(env.name, value)} errors={errors?.[env.name]} /> @@ -36,7 +36,7 @@ export function EnvVarsFieldset({ ); function handleChange(name: string, envValue: string) { - onChange({ ...values, [name]: envValue }); + onChange({ ...values, [name]: envValue || undefined }); } } @@ -55,7 +55,7 @@ function Item({ return ( @@ -101,7 +101,9 @@ export function envVarsFieldsetValidation( ): SchemaOf { return object( Object.fromEntries( - definitions.map((v) => [v.name, string().required('Required')]) + definitions + .filter((v) => !v.preset) + .map((v) => [v.name, string().optional()]) ) ); } diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/types.ts b/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/types.ts index d94a2412e..f40d79c56 100644 --- a/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/types.ts +++ b/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/types.ts @@ -2,6 +2,6 @@ import { AccessControlFormData } from '@/react/portainer/access-control/types'; export interface FormValues { name: string; - envVars: Record; + envVars: Record; accessControl: AccessControlFormData; } diff --git a/app/react/portainer/templates/app-templates/view-model.ts b/app/react/portainer/templates/app-templates/view-model.ts index 94ea24bc3..3619caa20 100644 --- a/app/react/portainer/templates/app-templates/view-model.ts +++ b/app/react/portainer/templates/app-templates/view-model.ts @@ -88,10 +88,8 @@ function setTemplatesV3(this: TemplateViewModel, template: AppTemplate) { this.Id = template.id; } -let templateV2ID = 0; - function setTemplatesV2(this: TemplateViewModel, template: AppTemplate) { - this.Id = templateV2ID++; + this.Id = template.id; this.Title = template.title; this.Type = template.type; this.Description = template.description; From 0a36d4fbfd42362f05363c6e6f75cebbffce2e4f Mon Sep 17 00:00:00 2001 From: Steven Kang Date: Mon, 7 Jul 2025 11:29:29 +1200 Subject: [PATCH 29/46] fix: `kubectl` sdk - capture fatal error and return instead of exiting 1 [r7s-371] (#841) --- pkg/libkubectl/apply.go | 16 ++++++++++++++-- pkg/libkubectl/client.go | 5 +++++ pkg/libkubectl/delete.go | 15 ++++++++++++++- pkg/libkubectl/restart.go | 15 ++++++++++++++- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/pkg/libkubectl/apply.go b/pkg/libkubectl/apply.go index 5dad5adc5..23619e532 100644 --- a/pkg/libkubectl/apply.go +++ b/pkg/libkubectl/apply.go @@ -6,18 +6,30 @@ import ( "fmt" "k8s.io/kubectl/pkg/cmd/apply" + cmdutil "k8s.io/kubectl/pkg/cmd/util" ) func (c *Client) Apply(ctx context.Context, manifests []string) (string, error) { buf := new(bytes.Buffer) + var fatalErr error + cmdutil.BehaviorOnFatal(func(msg string, code int) { + fatalErr = newKubectlFatalError(code, msg) + }) + defer cmdutil.DefaultBehaviorOnFatal() + cmd := apply.NewCmdApply("kubectl", c.factory, c.streams) cmd.SetArgs(resourcesToArgs(manifests)) cmd.SetOut(buf) - if err := cmd.ExecuteContext(ctx); err != nil { + err := cmd.ExecuteContext(ctx) + // check for the fatal error first so we don't return the error from the command execution + if fatalErr != nil { + return "", fatalErr + } + // if there is no fatal error, return the error from the command execution + if err != nil { return "", fmt.Errorf("error applying resources: %w", err) } - return buf.String(), nil } diff --git a/pkg/libkubectl/client.go b/pkg/libkubectl/client.go index f9d4137f6..0229c8167 100644 --- a/pkg/libkubectl/client.go +++ b/pkg/libkubectl/client.go @@ -3,6 +3,7 @@ package libkubectl import ( "bytes" "errors" + "fmt" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/genericiooptions" @@ -61,3 +62,7 @@ func generateConfigFlags(token, server, namespace, kubeconfigPath string, insecu return configFlags, nil } + +func newKubectlFatalError(code int, msg string) error { + return fmt.Errorf("kubectl fatal error (exit code %d): %s", code, msg) +} diff --git a/pkg/libkubectl/delete.go b/pkg/libkubectl/delete.go index bb093ab4d..4544dfc76 100644 --- a/pkg/libkubectl/delete.go +++ b/pkg/libkubectl/delete.go @@ -6,17 +6,30 @@ import ( "fmt" "k8s.io/kubectl/pkg/cmd/delete" + cmdutil "k8s.io/kubectl/pkg/cmd/util" ) func (c *Client) Delete(ctx context.Context, manifests []string) (string, error) { buf := new(bytes.Buffer) + var fatalErr error + cmdutil.BehaviorOnFatal(func(msg string, code int) { + fatalErr = newKubectlFatalError(code, msg) + }) + defer cmdutil.DefaultBehaviorOnFatal() + cmd := delete.NewCmdDelete(c.factory, c.streams) cmd.SetArgs(resourcesToArgs(manifests)) cmd.Flags().Set("ignore-not-found", "true") cmd.SetOut(buf) - if err := cmd.ExecuteContext(ctx); err != nil { + err := cmd.ExecuteContext(ctx) + // check for the fatal error first so we don't return the error from the command execution + if fatalErr != nil { + return "", fatalErr + } + // if there is no fatal error, return the error from the command execution + if err != nil { return "", fmt.Errorf("error deleting resources: %w", err) } diff --git a/pkg/libkubectl/restart.go b/pkg/libkubectl/restart.go index fcfa7fd15..9ce7149aa 100644 --- a/pkg/libkubectl/restart.go +++ b/pkg/libkubectl/restart.go @@ -6,11 +6,18 @@ import ( "fmt" "k8s.io/kubectl/pkg/cmd/rollout" + cmdutil "k8s.io/kubectl/pkg/cmd/util" ) func (c *Client) RolloutRestart(ctx context.Context, manifests []string) (string, error) { buf := new(bytes.Buffer) + var fatalErr error + cmdutil.BehaviorOnFatal(func(msg string, code int) { + fatalErr = newKubectlFatalError(code, msg) + }) + defer cmdutil.DefaultBehaviorOnFatal() + cmd := rollout.NewCmdRollout(c.factory, c.streams) args := []string{"restart"} args = append(args, resourcesToArgs(manifests)...) @@ -18,7 +25,13 @@ func (c *Client) RolloutRestart(ctx context.Context, manifests []string) (string cmd.SetArgs(args) cmd.SetOut(buf) - if err := cmd.ExecuteContext(ctx); err != nil { + err := cmd.ExecuteContext(ctx) + // check for the fatal error first so we don't return the error from the command execution + if fatalErr != nil { + return "", fatalErr + } + // if there is no fatal error, return the error from the command execution + if err != nil { return "", fmt.Errorf("error restarting resources: %w", err) } From 0c80b1067d8e3057046bfa8b68eea5fb7acf69bc Mon Sep 17 00:00:00 2001 From: Viktor Pettersson Date: Mon, 7 Jul 2025 20:54:44 +1200 Subject: [PATCH 30/46] fix(styles): update datetime picker styles for improved dark mode support [BE-11672] (#863) --- .../css/react-datetime-picker-override.css | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/app/assets/css/react-datetime-picker-override.css b/app/assets/css/react-datetime-picker-override.css index acd26fb58..dbbea4766 100644 --- a/app/assets/css/react-datetime-picker-override.css +++ b/app/assets/css/react-datetime-picker-override.css @@ -12,35 +12,40 @@ /* Extending Calendar.css from react-daterange-picker__calendar */ -.react-daterange-picker__calendar .react-calendar { +.react-calendar { background: var(--bg-calendar-color); color: var(--text-main-color); + @apply th-dark:bg-gray-iron-10; } /* calendar nav buttons */ -.react-daterange-picker__calendar .react-calendar__navigation button:disabled { +.react-calendar__navigation button:disabled { background: var(--bg-calendar-color); @apply opacity-60; @apply brightness-95 th-dark:brightness-110; + @apply th-dark:bg-gray-iron-7; } -.react-daterange-picker__calendar .react-calendar__navigation button:enabled:hover, -.react-daterange-picker__calendar .react-calendar__navigation button:enabled:focus { +.react-calendar__navigation button:enabled:hover, +.react-calendar__navigation button:enabled:focus { background: var(--bg-daterangepicker-color); + @apply th-dark:bg-gray-iron-7; } /* date tile */ -.react-daterange-picker__calendar .react-calendar__tile:disabled { - background: var(--bg-calendar-color); +.react-calendar__tile:disabled { @apply opacity-60; @apply brightness-95 th-dark:brightness-110; + @apply th-dark:bg-gray-iron-7; } -.react-daterange-picker__calendar .react-calendar__tile:enabled:hover, -.react-daterange-picker__calendar .react-calendar__tile:enabled:focus { + +.react-calendar__tile:enabled:hover, +.react-calendar__tile:enabled:focus { background: var(--bg-daterangepicker-hover); + @apply th-dark:bg-gray-iron-7; } /* today's date tile */ -.react-daterange-picker__calendar .react-calendar__tile--now { +.react-calendar__tile--now { @apply th-highcontrast:text-[color:var(--bg-calendar-color)] th-dark:text-[color:var(--bg-calendar-color)]; border-radius: 0.25rem !important; } @@ -48,23 +53,27 @@ .react-daterange-picker__calendar .react-calendar__tile--now:enabled:focus { background: var(--bg-daterangepicker-hover); color: var(--text-daterangepicker-hover); + @apply th-dark:bg-gray-iron-7; } /* probably date tile in range */ -.react-daterange-picker__calendar .react-calendar__tile--hasActive { +.react-calendar__tile--hasActive { background: var(--bg-daterangepicker-end-date); color: var(--text-daterangepicker-end-date); + @apply th-dark:bg-gray-iron-7; } -.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:hover, -.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:focus { +.react-calendar__tile--hasActive:enabled:hover, +.react-calendar__tile--hasActive:enabled:focus { background: var(--bg-daterangepicker-hover); color: var(--text-daterangepicker-hover); + @apply th-dark:bg-gray-iron-7; } -.react-daterange-picker__calendar .react-calendar__tile--active:enabled:hover, -.react-daterange-picker__calendar .react-calendar__tile--active:enabled:focus { +.react-calendar__tile--active:enabled:hover, +.react-calendar__tile--active:enabled:focus { background: var(--bg-daterangepicker-hover); color: var(--text-daterangepicker-hover); + @apply th-dark:bg-gray-iron-7; } .react-daterange-picker__calendar @@ -75,9 +84,10 @@ } /* on range select hover */ -.react-daterange-picker__calendar .react-calendar--selectRange .react-calendar__tile--hover { +.react-calendar--selectRange .react-calendar__tile--hover { background: var(--bg-daterangepicker-in-range); color: var(--text-daterangepicker-in-range); + @apply th-dark:bg-gray-iron-7; } /* @@ -111,4 +121,5 @@ .react-calendar__tile--active.react-calendar__month-view__days__day--weekend { color: var(--text-daterangepicker-active); + @apply th-dark:bg-gray-iron-7; } From 302deb82999e813239fe42376317e77cb5b5d41f Mon Sep 17 00:00:00 2001 From: andres-portainer <91705312+andres-portainer@users.noreply.github.com> Date: Mon, 7 Jul 2025 14:29:56 -0300 Subject: [PATCH 31/46] chore(dataservices): enhance ReadAll() so it takes predicates for filtering results BE-12016 (#866) --- api/dataservices/base.go | 7 +- api/dataservices/base_test.go | 92 +++++++++++++++++++++++++++ api/dataservices/base_tx.go | 23 ++++++- api/internal/testhelpers/datastore.go | 36 +++++++++-- 4 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 api/dataservices/base_test.go diff --git a/api/dataservices/base.go b/api/dataservices/base.go index 04af70b02..18839b60f 100644 --- a/api/dataservices/base.go +++ b/api/dataservices/base.go @@ -10,7 +10,7 @@ type BaseCRUD[T any, I constraints.Integer] interface { Create(element *T) error Read(ID I) (*T, error) Exists(ID I) (bool, error) - ReadAll() ([]T, error) + ReadAll(predicates ...func(T) bool) ([]T, error) Update(ID I, element *T) error Delete(ID I) error } @@ -56,12 +56,13 @@ func (service BaseDataService[T, I]) Exists(ID I) (bool, error) { return exists, err } -func (service BaseDataService[T, I]) ReadAll() ([]T, error) { +// ReadAll retrieves all the elements that satisfy all the provided predicates. +func (service BaseDataService[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) { var collection = make([]T, 0) return collection, service.Connection.ViewTx(func(tx portainer.Transaction) error { var err error - collection, err = service.Tx(tx).ReadAll() + collection, err = service.Tx(tx).ReadAll(predicates...) return err }) diff --git a/api/dataservices/base_test.go b/api/dataservices/base_test.go new file mode 100644 index 000000000..e97a09963 --- /dev/null +++ b/api/dataservices/base_test.go @@ -0,0 +1,92 @@ +package dataservices + +import ( + "strconv" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/slicesx" + + "github.com/stretchr/testify/require" +) + +type testObject struct { + ID int + Value int +} + +type mockConnection struct { + store map[int]testObject + + portainer.Connection +} + +func (m mockConnection) UpdateObject(bucket string, key []byte, value interface{}) error { + obj := value.(*testObject) + + m.store[obj.ID] = *obj + + return nil +} + +func (m mockConnection) GetAll(bucketName string, obj any, appendFn func(o any) (any, error)) error { + for _, v := range m.store { + if _, err := appendFn(&v); err != nil { + return err + } + } + + return nil +} + +func (m mockConnection) UpdateTx(fn func(portainer.Transaction) error) error { + return fn(m) +} + +func (m mockConnection) ViewTx(fn func(portainer.Transaction) error) error { + return fn(m) +} + +func (m mockConnection) ConvertToKey(v int) []byte { + return []byte(strconv.Itoa(v)) +} + +func TestReadAll(t *testing.T) { + service := BaseDataService[testObject, int]{ + Bucket: "testBucket", + Connection: mockConnection{store: make(map[int]testObject)}, + } + + data := []testObject{ + {ID: 1, Value: 1}, + {ID: 2, Value: 2}, + {ID: 3, Value: 3}, + {ID: 4, Value: 4}, + {ID: 5, Value: 5}, + } + + for _, item := range data { + err := service.Update(item.ID, &item) + require.NoError(t, err) + } + + // ReadAll without predicates + result, err := service.ReadAll() + require.NoError(t, err) + + expected := append([]testObject{}, data...) + + require.ElementsMatch(t, expected, result) + + // ReadAll with predicates + hasLowID := func(obj testObject) bool { return obj.ID < 3 } + isEven := func(obj testObject) bool { return obj.Value%2 == 0 } + + result, err = service.ReadAll(hasLowID, isEven) + require.NoError(t, err) + + expected = slicesx.Filter(expected, hasLowID) + expected = slicesx.Filter(expected, isEven) + + require.ElementsMatch(t, expected, result) +} diff --git a/api/dataservices/base_tx.go b/api/dataservices/base_tx.go index d9915b64c..5d7e7eee0 100644 --- a/api/dataservices/base_tx.go +++ b/api/dataservices/base_tx.go @@ -34,13 +34,32 @@ func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) { return service.Tx.KeyExists(service.Bucket, identifier) } -func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) { +// ReadAll retrieves all the elements that satisfy all the provided predicates. +func (service BaseDataServiceTx[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) { var collection = make([]T, 0) + if len(predicates) == 0 { + return collection, service.Tx.GetAll( + service.Bucket, + new(T), + AppendFn(&collection), + ) + } + + filterFn := func(element T) bool { + for _, p := range predicates { + if !p(element) { + return false + } + } + + return true + } + return collection, service.Tx.GetAll( service.Bucket, new(T), - AppendFn(&collection), + FilterFn(&collection, filterFn), ) } diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index 392f21e97..19254f540 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/portainer/api/database" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices/errors" + "github.com/portainer/portainer/api/slicesx" ) var _ dataservices.DataStore = &testDatastore{} @@ -152,8 +153,17 @@ type stubUserService struct { users []portainer.User } -func (s *stubUserService) BucketName() string { return "users" } -func (s *stubUserService) ReadAll() ([]portainer.User, error) { return s.users, nil } +func (s *stubUserService) BucketName() string { return "users" } +func (s *stubUserService) ReadAll(predicates ...func(portainer.User) bool) ([]portainer.User, error) { + filtered := s.users + + for _, p := range predicates { + filtered = slicesx.Filter(filtered, p) + } + + return filtered, nil +} + func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { return s.users, nil } @@ -171,8 +181,16 @@ type stubEdgeJobService struct { jobs []portainer.EdgeJob } -func (s *stubEdgeJobService) BucketName() string { return "edgejobs" } -func (s *stubEdgeJobService) ReadAll() ([]portainer.EdgeJob, error) { return s.jobs, nil } +func (s *stubEdgeJobService) BucketName() string { return "edgejobs" } +func (s *stubEdgeJobService) ReadAll(predicates ...func(portainer.EdgeJob) bool) ([]portainer.EdgeJob, error) { + filtered := s.jobs + + for _, p := range predicates { + filtered = slicesx.Filter(filtered, p) + } + + return filtered, nil +} // WithEdgeJobs option will instruct testDatastore to return provided jobs func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption { @@ -362,8 +380,14 @@ func (s *stubStacksService) Read(ID portainer.StackID) (*portainer.Stack, error) return nil, errors.ErrObjectNotFound } -func (s *stubStacksService) ReadAll() ([]portainer.Stack, error) { - return s.stacks, nil +func (s *stubStacksService) ReadAll(predicates ...func(portainer.Stack) bool) ([]portainer.Stack, error) { + filtered := s.stacks + + for _, p := range predicates { + filtered = slicesx.Filter(filtered, p) + } + + return filtered, nil } func (s *stubStacksService) StacksByEndpointID(endpointID portainer.EndpointID) ([]portainer.Stack, error) { From 4d11aa8655aa3d010193cf1b42293c2c42ec33d3 Mon Sep 17 00:00:00 2001 From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com> Date: Thu, 10 Jul 2025 00:55:59 +1200 Subject: [PATCH 32/46] fix(tag): ignore "environment not found" when deleting tag [BE-11944] (#869) --- api/http/handler/tags/tag_delete.go | 3 +++ api/http/handler/tags/tag_delete_test.go | 25 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go index 1fa98e394..5e67c4f4c 100644 --- a/api/http/handler/tags/tag_delete.go +++ b/api/http/handler/tags/tag_delete.go @@ -59,6 +59,9 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error { for endpointID := range tag.Endpoints { endpoint, err := tx.Endpoint().Endpoint(endpointID) + if tx.IsErrObjectNotFound(err) { + continue + } if err != nil { return httperror.InternalServerError("Unable to retrieve environment from the database", err) } diff --git a/api/http/handler/tags/tag_delete_test.go b/api/http/handler/tags/tag_delete_test.go index cabf20963..a215e7edd 100644 --- a/api/http/handler/tags/tag_delete_test.go +++ b/api/http/handler/tags/tag_delete_test.go @@ -84,3 +84,28 @@ func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) { t.Fatal("the edge group is not consistent") } } + +func TestDeleteTag(t *testing.T) { + _, store := datastore.MustNewTestStore(t, true, false) + + // Test the tx.IsErrObjectNotFound logic when endpoint is not found during cleanup + t.Run("should continue gracefully when endpoint not found during cleanup", func(t *testing.T) { + // Create a tag with a reference to a non-existent endpoint + tag := &portainer.Tag{ + ID: 1, + Name: "test-tag", + Endpoints: map[portainer.EndpointID]bool{999: true}, // Non-existent endpoint + EndpointGroups: make(map[portainer.EndpointGroupID]bool), + } + + err := store.Tag().Create(tag) + if err != nil { + t.Fatal("could not create tag:", err) + } + + err = deleteTag(store, 1) + if err != nil { + t.Fatal("could not delete tag:", err) + } + }) +} From ea4b334c7e0f5d3d6e3b24cfcdd74b113c5ce122 Mon Sep 17 00:00:00 2001 From: andres-portainer <91705312+andres-portainer@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:15:43 -0300 Subject: [PATCH 33/46] feat(csp): enable CSP by default BE-11961 (#872) --- api/cli/cli.go | 1 + api/cmd/portainer/main.go | 1 + api/http/handler/file/handler.go | 7 +++-- api/http/security/bouncer.go | 10 +++++-- api/http/security/bouncer_test.go | 31 +++++++++++++++++++++ api/http/server.go | 6 +++- api/internal/testhelpers/request_bouncer.go | 2 ++ api/portainer.go | 3 ++ 8 files changed, 56 insertions(+), 5 deletions(-) diff --git a/api/cli/cli.go b/api/cli/cli.go index 7f6d80e68..0722c0b2e 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -62,6 +62,7 @@ func CLIFlags() *portainer.CLIFlags { KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(), PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(), TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(), + CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(), } } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 23bd540a5..30493a3da 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -559,6 +559,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { Status: applicationStatus, BindAddress: *flags.Addr, BindAddressHTTPS: *flags.AddrHTTPS, + CSP: *flags.CSP, HTTPEnabled: sslDBSettings.HTTPEnabled, AssetsPath: *flags.Assets, DataStore: dataStore, diff --git a/api/http/handler/file/handler.go b/api/http/handler/file/handler.go index 66f81b64a..9e57478c8 100644 --- a/api/http/handler/file/handler.go +++ b/api/http/handler/file/handler.go @@ -17,12 +17,12 @@ type Handler struct { } // NewHandler creates a handler to serve static files. -func NewHandler(assetPublicPath string, wasInstanceDisabled func() bool) *Handler { +func NewHandler(assetPublicPath string, csp bool, wasInstanceDisabled func() bool) *Handler { h := &Handler{ Handler: security.MWSecureHeaders( gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))), featureflags.IsEnabled("hsts"), - featureflags.IsEnabled("csp"), + csp, ), wasInstanceDisabled: wasInstanceDisabled, } @@ -36,6 +36,7 @@ func isHTML(acceptContent []string) bool { return true } } + return false } @@ -43,11 +44,13 @@ func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if handler.wasInstanceDisabled() { if r.RequestURI == "/" || r.RequestURI == "/index.html" { http.Redirect(w, r, "/timeout.html", http.StatusTemporaryRedirect) + return } } else { if strings.HasPrefix(r.RequestURI, "/timeout.html") { http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return } } diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index eb240692d..fa7360f4c 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -35,6 +35,7 @@ type ( JWTAuthLookup(*http.Request) (*portainer.TokenData, error) TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error RevokeJWT(string) + DisableCSP() } // RequestBouncer represents an entity that manages API request accesses @@ -72,7 +73,7 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW jwtService: jwtService, apiKeyService: apiKeyService, hsts: featureflags.IsEnabled("hsts"), - csp: featureflags.IsEnabled("csp"), + csp: true, } go b.cleanUpExpiredJWT() @@ -80,6 +81,11 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW return b } +// DisableCSP disables Content Security Policy +func (bouncer *RequestBouncer) DisableCSP() { + bouncer.csp = false +} + // PublicAccess defines a security check for public API endpoints. // No authentication is required to access these endpoints. func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler { @@ -528,7 +534,7 @@ func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler { } if csp { - w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud") + w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud; frame-ancestors 'none';") } w.Header().Set("X-Content-Type-Options", "nosniff") diff --git a/api/http/security/bouncer_test.go b/api/http/security/bouncer_test.go index 4d84dcfee..3dd42fdc5 100644 --- a/api/http/security/bouncer_test.go +++ b/api/http/security/bouncer_test.go @@ -530,3 +530,34 @@ func TestJWTRevocation(t *testing.T) { require.Equal(t, 1, revokeLen()) } + +func TestCSPHeaderDefault(t *testing.T) { + b := NewRequestBouncer(nil, nil, nil) + + srv := httptest.NewServer( + b.PublicAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})), + ) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/") + require.NoError(t, err) + defer resp.Body.Close() + + require.Contains(t, resp.Header, "Content-Security-Policy") +} + +func TestCSPHeaderDisabled(t *testing.T) { + b := NewRequestBouncer(nil, nil, nil) + b.DisableCSP() + + srv := httptest.NewServer( + b.PublicAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})), + ) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/") + require.NoError(t, err) + defer resp.Body.Close() + + require.NotContains(t, resp.Header, "Content-Security-Policy") +} diff --git a/api/http/server.go b/api/http/server.go index e876ca5d2..5bfa36379 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -77,6 +77,7 @@ type Server struct { AuthorizationService *authorization.Service BindAddress string BindAddressHTTPS string + CSP bool HTTPEnabled bool AssetsPath string Status *portainer.Status @@ -121,6 +122,9 @@ func (server *Server) Start() error { kubernetesTokenCacheManager := server.KubernetesTokenCacheManager requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService, server.APIKeyService) + if !server.CSP { + requestBouncer.DisableCSP() + } rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) offlineGate := offlinegate.NewOfflineGate() @@ -200,7 +204,7 @@ func (server *Server) Start() error { var dockerHandler = dockerhandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.DockerClientFactory, containerService) - var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), adminMonitor.WasInstanceDisabled) + var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), server.CSP, adminMonitor.WasInstanceDisabled) var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeClusterAccessService) diff --git a/api/internal/testhelpers/request_bouncer.go b/api/internal/testhelpers/request_bouncer.go index b89154549..0586dffef 100644 --- a/api/internal/testhelpers/request_bouncer.go +++ b/api/internal/testhelpers/request_bouncer.go @@ -60,6 +60,8 @@ func (testRequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData, func (testRequestBouncer) RevokeJWT(jti string) {} +func (testRequestBouncer) DisableCSP() {} + // AddTestSecurityCookie adds a security cookie to the request func AddTestSecurityCookie(r *http.Request, jwt string) { r.AddCookie(&http.Cookie{ diff --git a/api/portainer.go b/api/portainer.go index bdd52aa34..da15479ee 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -110,6 +110,7 @@ type ( AdminPassword *string AdminPasswordFile *string Assets *string + CSP *bool Data *string FeatureFlags *[]string EnableEdgeComputeFeatures *bool @@ -1791,6 +1792,8 @@ const ( LicenseCheckInURL = LicenseServerBaseURL + "/licenses/checkin" // TrustedOriginsEnvVar is the environment variable used to set the trusted origins for CSRF protection TrustedOriginsEnvVar = "TRUSTED_ORIGINS" + // CSPEnvVar is the environment variable used to enable/disable the Content Security Policy + CSPEnvVar = "CSP" ) // List of supported features From 3bf84e8b0ccd68aba9c17c061ce1d4774071cc28 Mon Sep 17 00:00:00 2001 From: Viktor Pettersson Date: Thu, 10 Jul 2025 10:52:12 +1200 Subject: [PATCH 34/46] fix(tags): reconcile edge relations prior to deletion [BE-11969] (#867) --- api/http/handler/tags/tag_delete.go | 44 ++++++-- api/http/handler/tags/tag_delete_test.go | 124 ++++++++++++++++++++--- 2 files changed, 147 insertions(+), 21 deletions(-) diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go index 5e67c4f4c..f8f1b7786 100644 --- a/api/http/handler/tags/tag_delete.go +++ b/api/http/handler/tags/tag_delete.go @@ -107,14 +107,10 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error { return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err) } - for _, endpoint := range endpoints { - if (tag.Endpoints[endpoint.ID] || tag.EndpointGroups[endpoint.GroupID]) && endpointutils.IsEdgeEndpoint(&endpoint) { - if err := updateEndpointRelations(tx, endpoint, edgeGroups, edgeStacks); err != nil { - return httperror.InternalServerError("Unable to update environment relations in the database", err) - } - } + edgeJobs, err := tx.EdgeJob().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve edge job configurations from the database", err) } - for _, edgeGroup := range edgeGroups { edgeGroup.TagIDs = slices.DeleteFunc(edgeGroup.TagIDs, func(t portainer.TagID) bool { return t == tagID @@ -126,6 +122,16 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error { } } + for _, endpoint := range endpoints { + if (!tag.Endpoints[endpoint.ID] && !tag.EndpointGroups[endpoint.GroupID]) || !endpointutils.IsEdgeEndpoint(&endpoint) { + continue + } + + if err := updateEndpointRelations(tx, endpoint, edgeGroups, edgeStacks, edgeJobs); err != nil { + return httperror.InternalServerError("Unable to update environment relations in the database", err) + } + } + err = tx.Tag().Delete(tagID) if err != nil { return httperror.InternalServerError("Unable to remove the tag from the database", err) @@ -134,7 +140,7 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error { return nil } -func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error { +func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack, edgeJobs []portainer.EdgeJob) error { endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) if err != nil { return err @@ -153,5 +159,25 @@ func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.End endpointRelation.EdgeStacks = stacksSet - return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation) + if err := tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation); err != nil { + return err + } + + for _, edgeJob := range edgeJobs { + endpoints, err := edge.GetEndpointsFromEdgeGroups(edgeJob.EdgeGroups, tx) + if err != nil { + return err + } + if slices.Contains(endpoints, endpoint.ID) { + continue + } + + delete(edgeJob.GroupLogsCollection, endpoint.ID) + + if err := tx.EdgeJob().Update(edgeJob.ID, &edgeJob); err != nil { + return err + } + } + + return nil } diff --git a/api/http/handler/tags/tag_delete_test.go b/api/http/handler/tags/tag_delete_test.go index a215e7edd..ecced7c0d 100644 --- a/api/http/handler/tags/tag_delete_test.go +++ b/api/http/handler/tags/tag_delete_test.go @@ -1,6 +1,7 @@ package tags import ( + "github.com/portainer/portainer/api/dataservices" "net/http" "net/http/httptest" "strconv" @@ -8,23 +9,18 @@ import ( "testing" portainer "github.com/portainer/portainer/api" + portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors" "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) { const tagsCount = 100 - _, store := datastore.MustNewTestStore(t, true, false) - - user := &portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole} - if err := store.User().Create(user); err != nil { - t.Fatal("could not create admin user:", err) - } - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - + handler, store := setUpHandler(t) // Create all the tags and add them to the same edge group var tagIDs []portainer.TagID @@ -85,11 +81,101 @@ func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) { } } -func TestDeleteTag(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, false) +func TestHandler_tagDelete(t *testing.T) { + t.Run("should delete tag and update related endpoints and edge groups", func(t *testing.T) { + handler, store := setUpHandler(t) + + tag := &portainer.Tag{ + ID: 1, + Name: "tag-1", + Endpoints: make(map[portainer.EndpointID]bool), + EndpointGroups: make(map[portainer.EndpointGroupID]bool), + } + require.NoError(t, store.Tag().Create(tag)) + + endpointGroup := &portainer.EndpointGroup{ + ID: 2, + Name: "endpoint-group-1", + TagIDs: []portainer.TagID{tag.ID}, + } + require.NoError(t, store.EndpointGroup().Create(endpointGroup)) + + endpoint1 := &portainer.Endpoint{ + ID: 1, + Name: "endpoint-1", + GroupID: endpointGroup.ID, + } + require.NoError(t, store.Endpoint().Create(endpoint1)) + + endpoint2 := &portainer.Endpoint{ + ID: 2, + Name: "endpoint-2", + TagIDs: []portainer.TagID{tag.ID}, + } + require.NoError(t, store.Endpoint().Create(endpoint2)) + + tag.Endpoints[endpoint2.ID] = true + tag.EndpointGroups[endpointGroup.ID] = true + require.NoError(t, store.Tag().Update(tag.ID, tag)) + + dynamicEdgeGroup := &portainer.EdgeGroup{ + ID: 1, + Name: "edgegroup-1", + TagIDs: []portainer.TagID{tag.ID}, + Dynamic: true, + } + require.NoError(t, store.EdgeGroup().Create(dynamicEdgeGroup)) + + staticEdgeGroup := &portainer.EdgeGroup{ + ID: 2, + Name: "edgegroup-2", + Endpoints: []portainer.EndpointID{endpoint2.ID}, + } + require.NoError(t, store.EdgeGroup().Create(staticEdgeGroup)) + + req, err := http.NewRequest(http.MethodDelete, "/tags/"+strconv.Itoa(int(tag.ID)), nil) + if err != nil { + t.Fail() + + return + } + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusNoContent, rec.Code) + + // Check that the tag is deleted + _, err = store.Tag().Read(tag.ID) + require.ErrorIs(t, err, portainerDsErrors.ErrObjectNotFound) + + // Check that the endpoints are updated + endpoint1, err = store.Endpoint().Endpoint(endpoint1.ID) + require.NoError(t, err) + assert.Len(t, endpoint1.TagIDs, 0, "endpoint-1 should not have any tags") + assert.Equal(t, endpoint1.GroupID, endpointGroup.ID, "endpoint-1 should still belong to the endpoint group") + + endpoint2, err = store.Endpoint().Endpoint(endpoint2.ID) + require.NoError(t, err) + assert.Len(t, endpoint2.TagIDs, 0, "endpoint-2 should not have any tags") + + // Check that the dynamic edge group is updated + dynamicEdgeGroup, err = store.EdgeGroup().Read(dynamicEdgeGroup.ID) + require.NoError(t, err) + assert.Len(t, dynamicEdgeGroup.TagIDs, 0, "dynamic edge group should not have any tags") + assert.Len(t, dynamicEdgeGroup.Endpoints, 0, "dynamic edge group should not have any endpoints") + + // Check that the static edge group is not updated + staticEdgeGroup, err = store.EdgeGroup().Read(staticEdgeGroup.ID) + require.NoError(t, err) + assert.Len(t, staticEdgeGroup.TagIDs, 0, "static edge group should not have any tags") + assert.Len(t, staticEdgeGroup.Endpoints, 1, "static edge group should have one endpoint") + assert.Equal(t, endpoint2.ID, staticEdgeGroup.Endpoints[0], "static edge group should have the endpoint-2") + }) // Test the tx.IsErrObjectNotFound logic when endpoint is not found during cleanup t.Run("should continue gracefully when endpoint not found during cleanup", func(t *testing.T) { + _, store := setUpHandler(t) // Create a tag with a reference to a non-existent endpoint tag := &portainer.Tag{ ID: 1, @@ -109,3 +195,17 @@ func TestDeleteTag(t *testing.T) { } }) } + +func setUpHandler(t *testing.T) (*Handler, dataservices.DataStore) { + _, store := datastore.MustNewTestStore(t, true, false) + + user := &portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole} + if err := store.User().Create(user); err != nil { + t.Fatal("could not create admin user:", err) + } + + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + + return handler, store +} From ef10ea2a7dff38a64286c23dc52122b17f5d5c56 Mon Sep 17 00:00:00 2001 From: James Player Date: Fri, 11 Jul 2025 11:01:37 +1200 Subject: [PATCH 35/46] fix(ui): Fixed TagsDatatable name column link (#847) --- .../TagsDatatable/TagsDatatable.test.tsx | 169 ++++++++++++++++++ .../TagsDatatable/columns/useColumns.ts | 16 +- 2 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 app/react/portainer/registries/repositories/ItemView/TagsDatatable/TagsDatatable.test.tsx diff --git a/app/react/portainer/registries/repositories/ItemView/TagsDatatable/TagsDatatable.test.tsx b/app/react/portainer/registries/repositories/ItemView/TagsDatatable/TagsDatatable.test.tsx new file mode 100644 index 000000000..6ec8599e1 --- /dev/null +++ b/app/react/portainer/registries/repositories/ItemView/TagsDatatable/TagsDatatable.test.tsx @@ -0,0 +1,169 @@ +import { render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; + +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { withTestRouter } from '@/react/test-utils/withRouter'; + +import { TagsDatatable } from './TagsDatatable'; +import { Tag } from './types'; +import { RepositoryTagViewModel } from './view-model'; + +// Mock the necessary hooks +const mockUseCurrentStateAndParams = vi.fn(); + +vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({ + ...(await importOriginal()), + useCurrentStateAndParams: () => mockUseCurrentStateAndParams(), +})); + +// Mock the Link component to capture route parameters and generate proper hrefs +vi.mock('@@/Link', () => ({ + Link: ({ + children, + params, + 'data-cy': dataCy, + title, + }: { + children: React.ReactNode; + params?: Record; + 'data-cy'?: string; + title?: string; + }) => { + // Simulate href generation based on route and params + // For 'portainer.registries.registry.repository.tag' route + const baseParams = { + endpointId: '1', + id: '1', + repository: 'test-repo', + ...params, + }; + + const tag = (baseParams as Record).tag || ''; + const href = `/endpoints/${baseParams.endpointId}/registries/${baseParams.id}/repositories/${baseParams.repository}/tags/${tag}`; + + return ( + + {children} + + ); + }, +})); + +vi.mock('../../queries/useTagDetails', () => ({ + useTagDetails: vi.fn( + ( + params, + { select }: { select?: (data: RepositoryTagViewModel) => string } = {} + ) => { + const data: RepositoryTagViewModel = { + Name: params.tag, + Os: 'linux', + Architecture: 'amd64', + ImageId: `sha256:${params.tag}123`, + Size: 1024, + ImageDigest: '', + ManifestV2: { + digest: `sha256:${params.tag}123`, + schemaVersion: 2, + mediaType: 'application/vnd.docker.distribution.manifest.v2+json', + config: { + digest: `sha256:${params.tag}123`, + mediaType: 'application/vnd.docker.container.image.v1+json', + size: 1024, + }, + layers: [], + }, + History: [], + }; + + return { + data: select?.(data) || data, + isLoading: false, + error: null, + }; + } + ), +})); + +// Create mock data +const mockTags: Tag[] = [ + { Name: 'latest' }, + { Name: 'v1.0.0' }, + { Name: 'dev-branch' }, + { Name: 'feature/new-ui' }, +]; + +const defaultProps = { + dataset: mockTags, + advancedFeaturesAvailable: true, + onRemove: vi.fn(), + onRetag: vi.fn().mockResolvedValue(undefined), +}; + +function renderComponent() { + const Wrapped = withTestQueryProvider( + withTestRouter(() => ) + ); + return render(); +} + +describe('TagsDatatable', () => { + beforeEach(() => { + // Set up default mock values + mockUseCurrentStateAndParams.mockReturnValue({ + params: { + endpointId: '1', + id: '1', + repository: 'test-repo', + }, + }); + }); + + it('renders basic table structure', () => { + renderComponent(); + expect(screen.getByText('Tags')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument(); + }); + + it('renders tag data in table', () => { + renderComponent(); + + // Check that our mock tags are rendered somewhere in the table + expect(screen.getByText('latest')).toBeInTheDocument(); + expect(screen.getByText('v1.0.0')).toBeInTheDocument(); + expect(screen.getByText('dev-branch')).toBeInTheDocument(); + expect(screen.getByText('feature/new-ui')).toBeInTheDocument(); + }); + + it('creates correct hrefs for tag name links', () => { + renderComponent(); + + // Get the links by their data-cy attributes + const latestLink = screen.getByTestId( + 'registry-tag-name_latest' + ) as HTMLAnchorElement; + const v100Link = screen.getByTestId( + 'registry-tag-name_v1.0.0' + ) as HTMLAnchorElement; + const devBranchLink = screen.getByTestId( + 'registry-tag-name_dev-branch' + ) as HTMLAnchorElement; + const featureLink = screen.getByTestId( + 'registry-tag-name_feature/new-ui' + ) as HTMLAnchorElement; + + // Verify the exact path portion of the href + expect(new URL(latestLink.href).pathname).toBe( + '/endpoints/1/registries/1/repositories/test-repo/tags/latest' + ); + expect(new URL(v100Link.href).pathname).toBe( + '/endpoints/1/registries/1/repositories/test-repo/tags/v1.0.0' + ); + expect(new URL(devBranchLink.href).pathname).toBe( + '/endpoints/1/registries/1/repositories/test-repo/tags/dev-branch' + ); + expect(new URL(featureLink.href).pathname).toBe( + '/endpoints/1/registries/1/repositories/test-repo/tags/feature/new-ui' + ); + }); +}); diff --git a/app/react/portainer/registries/repositories/ItemView/TagsDatatable/columns/useColumns.ts b/app/react/portainer/registries/repositories/ItemView/TagsDatatable/columns/useColumns.ts index 4f07b5bec..bcedccb47 100644 --- a/app/react/portainer/registries/repositories/ItemView/TagsDatatable/columns/useColumns.ts +++ b/app/react/portainer/registries/repositories/ItemView/TagsDatatable/columns/useColumns.ts @@ -4,7 +4,7 @@ import _ from 'lodash'; import { humanize } from '@/portainer/filters/filters'; import { trimSHA } from '@/docker/filters/utils'; -import { buildNameColumn } from '@@/datatables/buildNameColumn'; +import { buildNameColumnFromObject } from '@@/datatables/buildNameColumn'; import { Tag } from '../types'; @@ -13,13 +13,13 @@ import { buildCell } from './buildCell'; import { actions } from './actions'; const columns = [ - buildNameColumn( - 'Name', - 'portainer.registries.registry.repository.tag', - 'tag', - 'registry-tag-name', - (item) => item.Name - ), + buildNameColumnFromObject({ + nameKey: 'Name', + path: 'portainer.registries.registry.repository.tag', + dataCy: 'registry-tag-name', + idParam: 'tag', + idGetter: (item) => item.Name, + }), helper.display({ header: 'OS/Architecture', cell: buildCell((model) => `${model.Os}/${model.Architecture}`), From 150d98617988da4cde4be34a6dc77f8773dd8fdc Mon Sep 17 00:00:00 2001 From: Steven Kang Date: Fri, 11 Jul 2025 13:57:21 +1200 Subject: [PATCH 36/46] fix: CVE-2025-53547 (#880) --- go.mod | 67 ++++++++++----------- go.sum | 183 +++++++++++++++++++++++++++++++++------------------------ 2 files changed, 141 insertions(+), 109 deletions(-) diff --git a/go.mod b/go.mod index f914962d0..f20d26f57 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/csrf v1.7.3 github.com/gorilla/mux v1.8.1 - github.com/gorilla/websocket v1.5.0 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/hashicorp/go-version v1.7.0 github.com/hashicorp/golang-lru v0.5.4 github.com/joho/godotenv v1.4.0 @@ -47,21 +47,21 @@ require ( github.com/urfave/negroni v1.0.0 github.com/viney-shih/go-lock v1.1.1 go.etcd.io/bbolt v1.4.0 - golang.org/x/crypto v0.37.0 + golang.org/x/crypto v0.39.0 golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 - golang.org/x/mod v0.24.0 + golang.org/x/mod v0.25.0 golang.org/x/oauth2 v0.29.0 - golang.org/x/sync v0.14.0 + golang.org/x/sync v0.15.0 gopkg.in/alecthomas/kingpin.v2 v2.2.6 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 - helm.sh/helm/v3 v3.17.3 - k8s.io/api v0.32.3 - k8s.io/apimachinery v0.32.3 - k8s.io/cli-runtime v0.32.2 - k8s.io/client-go v0.32.3 - k8s.io/kubectl v0.32.2 - k8s.io/metrics v0.32.2 + helm.sh/helm/v3 v3.18.4 + k8s.io/api v0.33.2 + k8s.io/apimachinery v0.33.2 + k8s.io/cli-runtime v0.33.2 + k8s.io/client-go v0.33.2 + k8s.io/kubectl v0.33.2 + k8s.io/metrics v0.33.2 software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78 ) @@ -69,11 +69,10 @@ require github.com/gorilla/securecookie v1.1.2 // indirect require ( dario.cat/mergo v1.0.1 // indirect - github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c // indirect - github.com/BurntSushi/toml v1.4.0 // indirect + github.com/BurntSushi/toml v1.5.0 // indirect github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect @@ -81,6 +80,7 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect github.com/ProtonMail/go-crypto v1.1.3 // indirect + github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect @@ -107,7 +107,7 @@ require ( github.com/cloudflare/cfssl v1.6.4 // indirect github.com/cloudflare/circl v1.3.7 // indirect github.com/containerd/console v1.0.4 // indirect - github.com/containerd/containerd v1.7.24 // indirect + github.com/containerd/containerd v1.7.27 // indirect github.com/containerd/containerd/api v1.9.0 // indirect github.com/containerd/containerd/v2 v2.1.1 // indirect github.com/containerd/continuity v0.4.5 // indirect @@ -120,7 +120,7 @@ require ( github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect github.com/containers/ocicrypt v1.2.1 // indirect github.com/containers/storage v1.53.0 // indirect - github.com/cyphar/filepath-securejoin v0.3.6 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/buildx v0.24.0 // indirect @@ -134,7 +134,7 @@ require ( github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/evanphx/json-patch v5.9.0+incompatible // indirect + github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/fatih/camelcase v1.0.0 // indirect github.com/fatih/color v1.15.0 // indirect @@ -160,9 +160,8 @@ require ( github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect - github.com/google/btree v1.0.1 // indirect - github.com/google/gnostic-models v0.6.8 // indirect - github.com/google/gofuzz v1.2.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.6.9 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gosuri/uitable v0.0.4 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect @@ -237,7 +236,7 @@ require ( github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/rivo/uniseg v0.4.4 // indirect - github.com/rubenv/sql-migrate v1.7.1 // indirect + github.com/rubenv/sql-migrate v1.8.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect github.com/segmentio/asm v1.1.3 // indirect @@ -274,8 +273,8 @@ require ( go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.56.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.31.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect @@ -285,10 +284,10 @@ require ( go.opentelemetry.io/otel/trace v1.35.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.uber.org/mock v0.5.2 // indirect - golang.org/x/net v0.39.0 // indirect + golang.org/x/net v0.40.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.31.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/term v0.32.0 // indirect + golang.org/x/text v0.26.0 // indirect golang.org/x/time v0.11.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect @@ -298,17 +297,19 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - k8s.io/apiextensions-apiserver v0.32.2 // indirect - k8s.io/apiserver v0.32.3 // indirect - k8s.io/component-base v0.32.3 // indirect + k8s.io/apiextensions-apiserver v0.33.2 // indirect + k8s.io/apiserver v0.33.2 // indirect + k8s.io/component-base v0.33.2 // indirect + k8s.io/component-helpers v0.33.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect + k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect - oras.land/oras-go v1.2.5 // indirect + oras.land/oras-go/v2 v2.6.0 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect - sigs.k8s.io/kustomize/api v0.18.0 // indirect - sigs.k8s.io/kustomize/kyaml v0.18.1 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect + sigs.k8s.io/kustomize/api v0.19.0 // indirect + sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect sigs.k8s.io/yaml v1.4.0 // indirect tags.cncf.io/container-device-interface v1.0.1 // indirect ) diff --git a/go.sum b/go.sum index b79c1d644..d0f60eefc 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg6 github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c h1:/IBSNwUN8+eKzUzbJPqhK839ygXJ82sde8x3ogr6R28= github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= -github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e h1:rd4bOvKmDIx0WeTv9Qz+hghsgyjikFiPrseXHlKepO0= @@ -136,8 +136,8 @@ github.com/containerd/cgroups/v3 v3.0.5 h1:44na7Ud+VwyE7LIoJ8JTNQOa549a8543BmzaJ github.com/containerd/cgroups/v3 v3.0.5/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins= github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= -github.com/containerd/containerd v1.7.24 h1:zxszGrGjrra1yYJW/6rhm9cJ1ZQ8rkKBR48brqsa7nA= -github.com/containerd/containerd v1.7.24/go.mod h1:7QUzfURqZWCZV7RLNEn1XjUCQLEf0bkaK4GjUaZehxw= +github.com/containerd/containerd v1.7.27 h1:yFyEyojddO3MIGVER2xJLWoCIn+Up4GaHFquP7hsFII= +github.com/containerd/containerd v1.7.27/go.mod h1:xZmPnl75Vc+BLGt4MIfu6bp+fy03gdHAn9bz+FreFR0= github.com/containerd/containerd/api v1.9.0 h1:HZ/licowTRazus+wt9fM6r/9BQO7S0vD5lMcWspGIg0= github.com/containerd/containerd/api v1.9.0/go.mod h1:GhghKFmTR3hNtyznBoQ0EMWr9ju5AqHjcZPsSpTKutI= github.com/containerd/containerd/v2 v2.1.1 h1:znnkm7Ajz8lg8BcIPMhc/9yjBRN3B+OkNKqKisKfwwM= @@ -176,13 +176,15 @@ github.com/containers/storage v1.53.0/go.mod h1:pujcoOSc+upx15Jirdkebhtd8uJiLwbS github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM= -github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -190,8 +192,10 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs= github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= github.com/denisenkom/go-mssqldb v0.0.0-20191128021309-1d7a30a10f73/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= -github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2 h1:aBfCb7iqHmDEIp6fBvC/hQUddQfg+3qdYjwzaiP9Hnc= -github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2/go.mod h1:WHNsWjnIn2V1LYOrME7e8KxSeKunYHsxEm4am0BUtcI= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= +github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/buildx v0.24.0 h1:qiD+xktY+Fs3R79oz8M+7pbhip78qGLx6LBuVmyb+64= @@ -233,8 +237,8 @@ github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRr github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= -github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= -github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8= @@ -326,15 +330,13 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/gomodule/redigo v1.8.2 h1:H5XSIre1MB5NbPYFp+i1NBbb5qN1W8Y8YAQoAYbkm8k= -github.com/gomodule/redigo v1.8.2/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= -github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= -github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/certificate-transparency-go v1.0.10-0.20180222191210-5ab67e519c93/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= github.com/google/certificate-transparency-go v1.1.4 h1:hCyXHDbtqlr/lMXU0D4WgbalXL0Zk4dSWWMbPV8VrqY= github.com/google/certificate-transparency-go v1.1.4/go.mod h1:D6lvbfwckhNrbM9WVl1EVeMOyzC19mpIjMOI4nxBHtQ= -github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= -github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -350,15 +352,15 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= -github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= -github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= @@ -378,6 +380,10 @@ github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKe github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -615,6 +621,12 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ= +github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= +github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -625,8 +637,8 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w= github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= -github.com/rubenv/sql-migrate v1.7.1 h1:f/o0WgfO/GqNuVg+6801K/KW3WdDSupzSjDYODmiUq4= -github.com/rubenv/sql-migrate v1.7.1/go.mod h1:Ob2Psprc0/3ggbM6wCzyYVFFuc6FyZrb2AS+ezLDFb4= +github.com/rubenv/sql-migrate v1.8.0 h1:dXnYiJk9k3wetp7GfQbKJcPHjVJL6YK19tKj8t2Ns0o= +github.com/rubenv/sql-migrate v1.8.0/go.mod h1:F2bGFBwCU+pnmbtNYDeKvSuvL6lBVtXDXUUv5t+u1qw= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/secure-systems-lab/go-securesystemslib v0.8.0 h1:mr5An6X45Kb2nddcFlbmfHkLguCE9laoZCUzEEpIZXA= @@ -732,12 +744,6 @@ github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI= -github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= -github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50 h1:hlE8//ciYMztlGpl/VA+Zm1AcTPHYkHJPbHqE6WJUXE= -github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= -github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f h1:ERexzlUfuTvpE74urLSbIQW0Z/6hF9t8U4NsJLaioAY= -github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70= github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc h1:zkGwegkOW709y0oiAraH/3D8njopUR/pARHv4tZZ6pw= @@ -750,6 +756,10 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w= +go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.56.0 h1:4BZHA+B1wXEQoGNHxW8mURaLhcdGwvRnmhGbm+odRbc= @@ -758,20 +768,36 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRND go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0 h1:FZ6ei8GFW7kyPYdxJaV2rgI6M+4tvZzhYsQ2wgyVC08= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0/go.mod h1:MdEu/mC6j3D+tTEfvI15b5Ci2Fn7NneJ71YMoiS3tpI= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.31.0 h1:ZsXq73BERAiNuuFXYqP4MR5hBrjXfMGSO+Cx7qoOZiM= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.31.0/go.mod h1:hg1zaDMpyZJuUzjFxFsRYBoccE86tM9Uf4IqNMUxvrY= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk= +go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU= +go.opentelemetry.io/otel/exporters/prometheus v0.54.0/go.mod h1:QyjcV9qDP6VeK5qPyKETvNjmaaEc7+gqjh4SS0ZYzDU= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0/go.mod h1:zKU4zUgKiaRxrdovSS2amdM5gOc59slmo/zJwGX+YBg= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 h1:SZmDnHcgp3zwlPBS2JX2urGYe/jBKEIT6ZedHRUyCz8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0/go.mod h1:fdWW0HtZJ7+jNpTKUR0GpMEDP69nR8YBJQxNiVCE3jk= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= +go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk= +go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= +go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= @@ -793,15 +819,15 @@ golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -812,8 +838,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -824,8 +850,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -858,15 +884,15 @@ golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -874,8 +900,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= -golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -924,42 +950,47 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -helm.sh/helm/v3 v3.17.3 h1:3n5rW3D0ArjFl0p4/oWO8IbY/HKaNNwJtOQFdH2AZHg= -helm.sh/helm/v3 v3.17.3/go.mod h1:+uJKMH/UiMzZQOALR3XUf3BLIoczI2RKKD6bMhPh4G8= -k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls= -k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= -k8s.io/apiextensions-apiserver v0.32.2 h1:2YMk285jWMk2188V2AERy5yDwBYrjgWYggscghPCvV4= -k8s.io/apiextensions-apiserver v0.32.2/go.mod h1:GPwf8sph7YlJT3H6aKUWtd0E+oyShk/YHWQHf/OOgCA= -k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= -k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= -k8s.io/apiserver v0.32.3 h1:kOw2KBuHOA+wetX1MkmrxgBr648ksz653j26ESuWNY8= -k8s.io/apiserver v0.32.3/go.mod h1:q1x9B8E/WzShF49wh3ADOh6muSfpmFL0I2t+TG0Zdgc= -k8s.io/cli-runtime v0.32.2 h1:aKQR4foh9qeyckKRkNXUccP9moxzffyndZAvr+IXMks= -k8s.io/cli-runtime v0.32.2/go.mod h1:a/JpeMztz3xDa7GCyyShcwe55p8pbcCVQxvqZnIwXN8= -k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= -k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= -k8s.io/component-base v0.32.3 h1:98WJvvMs3QZ2LYHBzvltFSeJjEx7t5+8s71P7M74u8k= -k8s.io/component-base v0.32.3/go.mod h1:LWi9cR+yPAv7cu2X9rZanTiFKB2kHA+JjmhkKjCZRpI= +helm.sh/helm/v3 v3.18.4 h1:pNhnHM3nAmDrxz6/UC+hfjDY4yeDATQCka2/87hkZXQ= +helm.sh/helm/v3 v3.18.4/go.mod h1:WVnwKARAw01iEdjpEkP7Ii1tT1pTPYfM1HsakFKM3LI= +k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY= +k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs= +k8s.io/apiextensions-apiserver v0.33.2 h1:6gnkIbngnaUflR3XwE1mCefN3YS8yTD631JXQhsU6M8= +k8s.io/apiextensions-apiserver v0.33.2/go.mod h1:IvVanieYsEHJImTKXGP6XCOjTwv2LUMos0YWc9O+QP8= +k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= +k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/apiserver v0.33.2 h1:KGTRbxn2wJagJowo29kKBp4TchpO1DRO3g+dB/KOJN4= +k8s.io/apiserver v0.33.2/go.mod h1:9qday04wEAMLPWWo9AwqCZSiIn3OYSZacDyu/AcoM/M= +k8s.io/cli-runtime v0.33.2 h1:koNYQKSDdq5AExa/RDudXMhhtFasEg48KLS2KSAU74Y= +k8s.io/cli-runtime v0.33.2/go.mod h1:gnhsAWpovqf1Zj5YRRBBU7PFsRc6NkEkwYNQE+mXL88= +k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E= +k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo= +k8s.io/component-base v0.33.2 h1:sCCsn9s/dG3ZrQTX/Us0/Sx2R0G5kwa0wbZFYoVp/+0= +k8s.io/component-base v0.33.2/go.mod h1:/41uw9wKzuelhN+u+/C59ixxf4tYQKW7p32ddkYNe2k= +k8s.io/component-helpers v0.33.2 h1:AjCtYzst11NV8ensxV/2LEEXRwctqS7Bs44bje9Qcnw= +k8s.io/component-helpers v0.33.2/go.mod h1:PsPpiCk74n8pGWp1d6kjK/iSKBTyQfIacv02BNkMenU= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= -k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= -k8s.io/kubectl v0.32.2 h1:TAkag6+XfSBgkqK9I7ZvwtF0WVtUAvK8ZqTt+5zi1Us= -k8s.io/kubectl v0.32.2/go.mod h1:+h/NQFSPxiDZYX/WZaWw9fwYezGLISP0ud8nQKg+3g8= -k8s.io/metrics v0.32.2 h1:7t/rZzTHFrGa9f94XcgLlm3ToAuJtdlHANcJEHlYl9g= -k8s.io/metrics v0.32.2/go.mod h1:VL3nJpzcgB6L5nSljkkzoE0nilZhVgcjCfNRgoylaIQ= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= +k8s.io/kubectl v0.33.2 h1:7XKZ6DYCklu5MZQzJe+CkCjoGZwD1wWl7t/FxzhMz7Y= +k8s.io/kubectl v0.33.2/go.mod h1:8rC67FB8tVTYraovAGNi/idWIK90z2CHFNMmGJZJ3KI= +k8s.io/metrics v0.33.2 h1:gNCBmtnUMDMCRg9Ly5ehxP3OdKISMsOnh1vzk01iCgE= +k8s.io/metrics v0.33.2/go.mod h1:yxoAosKGRsZisv3BGekC5W6T1J8XSV+PoUEevACRv7c= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -oras.land/oras-go v1.2.5 h1:XpYuAwAb0DfQsunIyMfeET92emK8km3W4yEzZvUbsTo= -oras.land/oras-go v1.2.5/go.mod h1:PuAwRShRZCsZb7g8Ar3jKKQR/2A/qN+pkYxIOd/FAoo= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= -sigs.k8s.io/kustomize/api v0.18.0 h1:hTzp67k+3NEVInwz5BHyzc9rGxIauoXferXyjv5lWPo= -sigs.k8s.io/kustomize/api v0.18.0/go.mod h1:f8isXnX+8b+SGLHQ6yO4JG1rdkZlvhaCf/uZbLVMb0U= -sigs.k8s.io/kustomize/kyaml v0.18.1 h1:WvBo56Wzw3fjS+7vBjN6TeivvpbW9GmRaWZ9CIVmt4E= -sigs.k8s.io/kustomize/kyaml v0.18.1/go.mod h1:C3L2BFVU1jgcddNBE1TxuVLgS46TjObMwW5FT9FcjYo= -sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= -sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +sigs.k8s.io/kustomize/api v0.19.0 h1:F+2HB2mU1MSiR9Hp1NEgoU2q9ItNOaBJl0I4Dlus5SQ= +sigs.k8s.io/kustomize/api v0.19.0/go.mod h1:/BbwnivGVcBh1r+8m3tH1VNxJmHSk1PzP5fkP6lbL1o= +sigs.k8s.io/kustomize/kyaml v0.19.0 h1:RFge5qsO1uHhwJsu3ipV7RNolC7Uozc0jUBC/61XSlA= +sigs.k8s.io/kustomize/kyaml v0.19.0/go.mod h1:FeKD5jEOH+FbZPpqUghBP8mrLjJ3+zD3/rf9NNu1cwY= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78 h1:SqYE5+A2qvRhErbsXFfUEUmpWEKxxRSMgGLkvRAFOV4= From b7e906701adffa6ed1f9b002cddf6b5dcc409919 Mon Sep 17 00:00:00 2001 From: Cara Ryan Date: Fri, 11 Jul 2025 14:55:48 +1200 Subject: [PATCH 37/46] fix(kubernetes): Namespace access permission changes role bindings not created [R8S-366] (#826) --- api/http/handler/auth/authenticate.go | 6 +++ api/http/handler/auth/handler.go | 5 ++- api/http/handler/auth/logout.go | 2 + .../endpoints/endpoint_registries_list.go | 18 ++++----- api/http/handler/kubernetes/client.go | 10 ++++- api/http/handler/kubernetes/handler.go | 6 +-- .../proxy/factory/kubernetes/transport.go | 1 + api/http/server.go | 2 +- api/kubernetes/cli/client.go | 40 ++++++++++++++++++- api/kubernetes/cli/client_test.go | 22 ++++++++++ 10 files changed, 95 insertions(+), 17 deletions(-) create mode 100644 api/kubernetes/cli/client_test.go diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index 989949daa..4df31c92c 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -2,6 +2,7 @@ package auth import ( "net/http" + "strconv" "strings" portainer "github.com/portainer/portainer/api" @@ -82,6 +83,11 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h } } + // Clear any existing user caches + if user != nil { + handler.KubernetesClientFactory.ClearUserClientCache(strconv.Itoa(int(user.ID))) + } + if user != nil && isUserInitialAdmin(user) || settings.AuthenticationMethod == portainer.AuthenticationInternal { return handler.authenticateInternal(rw, user, payload.Password) } diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index 3b7210fbf..035ceabf8 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -8,6 +8,7 @@ import ( "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/gorilla/mux" @@ -23,16 +24,18 @@ type Handler struct { OAuthService portainer.OAuthService ProxyManager *proxy.Manager KubernetesTokenCacheManager *kubernetes.TokenCacheManager + KubernetesClientFactory *cli.ClientFactory passwordStrengthChecker security.PasswordStrengthChecker bouncer security.BouncerService } // NewHandler creates a handler to manage authentication operations. -func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimiter, passwordStrengthChecker security.PasswordStrengthChecker) *Handler { +func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimiter, passwordStrengthChecker security.PasswordStrengthChecker, kubernetesClientFactory *cli.ClientFactory) *Handler { h := &Handler{ Router: mux.NewRouter(), passwordStrengthChecker: passwordStrengthChecker, bouncer: bouncer, + KubernetesClientFactory: kubernetesClientFactory, } h.Handle("/auth/oauth/validate", diff --git a/api/http/handler/auth/logout.go b/api/http/handler/auth/logout.go index 977fafa69..73288565d 100644 --- a/api/http/handler/auth/logout.go +++ b/api/http/handler/auth/logout.go @@ -2,6 +2,7 @@ package auth import ( "net/http" + "strconv" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/logoutcontext" @@ -23,6 +24,7 @@ func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperro if tokenData != nil { handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID) + handler.KubernetesClientFactory.ClearUserClientCache(strconv.Itoa(int(tokenData.ID))) logoutcontext.Cancel(tokenData.Token) } diff --git a/api/http/handler/endpoints/endpoint_registries_list.go b/api/http/handler/endpoints/endpoint_registries_list.go index e81bc34a9..5bc4a930d 100644 --- a/api/http/handler/endpoints/endpoint_registries_list.go +++ b/api/http/handler/endpoints/endpoint_registries_list.go @@ -75,7 +75,7 @@ func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Requ return nil, httperror.InternalServerError("Unable to retrieve registries from the database", err) } - registries, handleError := handler.filterRegistriesByAccess(r, registries, endpoint, user, securityContext.UserMemberships) + registries, handleError := handler.filterRegistriesByAccess(tx, r, registries, endpoint, user, securityContext.UserMemberships) if handleError != nil { return nil, handleError } @@ -87,15 +87,15 @@ func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Requ return registries, err } -func (handler *Handler) filterRegistriesByAccess(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) { +func (handler *Handler) filterRegistriesByAccess(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) { if !endpointutils.IsKubernetesEndpoint(endpoint) { return security.FilterRegistries(registries, user, memberships, endpoint.ID), nil } - return handler.filterKubernetesEndpointRegistries(r, registries, endpoint, user, memberships) + return handler.filterKubernetesEndpointRegistries(tx, r, registries, endpoint, user, memberships) } -func (handler *Handler) filterKubernetesEndpointRegistries(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) { +func (handler *Handler) filterKubernetesEndpointRegistries(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) { namespaceParam, _ := request.RetrieveQueryParameter(r, "namespace", true) isAdmin, err := security.IsAdmin(r) if err != nil { @@ -116,7 +116,7 @@ func (handler *Handler) filterKubernetesEndpointRegistries(r *http.Request, regi return registries, nil } - return handler.filterKubernetesRegistriesByUserRole(r, registries, endpoint, user) + return handler.filterKubernetesRegistriesByUserRole(tx, r, registries, endpoint, user) } func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership, isAdmin bool) (bool, error) { @@ -169,7 +169,7 @@ func registryAccessPoliciesContainsNamespace(registryAccess portainer.RegistryAc return false } -func (handler *Handler) filterKubernetesRegistriesByUserRole(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) { +func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) { err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if errors.Is(err, security.ErrAuthorizationRequired) { return nil, httperror.Forbidden("User is not authorized", err) @@ -178,7 +178,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(r *http.Request, re return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) } - userNamespaces, err := handler.userNamespaces(endpoint, user) + userNamespaces, err := handler.userNamespaces(tx, endpoint, user) if err != nil { return nil, httperror.InternalServerError("unable to retrieve user namespaces", err) } @@ -186,7 +186,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(r *http.Request, re return filterRegistriesByNamespaces(registries, endpoint.ID, userNamespaces), nil } -func (handler *Handler) userNamespaces(endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) { +func (handler *Handler) userNamespaces(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) { kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return nil, err @@ -197,7 +197,7 @@ func (handler *Handler) userNamespaces(endpoint *portainer.Endpoint, user *porta return nil, err } - userMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID) + userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID) if err != nil { return nil, err } diff --git a/api/http/handler/kubernetes/client.go b/api/http/handler/kubernetes/client.go index 6612ab7f4..a7f2485e3 100644 --- a/api/http/handler/kubernetes/client.go +++ b/api/http/handler/kubernetes/client.go @@ -2,8 +2,10 @@ package kubernetes import ( "net/http" + "strconv" "github.com/portainer/portainer/api/http/middlewares" + "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/kubernetes/cli" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/rs/zerolog/log" @@ -25,7 +27,13 @@ func (handler *Handler) prepareKubeClient(r *http.Request) (*cli.KubeClient, *ht return nil, httperror.NotFound("Unable to find the Kubernetes endpoint associated to the request.", err) } - pcli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to retrieve token data associated to the request.") + return nil, httperror.InternalServerError("Unable to retrieve token data associated to the request.", err) + } + + pcli, err := handler.KubernetesClientFactory.GetPrivilegedUserKubeClient(endpoint, strconv.Itoa(int(tokenData.ID))) if err != nil { log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to get a privileged Kubernetes client for the user.") return nil, httperror.InternalServerError("Unable to get a privileged Kubernetes client for the user.", err) diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index 07f6bdf3f..a8a5898c8 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -146,7 +146,7 @@ func (h *Handler) getProxyKubeClient(r *http.Request) (portainer.KubeClient, *ht return nil, httperror.Forbidden(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, permission denied to access the environment /api/kubernetes/%d. Error: ", endpointID), err) } - cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token) + cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), strconv.Itoa(int(tokenData.ID))) if !ok { return nil, httperror.InternalServerError("an error occurred during the getProxyKubeClient operation,failed to get proxy KubeClient", nil) } @@ -179,7 +179,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler { } // Check if we have a kubeclient against this auth token already, otherwise generate a new one - _, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token) + _, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), strconv.Itoa(int(tokenData.ID))) if ok { next.ServeHTTP(w, r) return @@ -269,7 +269,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler { return } - handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), tokenData.Token, kubeCli) + handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), strconv.Itoa(int(tokenData.ID)), kubeCli) next.ServeHTTP(w, r) }) } diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go index 76e9daa68..ddab7a096 100644 --- a/api/http/proxy/factory/kubernetes/transport.go +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -58,6 +58,7 @@ func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (* switch { case strings.EqualFold(requestPath, "/namespaces/portainer/configmaps/portainer-config") && (request.Method == "PUT" || request.Method == "POST"): + transport.k8sClientFactory.ClearClientCache() defer transport.tokenManager.UpdateUserServiceAccountsForEndpoint(portainer.EndpointID(endpointID)) return transport.executeKubernetesRequest(request) case strings.EqualFold(requestPath, "/namespaces"): diff --git a/api/http/server.go b/api/http/server.go index 5bfa36379..8f073ce58 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -131,7 +131,7 @@ func (server *Server) Start() error { passwordStrengthChecker := security.NewPasswordStrengthChecker(server.DataStore.Settings()) - var authHandler = auth.NewHandler(requestBouncer, rateLimiter, passwordStrengthChecker) + var authHandler = auth.NewHandler(requestBouncer, rateLimiter, passwordStrengthChecker, server.KubernetesClientFactory) authHandler.DataStore = server.DataStore authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go index a40a865f1..550ade1d3 100644 --- a/api/kubernetes/cli/client.go +++ b/api/kubernetes/cli/client.go @@ -77,9 +77,26 @@ func (factory *ClientFactory) ClearClientCache() { factory.endpointProxyClients.Flush() } +// ClearClientCache removes all cached kube clients for a userId +func (factory *ClientFactory) ClearUserClientCache(userID string) { + for key := range factory.endpointProxyClients.Items() { + if strings.HasSuffix(key, "."+userID) { + factory.endpointProxyClients.Delete(key) + } + } +} + // Remove the cached kube client so a new one can be created func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID) { factory.endpointProxyClients.Delete(strconv.Itoa(int(endpointID))) + + endpointPrefix := strconv.Itoa(int(endpointID)) + "." + + for key := range factory.endpointProxyClients.Items() { + if strings.HasPrefix(key, endpointPrefix) { + factory.endpointProxyClients.Delete(key) + } + } } func (factory *ClientFactory) GetAddrHTTPS() string { @@ -104,6 +121,24 @@ func (factory *ClientFactory) GetPrivilegedKubeClient(endpoint *portainer.Endpoi return kcl, nil } +// GetPrivilegedUserKubeClient checks if an existing admin client is already registered for the environment(endpoint) and user and returns it if one is found. +// If no client is registered, it will create a new client, register it, and returns it. +func (factory *ClientFactory) GetPrivilegedUserKubeClient(endpoint *portainer.Endpoint, userID string) (*KubeClient, error) { + key := strconv.Itoa(int(endpoint.ID)) + ".admin." + userID + pcl, ok := factory.endpointProxyClients.Get(key) + if ok { + return pcl.(*KubeClient), nil + } + + kcl, err := factory.createCachedPrivilegedKubeClient(endpoint) + if err != nil { + return nil, err + } + + factory.endpointProxyClients.Set(key, kcl, cache.DefaultExpiration) + return kcl, nil +} + // GetProxyKubeClient retrieves a KubeClient from the cache. You should be // calling SetProxyKubeClient before first. It is normally, called the // kubernetes middleware. @@ -156,8 +191,9 @@ func (factory *ClientFactory) createCachedPrivilegedKubeClient(endpoint *portain } return &KubeClient{ - cli: cli, - instanceID: factory.instanceID, + cli: cli, + instanceID: factory.instanceID, + IsKubeAdmin: true, }, nil } diff --git a/api/kubernetes/cli/client_test.go b/api/kubernetes/cli/client_test.go new file mode 100644 index 000000000..993a966e3 --- /dev/null +++ b/api/kubernetes/cli/client_test.go @@ -0,0 +1,22 @@ +package cli + +import ( + "testing" +) + +func TestClearUserClientCache(t *testing.T) { + factory, _ := NewClientFactory(nil, nil, nil, "", "", "") + kcl := &KubeClient{} + factory.endpointProxyClients.Set("12.1", kcl, 0) + factory.endpointProxyClients.Set("12.12", kcl, 0) + factory.endpointProxyClients.Set("12", kcl, 0) + + factory.ClearUserClientCache("12") + + if len(factory.endpointProxyClients.Items()) != 2 { + t.Errorf("Incorrect clients cached after clearUserClientCache;\ngot=\n%d\nwant=\n%d", len(factory.endpointProxyClients.Items()), 2) + } + if _, ok := factory.GetProxyKubeClient("12", "12"); ok { + t.Errorf("Expected not to find client cache for user after clear") + } +} From 96f2d69ae548d61e37cbe09b1b44a64878f16e13 Mon Sep 17 00:00:00 2001 From: Steven Kang Date: Fri, 11 Jul 2025 16:55:23 +1200 Subject: [PATCH 38/46] feat(observability): alerting experimental feature (#801) Co-authored-by: JamesPlayer --- .../test_data/output_24_to_latest.json | 1 + api/portainer.go | 61 +++++++++++-------- go.mod | 1 + go.sum | 2 + pkg/snapshot/docker.go | 5 +- pkg/snapshot/kubernetes.go | 56 ++++++++++++++++- 6 files changed, 98 insertions(+), 28 deletions(-) diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json index 41a1b49df..dc50e6788 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -776,6 +776,7 @@ "ImageCount": 9, "IsPodman": false, "NodeCount": 0, + "PerformanceMetrics": null, "RunningContainerCount": 5, "ServiceCount": 0, "StackCount": 2, diff --git a/api/portainer.go b/api/portainer.go index da15479ee..0dc235550 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -215,26 +215,34 @@ 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"` - SnapshotRaw DockerSnapshotRaw `json:"DockerSnapshotRaw"` - NodeCount int `json:"NodeCount"` - GpuUseAll bool `json:"GpuUseAll"` - GpuUseList []string `json:"GpuUseList"` - IsPodman bool `json:"IsPodman"` - DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"` + 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"` + 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"` + } + + // PerformanceMetrics represents the performance metrics of a Docker, Swarm, Podman, and Kubernetes environments + PerformanceMetrics struct { + CPUUsage float64 `json:"CPUUsage,omitempty"` + MemoryUsage float64 `json:"MemoryUsage,omitempty"` + NetworkUsage float64 `json:"NetworkUsage,omitempty"` } // DockerContainerSnapshot is an extent of Docker's Container struct @@ -663,12 +671,13 @@ type ( // 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"` + 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"` } // KubernetesConfiguration represents the configuration of a Kubernetes environment(endpoint) diff --git a/go.mod b/go.mod index f20d26f57..d589f19d9 100644 --- a/go.mod +++ b/go.mod @@ -61,6 +61,7 @@ require ( k8s.io/cli-runtime v0.33.2 k8s.io/client-go v0.33.2 k8s.io/kubectl v0.33.2 + k8s.io/kubelet v0.33.2 k8s.io/metrics v0.33.2 software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78 ) diff --git a/go.sum b/go.sum index d0f60eefc..ad8e48908 100644 --- a/go.sum +++ b/go.sum @@ -974,6 +974,8 @@ k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUy k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= k8s.io/kubectl v0.33.2 h1:7XKZ6DYCklu5MZQzJe+CkCjoGZwD1wWl7t/FxzhMz7Y= k8s.io/kubectl v0.33.2/go.mod h1:8rC67FB8tVTYraovAGNi/idWIK90z2CHFNMmGJZJ3KI= +k8s.io/kubelet v0.33.2 h1:wxEau5/563oJb3j3KfrCKlNWWx35YlSgDLOYUBCQ0pg= +k8s.io/kubelet v0.33.2/go.mod h1:way8VCDTUMiX1HTOvJv7M3xS/xNysJI6qh7TOqMe5KM= k8s.io/metrics v0.33.2 h1:gNCBmtnUMDMCRg9Ly5ehxP3OdKISMsOnh1vzk01iCgE= k8s.io/metrics v0.33.2/go.mod h1:yxoAosKGRsZisv3BGekC5W6T1J8XSV+PoUEevACRv7c= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= diff --git a/pkg/snapshot/docker.go b/pkg/snapshot/docker.go index deaabdb08..12f3a3089 100644 --- a/pkg/snapshot/docker.go +++ b/pkg/snapshot/docker.go @@ -100,7 +100,10 @@ func dockerSnapshotNodes(snapshot *portainer.DockerSnapshot, cli *client.Client) snapshot.TotalCPU = int(nanoCpus / 1e9) snapshot.TotalMemory = totalMem - snapshot.NodeCount = len(nodes) + snapshot.NodeCount = 1 + if snapshot.Swarm { + snapshot.NodeCount = len(nodes) + } return nil } diff --git a/pkg/snapshot/kubernetes.go b/pkg/snapshot/kubernetes.go index d8e550e84..77fda14bd 100644 --- a/pkg/snapshot/kubernetes.go +++ b/pkg/snapshot/kubernetes.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "io" + "math" "os" + "reflect" "strings" "time" @@ -19,11 +21,11 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" + statsapi "k8s.io/kubelet/pkg/apis/stats/v1alpha1" ) func CreateKubernetesSnapshot(cli *kubernetes.Clientset) (*portainer.KubernetesSnapshot, error) { kubernetesSnapshot := &portainer.KubernetesSnapshot{} - err := kubernetesSnapshotVersion(kubernetesSnapshot, cli) if err != nil { log.Warn().Err(err).Msg("unable to snapshot cluster version") @@ -54,10 +56,28 @@ func kubernetesSnapshotNodes(snapshot *portainer.KubernetesSnapshot, cli *kubern return err } + if len(nodeList.Items) == 0 { + return nil + } + var totalCPUs, totalMemory int64 + performanceMetrics := &portainer.PerformanceMetrics{ + CPUUsage: 0, + MemoryUsage: 0, + NetworkUsage: 0, + } + for _, node := range nodeList.Items { totalCPUs += node.Status.Capacity.Cpu().Value() totalMemory += node.Status.Capacity.Memory().Value() + + performanceMetrics, err = kubernetesSnapshotNodePerformanceMetrics(cli, node, performanceMetrics) + if err != nil { + return fmt.Errorf("failed to get node performance metrics: %w", err) + } + if performanceMetrics != nil { + snapshot.PerformanceMetrics = performanceMetrics + } } snapshot.TotalCPU = totalCPUs @@ -123,6 +143,40 @@ func kubernetesSnapshotPodErrorLogs(snapshot *portainer.KubernetesSnapshot, cli return nil } +func kubernetesSnapshotNodePerformanceMetrics(cli *kubernetes.Clientset, node corev1.Node, performanceMetrics *portainer.PerformanceMetrics) (*portainer.PerformanceMetrics, error) { + result := cli.RESTClient().Get().AbsPath(fmt.Sprintf("/api/v1/nodes/%s/proxy/stats/summary", node.Name)).Do(context.TODO()) + if result.Error() != nil { + return nil, fmt.Errorf("failed to get node performance metrics: %w", result.Error()) + } + + raw, err := result.Raw() + if err != nil { + return nil, fmt.Errorf("failed to get node performance metrics: %w", err) + } + + stats := statsapi.Summary{} + err = json.Unmarshal(raw, &stats) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal node performance metrics: %w", err) + } + + nodeStats := stats.Node + if reflect.DeepEqual(nodeStats, statsapi.NodeStats{}) { + return nil, nil + } + + if nodeStats.CPU != nil && nodeStats.CPU.UsageNanoCores != nil { + performanceMetrics.CPUUsage += math.Round(float64(*nodeStats.CPU.UsageNanoCores) / float64(node.Status.Capacity.Cpu().Value()*1000000000) * 100) + } + if nodeStats.Memory != nil && nodeStats.Memory.WorkingSetBytes != nil { + performanceMetrics.MemoryUsage += math.Round(float64(*nodeStats.Memory.WorkingSetBytes) / float64(node.Status.Capacity.Memory().Value()) * 100) + } + if nodeStats.Network != nil && nodeStats.Network.RxBytes != nil && nodeStats.Network.TxBytes != nil { + performanceMetrics.NetworkUsage += math.Round((float64(*nodeStats.Network.RxBytes) + float64(*nodeStats.Network.TxBytes)) / 1024 / 1024) // MB + } + return performanceMetrics, nil +} + // filterLogsByPattern filters the logs by the given patterns and returns a list of logs that match the patterns // the logs are returned as a list of maps with the keys "timestamp" and "message" func filterLogsByPattern(logBytes []byte, patterns []string) []map[string]string { From 89f6a94bd839e4a3a595e4b704ca1178b7869a0a Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Fri, 11 Jul 2025 20:06:41 +1200 Subject: [PATCH 39/46] chore(select): show data-cy react select [r8s-402] (#881) --- .../form-components/ReactSelect.tsx | 87 +++++++++++++++++-- 1 file changed, 79 insertions(+), 8 deletions(-) diff --git a/app/react/components/form-components/ReactSelect.tsx b/app/react/components/form-components/ReactSelect.tsx index a9f4e6282..c7ea47366 100644 --- a/app/react/components/form-components/ReactSelect.tsx +++ b/app/react/components/form-components/ReactSelect.tsx @@ -5,12 +5,14 @@ import ReactSelectAsync, { AsyncProps as ReactSelectAsyncProps, } from 'react-select/async'; import ReactSelect, { + components, GroupBase, + InputProps, OptionsOrGroups, Props as ReactSelectProps, } from 'react-select'; import clsx from 'clsx'; -import { RefAttributes, useMemo } from 'react'; +import { RefAttributes, useMemo, useCallback } from 'react'; import ReactSelectType from 'react-select/dist/declarations/src/Select'; import './ReactSelect.css'; @@ -52,6 +54,9 @@ type Props< | CreatableProps | RegularProps; +/** + * DO NOT use this component directly, use PortainerSelect instead. + */ export function Select< Option = DefaultOption, IsMulti extends boolean = false, @@ -68,24 +73,37 @@ export function Select< id: string; }) { const Component = isCreatable ? ReactSelectCreatable : ReactSelect; - const { options } = props; + const { + options, + 'data-cy': dataCy, + components: componentsProp, + ...rest + } = props; + + const memoizedComponents = useMemoizedSelectComponents< + Option, + IsMulti, + Group + >(dataCy, componentsProp); if ((options?.length || 0) > 1000) { return ( ); } return ( ); } @@ -94,13 +112,25 @@ export function Creatable< Option = DefaultOption, IsMulti extends boolean = false, Group extends GroupBase